using System; using System.Collections.Generic; using DatReaderWriter.DBObjs; using DRWSound = DatReaderWriter.Enums.Sound; using DRWSoundEntry = DatReaderWriter.Types.SoundEntry; namespace AcDream.Core.Audio; /// /// Retail's sound-selection model over a . Two /// INDEPENDENT steps, in retail's own order — a uniform variant pick, then a /// Bernoulli play/skip gate on the picked entry. /// /// /// Step 1 — . SoundManager::GetSound @ /// 0x00550680: idx = (int)(roll * (n - 1)), truncating toward /// zero, where roll is . /// The entry's Probability plays NO part in selection. /// /// /// /// Step 2 — . SoundManager::PlayProbability /// @ 0x005500E0: rand() * (1/32767) < probability. Failing the /// gate means the sound is simply not played — there is no fallback entry and /// no retry. /// /// /// /// Corrects a pre-2026-08-08 divergence. 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 (Speak1, 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: docs/research/2026-08-08-audio-retail-dat-layer.md §2. /// /// public static class SoundCookbook { /// /// Retail's uniform variant pick. Returns null only for an empty list. /// /// /// The n - 1 is retail's, at 0x005506C8, and it is a real /// Turbine off-by-one: because the roll is clamped below 1.0, the index /// never reaches n - 1 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 /// 0x200000A8 / SoundType 31 has two entries, so /// 0x0A00051E is retail-unreachable. Changing this to n /// would need a divergence-register row. /// /// public static DRWSoundEntry? PickVariant( IReadOnlyList entries, ISoundRandom rng) { ArgumentNullException.ThrowIfNull(entries); ArgumentNullException.ThrowIfNull(rng); if (entries.Count == 0) return 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; } /// /// Retail's Bernoulli play gate. True = play, false = silence. /// public static bool PlayProbability(float probability, ISoundRandom rng) { ArgumentNullException.ThrowIfNull(rng); return rng.NextProbabilityRoll() < probability; } /// /// 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. /// public static DRWSoundEntry? Select( IReadOnlyList entries, ISoundRandom rng) { DRWSoundEntry? picked = PickVariant(entries, rng); if (picked is null) return null; return PlayProbability(picked.Probability, rng) ? picked : null; } /// /// with /// the table lookup in front: given a and a /// retail slot, resolve the entry to play. /// public static DRWSoundEntry? Select( SoundTable table, DRWSound sound, ISoundRandom rng) { ArgumentNullException.ThrowIfNull(table); if (!table.Sounds.TryGetValue(sound, out var soundData)) return null; return Select(soundData.Entries, rng); } }