Audio-dropout fix: OpenAL source-pool lifecycle (uninit aliasing, atomic-delete leak, stranded partials)
Field report: audio cutting in and out toward the end of the match. Three structural defects in the port's source-pool lifecycle: 1. SourceSet.sources[] was uninitialized heap garbage until first acquisition -- AL names are small ints, so a recycled-heap slot could alias a LIVE source owned by another sound; alIsSource() skipped generation, two sounds shared one source, and whichever released first deleted the other's mid-play. SourceSet ctor now zero-inits. 2. ReleaseSourceSet's bulk alDeleteSources(count, sources) is ATOMIC on invalid names (AL spec) -- one empty/-1 slot and NOTHING deleted; after the first pool exhaustion every partial release leaked and the pool never recovered. Now per-slot guarded delete, slots parked at 0. 3. Failed acquisitions stranded partial sets forever (dropped transients are never released by anything). ReleaseChannels() handback at both failure sites (StartRequest + dormant-resume) + a dtor backstop. Verified: 2-node MP smoke (mission + lobby-loop relaunch clean, census live/acquireFails 0/0) + 100s solo combat smoke (52 audio triggers, zero AL errors). Awaiting live playtest confirmation at scale. KB: context/wintesla-port.md audio-dropout section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cbebb0f1c1
commit
a999e5c49f
@@ -144,6 +144,27 @@ on top of it. Full detail: `docs/PROGRESS_LOG.md §5b, §8`.
|
|||||||
watcher timing encore-erratic that session. Harness kept (env-gated, additive): `[aud-tail]`
|
watcher timing encore-erratic that session. Harness kept (env-gated, additive): `[aud-tail]`
|
||||||
logging in `L4AUDLVL.cpp` (PLAY/STOP/fade) + `emitter.cpp` (state transitions), and
|
logging in `L4AUDLVL.cpp` (PLAY/STOP/fade) + `emitter.cpp` (state transitions), and
|
||||||
`BT_FIRE_PULSE="on,off"` single-shot trigger pulsing in `mech4.cpp`.
|
`BT_FIRE_PULSE="on,off"` single-shot trigger pulsing in `mech4.cpp`.
|
||||||
|
- **Audio-dropout fix (field 2026-07-23: "cutting in and out toward the end of the match")** [T2,
|
||||||
|
code-verified vs the AL spec; awaiting live playtest confirm]: three structural defects in the
|
||||||
|
port's OpenAL source-pool lifecycle (`L4AUDRND.cpp` / `L4AUDIO.cpp` / `L4AUDHDW.h`):
|
||||||
|
(1) `SourceSet.sources[]` was **uninitialized heap garbage** until first acquisition — AL names
|
||||||
|
are small ints, so a recycled-heap slot could alias a LIVE source owned by another sound;
|
||||||
|
`alIsSource()` then skipped generation, two sounds shared one source, and whichever released
|
||||||
|
first deleted the other's source mid-play (random sound deaths worsening with heap churn).
|
||||||
|
Fix: `SourceSet` ctor zero-inits (0 is never a valid name). (2) `ReleaseSourceSet` used bulk
|
||||||
|
`alDeleteSources(count, sources)`, which is **ATOMIC on invalid names** — one empty/-1 slot
|
||||||
|
(partial set, double release) and NOTHING deleted; after the first pool exhaustion every partial
|
||||||
|
release leaked and the pool never recovered. Fix: per-slot guarded delete, slots parked at 0.
|
||||||
|
(3) Failed acquisitions stranded partial sets forever — dropped transients are never released by
|
||||||
|
anything (the dtor didn't release; maintenance only touches running sources). Fix: handback via
|
||||||
|
`ReleaseChannels()` at both failure sites (StartRequest + dormant-resume) + a dtor backstop.
|
||||||
|
Context: the port's `RequestAudioChannels` has NO channel budget (the 1995 per-card budget is
|
||||||
|
the commented-out block) — it alGenSources until OpenAL Soft's ~256 cap; on failure a transient
|
||||||
|
is silently dropped (StartRequest early-return), a sustained goes dormant — which is exactly the
|
||||||
|
"cutting in and out" presentation. Priority-steal (`RequestAudioResources`) only engages at cap.
|
||||||
|
PERMANENT diagnostics: 30 s `[audio] source census: live/acquireFails` + an always-on
|
||||||
|
`[audio] ACQUIRE FAILED` line. Baseline (2-node soak): live ≈ 21 peak under fire, 0 at mission
|
||||||
|
end; exhaustion needs 7-player scale or the leak spiral, aliasing needs neither.
|
||||||
- **L4 HAL on Windows + the VS build system: DONE.** [T1]
|
- **L4 HAL on Windows + the VS build system: DONE.** [T1]
|
||||||
- **Red Planet game logic: COMPLETE & buildable** — `VTV/VTVMPPR/WEAPSYS/...`. RP is at/near playable. [T1]
|
- **Red Planet game logic: COMPLETE & buildable** — `VTV/VTVMPPR/WEAPSYS/...`. RP is at/near playable. [T1]
|
||||||
- **Multiplayer: the DOS `NETNUB` driver is already replaced by a ~3.5k-line WinSock2 TCP
|
- **Multiplayer: the DOS `NETNUB` driver is already replaced by a ~3.5k-line WinSock2 TCP
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ struct SourceSet
|
|||||||
{
|
{
|
||||||
int count;
|
int count;
|
||||||
ALuint sources[AUDIO_SOURCESET_CAPACITY];
|
ALuint sources[AUDIO_SOURCESET_CAPACITY];
|
||||||
|
|
||||||
|
// Audio-dropout fix (field 2026-07-23): sources[] was UNINITIALIZED heap
|
||||||
|
// garbage until the first acquisition. OpenAL names are small integers --
|
||||||
|
// exactly what recycled heap is full of -- so a garbage slot could alias a
|
||||||
|
// LIVE source owned by another sound; alIsSource() then skipped generation
|
||||||
|
// and two sounds shared one AL source, and whichever released first
|
||||||
|
// deleted the other's source mid-play (sounds randomly dying, worsening
|
||||||
|
// with heap churn late in a match). 0 is never a valid source name.
|
||||||
|
SourceSet() : count(0)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < AUDIO_SOURCESET_CAPACITY; i++)
|
||||||
|
sources[i] = 0;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MIDI types ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ MIDI types ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|||||||
@@ -719,6 +719,20 @@ void
|
|||||||
//
|
//
|
||||||
L4AudioSource::~L4AudioSource()
|
L4AudioSource::~L4AudioSource()
|
||||||
{
|
{
|
||||||
|
// Audio-dropout fix: backstop -- if this source dies still holding OpenAL
|
||||||
|
// sources (the stop/suspend maintenance never ran for it), hand them back
|
||||||
|
// so entity churn can't drain the pool over a long match.
|
||||||
|
if (application != NULL && application->GetAudioRenderer() != NULL)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < channelSet.count; i++)
|
||||||
|
{
|
||||||
|
if (alIsSource(channelSet.sources[i]))
|
||||||
|
{
|
||||||
|
application->GetAudioRenderer()->ReleaseSourceSet(channelSet);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -558,6 +558,12 @@ void
|
|||||||
);
|
);
|
||||||
if (!resources_available)
|
if (!resources_available)
|
||||||
{
|
{
|
||||||
|
// Audio-dropout fix: a failed acquisition can leave a PARTIAL set
|
||||||
|
// (alGenSources succeeded for some slots before the pool ran dry).
|
||||||
|
// A dropped transient never plays and nothing else ever released it,
|
||||||
|
// so the partial sources dangled forever -- once the pool exhausted
|
||||||
|
// ONCE, every drop leaked a little more and it never recovered.
|
||||||
|
audio_source->ReleaseChannels();
|
||||||
if (audio_source->GetAudioRenderType() == SustainedAudioRenderType)
|
if (audio_source->GetAudioRenderType() == SustainedAudioRenderType)
|
||||||
{
|
{
|
||||||
//
|
//
|
||||||
@@ -1024,7 +1030,12 @@ void
|
|||||||
if (!RequestAudioResources(audio_source,
|
if (!RequestAudioResources(audio_source,
|
||||||
source_result)
|
source_result)
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
// Audio-dropout fix: hand back any partial acquisition (see
|
||||||
|
// StartRequest) -- the source stays dormant and retries later.
|
||||||
|
audio_source->ReleaseChannels();
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Remove from dormant list and request list
|
// Remove from dormant list and request list
|
||||||
@@ -1445,26 +1456,28 @@ long gBTAudioAcquireFails = 0;
|
|||||||
|
|
||||||
void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
|
void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
|
||||||
{
|
{
|
||||||
|
// Audio-dropout fix: the old bulk alDeleteSources(count, sources) is
|
||||||
|
// ATOMIC per the AL spec -- ONE invalid name in the array (an empty slot
|
||||||
|
// of a partial set, or the old -1 sentinel on a double release) and
|
||||||
|
// NOTHING is deleted. After the first pool-exhaustion event every
|
||||||
|
// partial release leaked its real sources and the pool never recovered
|
||||||
|
// ("audio cutting in and out toward the end of the match"). Delete each
|
||||||
|
// valid source individually and park the slot at 0 (never a valid name).
|
||||||
extern long gBTAudioSourcesLive;
|
extern long gBTAudioSourcesLive;
|
||||||
for (int i = 0; i < sourceSet.count; i++)
|
for (int i = 0; i < sourceSet.count; i++)
|
||||||
{
|
{
|
||||||
if (alIsSource(sourceSet.sources[i]))
|
if (alIsSource(sourceSet.sources[i]))
|
||||||
--gBTAudioSourcesLive;
|
|
||||||
ALenum state;
|
|
||||||
alGetSourcei(sourceSet.sources[i], AL_SOURCE_STATE, &state);
|
|
||||||
|
|
||||||
if (state == AL_PLAYING)
|
|
||||||
{
|
{
|
||||||
alSourceStop(sourceSet.sources[i]);
|
ALint state = AL_STOPPED;
|
||||||
|
alGetSourcei(sourceSet.sources[i], AL_SOURCE_STATE, &state);
|
||||||
|
if (state == AL_PLAYING)
|
||||||
|
alSourceStop(sourceSet.sources[i]);
|
||||||
|
alDeleteSources(1, sourceSet.sources + i);
|
||||||
|
--gBTAudioSourcesLive;
|
||||||
}
|
}
|
||||||
|
sourceSet.sources[i] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
alDeleteSources(sourceSet.count, sourceSet.sources);
|
|
||||||
|
|
||||||
for (int i = 0; i < sourceSet.count; i++)
|
|
||||||
{
|
|
||||||
sourceSet.sources[i] = -1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//~~~~~~~~~~~~~~~~~~~~~~ L4AudioRenderer profile bits ~~~~~~~~~~~~~~~~~~~~~~~~~
|
//~~~~~~~~~~~~~~~~~~~~~~ L4AudioRenderer profile bits ~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|||||||
Reference in New Issue
Block a user