# OpenAL Audio Reliability Recovery Plan

Updated: 2026-07-01

## Summary

The current audio unreliability should be treated as an OpenAL backend recovery task, not as an HRTF tuning task. The target is not merely "stop the cutouts"; openQ4 should make strong, reasonable use of OpenAL/OpenAL Soft for reliable playback, clear spatialization, clean sample handling, and good headphone/speaker output quality while preserving Quake 4 asset behavior. The recent OpenAL work added useful capabilities, but it also made the backend exercise fragile paths that were already incomplete: sample upload can silently fail into a broken fallback path, OpenAL source queues are not pumped per frame, queued sources are not drained correctly, and leadin-to-loop playback is not represented as a real OpenAL state machine.

Highest-confidence diagnosis at the start of this recovery work:

- `idSoundVoice_OpenAL::Update()` was a stub, even though `idSoundChannel::UpdateHardware()` calls it every active sound frame. Phase 2 now covers static one-shot, same-sample loop, separate leadin-to-loop, and decoded PCM queue progress, with counters and focused SP refill/lifecycle/map-transition plus MP listen-server validation. Phase 4 now covers synthetic device-event debounce, requested-device in-place reopen, synthetic default-device in-place reopen under native static and forced queued playback, and synthetic active-device disconnect recovery; physical OS/hardware endpoint switching remains.
- The old `sample->openalBuffer == 0` queue fallback has been replaced with a real decoded-PCM queue path. It fills all available OpenAL queue buffers from the requested sample offset, pumps processed buffers every update, advances sample cursors through leadin and loop data, clears inherited `AL_LOOPING` state before queueing, and deliberately restarts an underrun only when queued buffers remain.
- ADPCM data was stored compressed by `LoadWav()` and decoded only inside `CreateOpenALBuffer()`. Phase 1 now normalizes ADPCM to PCM before playback and refuses unsafe compressed-as-PCM fallback.
- `CreateOpenALBuffer()` and `MakeDefault()` silently skipped OpenAL upload when the expected context was not current. Phase 1 now retries on the owned context and marks skipped uploads explicitly.
- `FlushSourceBuffers()` was empty, while `Stop()` and `DestroyInternal()` detached buffers directly. The first teardown fix now stops, unqueues, detaches, and clears local state; focused lifecycle/map-transition/pause/MP listen-server smokes cover `reloadSounds`, `s_restart`, an SP map change, repeated sound-world pause/resume, and real MP gameplay audio. Device-event debounce plus requested/default/disconnect in-place reopen are covered, while real OS default-device, physical hotplug, and requested-device-return stress remains.

The plan below prioritizes a deterministic sample/voice lifecycle first. HRTF, device reopen, deferred updates, EFX occlusion, and latency reporting should remain secondary until the fixed source queue and upload invariants have broader validation.

## Implementation Status

As of 2026-07-02:

- This recovery plan is superseded for the shipped runtime. The audio source tree has been restored to the end-of-day June 23 implementation from `2b0f19a7`, and the temporary OpenAL voice experiment was removed rather than retained behind a runtime switch.
- The sample upload, streaming, source-lifecycle, source-radius, resampler, device-reopen, callback-buffer, restored-save, and diagnostic ideas below remain historical investigation notes only. They should not be treated as live behavior until a future plan reintroduces them one reviewed slice at a time.
- Validation for the revert should compare `src/sound/` and the sound decl-reference hook against `2b0f19a7`, then run a normal build and SP map smoke on the restored path.

Remaining high-risk work:

- Run the remaining real device validation on hardware/OS state changes, especially actual default-device switch, physical hotplug, and a requested device disappearing then returning under the same OpenAL device name.
- Reassess whether any of the removed OpenAL work is still wanted after the restored June 23 path has enough gameplay soak time.

## Quality Target

The recovery work should aim for the best practical OpenAL result, not the smallest compatibility shim.

- Prefer native OpenAL static buffers for fully decoded in-memory samples. This gives OpenAL Soft the cleanest path for mixing, resampling, HRTF, and output-mode handling.
- Keep sample metadata truthful. If bytes are decoded PCM, the sample format should say PCM; if bytes are compressed, the voice layer must not pretend they are PCM.
- Use OpenAL Soft extensions when they improve quality or diagnostics and are robustly optional: HRTF, output mode selection, source resampler selection, source-radius spatialization, EFX filters, deferred updates, source latency, and eventually callback buffers or a proper streaming path.
- Treat callback buffers as a quality opportunity, not a shortcut. They should only replace the frame-pumped queued PCM path after sample lifetime and callback-thread rules are explicit and validated.
- Avoid quality-reducing shortcuts such as fake stereo mapping for unsupported multichannel data, compressed-as-PCM fallbacks, or silent backend degradation.
- Keep OpenAL features as graceful opt-ins or auto paths. A missing extension should reduce enhancement quality, not break core playback.
- Treat device changes, restarts, and reloads as first-class audio-quality cases: all samples should return to the best available OpenAL path after the backend is rebuilt.

## Current Audio System

The active update path is:

1. `idSoundSystemLocal::Render()` checks `needsRestart`, updates the current sound world, updates controller rumble, then calls `hardware.Update()` (`src/sound/snd_system.cpp`).
2. `idSoundWorldLocal::Update()` builds and sorts active emitter channels, mutes lower priority channels when limits are reached, applies cushion fading, calls `hardware.BeginDeferredUpdates()`, updates every active channel, then calls `hardware.EndDeferredUpdates()` (`src/sound/snd_world.cpp`).
3. `idSoundChannel::UpdateHardware()` allocates an OpenAL voice if needed, applies position, velocity, gain, inner radius, wet/dry levels, pitch, portal occlusion, and environment muffling, then either calls `Start()` or `Update()` on the hardware voice (`src/sound/snd_emitter.cpp`).
4. `idSoundHardware_OpenAL::Update()` monitors OpenAL device state, applies listener mute gain, tries to reclaim zombie voices, and prints optional performance data (`src/sound/OpenAL/AL_SoundHardware.cpp`).
5. `idSoundSample_OpenAL::LoadResource()` resolves localized VO variants, prefers RoQ when explicitly requested, otherwise tries Ogg, generated `.idwav`, WAV, and then RoQ. Loaded samples then call `CreateOpenALBuffer()` (`src/sound/OpenAL/AL_SoundSample.cpp`).

That means `idSoundVoice_OpenAL` is the critical reliability layer. The higher-level sound code already provides fresh per-frame channel state; the backend voice must keep playback alive, transition leadins to loops, recover underruns, and clean up sources safely.

## Recent Audio And OpenAL Commits

Reviewed with `git fetch --all --prune` and `git log --since="72 hours ago"` on 2026-06-24.

### `c9dea097` - Cache loop counts and optimize GL/material/AAS

Audio relevance is limited but real. It changed voice muting policy so VO and global radio chatter are not muted because they cannot be restarted precisely enough for lip sync. This is compatible with the current reliability concern: backend voice churn is already dangerous, so preserving important voices is helpful, but it does not fix OpenAL queueing or upload.

### `3a1b576f` - Add pak manifest + robustness and safety fixes

This commit touched `AL_SoundSample.cpp`, `WaveFile.cpp`, `WaveFile.h`, `snd_world.cpp`, and the async networking message channel.

Positive changes:

- WAV and generated sample parsing now has stronger bounds and exact-read validation.
- Ogg decode validates channel count, sample rate, decoded byte count, and allocation.
- RoQ audio parsing validates chunk boundaries and allocation.

Remaining reliability issue:

- ADPCM WAV data is still stored compressed in `buffers[0]` by `LoadWav()`.
- `CreateOpenALBuffer()` decodes ADPCM before static upload, but if upload is skipped because the OpenAL context is not current, the sample remains loaded without `openalBuffer`.
- The voice fallback path later treats ADPCM and XMA2 as `AL_FORMAT_MONO16` or `AL_FORMAT_STEREO16`, which is not safe.
- The MsgChannel compressed-message hardening initially treated the run-length decoder's cursor return as an exact byte count. External MP client validation exposed this as repeated `truncated compressed message` disconnects even though OpenAL counters stayed clean. The follow-up fix accepts decoder returns at or beyond the requested logical size while retaining short-decode rejection.

### `f0b51cfa` - Restore retail sound ABI and release notes

This restored retail sound ABI and flag compatibility and added `attenuatedVolume` to demo/save sound update paths. It is important compatibility work, but it is not a likely source of OpenAL cutouts.

### `08abdc70` - Fix DDS padding, fog ordering, OpenAL occlusion

OpenAL/audio changes:

- Added `efxFiltersAvailable` and `HasEFXFilters()`.
- Created EFX low-pass filters even when EAX-style wet reverb is disabled.
- Added portal and enviro-suit occlusion paths through `SetOcclusion()`.
- Propagated blocked portal counts through `ResolveOrigin()`.

Risk:

- This can make some sounds quieter or more muffled, especially through portals or with the enviro-suit active.
- EFX filter errors can poison later AL calls if not detected and disabled.

Likely role:

- Plausible contributor to "sounds wrong" or "sounds too quiet."
- Not the strongest explanation for intermittent cutouts because source queueing and update pumping are still broken independently of occlusion.

### `b648aa1f` - Add OpenAL HRTF option and device monitoring

OpenAL/audio changes:

- Added `s_openALHRTF`.
- Requests/reports `ALC_SOFT_HRTF` and `ALC_SOFT_output_mode`.
- Added device enumeration, default-device tracking, device event monitoring, and in-place reopen support.
- Added `AL_SOFT_deferred_updates` batching around the sound-world channel update loop.
- Added source latency diagnostics through `AL_SOFT_source_latency`.
- Restart/reopen logic now reacts to HRTF, speaker mode, requested device, default device, and disconnect changes.

Risk:

- Device/default-device event churn can trigger in-place reopen or full sound restart during gameplay.
- HRTF/speaker changes deliberately restart the sound system, which reloads samples and can expose the context-sensitive upload path.
- Deferred updates are useful, but any AL error inside the deferred batch can be harder to attribute without more instrumentation.

Likely role:

- A strong trigger for existing upload/restart/teardown weaknesses.
- Not the root cause of the incomplete voice streaming path.

## Primary Failure Modes

### 1. Static upload can silently fail into a broken fallback path

`openQ4_CanUploadSampleToOpenAL()` returns false when the expected OpenAL context is missing or when another context is current on the caller thread. `CreateOpenALBuffer()` then returns without logging, without queueing a deferred upload, and without marking the sample unusable for normal playback.

Result: a loaded sample can reach playback with `openalBuffer == 0`, which forces `idSoundVoice_OpenAL::SubmitBuffer()` into the fallback queue path. That path is not production-ready.

Required invariant:

- After `LoadResource()` succeeds, every normally playable in-memory sample should either have a valid OpenAL buffer or an explicit backend state saying it requires a known-good streaming/decode path.
- A missing `openalBuffer` must not silently mean "try the legacy fallback."

### 2. The fallback queue path must remain a valid OpenAL streaming implementation

Original problems in `SubmitBuffer()`:

- It read `AL_BUFFERS_PROCESSED` but could unqueue into `openalStreamingBuffer[0]` regardless of which buffer was actually processed.
- It refilled at most one buffer due to `j < 1`.
- It queued only `openalStreamingBuffer[0]`.
- It did not advance `bufferNumber`, `openalStreamingOffset`, or leadin/loop state.
- It ignored `offset` when calculating the data pointer and data length.
- It did not inspect `AL_SOURCE_STATE` and restart playback after underrun.

Current implementation:

