feat(audio): Campaign A slice A2 — retail's 2D pan+gain mixer replaces AL 3D
Retail is not a 3D audio engine. Every gameplay buffer is created with m_3D = 0 and the DirectSound 3D listener the client sets up is dead code; spatialization is two CPU scalars per voice, frozen at emission. This slice ports that math and demotes OpenAL to a voice bank. RetailSoundMixer (new, Core) carries the byte-decoded curve from SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2, clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g), with a hard -50 dB floor at which retail does not start the voice at all (audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's (int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero, forced to dead centre when (int)distance < 5, with no front/back and no elevation cue. Every AL source is now source-relative with rolloff 0 and the global distance model is None: AL's InverseDistanceClamped was first-power (2/d), quieter than retail up close and far louder at range with no cutoff whatsoever. That was the largest audible divergence in the subsystem (AP-28, retired here). RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan for a free or finished slot, then evict the first slot whose DAT priority is strictly lower, else drop. Eviction compared GAIN before, so a loud unimportant sound could silence a quiet important one. It lives in Core because the engine's play path talks to native AL handles and could not be tested; the pool now has 12 conformance tests. The listener keeps using the camera position, which the decode shows is retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided camera Position to SoundManager) — only the heading extraction changes, since retail reads one compass bearing and never a forward/up basis. An earlier draft of the plan called this a defect; corrected in the plan so it is not fixed backwards. Opus review found and this commit fixes: a linear pan-to-azimuth mapping that saturated to full separation at 30 degrees (OpenAL Soft's own speaker angle) where retail gives 15 dB — now inverts the constant-power pan law, so full deflection reaches 0.776 of the arc and both channels stay live; the stale FUN_00550ad0 / gain-eviction class header, which contradicted the register row this commit writes; missing discriminating tests for clamp order and pan truncation; dead PlayingGain state whose comment invented a retail symbol; and a third in-tree copy of Position::heading, now delegating to MoveToMath.PositionHeading. MasterVolume folds into the mixer's one multiply instead of AL listener gain, so the cutoff, radius and dB quantisation move with the slider. Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy), TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk, applied on the ambient path only) filed. Research note corrected twice where its summary contradicted its own decode (30 m dB, floor vs trunc). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c69b3bde04
commit
e42b99482e
13 changed files with 1123 additions and 106 deletions
File diff suppressed because one or more lines are too long
|
|
@ -128,7 +128,7 @@ Divergent or missing, ranked by audible impact:
|
|||
|---|---|---|---|
|
||||
| 1 | Probability gate absent: `SoundCookbook.Roll` short-circuits single-entry lists (4,183/4,184 entries!) before any roll; CDF walk instead of `(n−1)` pick + gate | `SoundCookbook.cs` | Idle chatter ~20× too often; nothing ever randomly silent — the "incorrect ambient-ish noise" complaint |
|
||||
| 2 | 0xF750 unhandled — zero hits in `src/` | `Core.Net` routing | Every server cue silent (hits, wounds, pickup, locks, lifestone…) |
|
||||
| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no −50 dB cutoff; listener = CAMERA; AL 3D pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve both directions; pan wrong from spring-arm offset (AP-28) |
|
||||
| 3 | Falloff: AL `InverseDistanceClamped` ref 2 m ⇒ `2/d` first-power, no −50 dB cutoff; AL's 3D panner instead of retail's ±15 dB angular pan | engine + `WorldRenderFrameBuilder` | Wrong loudness curve in both directions — quieter than retail up close, audible where retail is silent; stereo image wider and 3-D where retail's is a narrow angular pan (AP-28) |
|
||||
| 4 | Priority float [0,1] cast to int 0..7 → 4,100 entries collapse to 0; eviction compares gain not priority | `AudioModel`/engine | Eviction ordering gutted under voice pressure |
|
||||
| 5 | Volume clamped at field instead of after distance divide | `AudioHookSink` | >1-gain sounds lose up to 3× audible range |
|
||||
| 6 | Region ambient system absent (`StartAmbient` stub) | engine | Silent outdoors atmosphere (TS-29 half) |
|
||||
|
|
@ -172,9 +172,23 @@ Port `GetAttenuation` + pan CPU-side exactly (5 m knee, `25·vol/d²`,
|
|||
clamp-after, ceil-dB, −50 dB no-allocate floor, `−15·sin(Δheading)` pan
|
||||
±15 dB with 5 m dead zone, `Sound Features==1` pan disable). OpenAL
|
||||
becomes a dumb 2D voice bank: source-relative sources, per-voice gain +
|
||||
pan (AL_POSITION ±x from pan only); remove `SelectRetailDistanceModel`
|
||||
and listener orientation math. Listener feed moves from camera pose to
|
||||
player position/heading. Eviction compares float priority (equal never
|
||||
pan (AL_POSITION azimuth from pan only); remove AL's distance model and the
|
||||
listener orientation math.
|
||||
|
||||
**Listener correction (2026-08-08, from the lane-1 decode):** an earlier draft
|
||||
of this plan said the listener must move "from camera pose to player
|
||||
position/heading" and listed "listener = CAMERA" as a defect. That was wrong,
|
||||
and it was written before lane 1 landed. Retail's listener IS the camera:
|
||||
`SmartBox::set_viewer` @ `0x00452D36` hands the same COLLIDED third-person
|
||||
camera Position to `SoundManager::SetPlayerPosition`, the sky, and the camera
|
||||
setup, refreshed once per rendered frame from `SmartBox::update_viewer` @
|
||||
`0x00453CE0` (falling back to the player's own position when the sphere sweep
|
||||
fails). acdream's chase camera collides too, so the position source was already
|
||||
faithful; only the HEADING extraction changes, since retail reads
|
||||
`Frame::get_heading` — one compass bearing — and never a forward/up basis.
|
||||
Do not "fix" this back.
|
||||
|
||||
Eviction compares float priority (equal never
|
||||
evicts); fix the pool citation to `PlaySoundInternal @ 0x0054FEC0`.
|
||||
Keep the squared-volume quirk faithful (register row if we later soften
|
||||
it). Map settings: Master (ours, AL listener gain) + Effect + Ambient +
|
||||
|
|
@ -281,8 +295,8 @@ global kill switch.
|
|||
|
||||
| Slice | Status | Commit | Gates |
|
||||
|---|---|---|---|
|
||||
| A1 | **COMPLETE** 2026-08-08 | (this commit) | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
|
||||
| A2 | — | — | — |
|
||||
| A1 | **COMPLETE** 2026-08-08 | `c69b3bde` | 42 Core audio tests; full Release suite 11,563 passed / 4 skipped / 0 failed. Closes #355. |
|
||||
| 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 | — | — | — |
|
||||
| A4 | — | — | — |
|
||||
| A5 | — | — | — |
|
||||
|
|
|
|||
|
|
@ -633,13 +633,19 @@ finds no caller.
|
|||
|
||||
### D4/D6 numbers side by side (vol = master = 1.0)
|
||||
|
||||
> **Corrected 2026-08-08 at the A2 code review:** the 30 m row read −35 dB, which
|
||||
> contradicted both its own gain column (0.0278) and the formula —
|
||||
> `ceil(20·log10 0.027778) = ceil(-31.13) = -31`. It is now −31. The conformance
|
||||
> tests in `RetailSoundMixerTests` recompute every row from the decoded formula
|
||||
> rather than reading this table, which is how the slip surfaced.
|
||||
|
||||
| distance | retail gain | retail dB (`ceil`) | acdream gain (`2/max(d,2)`) | acdream dB |
|
||||
|---|---|---|---|---|
|
||||
| 2 m | 1.000 | 0 | 1.000 | 0.0 |
|
||||
| 5 m | 1.000 | 0 | 0.400 | −8.0 |
|
||||
| 10 m | 0.250 | −12 | 0.200 | −14.0 |
|
||||
| 20 m | 0.0625 | −24 | 0.100 | −20.0 |
|
||||
| 30 m | 0.0278 | −35 | 0.0667 | −23.5 |
|
||||
| 30 m | 0.0278 | −31 | 0.0667 | −23.5 |
|
||||
| 50 m | 0.0100 | −40 | 0.0400 | −28.0 |
|
||||
| 90 m | 0.00309 | −50 (last audible) | 0.0222 | −33.1 |
|
||||
| ≥94.2 m | — | **not played** | 0.0212 | −33.5 |
|
||||
|
|
@ -670,7 +676,10 @@ Per play (3D):
|
|||
if g <= 0: drop
|
||||
db = ceil(20*log10(g)); if db < -50: drop
|
||||
delta = normalise180( bearing(source -> listener) - listenerHeadingDegrees )
|
||||
pan = (int)floor(-15 * sin(delta * pi/180)) clamped [-15, 15]
|
||||
pan = (int)(-15 * sin(delta * pi/180)) # TRUNCATE toward zero (retail _ftol2),
|
||||
# NOT floor: they differ by 1 dB for
|
||||
# negative pans. Corrected 2026-08-08 at
|
||||
# the A2 review; §1 was already right.
|
||||
if (int)dist < 5: pan = 0
|
||||
allocate voice: ring scan from cursor for free/finished;
|
||||
else first slot with slotPriority < newPriority;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue