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:
Erik 2026-08-08 21:28:16 +02:00
parent ffa5087527
commit c69b3bde04
8 changed files with 445 additions and 161 deletions

View file

@ -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

View file

@ -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 | — | — | — |