- Queue fallback is restricted to decoded PCM16 mono/stereo data.
- `BeginStreaming()` fills available OpenAL queue buffers from the requested sample offset using bounded 100 ms chunks so the queue remains refillable under sustained playback.
- `PumpStreamingBuffers()` owns per-frame queue progress by unqueueing processed buffers individually, refilling the same buffer, and requeueing while data remains.
- The queue cursor advances through sample buffers and into the loop sample when a leadin ends.
- `AL_LOOPING` is cleared before queue playback so a reused source cannot inherit static-loop behavior.
- Stopped queued sources are restarted only when buffers remain queued, which makes underrun recovery deliberate instead of masking end-of-stream.

Required invariant:

- `Update()` must be the owner of OpenAL voice progress. It should either keep the queue filled, transition the source to the next playback state, or return false when playback is complete.

### 3. Compressed formats can be submitted as PCM

Original problem: `LoadWav()` left ADPCM data in compressed blocks. `CreateOpenALBuffer()` decoded ADPCM only as part of static upload, and the fallback queue path treated ADPCM and XMA2 as PCM16 before calling `alBufferData()` on the stored bytes.

Current implementation:

- ADPCM is normalized to PCM during sample load.
- The queued fallback refuses formats that are not decoded PCM16 mono/stereo.

Required invariant:

- The voice layer should never have to guess whether `buffers[]` contains decoded PCM or compressed source bytes.
- Either decode ADPCM into canonical PCM during sample load, or explicitly refuse playback when compressed data has no supported OpenAL decode path.

### 4. Source stop/reuse/destruction does not drain queues

Original problem: `FreeVoice()` still followed the XAudio-era zombie model. `Stop()` called `alSourceStop()` and detached `AL_BUFFER`, while `FlushSourceBuffers()` did nothing. `DestroyInternal()` detached buffers and deleted source/buffers without first unqueueing all queued buffers.

Current implementation:

- `Stop()`, `DestroyInternal()`, and reuse paths route through `FlushSourceBuffers()`.
- `FlushSourceBuffers()` stops the source, unqueues queued streaming buffers when present, detaches static buffer state, and clears local playback state.

Required invariant:

- Before an OpenAL source is reused or destroyed: stop it, unqueue all queued buffers, detach static buffer state, clear filters if needed, then delete or recycle.

### 5. Leadin-to-loop playback is not modeled

Original problem: `OnBufferStart()` existed and knew how to advance through sample buffers and looping samples, but the OpenAL backend did not call it. Static playback could loop a single buffer when `sample == loopingSample`, but a separate leadin plus loop sample had no reliable transition path in the OpenAL voice.

Current implementation:

- Static leadin playback is polled through `Update()`.
- When the leadin source stops, the backend attaches and starts the loop sample with `AL_LOOPING` enabled.

Required invariant:

- Voice state must explicitly handle one-shot, same-sample loop, and leadin-then-loop sound shaders.

## Recovery Plan

### Phase 0: Add diagnostics before broad behavior changes

Add focused diagnostics behind existing debug cvars or a new OpenAL debug cvar:

- Log every sample that finishes `LoadResource()` with `openalBuffer == 0`, including name, format tag, channels, rate, `totalBufferSize`, context state, and load source.
- Log when the voice fallback path is entered, including sample name and format tag.
- Count OpenAL underruns, queue refills, queue restarts, failed uploads, refused compressed fallbacks, device reopen attempts, and full sound restarts.
- Check and log `alGetError()` around source queue, source detach, EFX filter routing, deferred update begin/end, and device reopen paths.
- Print the counters in `s_showPerfData` or `listSoundDecoders`.

Acceptance for this phase:

- A single SP map run can show whether cutouts correlate with `openalBuffer == 0`, AL errors, device restarts, or underruns.

Status:

- Implemented after the sample/voice recovery work so the counters describe the repaired paths. The diagnostic lines are intentionally attached to `s_showPerfData` and `listSoundDecoders`, which were already part of the validation workflow.
- The queued PCM fallback counters, active static/streaming voice split, world-channel start/update failure handling, and experimental source-state checks were removed with the reverted audio work. Keep this section as background only unless a future implementation restores those diagnostics deliberately.

### Phase 1: Make sample upload deterministic

Fix `CreateOpenALBuffer()` and `MakeDefault()` so upload failure is explicit.

