fix(audio): Campaign A slice A1 — retail's sound probability gate (#355)
The SoundTable probability field is a Bernoulli play/skip gate applied at the play site (SoundManager::PlayProbability @0x005500E0), not a selection weight — and variant selection (SoundManager::GetSound @0x00550680) is a uniform index over (n-1) that ignores probability entirely. SoundCookbook did the opposite: a cumulative-distribution walk weighted BY probability, short-circuiting single-entry lists before rolling at all. A dat census says 4,183 of 4,184 entries are single-entry and 686 of those carry probability < 1.0, so the gate was categorically absent: Speak1 idle chatter authored at 0.05 fired every trigger (~20x too often), wound/attack/ swoosh variants never dropped, and six 0.0001 entries always played. Split into retail's two steps (PickVariant + PlayProbability, composed by Select) over a new ISoundRandom modelling both retail roll ranges: the variant roll clamped below 1.0 (0x00797D48) and the gate's 1/32767 grid, which is why 0.0001 resolves to ~1.2e-4. PickVariant reproduces retail's (n-1) off-by-one verbatim per the port-faithfully rule — the last variant of a multi-entry sound is unreachable, costing exactly one wave (0x0A00051E) in the shipped dats. Also removes invented mechanism this review disproved: the dead Core SoundEntry/ISoundCache scaffold (PitchMin/PitchMax, Loop, Is3D — retail never calls SetFrequency, never sets the loop flag, and creates every gameplay buffer 2D), the engine's pitch plumbing, the int 0..7 priority cast (the dat field is a float in [0,1]; 4,100 entries collapsed to 0), and the clamp-at-the-field on volume (an unbounded gain retail clamps only after the distance divide). Tests rewritten as conformance against the disassembled values, replacing a self-referential suite that pinned the wrong model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
ffa5087527
commit
c69b3bde04
8 changed files with 445 additions and 161 deletions
|
|
@ -24,6 +24,47 @@ What does NOT go here:
|
|||
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
|
||||
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
|
||||
|
||||
## #355 — Sound probability was never applied: every gated cue played on every trigger
|
||||
|
||||
**Status:** CLOSED 2026-08-08 (Campaign A slice A1) — user gate finding
|
||||
("we get incorrect ambient and stuff like that"), root-caused during the
|
||||
six-lane audio review.
|
||||
|
||||
Retail's `SoundTable` entries carry a `probability` field that is a **Bernoulli
|
||||
play/skip gate** applied at the play site (`SoundManager::PlayProbability` @
|
||||
`0x005500E0`: `rand() * (1/32767) < probability`, else silence), entirely
|
||||
separate from variant selection (`SoundManager::GetSound` @ `0x00550680`:
|
||||
`idx = (int)(roll * (n - 1))`, which ignores probability).
|
||||
|
||||
`SoundCookbook.Roll` instead treated probability as a cumulative selection
|
||||
weight AND short-circuited single-entry lists before rolling at all:
|
||||
|
||||
```csharp
|
||||
if (entries.Count == 1) return entries[0]; // probability never consulted
|
||||
```
|
||||
|
||||
An independent walk of the shipped dats found 4,183 of 4,184 entries are
|
||||
single-entry lists, and **686 of those carry probability < 1.0** — so the gate
|
||||
was categorically absent from the client. Loudest symptom: `Speak1` creature
|
||||
idle chatter (49 entries authored at 0.05) fired ~20× too often; wound / attack
|
||||
/ swoosh variants never dropped; six entries authored at 0.0001 played on every
|
||||
trigger. Affected 20 of 123 SoundTypes.
|
||||
|
||||
Fixed by splitting the model into retail's two steps
|
||||
(`SoundCookbook.PickVariant` + `PlayProbability`, composed by `Select`) over a
|
||||
new `ISoundRandom` that reproduces both of retail's roll ranges — the variant
|
||||
roll clamped below 1.0 (`0x00797D48`) and the gate's 1/32767 grid, which is
|
||||
what makes a 0.0001 probability resolve to ~1.2e-4 rather than 1e-4.
|
||||
`PickVariant` deliberately reproduces retail's `(n-1)` off-by-one (the last
|
||||
variant of a multi-entry sound is unreachable; blast radius in the shipped dats
|
||||
is exactly one wave, `0x0A00051E`).
|
||||
|
||||
Evidence: `docs/research/2026-08-08-audio-retail-dat-layer.md` §2 (census +
|
||||
disassembly, both elided by Binary Ninja — BN also renders `PlayProbability`'s
|
||||
branch **inverted**, so porting its rendering would have played sounds exactly
|
||||
when retail stays silent). Campaign:
|
||||
`docs/plans/2026-08-08-audio-parity-campaign.md`.
|
||||
|
||||
## #354 — Spell-bar drag reorder did not work: lifting a favorite canceled the drag before the drop could land
|
||||
|
||||
**Status:** CLOSED 2026-08-08 — user gate finding ("I should be able to
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ global kill switch.
|
|||
|
||||
| Slice | Status | Commit | Gates |
|
||||
|---|---|---|---|
|
||||
| A1 | — | — | — |
|
||||
| A1 | **COMPLETE** 2026-08-08 | (this commit) | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
|
||||
| A2 | — | — | — |
|
||||
| A3 | — | — | — |
|
||||
| A4 | — | — | — |
|
||||
|
|
|
|||
|
|
@ -50,18 +50,18 @@ public sealed class AudioHookSink : IAnimationHookSink
|
|||
private readonly OpenAlAudioEngine _engine;
|
||||
private readonly DatSoundCache _cache;
|
||||
private readonly IEntitySoundTable _entitySoundTables;
|
||||
private readonly Random _rng;
|
||||
private readonly ISoundRandom _rng;
|
||||
|
||||
public AudioHookSink(
|
||||
OpenAlAudioEngine engine,
|
||||
DatSoundCache cache,
|
||||
IEntitySoundTable entitySoundTables,
|
||||
Random? rng = null)
|
||||
ISoundRandom? rng = null)
|
||||
{
|
||||
_engine = engine ?? throw new ArgumentNullException(nameof(engine));
|
||||
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
_entitySoundTables = entitySoundTables ?? throw new ArgumentNullException(nameof(entitySoundTables));
|
||||
_rng = rng ?? Random.Shared;
|
||||
_rng = rng ?? new SoundRandom();
|
||||
}
|
||||
|
||||
public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook)
|
||||
|
|
@ -71,7 +71,10 @@ public sealed class AudioHookSink : IAnimationHookSink
|
|||
switch (hook)
|
||||
{
|
||||
case SoundHook s:
|
||||
Play(entityId, entityWorldPosition, (uint)s.Id, volume: 1f, priority: 4, pitch: 1f);
|
||||
// A bare wave with no authored volume/priority and no
|
||||
// probability to gate on: retail's `PlaySoundA(DataID, obj,
|
||||
// prio, prob, vol)` path is handed 1.0/1.0 by this hook.
|
||||
Play(entityId, entityWorldPosition, (uint)s.Id, volume: 1f, priority: 1f);
|
||||
break;
|
||||
|
||||
case SoundTableHook st:
|
||||
|
|
@ -83,11 +86,14 @@ public sealed class AudioHookSink : IAnimationHookSink
|
|||
// priority overrides baked into the hook itself (NOT a
|
||||
// SoundTable lookup — that's SoundTableHook). Retail uses
|
||||
// this for the rare "explicit wave + explicit volume" case.
|
||||
// Volume is NOT clamped here: the dat field is an unbounded
|
||||
// gain (shipped values reach 10.0) and retail clamps only
|
||||
// after the distance divide, so clamping at the field would
|
||||
// cut a loud sound's audible range. A2 owns that clamp.
|
||||
Play(entityId, entityWorldPosition,
|
||||
waveId: (uint)stw.SoundId,
|
||||
volume: Math.Clamp(stw.Volume > 0 ? stw.Volume : 1f, 0f, 1f),
|
||||
priority: stw.Priority,
|
||||
pitch: 1f);
|
||||
volume: stw.Volume > 0 ? stw.Volume : 1f,
|
||||
priority: stw.Priority);
|
||||
break;
|
||||
|
||||
// All the visual-only hooks (Scale, Luminous, Diffuse, …)
|
||||
|
|
@ -97,7 +103,7 @@ public sealed class AudioHookSink : IAnimationHookSink
|
|||
|
||||
private void PlayFromSoundTable(
|
||||
uint entityId, Vector3 worldPos, DRWSound sound,
|
||||
float volumeMult = 1f, float pitchMult = 1f)
|
||||
float volumeMult = 1f)
|
||||
{
|
||||
uint tableId = _entitySoundTables.GetSoundTableId(entityId);
|
||||
if (tableId == 0) return;
|
||||
|
|
@ -105,19 +111,25 @@ public sealed class AudioHookSink : IAnimationHookSink
|
|||
SoundTable? table = _cache.GetSoundTable(tableId);
|
||||
if (table is null) return;
|
||||
|
||||
var entry = SoundCookbook.Roll(table, sound, _rng);
|
||||
// Retail's two steps: uniform variant pick, then the entry's own
|
||||
// probability as a play/skip gate. A null here means retail would
|
||||
// have stayed silent on this trigger.
|
||||
var entry = SoundCookbook.Select(table, sound, _rng);
|
||||
if (entry is null) return;
|
||||
|
||||
// Unlike the wire path (which uses the message's volume and ignores
|
||||
// the table's), the animation-hook path plays at the AUTHORED entry
|
||||
// volume — see the asymmetry in
|
||||
// docs/research/2026-08-08-audio-retail-server-sounds.md.
|
||||
Play(
|
||||
entityId, worldPos,
|
||||
waveId: (uint)entry.Id,
|
||||
volume: Math.Clamp(entry.Volume * volumeMult, 0f, 1f),
|
||||
priority: entry.Priority,
|
||||
pitch: Math.Max(0.5f, Math.Min(2.0f, pitchMult)));
|
||||
volume: entry.Volume * volumeMult,
|
||||
priority: entry.Priority);
|
||||
}
|
||||
|
||||
private void Play(uint entityId, Vector3 worldPos, uint waveId,
|
||||
float volume, float priority, float pitch)
|
||||
float volume, float priority)
|
||||
{
|
||||
if (waveId == 0) return;
|
||||
WaveData? wave = _cache.GetWave(waveId);
|
||||
|
|
@ -128,8 +140,7 @@ public sealed class AudioHookSink : IAnimationHookSink
|
|||
wave,
|
||||
worldPos,
|
||||
volume,
|
||||
priority,
|
||||
pitch);
|
||||
priority);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,11 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
public uint OwnerId;
|
||||
public float PlayingGain; // gain at play time (for eviction compare)
|
||||
public bool InUse;
|
||||
public uint PriorityBase; // raw priority from SoundEntry.Priority
|
||||
// The DAT-authored priority, a float in [0,1] — NOT an 0..7 int. 4,100
|
||||
// of the shipped entries carry a sub-1.0 priority that an int cast
|
||||
// collapsed to 0, which flattened the eviction ordering this field
|
||||
// exists for. A2 makes eviction compare it.
|
||||
public float Priority;
|
||||
}
|
||||
private readonly Slot3D[] _pool3D = CreateWorldSlots();
|
||||
private int _pool3DCursor; // round-robin start
|
||||
|
|
@ -251,8 +255,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
WaveData wave,
|
||||
Vector3 position,
|
||||
float volume,
|
||||
float priority,
|
||||
float pitch = 1.0f)
|
||||
float priority)
|
||||
{
|
||||
if (_worldAudioSuspended || !_available || _al is null) return false;
|
||||
|
||||
|
|
@ -285,7 +288,8 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, 0); // detach old
|
||||
_al.SetSourceProperty(slot.SourceId, SourceInteger.Buffer, (int)buffer);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceFloat.Gain, effectiveGain);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceFloat.Pitch, pitch);
|
||||
// No pitch: retail never calls SetFrequency on a sound buffer, so
|
||||
// there is no per-play pitch variation to reproduce.
|
||||
_al.SetSourceProperty(slot.SourceId, SourceVector3.Position, position.X, position.Y, position.Z);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.SourceRelative, false);
|
||||
_al.SetSourceProperty(slot.SourceId, SourceBoolean.Looping, false);
|
||||
|
|
@ -294,7 +298,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
slot.PlayingGain = effectiveGain;
|
||||
slot.InUse = true;
|
||||
slot.OwnerId = ownerId;
|
||||
slot.PriorityBase = (uint)Math.Clamp((int)priority, 0, 7);
|
||||
slot.Priority = priority;
|
||||
_pool3DCursor = (slotIdx + 1) & (PoolSize3D - 1);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -330,7 +334,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
/// Play a raw WaveData blob as a 2D UI sound (no falloff, ignores
|
||||
/// listener position).
|
||||
/// </summary>
|
||||
public bool PlayUiWave(uint waveId, WaveData wave, float volume = 1f, float pitch = 1f)
|
||||
public bool PlayUiWave(uint waveId, WaveData wave, float volume = 1f)
|
||||
{
|
||||
if (!_available || _al is null) return false;
|
||||
|
||||
|
|
@ -350,7 +354,6 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
_al.SetSourceProperty(src, SourceInteger.Buffer, 0);
|
||||
_al.SetSourceProperty(src, SourceInteger.Buffer, (int)buffer);
|
||||
_al.SetSourceProperty(src, SourceFloat.Gain, Math.Clamp(volume, 0f, 1f) * SfxVolume);
|
||||
_al.SetSourceProperty(src, SourceFloat.Pitch, pitch);
|
||||
_al.SourcePlay(src);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -517,7 +520,7 @@ public sealed unsafe class OpenAlAudioEngine : IAudioEngine, IWorldAudioQuiescen
|
|||
|
||||
slot.OwnerId = 0;
|
||||
slot.PlayingGain = 0f;
|
||||
slot.PriorityBase = 0;
|
||||
slot.Priority = 0f;
|
||||
slot.InUse = false;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,22 +13,17 @@ namespace AcDream.Core.Audio;
|
|||
// 23-member subset was replaced by retail's full 205-entry SoundType
|
||||
// catalog: src/AcDream.Core/Audio/SoundId.cs (same namespace).
|
||||
|
||||
/// <summary>
|
||||
/// Per-SoundId entry from the SoundTable dat (0x20000000..0x2000FFFF).
|
||||
/// One Sound can have multiple entries with probabilities — that's
|
||||
/// retail's variation mechanism (e.g. 3 different footstep clips).
|
||||
/// </summary>
|
||||
public sealed class SoundEntry
|
||||
{
|
||||
public uint WaveId { get; init; } // → Wave dat (0x0A000000..0x0A00FFFF)
|
||||
public int Priority { get; init; } // eviction ordering (0..7)
|
||||
public float Probability{ get; init; } // for entries with multiple alternatives
|
||||
public float VolumeBase { get; init; } // 0..1 multiplier applied before falloff
|
||||
public float PitchMin { get; init; }
|
||||
public float PitchMax { get; init; }
|
||||
public bool Loop { get; init; }
|
||||
public bool Is3D { get; init; } // 3D positional vs UI/music flat
|
||||
}
|
||||
// A local `SoundEntry` scaffold class lived here until 2026-08-08 (Campaign A
|
||||
// slice A1). It had no implementers and no readers — the live path consumes
|
||||
// DatReaderWriter's `SoundEntry` directly — and four of its eight fields were
|
||||
// invented: `PitchMin`/`PitchMax` (retail never calls SetFrequency), `Loop`
|
||||
// (retail never sets the DirectSound loop flag; "looping" ambients are
|
||||
// re-fired one-shots), and `Is3D` (every retail gameplay buffer is created
|
||||
// with m_3D = 0). Its `Priority` was also typed `int` "0..7" where the dat
|
||||
// field is a float in [0,1]. The `ISoundCache` interface that returned it went
|
||||
// the same way. Evidence:
|
||||
// docs/research/2026-08-08-audio-retail-soundmanager-core.md,
|
||||
// docs/research/2026-08-08-audio-retail-dat-layer.md §1.
|
||||
|
||||
/// <summary>
|
||||
/// Raw decoded PCM data from a Wave dat. Set by <c>WaveDecoder</c> at
|
||||
|
|
@ -102,14 +97,3 @@ public interface IAudioEngine : IDisposable
|
|||
void PlayMusic(string resourceName, bool loop);
|
||||
void StopMusic();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cache of decoded waves + SoundTable lookups. Owned by the App-layer
|
||||
/// AudioEngine; Core exposes the interface.
|
||||
/// </summary>
|
||||
public interface ISoundCache
|
||||
{
|
||||
WaveData GetWave(uint waveId);
|
||||
IReadOnlyList<SoundEntry> GetSoundEntries(SoundId id);
|
||||
IReadOnlyList<SoundEntry> GetSoundEntries(uint soundTableId, SoundId id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,82 +2,109 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DRWSound = DatReaderWriter.Enums.Sound;
|
||||
using DRWSoundEntry = DatReaderWriter.Types.SoundEntry;
|
||||
|
||||
namespace AcDream.Core.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Probabilistic entry picker over a retail <see cref="SoundTable"/>.
|
||||
/// Retail's sound-selection model over a <see cref="SoundTable"/>. Two
|
||||
/// INDEPENDENT steps, in retail's own order — a uniform variant pick, then a
|
||||
/// Bernoulli play/skip gate on the picked entry.
|
||||
///
|
||||
/// <para>
|
||||
/// Each <see cref="DatReaderWriter.Enums.Sound"/> key in
|
||||
/// <c>SoundTable.Sounds</c> maps to a list of
|
||||
/// <see cref="DatReaderWriter.Types.SoundEntry"/> items each carrying a
|
||||
/// probability weight. Retail picks one entry per trigger by rolling the
|
||||
/// cumulative distribution — that's how footsteps sound slightly
|
||||
/// different each step, how weapon swings have 3 swoosh variants, etc.
|
||||
/// <b>Step 1 — <see cref="PickVariant"/></b>. <c>SoundManager::GetSound</c> @
|
||||
/// <c>0x00550680</c>: <c>idx = (int)(roll * (n - 1))</c>, truncating toward
|
||||
/// zero, where <c>roll</c> is <see cref="ISoundRandom.NextVariantRoll"/>.
|
||||
/// The entry's <c>Probability</c> plays NO part in selection.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// r05 §4: the picker samples a uniform random in [0,1) and walks the
|
||||
/// entries accumulating probabilities; the first entry whose running total
|
||||
/// exceeds the sample wins. If all probabilities sum to < 1, the
|
||||
/// remaining mass means "silence" — the call returns null. If probabilities
|
||||
/// sum to > 1 the picker still works correctly (it clamps on the last
|
||||
/// entry).
|
||||
/// <b>Step 2 — <see cref="PlayProbability"/></b>. <c>SoundManager::PlayProbability</c>
|
||||
/// @ <c>0x005500E0</c>: <c>rand() * (1/32767) < probability</c>. Failing the
|
||||
/// gate means the sound is simply not played — there is no fallback entry and
|
||||
/// no retry.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Corrects a pre-2026-08-08 divergence.</b> The previous implementation
|
||||
/// walked a cumulative distribution weighted BY probability and
|
||||
/// short-circuited single-entry lists before rolling at all. The shipped dats
|
||||
/// hold 4,184 entries of which 4,183 are single-entry and 686 of those carry
|
||||
/// a probability below 1.0, so the gate was categorically absent: retail's
|
||||
/// 5%-chance creature idle chatter (<c>Speak1</c>, 49 entries at 0.05) fired
|
||||
/// on every trigger, wound/attack/swoosh variants never dropped, and six
|
||||
/// 0.0001-probability easter eggs played every time. Census and
|
||||
/// disassembly: <c>docs/research/2026-08-08-audio-retail-dat-layer.md</c> §2.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class SoundCookbook
|
||||
{
|
||||
/// <summary>
|
||||
/// Pick one entry from a sound's variant list, weighted by probability.
|
||||
/// Returns <c>null</c> when the rolled sample falls into the "silence"
|
||||
/// remainder of the distribution (probability sum < 1).
|
||||
/// Retail's uniform variant pick. Returns null only for an empty list.
|
||||
///
|
||||
/// <para>
|
||||
/// The <c>n - 1</c> is retail's, at <c>0x005506C8</c>, and it is a real
|
||||
/// Turbine off-by-one: because the roll is clamped below 1.0, the index
|
||||
/// never reaches <c>n - 1</c> and the LAST entry of a multi-entry sound
|
||||
/// can never be selected. It is reproduced verbatim per the
|
||||
/// port-faithfully rule ("do not 'fix' the decompiled code"). Blast
|
||||
/// radius in the shipped dats is exactly one wave: only
|
||||
/// <c>0x200000A8</c> / SoundType 31 has two entries, so
|
||||
/// <c>0x0A00051E</c> is retail-unreachable. Changing this to <c>n</c>
|
||||
/// would need a divergence-register row.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static DatReaderWriter.Types.SoundEntry? Roll(
|
||||
IReadOnlyList<DatReaderWriter.Types.SoundEntry> entries,
|
||||
Random rng)
|
||||
public static DRWSoundEntry? PickVariant(
|
||||
IReadOnlyList<DRWSoundEntry> entries,
|
||||
ISoundRandom rng)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ArgumentNullException.ThrowIfNull(rng);
|
||||
|
||||
if (entries.Count == 0) return null;
|
||||
if (entries.Count == 1) return entries[0];
|
||||
|
||||
float sample = (float)rng.NextDouble();
|
||||
float cum = 0f;
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
{
|
||||
cum += Math.Max(0f, entries[i].Probability);
|
||||
if (sample < cum) return entries[i];
|
||||
}
|
||||
// Fell past the last entry — either probabilities sum to >1 (return
|
||||
// last) or < 1 and we rolled into the "silence" tail (return null).
|
||||
float total = 0f;
|
||||
for (int i = 0; i < entries.Count; i++)
|
||||
total += Math.Max(0f, entries[i].Probability);
|
||||
|
||||
return total > 0.999f ? entries[entries.Count - 1] : null;
|
||||
int idx = (int)(rng.NextVariantRoll() * (entries.Count - 1));
|
||||
// Retail bounds-checks here too (`cmp eax, esi; jae return`); the
|
||||
// branch is dead given the roll clamp, but it costs nothing to keep
|
||||
// the same shape.
|
||||
return idx < entries.Count ? entries[idx] : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convenience lookup: given a SoundTable + a retail
|
||||
/// <see cref="DRWSound"/> key (e.g. Swoosh1, Footstep1), roll the
|
||||
/// entry list and return the winning entry. Returns null if:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><paramref name="table"/> has no mapping for
|
||||
/// <paramref name="sound"/>.</description></item>
|
||||
/// <item><description>The mapping's entry list is empty.</description></item>
|
||||
/// <item><description>The probability roll hits the silence
|
||||
/// tail.</description></item>
|
||||
/// </list>
|
||||
/// Retail's Bernoulli play gate. True = play, false = silence.
|
||||
/// </summary>
|
||||
public static DatReaderWriter.Types.SoundEntry? Roll(
|
||||
public static bool PlayProbability(float probability, ISoundRandom rng)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rng);
|
||||
return rng.NextProbabilityRoll() < probability;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two steps composed the way every retail play site composes them:
|
||||
/// pick a variant, then gate it. Returns null when the sound is missing,
|
||||
/// its variant list is empty, or the gate says silence.
|
||||
/// </summary>
|
||||
public static DRWSoundEntry? Select(
|
||||
IReadOnlyList<DRWSoundEntry> entries,
|
||||
ISoundRandom rng)
|
||||
{
|
||||
DRWSoundEntry? picked = PickVariant(entries, rng);
|
||||
if (picked is null) return null;
|
||||
return PlayProbability(picked.Probability, rng) ? picked : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="Select(IReadOnlyList{DRWSoundEntry}, ISoundRandom)"/> with
|
||||
/// the table lookup in front: given a <see cref="SoundTable"/> and a
|
||||
/// retail <see cref="DRWSound"/> slot, resolve the entry to play.
|
||||
/// </summary>
|
||||
public static DRWSoundEntry? Select(
|
||||
SoundTable table,
|
||||
DRWSound sound,
|
||||
Random rng)
|
||||
ISoundRandom rng)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(table);
|
||||
if (!table.Sounds.TryGetValue(sound, out var soundData)) return null;
|
||||
return Roll(soundData.Entries, rng);
|
||||
return Select(soundData.Entries, rng);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
71
src/AcDream.Core/Audio/SoundRandom.cs
Normal file
71
src/AcDream.Core/Audio/SoundRandom.cs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
using System;
|
||||
|
||||
namespace AcDream.Core.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// The two random sources retail's sound code actually draws from. They are
|
||||
/// NOT interchangeable, and the difference is observable at small
|
||||
/// probabilities, so the seam models both rather than collapsing them.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Variant index</b> — <c>Random::RollDice(0, 1)</c> @ <c>0x0042C4C0</c>,
|
||||
/// a dual-LCG returning a float hard-clamped to <c>0.99999988</c>
|
||||
/// (<c>0x00797D48</c>), i.e. it can never return 1.0. That clamp is what
|
||||
/// makes the last variant of a multi-entry sound unreachable — see
|
||||
/// <see cref="SoundCookbook.PickVariant"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Probability gate</b> — C library <c>rand()</c> scaled by
|
||||
/// <c>1/32767</c> inside <c>SoundManager::PlayProbability</c> @
|
||||
/// <c>0x005500E0</c>. <c>rand()</c> returns 0..32767 INCLUSIVE, so the
|
||||
/// scaled value reaches exactly 1.0 and the grid step is ~3.05e-5. Two
|
||||
/// consequences we reproduce deliberately: a probability of 1.0 is skipped
|
||||
/// on the single roll where the value lands on 1.0 (1 chance in 32768), and
|
||||
/// the six authored 0.0001 probabilities in the shipped dats resolve to
|
||||
/// ~1.2e-4 rather than 1e-4 because only <c>rand() <= 3</c> passes.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface ISoundRandom
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail <c>Random::RollDice(0, 1)</c>: a float in
|
||||
/// <c>[0, 0.99999988]</c>, never 1.0.
|
||||
/// </summary>
|
||||
float NextVariantRoll();
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>rand() * (1/32767)</c>: a float in <c>[0, 1]</c> inclusive,
|
||||
/// quantised to the 1/32767 grid.
|
||||
/// </summary>
|
||||
float NextProbabilityRoll();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ISoundRandom"/> over <see cref="Random"/>. Retail's exact LCG
|
||||
/// streams are not reproduced — nothing observable depends on the sequence,
|
||||
/// only on each roll's range and quantisation, which this preserves.
|
||||
/// </summary>
|
||||
public sealed class SoundRandom : ISoundRandom
|
||||
{
|
||||
/// <summary>
|
||||
/// Retail's clamp on <c>Random::rand</c> (<c>0x00797D48</c>). Equals
|
||||
/// <c>1 - 2^-23</c>, the largest float below 1.0.
|
||||
/// </summary>
|
||||
internal const float MaxVariantRoll = 0.99999988f;
|
||||
|
||||
/// <summary>C's <c>RAND_MAX</c>; retail divides by exactly this.</summary>
|
||||
internal const int RandMax = 32767;
|
||||
|
||||
private readonly Random _rng;
|
||||
|
||||
public SoundRandom(Random? rng = null) => _rng = rng ?? Random.Shared;
|
||||
|
||||
public float NextVariantRoll() =>
|
||||
MathF.Min(MaxVariantRoll, (float)_rng.NextDouble());
|
||||
|
||||
// Next's upper bound is exclusive, so RandMax + 1 makes RAND_MAX itself
|
||||
// reachable — which is what lets the scaled roll reach exactly 1.0.
|
||||
public float NextProbabilityRoll() =>
|
||||
_rng.Next(0, RandMax + 1) * (1f / RandMax);
|
||||
}
|
||||
|
|
@ -2,96 +2,243 @@ using System;
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Core.Audio;
|
||||
using DatReaderWriter.DBObjs;
|
||||
using DRWSoundEntry = DatReaderWriter.Types.SoundEntry;
|
||||
using DatReaderWriter.Types;
|
||||
using DRWSoundEntry = DatReaderWriter.Types.SoundEntry;
|
||||
using DRWSound = DatReaderWriter.Enums.Sound;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Audio;
|
||||
|
||||
/// <summary>
|
||||
/// Conformance tests for retail's sound-selection model
|
||||
/// (<c>SoundManager::GetSound</c> @ 0x00550680 +
|
||||
/// <c>SoundManager::PlayProbability</c> @ 0x005500E0). Golden values are the
|
||||
/// disassembled behaviour recorded in
|
||||
/// <c>docs/research/2026-08-08-audio-retail-dat-layer.md</c> §2, not this
|
||||
/// implementation's own output.
|
||||
/// </summary>
|
||||
public sealed class SoundCookbookTests
|
||||
{
|
||||
// Deterministic Random for golden-value tests.
|
||||
private static Random Seed(int seed) => new Random(seed);
|
||||
|
||||
[Fact]
|
||||
public void Roll_EmptyList_ReturnsNull()
|
||||
/// <summary>
|
||||
/// Feeds exact rolls so index/gate boundaries are pinned rather than
|
||||
/// sampled. Variant and probability rolls are separate queues because
|
||||
/// retail draws them from two different generators.
|
||||
/// </summary>
|
||||
private sealed class ScriptedRandom : ISoundRandom
|
||||
{
|
||||
Assert.Null(SoundCookbook.Roll(new List<DRWSoundEntry>(), Seed(1)));
|
||||
}
|
||||
private readonly Queue<float> _variant;
|
||||
private readonly Queue<float> _probability;
|
||||
|
||||
[Fact]
|
||||
public void Roll_SingleEntry_AlwaysReturnsIt()
|
||||
{
|
||||
var e = new DRWSoundEntry { Probability = 0.5f, Priority = 4f, Volume = 1f };
|
||||
var entries = new List<DRWSoundEntry> { e };
|
||||
Assert.Same(e, SoundCookbook.Roll(entries, Seed(1)));
|
||||
Assert.Same(e, SoundCookbook.Roll(entries, Seed(999)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_WeightedEntries_DistributionMatches()
|
||||
{
|
||||
// Three entries: 50%, 30%, 20%. Roll 10000 times and verify counts
|
||||
// are within 5% of expected.
|
||||
var a = new DRWSoundEntry { Probability = 0.5f };
|
||||
var b = new DRWSoundEntry { Probability = 0.3f };
|
||||
var c = new DRWSoundEntry { Probability = 0.2f };
|
||||
var entries = new List<DRWSoundEntry> { a, b, c };
|
||||
|
||||
var rng = new Random(42);
|
||||
int countA = 0, countB = 0, countC = 0, countNull = 0;
|
||||
for (int i = 0; i < 10000; i++)
|
||||
public ScriptedRandom(float[]? variant = null, float[]? probability = null)
|
||||
{
|
||||
var picked = SoundCookbook.Roll(entries, rng);
|
||||
if (ReferenceEquals(picked, a)) countA++;
|
||||
else if (ReferenceEquals(picked, b)) countB++;
|
||||
else if (ReferenceEquals(picked, c)) countC++;
|
||||
else countNull++;
|
||||
_variant = new Queue<float>(variant ?? Array.Empty<float>());
|
||||
_probability = new Queue<float>(probability ?? Array.Empty<float>());
|
||||
}
|
||||
|
||||
Assert.InRange(countA, 4500, 5500);
|
||||
Assert.InRange(countB, 2500, 3500);
|
||||
Assert.InRange(countC, 1500, 2500);
|
||||
// Probabilities sum to 1.0 → no null rolls.
|
||||
Assert.True(countNull < 100);
|
||||
public float NextVariantRoll() => _variant.Dequeue();
|
||||
public float NextProbabilityRoll() => _probability.Dequeue();
|
||||
}
|
||||
|
||||
private static DRWSoundEntry Entry(uint id, float probability = 1f, float volume = 1f, float priority = 1f) =>
|
||||
new() { Id = id, Probability = probability, Volume = volume, Priority = priority };
|
||||
|
||||
// ── PickVariant: retail's uniform index over (n - 1) ────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Roll_SilenceTail_ReturnsNullOccasionally()
|
||||
public void PickVariant_EmptyList_ReturnsNull()
|
||||
{
|
||||
// Two entries that only cover 60% of the probability mass — the
|
||||
// remaining 40% should roll as "silence" (null return).
|
||||
var a = new DRWSoundEntry { Probability = 0.3f };
|
||||
var b = new DRWSoundEntry { Probability = 0.3f };
|
||||
var entries = new List<DRWSoundEntry> { a, b };
|
||||
|
||||
var rng = new Random(42);
|
||||
int nullCount = 0;
|
||||
for (int i = 0; i < 10000; i++)
|
||||
{
|
||||
if (SoundCookbook.Roll(entries, rng) is null)
|
||||
nullCount++;
|
||||
}
|
||||
Assert.InRange(nullCount, 3500, 4500); // ~40% ± margin
|
||||
Assert.Null(SoundCookbook.PickVariant(new List<DRWSoundEntry>(), new ScriptedRandom(new[] { 0f })));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_WithSoundTable_LooksUpBySound()
|
||||
public void PickVariant_SingleEntry_AlwaysIndexZero()
|
||||
{
|
||||
// n == 1 → idx = (int)(roll * 0) == 0 for every roll, including the
|
||||
// clamp ceiling. This is the one arity where (n-1) is harmless.
|
||||
var entries = new List<DRWSoundEntry> { Entry(0x0A000001) };
|
||||
foreach (float roll in new[] { 0f, 0.5f, SoundRandom.MaxVariantRoll })
|
||||
{
|
||||
var picked = SoundCookbook.PickVariant(entries, new ScriptedRandom(new[] { roll }));
|
||||
Assert.Equal(0x0A000001u, picked!.Id.DataId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickVariant_TwoEntries_LastIsUnreachable()
|
||||
{
|
||||
// Retail's `lea ecx,[esi-1]` @ 0x005506C8 with the roll clamped below
|
||||
// 1.0 means idx ∈ [0, n-2]: entry[1] can never be selected. This is a
|
||||
// genuine Turbine off-by-one, reproduced deliberately. In the shipped
|
||||
// dats it costs exactly one wave (0x0A00051E under table 0x200000A8).
|
||||
var entries = new List<DRWSoundEntry> { Entry(0x0A000001), Entry(0x0A00051E) };
|
||||
foreach (float roll in new[] { 0f, 0.25f, 0.5f, 0.75f, 0.999f, SoundRandom.MaxVariantRoll })
|
||||
{
|
||||
var picked = SoundCookbook.PickVariant(entries, new ScriptedRandom(new[] { roll }));
|
||||
Assert.Equal(0x0A000001u, picked!.Id.DataId);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// n == 3 → idx = (int)(roll * 2): halves of the roll range map to 0 and 1.
|
||||
[InlineData(0f, 0)]
|
||||
[InlineData(0.49f, 0)]
|
||||
[InlineData(0.5f, 1)]
|
||||
[InlineData(SoundRandom.MaxVariantRoll, 1)]
|
||||
public void PickVariant_ThreeEntries_TruncatesTowardZero(float roll, int expectedIndex)
|
||||
{
|
||||
var entries = new List<DRWSoundEntry>
|
||||
{
|
||||
Entry(0x0A000001), Entry(0x0A000002), Entry(0x0A000003),
|
||||
};
|
||||
var picked = SoundCookbook.PickVariant(entries, new ScriptedRandom(new[] { roll }));
|
||||
Assert.Equal(entries[expectedIndex].Id.DataId, picked!.Id.DataId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PickVariant_IgnoresProbabilityEntirely()
|
||||
{
|
||||
// Probability is NOT a selection weight in retail. A 0.0-probability
|
||||
// first entry is still the only selectable one at n == 2.
|
||||
var entries = new List<DRWSoundEntry>
|
||||
{
|
||||
Entry(0x0A000001, probability: 0f),
|
||||
Entry(0x0A000002, probability: 1f),
|
||||
};
|
||||
var picked = SoundCookbook.PickVariant(entries, new ScriptedRandom(new[] { 0.9f }));
|
||||
Assert.Equal(0x0A000001u, picked!.Id.DataId);
|
||||
}
|
||||
|
||||
// ── PlayProbability: the Bernoulli gate ────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.5f, 0.49f, true)]
|
||||
[InlineData(0.5f, 0.5f, false)] // strict <
|
||||
[InlineData(0.5f, 0.51f, false)]
|
||||
[InlineData(0f, 0f, false)] // probability 0 never plays
|
||||
[InlineData(1f, 0.99997f, true)]
|
||||
[InlineData(1f, 1f, false)] // rand() == RAND_MAX → skipped even at p=1
|
||||
public void PlayProbability_IsStrictLessThan(float probability, float roll, bool expected)
|
||||
{
|
||||
Assert.Equal(
|
||||
expected,
|
||||
SoundCookbook.PlayProbability(probability, new ScriptedRandom(probability: new[] { roll })));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayProbability_ZeroProbability_NeverPlaysAcrossTheWholeGrid()
|
||||
{
|
||||
// 686 single-entry dat rows carry probability < 1.0; a 0.0 row must be
|
||||
// silent for every possible roll on the 1/32767 grid.
|
||||
var rng = new SoundRandom(new Random(1));
|
||||
for (int i = 0; i < 20_000; i++)
|
||||
Assert.False(SoundCookbook.PlayProbability(0f, rng));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlayProbability_FivePercent_MatchesRetailRate()
|
||||
{
|
||||
// Speak1 creature idle chatter: 49 dat entries at 0.05. Before this
|
||||
// slice acdream played them at 100%.
|
||||
var rng = new SoundRandom(new Random(20260808));
|
||||
int played = 0;
|
||||
for (int i = 0; i < 100_000; i++)
|
||||
if (SoundCookbook.PlayProbability(0.05f, rng)) played++;
|
||||
|
||||
Assert.InRange(played, 4_600, 5_400); // 5% ± 0.4pp
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProbabilityRoll_ReachesExactlyOne_AndNeverExceedsIt()
|
||||
{
|
||||
// The gate's grid is rand()/32767 with rand() ∈ [0, 32767], so 1.0 is
|
||||
// attainable — that attainability is what makes p=1.0 skip 1-in-32768.
|
||||
var rng = new SoundRandom(new Random(7));
|
||||
bool sawOne = false;
|
||||
for (int i = 0; i < 500_000; i++)
|
||||
{
|
||||
float roll = rng.NextProbabilityRoll();
|
||||
Assert.InRange(roll, 0f, 1f);
|
||||
if (roll == 1f) sawOne = true;
|
||||
}
|
||||
Assert.True(sawOne, "rand()/32767 must be able to return exactly 1.0");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void VariantRoll_NeverReachesOne()
|
||||
{
|
||||
// Random::rand's clamp @ 0x00797D48. If this ever returned 1.0, a
|
||||
// 2-entry sound could select its (retail-unreachable) last entry.
|
||||
var rng = new SoundRandom(new Random(11));
|
||||
for (int i = 0; i < 500_000; i++)
|
||||
{
|
||||
float roll = rng.NextVariantRoll();
|
||||
Assert.InRange(roll, 0f, SoundRandom.MaxVariantRoll);
|
||||
Assert.True(roll < 1f);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Select: the two steps composed, as every play site composes them ───
|
||||
|
||||
[Fact]
|
||||
public void Select_GateFailure_ReturnsNull()
|
||||
{
|
||||
var entries = new List<DRWSoundEntry> { Entry(0x0A000001, probability: 0.05f) };
|
||||
var rng = new ScriptedRandom(variant: new[] { 0f }, probability: new[] { 0.9f });
|
||||
Assert.Null(SoundCookbook.Select(entries, rng));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_GatePass_ReturnsPickedEntry()
|
||||
{
|
||||
var entries = new List<DRWSoundEntry> { Entry(0x0A000001, probability: 0.05f) };
|
||||
var rng = new ScriptedRandom(variant: new[] { 0f }, probability: new[] { 0.01f });
|
||||
Assert.Equal(0x0A000001u, SoundCookbook.Select(entries, rng)!.Id.DataId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_SingleEntryBelowOne_IsGated_NotShortCircuited()
|
||||
{
|
||||
// The regression this slice exists for: 4,183 of 4,184 shipped entries
|
||||
// are single-entry lists, and the old implementation returned them
|
||||
// without ever consulting probability.
|
||||
var entries = new List<DRWSoundEntry> { Entry(0x0A000001, probability: 0.05f) };
|
||||
var rng = new SoundRandom(new Random(99));
|
||||
int played = 0;
|
||||
for (int i = 0; i < 20_000; i++)
|
||||
if (SoundCookbook.Select(entries, rng) is not null) played++;
|
||||
|
||||
Assert.InRange(played, 800, 1_200); // ~5% of 20k, not 20k
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_WithSoundTable_LooksUpBySound()
|
||||
{
|
||||
var table = new SoundTable();
|
||||
var footstep = new DRWSoundEntry { Probability = 1f, Volume = 0.7f };
|
||||
table.Sounds[DRWSound.Footstep1] = new SoundData();
|
||||
table.Sounds[DRWSound.Footstep1].Entries.Add(footstep);
|
||||
table.Sounds[DRWSound.Footstep1].Entries.Add(Entry(0x0A000123, volume: 0.7f));
|
||||
|
||||
var picked = SoundCookbook.Roll(table, DRWSound.Footstep1, Seed(1));
|
||||
Assert.Same(footstep, picked);
|
||||
var rng = new ScriptedRandom(variant: new[] { 0f }, probability: new[] { 0f });
|
||||
var picked = SoundCookbook.Select(table, DRWSound.Footstep1, rng);
|
||||
Assert.Equal(0x0A000123u, picked!.Id.DataId);
|
||||
Assert.Equal(0.7f, picked.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roll_WithSoundTable_MissingSound_ReturnsNull()
|
||||
public void Select_WithSoundTable_MissingSound_ReturnsNull()
|
||||
{
|
||||
var table = new SoundTable(); // no entries at all
|
||||
Assert.Null(SoundCookbook.Roll(table, DRWSound.Attack1, Seed(1)));
|
||||
var table = new SoundTable();
|
||||
var rng = new ScriptedRandom(variant: new[] { 0f }, probability: new[] { 0f });
|
||||
Assert.Null(SoundCookbook.Select(table, DRWSound.Attack1, rng));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Select_EmptyEntryList_DoesNotConsumeAProbabilityRoll()
|
||||
{
|
||||
// Retail returns from GetSound before reaching any play site, so the
|
||||
// gate is never rolled. An empty probability queue proves it.
|
||||
var table = new SoundTable();
|
||||
table.Sounds[DRWSound.Attack1] = new SoundData();
|
||||
var rng = new ScriptedRandom(variant: new[] { 0f });
|
||||
Assert.Null(SoundCookbook.Select(table, DRWSound.Attack1, rng));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue