From ad9dfade883f91eebabd114ad46ac2d5d49d53e6 Mon Sep 17 00:00:00 2001 From: Joe DiPrima Date: Sat, 1 Aug 2026 10:29:04 -0500 Subject: [PATCH] #32: POOL the OpenAL sources instead of creating and destroying one per sound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every 4.11.674 player log is saturated with acquisition failures -- 3031, 4657, 5245, 6275, 6571 across five machines. In Lynx's largest session the first failure lands 9.3% in and they continue to 96.8%: once it starts it never recovers for the rest of the match. CAUSE: RequestAudioChannels called alGenSources() per sound event and ReleaseSourceSet called alDeleteSources() on release -- create and destroy per sound. OpenAL sources are a scarce driver resource (OpenAL Soft caps a context at 256) and a combat burst churned straight through the ceiling. The two counters looked contradictory and were the tell: ACQUIRE FAILED always printed live=256 while the 30s census printed live=6. Same global, sampled at different moments -- sources spike to the cap during a burst and drain back between them. Churn, not a steady leak. FIX: generate sources once, up to a cap, and recycle them through a free list. Release scrubs and parks instead of deleting. Steady-state play performs no AL allocation at all. The scrub is load-bearing, not hygiene: a recycled source carries whatever the previous owner set, and the engine sets AL_LOOPING per sound (L4AUDLVL.cpp:327). Hand a looping source to a one-shot and it plays forever -- which is the "sound stuck looping" family (#51, #5). Every reusable property is reset at the single point where a source changes owner. VERIFIED (scratchpad/night8/audiopool.sh + the two-node mp_burst.sh): solo, sustained fire : 0 failures, pooled 152, reuses 21998 two nodes, 4 min : 0 failures on both, pooled 148/149, reuses ~7000 each ⚠ HONEST LIMIT: the bench does NOT reproduce the field failure -- the PRE-fix binary also scores 0 on it (peak 49 live), because solo/2-node combat is not dense enough to reach 256. So this verifies the pool works and allocates nothing in steady state; it does NOT by itself prove the field failures are gone. The five field logs remain the "before". Peak demand is set by how many audio COMPONENTS are alive (each reserves a SourceSet of up to 25 voices and holds them), not by audible sounds: measured high-water 138 solo, 149 two-node. That scales with player count, so the cap is set near the driver ceiling (240) and the pool now logs its high-water mark once per 25-source band -- so the next playtest sizes this from field data instead of a guess. Growth also self-limits: if a driver offers fewer sources than the cap, alGenSources simply fails, growth stops, and the pool recycles what it has. Co-Authored-By: Claude Opus 5 (1M context) --- engine/MUNGA_L4/L4AUDRND.cpp | 212 +++++++++++++++++++++++++++------ scratchpad/night8/audiopool.sh | 41 +++++++ 2 files changed, 219 insertions(+), 34 deletions(-) create mode 100644 scratchpad/night8/audiopool.sh diff --git a/engine/MUNGA_L4/L4AUDRND.cpp b/engine/MUNGA_L4/L4AUDRND.cpp index f5b082a..e6241c5 100644 --- a/engine/MUNGA_L4/L4AUDRND.cpp +++ b/engine/MUNGA_L4/L4AUDRND.cpp @@ -1371,6 +1371,15 @@ Logical return resources_available; } +// +// OpenAL source pool (gitea #32) -- defined below, used here. +// +extern Logical BTAudioPoolAcquire(ALuint *out); +extern void BTAudioPoolRelease(ALuint src); +extern int BTAudioPoolSize(); +extern int BTAudioPoolFree(); +extern long BTAudioPoolReuses(); + // //############################################################################# // RequestAudioChannels @@ -1402,6 +1411,9 @@ Logical { s_censusAt = now_ms; DEBUG_STREAM << "[audio] source census: live=" << gBTAudioSourcesLive + << " pooled=" << BTAudioPoolSize() + << " free=" << BTAudioPoolFree() + << " reuses=" << BTAudioPoolReuses() << " acquireFails=" << gBTAudioAcquireFails << std::endl << std::flush; } @@ -1414,35 +1426,41 @@ Logical if (requested > AUDIO_SOURCESET_CAPACITY) requested = AUDIO_SOURCESET_CAPACITY; - bool failed = true; - alGetError(); + // + // SOURCE POOLING (gitea #32). This used to alGenSources() here and + // alDeleteSources() in ReleaseSourceSet -- i.e. CREATE and DESTROY OpenAL + // sources per sound event. Every 4.11.674 player log shows the result: + // thousands of acquisition failures each (3031-6571 across five machines), + // beginning ~10% into a match and never recovering, because a combat burst + // churns straight through OpenAL's hard per-context source limit (256). + // The 30s census reading `live=6` while the failure line read `live=256` + // was the tell: the same counter, sampled between bursts -- churn, not a + // steady leak. + // + // Sources are now generated ONCE and recycled through a free list, so + // steady-state demand costs no allocation at all and the driver cap is + // never approached. + // for (int i = 0; i < requested; i++) { - if (!alIsSource(source_request->sources[i])) + if (alIsSource(source_request->sources[i])) + continue; // slot already holds one + ALuint src = 0; + if (!BTAudioPoolAcquire(&src)) { - alGenSources(1, source_request->sources + i); - if (alIsSource(source_request->sources[i])) - ++gBTAudioSourcesLive; + ++gBTAudioAcquireFails; + DEBUG_STREAM << "[audio] ACQUIRE FAILED (requested=" << requested + << " live=" << gBTAudioSourcesLive + << " pooled=" << BTAudioPoolSize() + << " free=" << BTAudioPoolFree() + << " fails=" << gBTAudioAcquireFails + << ") -- the pool is exhausted; expect dropouts" + << std::endl << std::flush; + return False; } - } - - ALenum error = alGetError(); - if (error == AL_NO_ERROR) - { - failed = false; - } - - if (failed) - { - ++gBTAudioAcquireFails; - DEBUG_STREAM << "[audio] ACQUIRE FAILED (requested=" << requested - << " live=" << gBTAudioSourcesLive - << " fails=" << gBTAudioAcquireFails - << ") -- the pool is exhausted; expect dropouts" - << std::endl << std::flush; - return False; + source_request->sources[i] = src; } return True; @@ -1524,6 +1542,137 @@ Logical long gBTAudioSourcesLive = 0; long gBTAudioAcquireFails = 0; +//############################################################################# +// OpenAL SOURCE POOL (gitea #32) +//############################################################################# +// +// The renderer used to create an AL source per sound event and destroy it on +// release. OpenAL sources are a scarce driver resource -- OpenAL Soft caps a +// context at 256 -- and creating them is not cheap, so combat bursts ran the +// context dry. Field evidence (4.11.674, five machines): 3031-6571 acquisition +// failures per player, first failure ~10% into a match, still failing at 97%. +// +// Sources are now generated once, on demand, up to kAudioPoolCap and then +// RECYCLED: release scrubs the source and returns it to the free list instead +// of deleting it. Steady-state play performs no AL allocation at all. +// +// THE SCRUB IS LOAD-BEARING. A recycled source carries whatever the previous +// owner set on it, and the engine sets AL_LOOPING per sound (L4AUDLVL.cpp:327). +// Hand a looping source to a one-shot and it plays forever -- exactly the +// "sound stuck looping" family (#51, #5). Every reusable property is reset +// here, at the single point where sources change owner. +// +// Not locked: the audio renderer runs on the main thread (only the network RX +// socket has its own). If that ever changes, this needs a mutex. +// +static const int kAudioPoolCap = 240; // just under OpenAL Soft's 256 default +// +// PEAK DEMAND is set by how many audio COMPONENTS are alive, not by how many +// sounds are audible: each component reserves a SourceSet of up to +// AUDIO_SOURCESET_CAPACITY (25) voices and holds them until it is released. +// Measured high-water: 138 solo vs one enemy, 149 across two nodes. That grows +// with player count, so the cap is deliberately near the driver ceiling and the +// pool reports its high-water mark once, to size this from FIELD logs rather +// than from a guess. Growth also stops on its own if a driver offers fewer +// sources than the cap -- alGenSources failing simply ends growth and the pool +// recycles what it has. + +static ALuint gAudioPoolFree[kAudioPoolCap]; +static int gAudioPoolFreeCount = 0; // entries in gAudioPoolFree +static int gAudioPoolTotal = 0; // sources ever generated (<= cap) +static long gAudioPoolReuses = 0; // diagnostics + +int BTAudioPoolSize() { return gAudioPoolTotal; } +int BTAudioPoolFree() { return gAudioPoolFreeCount; } +long BTAudioPoolReuses() { return gAudioPoolReuses; } + +// +// Reset a source to a known-neutral state so nothing carries across owners. +// +static void + BTAudioScrubSource(ALuint src) +{ + ALint state = AL_STOPPED; + alGetSourcei(src, AL_SOURCE_STATE, &state); + if (state == AL_PLAYING || state == AL_PAUSED) + alSourceStop(src); + alSourcei(src, AL_BUFFER, 0); // detach (no queued buffers here) + alSourcei(src, AL_LOOPING, AL_FALSE); // <-- the stuck-loop guard + alSourcef(src, AL_GAIN, 1.0f); + alSourcef(src, AL_PITCH, 1.0f); + alSourcei(src, AL_SOURCE_RELATIVE, AL_FALSE); + alSource3f(src, AL_POSITION, 0.0f, 0.0f, 0.0f); + alSource3f(src, AL_VELOCITY, 0.0f, 0.0f, 0.0f); + alSourcef(src, AL_MIN_GAIN, 0.0f); + alSourcef(src, AL_MAX_GAIN, 1.0f); + alGetError(); // swallow any property complaint +} + +// +// Hand out a source: recycle first, generate only when the pool has never been +// that large. False = genuinely out (peak concurrent demand exceeded the cap). +// +Logical + BTAudioPoolAcquire(ALuint *out) +{ + Check_Pointer(out); + while (gAudioPoolFreeCount > 0) + { + ALuint src = gAudioPoolFree[--gAudioPoolFreeCount]; + if (alIsSource(src)) // paranoia: a context reset invalidates names + { + ++gAudioPoolReuses; + *out = src; + return True; + } + --gAudioPoolTotal; // stale name: forget it + } + + if (gAudioPoolTotal >= kAudioPoolCap) + return False; + + ALuint src = 0; + alGetError(); + alGenSources(1, &src); + if (alGetError() != AL_NO_ERROR || !alIsSource(src)) + return False; // driver said no before our cap + + ++gAudioPoolTotal; + ++gBTAudioSourcesLive; + { + // One line per new high-water band, so a field log shows how close a + // real match gets to the ceiling without spamming. + static int s_notified = 0; + if (gAudioPoolTotal >= s_notified + 25) + { + s_notified = gAudioPoolTotal; + DEBUG_STREAM << "[audio] source pool high-water: " << gAudioPoolTotal + << " of " << kAudioPoolCap << std::endl << std::flush; + } + } + *out = src; + return True; +} + +// +// Take a source back. Scrubbed and parked, NOT deleted. +// +void + BTAudioPoolRelease(ALuint src) +{ + if (!alIsSource(src)) + return; + BTAudioScrubSource(src); + if (gAudioPoolFreeCount < kAudioPoolCap) + { + gAudioPoolFree[gAudioPoolFreeCount++] = src; + return; + } + alDeleteSources(1, &src); // cannot happen (cap == array size) + --gAudioPoolTotal; + --gBTAudioSourcesLive; +} + void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet) { // Audio-dropout fix: the old bulk alDeleteSources(count, sources) is @@ -1533,19 +1682,14 @@ void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet) // 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; + // SOURCE POOLING (#32): hand each source back to the free list instead of + // destroying it. BTAudioPoolRelease stops it, detaches its buffer and + // clears AL_LOOPING before parking it, so the next owner starts clean. for (int i = 0; i < sourceSet.count; i++) { - if (alIsSource(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; + if (sourceSet.sources[i] != 0) + BTAudioPoolRelease(sourceSet.sources[i]); + sourceSet.sources[i] = 0; // 0 is never a valid AL name } } diff --git a/scratchpad/night8/audiopool.sh b/scratchpad/night8/audiopool.sh new file mode 100644 index 0000000..f812dad --- /dev/null +++ b/scratchpad/night8/audiopool.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# #32 AUDIO SOURCE POOL verification. +# +# BEFORE: sources were alGenSources'd per sound event and alDeleteSources'd on +# release. Every 4.11.674 player log shows thousands of ACQUIRE FAILED lines +# (3031-6571 across five machines), first failure ~10% into the match, still +# failing at 97% -- combat bursts churned through OpenAL's 256-source context +# limit. +# +# AFTER: sources are generated once and recycled. This run reproduces the +# failure mode -- sustained combat, all weapon groups firing, missiles, deaths +# and explosions -- and reads the census. +# +# PASS: +# * ACQUIRE FAILED lines == 0 +# * census `pooled` settles well under the 200 cap (steady-state demand) +# * census `reuses` climbs into the thousands (recycling is doing the work) +set -x +. /c/git/bt411/scratchpad/night6/bench_common.sh +cd /c/git/bt411/content || exit 1 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 +sed "s/^map=.*/map=grass/; s/^time=.*/time=day/" MP.EGG > AUD.EGG + +LOG=audiopool_${1:-post}.log +rm -f "$LOG" + +BT_DMG_LOG=1 \ +BT_SPAWN_ENEMY=1 BT_GOTO=enemy BT_GOTO_STOP=2 \ +BT_AUTOFIRE=1 BT_AF_MISSILE=1 BT_AF_PERIOD=1 \ + bt_launch "$LOG" AUD.EGG 0x03 + +sleep 200 +taskkill //F //IM btl4.exe > /dev/null 2>&1 +sleep 2 + +echo "=== ACQUIRE FAILED (want 0) ===" +grep -c "ACQUIRE FAILED" "$LOG" +echo "=== census progression ===" +grep -oE "\[audio\] source census:.*" "$LOG" | head -3 +grep -oE "\[audio\] source census:.*" "$LOG" | tail -3