Preferred direction:

- Centralize OpenAL object creation on the owned OpenAL context.
- If sample parsing happens while another context is current, claim the owned OpenAL backend context on that thread or queue a deferred upload instead of silently returning.
- After sound hardware initialization or restart, reload/upload samples in a context-owned section.

Minimum safe direction:

- Emit a warning when upload is skipped because the context is unavailable or mismatched.
- Mark the sample as needing upload and retry before playback.
- Refuse the fallback queue path for non-PCM sample formats until there is a real decode path.

Format policy:

- Ogg and RoQ already decode to PCM in memory.
- PCM WAV stays PCM.
- ADPCM should be decoded to PCM before the voice layer sees it, not only during OpenAL upload. Once decoded, its metadata should be rewritten to PCM so later code does not need historical knowledge of the source encoding.
- XMA2 should remain unsupported on this backend unless a decoder is added. It must not be submitted as PCM.
- Unsupported channel layouts should not be mislabeled as stereo; add true OpenAL Soft multichannel support deliberately if retail assets require it.

Acceptance for this phase:

- `listSamples` or new diagnostics show no ordinary PCM/Ogg/RoQ/ADPCM sample with `loaded == true` and `openalBuffer == 0` after preload/reload, except samples explicitly marked for a real streaming path.
- No compressed sample can enter `SubmitBuffer()` as fake PCM.

### Phase 2: Replace the OpenAL voice state machine

Implement a real `idSoundVoice_OpenAL` playback state.

Minimum state to track:

- Current sample.
- Current sample buffer index.
- Current sample-frame cursor.
- Whether the voice is one-shot, same-sample looping, or leadin-then-loop.
- Whether the source is using static attachment or queued buffers.
- Which OpenAL streaming buffers are currently queued.

Static path:

- For one-shot static samples, attach the OpenAL buffer and play.
- For same-sample loops, attach the buffer with `AL_LOOPING`.
- For leadin-then-loop, either queue both buffers with correct state handling or poll for leadin completion and switch to the looping sample without relying on the unused `OnBufferStart()` callback.

Queued path:

- Only use for samples whose data is known to be PCM, or for a future decode-on-demand implementation.
- On start, fill all available queue buffers from the correct offset.
- On each `Update()`, query `AL_BUFFERS_PROCESSED`, unqueue each processed buffer individually, refill that same buffer, requeue it, and advance cursor/loop state.
- Query `AL_SOURCE_STATE`; if buffers remain queued and the source is stopped, restart it and increment an underrun recovery counter.
- Return false when a non-looping source has no more queued buffers and is no longer playing.

Acceptance for this phase:

- Long ambient loops, music, VO, and leadin-then-loop shaders keep playing without intermittent dropouts.
- `Update()` is no longer a stub.
- The old `SubmitBuffer()` fallback no longer contains ad hoc OpenAL queue ownership.

### Phase 3: Fix source teardown and voice reuse

Implement `FlushSourceBuffers()` for OpenAL:

1. Stop the source if needed.
2. Query `AL_BUFFERS_QUEUED`.
3. Unqueue all queued buffers.
4. Detach static buffer state with `AL_BUFFER = AL_NONE` only after the source is stopped.
5. Clear direct and auxiliary filters if they were attached.
6. Reset local playback state.

Then use it from:

- `Stop()`
- `DestroyInternal()`
- Source reuse in `Create()`
- Sample reload/free paths before deleting OpenAL buffers

Also revisit the zombie voice model:

- OpenAL `alSourceStop()` is synchronous enough for this use case. If a zombie list remains, it should be for deliberate fade/drain behavior, not because of stale XAudio callback assumptions.

Acceptance for this phase:

- Repeated `s_restart`, `reloadSounds`, pause/unpause, map transitions, and sound-device changes do not produce AL queue/detach/delete errors.
- No OpenAL buffer is deleted while still queued on a source.

### Phase 4: Stabilize recent OpenAL features around the fixed core

After upload, queueing, and teardown are correct, keep the recent features but harden their failure behavior.

Device monitoring and reopen:

- Implemented: add/remove/default-device events are atomically accumulated, logged, coalesced behind a short settle window, and throttled behind a recovery cooldown after reopen/restart requests.
- Implemented: in-place reopen remains the first recovery path when `ALC_SOFT_reopen_device` is available; full sound restart is the fallback for failed reopen, active disconnect recovery without reopen, HRTF changes, and speaker-mode changes.
- Implemented: in-place reopen now carries requested OpenAL output-mode attributes, retries with runtime defaults if a device rejects the requested mode, and reapplies the explicit HRTF preference before recapturing active device state.
- Implemented: recovery logs state whether a settled event needed no action, used in-place reopen, or required restart.
- Implemented: `openALSimulateDefaultDeviceChange` safely exercises the system-default recovery branch by changing only captured backend bookkeeping and queuing a synthetic default-device event; native static and forced queued gameplay validation now cover that branch without changing the user's Windows default endpoint.
- Implemented: `openALSimulateDeviceDisconnect` safely exercises the settled removed-device recovery branch without unplugging hardware; real disconnect, requested-device-return, default-device, and explicit requested-device changes now share one reopen-or-restart helper so cooldown and pending event cleanup stay consistent.

HRTF and output mode:

- Keep `s_openALHRTF` and speaker output mode as restart-required backend settings.
- During reliability testing, treat them as matrix variables, not first-line suspects.

Source resampler:

- Implemented: `s_openALResampler` controls `AL_SOFT_source_resampler` when the runtime exposes it. `-2` is the default quality-auto mode, `-1` uses the runtime default, and non-negative values select an advertised runtime resampler index.
- Implemented: quality-auto prefers the highest-quality named Sinc/BSinc resampler exposed by OpenAL Soft, validates the selected index on a scratch source during startup, and applies the selected resampler to every created/reused playback source.
- Implemented: startup logs list every advertised source resampler and the selected mode, so validation can confirm whether a runtime is using its default or openQ4's quality-oriented choice.

Source radius:

- Implemented: `idSoundVoice_Base::SetInnerRadius()` is virtual, and the OpenAL voice forwards sound shader min-distance/inner-radius values to `AL_SOURCE_RADIUS` when `AL_EXT_SOURCE_RADIUS` is available.
- Implemented: the optional source-radius path logs availability at startup and disables itself for the session with a one-shot warning if a runtime rejects `AL_SOURCE_RADIUS`.

Callback buffers and future streaming:

- Implemented: `AL_SOFT_callback_buffer` is now probed by loading `alBufferCallbackSOFT`, attaching a scratch silence callback to a temporary buffer, and requiring clean AL status before the backend records callback buffers as usable.
- Deferred: do not route gameplay through callback buffers until the callback owns a no-allocation, no-engine-call cursor with clear sample lifetime protection. The current native-static path remains the quality default, and the queued PCM fallback remains the validated recovery/diagnostic streaming path for now.

Deferred updates:

- Implemented: `idSoundWorldLocal::Update()` now brackets source updates with a scoped deferred-update guard, so begin/end cannot straddle future early returns in the channel-update block.
- Implemented: if `qalDeferUpdatesSOFT()` or `qalProcessUpdatesSOFT()` errors, deferred updates are disabled for the session, `deferredErrors` is incremented, and the backend logs a one-shot warning that names the failed operation.
- Implemented: current-binary MP listen-server validation exercises active queued voices under `AL_SOFT_deferred_updates` with zero AL/ALC/deferred/EFX-filter errors.

EFX filters and occlusion:

- Keep portal occlusion and environment muffling separate.
- Implemented: if filter creation, direct low-pass routing, auxiliary send routing, or filter detach fails, disable optional EFX filter routing for the session, increment `efxFilterErrors`, clear the source's EFX sends where possible, and fall back to direct gain/occlusion instead of leaving OpenAL in an error state.
- Use `s_useOcclusion 0`, `s_useEAXReverb 0`, and `s_openALEfxDebugMode` to separate "filtered/muffled" reports from true cutouts.

Acceptance for this phase:

- Device changes are visible in logs and do not create repeated restart loops.
- HRTF/speaker setting changes restart once and restore all samples.
- Occlusion changes cannot silence unrelated voices through lingering AL errors.

## Validation Matrix

Use the project debug-loop rules: mode-specific launch tasks, real in-game/map validation, and `fs_savepath/<gameDir>/logs/openq4.log` review. Do not treat main-menu startup as enough.

Baseline cvars:

- `s_useOpenAL 1`
- `s_useEAXReverb 0`
- `s_openALHRTF 0`
- `s_numberOfSpeakers 2`
- `s_showVoices 1`
- `s_showPerfData 1`
- `s_debugHardware 1`

Required scenarios:

| Scenario | Purpose | Expected result |
|---|---|---|
| SP map with long ambience for 5 minutes | Queue starvation and loop stability | No cutouts, underrun counter zero or recovered without audible loss |
| SP combat with weapons, VO, and ambience | Voice churn and priority | VO is not stolen, short sounds do not disrupt loops |
| MP listen-server match with death/respawn/world sounds | MP voice churn in the server-local client | Gameplay audio allocates real voices, static and forced queued counters stay free of AL/ALC errors |
| MP loopback/client join | Networked client role coverage | Client remains connected in-game and OpenAL counters stay free of AL/ALC errors; use an early client launch plus extra server settle time so the server does not quit before the client finishes loading |
| Leadin-then-loop shader test | Explicit transition coverage | Leadin transitions to loop exactly once, then loops |
| `reloadSounds` in-game | Sample reload and upload ownership | Samples re-upload, no fallback-compressed-as-PCM, no AL errors |
| `s_restart` in-game | Full hardware lifecycle | No stale queued buffers, no invalid detach/delete errors |
| Pause/unpause repeatedly | Source state recovery | Paused voices resume without restarting from the wrong offset or draining queued buffers during the pause |
| `s_useCompression 1` and `0` | ADPCM/Ogg/PCM differences | No compressed data reaches PCM fallback |
| `s_useOcclusion 1` and `0` | EFX/filter separation | Occlusion only changes tone/level, never causes unrelated cutouts |
| HRTF Off/Auto/On | Recent HRTF path | One clean restart per setting change, samples reloaded |
| Speaker mode 2/6 | Output mode path | One clean restart per setting change, no repeated restart loop |
| Default device switch/hotplug | Device monitoring path | In-place reopen or one restart, no repeated churn; synthetic event bursts, requested-device reopen, synthetic default-device reopen, and synthetic active-device disconnect are covered, physical endpoint switching and requested-device-return remain required |

Suggested build and staging checks after implementation:

```powershell
tools/build/meson_setup.ps1 compile -C builddir
tools/build/meson_setup.ps1 install -C builddir --no-rebuild --skip-subprojects
git diff --check
```

## Immediate Triage Option

If a short-term build must be stabilized before the full state-machine fix lands:

1. Refuse fallback playback for non-PCM samples when `openalBuffer == 0`.
2. Add a loud warning for any sample that reaches playback with `openalBuffer == 0`.
3. Disable or gate device event monitoring/reopen with a debug cvar if logs show repeated restart churn.
4. Keep HRTF defaulted to Auto and EFX disabled during diagnosis.

This is only a containment path. The real fix is deterministic upload, valid queue pumping, and safe source teardown.

## Definition Of Done

- `idSoundVoice_OpenAL::Update()` owns voice progress and is no longer a stub.
- OpenAL streaming/queued playback uses a valid unqueue/refill/requeue loop.
- Static samples do not silently fall back to streaming because of context mismatch.
- ADPCM/XMA2 cannot be submitted to OpenAL as PCM16.
- Stop, restart, reload, and shutdown paths drain queued buffers before detach/delete.
- Separate leadin plus looping samples play reliably.
- OpenAL logs show no recurring AL errors during SP and MP in-game validation.
- Release notes are updated if the implementation change ships to users.
