Audio: fix five hook/unhook concurrency + over-write bugs

Found by the mock-game capture/audio/hook stress test (toggling the audio
subsystem while a game renders):

1. Guessed-stream silence over-WRITE: hk_ReleaseBuffer zeroed num_frames *
   guessed_block bytes, but a guess can be larger than the real per-frame size
   (e.g. an 8ch device guess for a 2ch game), so the memset wrote past the real
   buffer into adjacent audio memory -> intermittent access violation in the
   game. Fix: capture but do NOT silence a guessed stream (it stays audible --
   echo); only an exact/override format, whose frame size is known, gets the
   no-echo silence.
2. VtableHook::remove nulled m_original, racing an in-flight detour into a null
   call -> keep it valid (the original function stays mapped).
3. g_ipc was a non-atomic pointer read on the hot path while unhook nulled it
   (TOCTOU) -> make it atomic, load once.
4. Stale GetBuffer/ReleaseBuffer pairing across a toggle -> epoch-stamp the
   GetBuffer and only capture in the same hooked epoch.
5. COM-object churn: re-creating the probe client every enable raced AudioSes ->
   build the probe once, keep it across toggles (only swap vtable slots);
   release on detach (shutdown_audio_hooks). Plus drain in-flight detours before
   tearing down state.

Stress test: 0 crashes in many repeated runs (was ~50%). Guessed streams now
echo (the no-echo path is reached via an exact/auto-attach format or override).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 05:21:17 +02:00
parent 549b91a9ce
commit e2562ac63c
4 changed files with 195 additions and 79 deletions

View File

@@ -74,9 +74,14 @@ and covers anything the hooked path doesn't (Vulkan, D3D9 — see Roadmap).
client's format — so they're *assumed* to match the device mix format. That's correct
for the common case (engines render stereo float, matching the endpoint, differing
only in rate). A game rendering a *different* channel count or bit depth than the
device would be mirrored with the wrong layout (garbled audio) on the hooked path —
but never an over-read/crash (a `VirtualQuery` clamp guards the copy), and the
loopback fallback is always format-correct. The Audio panel shows each stream's
device is mirrored with the wrong layout (garbled audio) on the hooked path, but never
an over-read/crash: a guessed stream is **captured but not silenced** (so it stays
audible locally — an echo), because zeroing it could over-*write* past the real buffer
(zeroing 8-channel-worth into a 2-channel buffer corrupts adjacent audio memory). Only
an **exact / override** format gets the no-echo silence (its frame size is known), so
the no-echo experience comes from an early (auto-attach) exact format or an operator
override. The loopback fallback is always format-correct. The Audio panel shows each
stream's
format provenance (*known* / *measuring* / *measured rate* / *low-confidence* /
*override*) so the assumption is visible, and (under Debug details) lets the operator
**re-measure** the rate or **override** the format when the guess is wrong. Overrides
@@ -378,6 +383,18 @@ Non-obvious things that cost time and constrain the design:
simulation is brittle. A tiny debug-only command channel (`-DCOOP_TEST_HARNESS`, file-
based) that calls the *same* code the buttons do — and replies with state — makes UI
validation deterministic and scriptable, and is compiled out of the shipped product.
- **A capture-style stress test against a frame-numbered mock game is worth a lot.** An
animated game that encodes its frame number in the pixels lets a test assert the mirror
shows a *monotonic, advancing* sequence (the bar for "no dropped / stale / out-of-order
frames"), and toggling subsystems while it renders flushes out concurrency bugs. This
one caught **five** real audio races: a `memset` over-*write* past a guessed stream's
real buffer (zeroing 8ch into a 2ch buffer corrupts adjacent audio memory → crash; the
fix: capture but don't silence a guessed stream), a null call through a vtable hook's
`original` after unhook (keep it valid), a non-atomic `g_ipc` TOCTOU, a stale
GetBuffer/ReleaseBuffer pairing across a hook toggle (epoch-stamp it), and COM-object
churn from re-creating the probe each toggle (build it once, keep it, only swap vtable
slots). **Silently silencing/zeroing a buffer whose true size you only guessed is an
over-write, not just an over-read** — clamp the read, but don't write what you can't size.
- **Capturing at `Present` decouples the mirror from DWM composition.** The hook copies
the backbuffer inside the game's `Present`, which the game issues at its true render
rate regardless of how DWM composites that *window*. So an unfocused game window can

View File

@@ -88,7 +88,11 @@ public:
VirtualProtect(&m_vtable[m_index], sizeof(void*), old_protect, &old_protect);
}
m_vtable = nullptr;
m_original = nullptr;
// Deliberately keep m_original valid: a detour already running on the audio thread
// (it doesn't hold our setup lock) may still call original() after we restore the
// slot. The original function lives in the loaded audio module, so the pointer stays
// valid; nulling it here would race that in-flight detour into a null call (a rapid
// hook/unhook crash the mock-game stress test caught). A re-install re-reads it.
m_index = 0;
}
@@ -113,7 +117,11 @@ struct CapturedFormat
// --- Global hook state -----------------------------------------------------
IpcClient* g_ipc = nullptr;
// Atomic: the audio hot path (hk_ReleaseBuffer) reads it without the setup lock, while
// remove_audio_hooks nulls it under the lock during an unhook. A plain pointer was a
// TOCTOU null-deref (check non-null, then it's nulled, then the call) -- a rapid
// hook/unhook crash the mock-game stress test caught. Load it once and use that.
std::atomic<IpcClient*> g_ipc{nullptr};
// One ring per tracked stream (index = the stream's debug slot). Stream 0 is the
// primary; the host creates a ring per stream and mixes them.
std::atomic<AudioRingHeader*> g_rings[kMaxAudioStreams]{};
@@ -126,6 +134,29 @@ VtableHook g_vh_getservice;
VtableHook g_vh_getbuffer;
VtableHook g_vh_releasebuffer;
bool g_audioclient_hooked = false;
// Bumped on every detour install/remove. hk_GetBuffer stamps the current epoch into a
// thread-local; hk_ReleaseBuffer captures only if the epoch still matches -- so a
// GetBuffer/ReleaseBuffer pair that straddles a hook toggle (the stashed buffer pointer is
// then stale) is skipped instead of memset-ing a freed buffer (a rapid hook/unhook crash
// the mock-game stress test caught).
std::atomic<std::uint32_t> g_hook_epoch{0};
// Number of detours (hk_GetBuffer/hk_ReleaseBuffer) currently executing on the audio
// thread. remove_audio_hooks restores the vtable slots (so no NEW detour starts) and then
// waits for this to drain to 0 before tearing down shared state -- guaranteeing no detour
// is mid-flight when state is cleared. The classic safe-unhook race; the alternative was an
// intermittent access violation in the game during a hook/unhook (the stress test caught it).
std::atomic<int> g_detours_active{0};
struct DetourGuard
{
DetourGuard()
{
g_detours_active.fetch_add(1, std::memory_order_acq_rel);
}
~DetourGuard()
{
g_detours_active.fetch_sub(1, std::memory_order_acq_rel);
}
};
// Registry ids for the hook list.
int g_id_activate = -1;
@@ -139,8 +170,13 @@ int g_id_releasebuffer = -1;
// *proactively* — so render clients the game created before we injected (the
// common case: we attach to a game that's already playing) are still caught.
// g_self_render is excluded from capture (it's silent and never rendered).
IMMDevice* g_self_device = nullptr; // kept alive too, so the Activate hook can be re-installed
IAudioClient* g_self_client = nullptr;
std::atomic<IAudioRenderClient*> g_self_render{nullptr};
// The probe objects above are built ONCE and kept for the DLL's lifetime; an audio
// enable/disable toggle then only swaps vtable slots, never creates/destroys COM objects.
// Rapid create/destroy raced AudioSes and crashed the game (the mock-game stress test
// caught this). Released only on detach (shutdown_audio_hooks).
// Device mix format, captured from our probe client. Used as the assumed format
// for a stream we discover on the hot path (we never saw its Initialize, so we
@@ -208,6 +244,7 @@ std::uint32_t g_last_op_seq[kMaxAudioStreams] = {};
thread_local IAudioRenderClient* t_gb_client = nullptr;
thread_local BYTE* t_gb_data = nullptr;
thread_local UINT32 t_gb_frames = 0;
thread_local std::uint32_t t_gb_epoch = 0; // hook epoch at the GetBuffer (see g_hook_epoch)
CapturedFormat capture_format(const WAVEFORMATEX* wfx)
{
@@ -263,6 +300,7 @@ std::uint32_t readable_bytes(const void* ptr, std::uint32_t want)
HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data)
{
DetourGuard guard; // counts this detour as in-flight (drained before an unhook tears down)
hook_note_call(g_id_getbuffer);
const HRESULT hr = g_vh_getbuffer.original<GetBufferFn>()(self, num_frames, data);
if (SUCCEEDED(hr) && data != nullptr)
@@ -270,12 +308,14 @@ HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_fram
t_gb_client = self;
t_gb_data = *data;
t_gb_frames = num_frames;
t_gb_epoch = g_hook_epoch.load(std::memory_order_acquire);
}
return hr;
}
HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_frames, DWORD flags)
{
DetourGuard guard; // counts this detour as in-flight (drained before an unhook tears down)
hook_note_call(g_id_releasebuffer);
// A render client we've never seen actively rendering is almost certainly one
// the game created before we injected; adopt it now (the first becomes the
@@ -296,9 +336,9 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_
}
const std::uint64_t total =
g_streams[i].frames.fetch_add(num_frames, std::memory_order_relaxed) + num_frames;
if (g_ipc != nullptr)
if (IpcClient* ipc = g_ipc.load(std::memory_order_acquire)) // load once (unhook may null it)
{
g_ipc->note_audio_frames(i, total);
ipc->note_audio_frames(i, total);
}
if (num_frames > 0 && (flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0)
@@ -309,14 +349,16 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_
// mis-rate (and don't build a backlog while measuring; the game stays audible).
if (ring != nullptr && ring->capture_enabled.load(std::memory_order_relaxed) != 0 &&
audio_ring_format_ready(*ring) && t_gb_client == self && t_gb_data != nullptr &&
t_gb_frames == num_frames)
t_gb_frames == num_frames &&
t_gb_epoch == g_hook_epoch.load(std::memory_order_acquire)) // same hooked epoch as the GetBuffer
{
const bool guessed = g_streams[i].assumed_format.load(std::memory_order_relaxed) != 0;
const std::uint32_t block = g_streams[i].block_align.load(std::memory_order_relaxed);
std::uint32_t bytes = num_frames * block;
// If this stream's channels/bits were guessed (pre-existing client), the block
// may be too large for the real buffer; clamp to what's actually readable so the
// copy can never over-read the game's buffer (no-op when the guess is right).
if (g_streams[i].assumed_format.load(std::memory_order_relaxed) != 0)
// A guessed (pre-existing client) stream's block may be larger than the real
// per-frame size, so clamp the COPY to what's actually readable -- never over-read
// the game's buffer (no-op when the guess is right).
if (guessed)
{
bytes = readable_bytes(t_gb_data, bytes);
}
@@ -325,10 +367,19 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_
// silent — degrades to today's echo, never to silence.
if (block != 0 && audio_ring_push(*ring, t_gb_data, bytes, num_frames))
{
std::memset(t_gb_data, 0, bytes); // belt-and-suspenders vs a driver ignoring SILENT
g_frames_captured.fetch_add(num_frames, std::memory_order_relaxed);
return g_vh_releasebuffer.original<ReleaseBufferFn>()(
self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT);
// Only silence (zero the buffer) for an EXACT/override format, where `block`
// is the real frame size so the memset stays in-bounds. For a guessed format
// the block can exceed the real buffer, so zeroing it would over-WRITE into
// adjacent audio memory (an intermittent crash the stress test caught) -- so we
// capture but leave the game audible (echo). The no-echo path is reached via an
// exact format (auto-attach early) or an operator override.
if (!guessed)
{
std::memset(t_gb_data, 0, bytes);
return g_vh_releasebuffer.original<ReleaseBufferFn>()(
self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT);
}
}
}
}
@@ -342,7 +393,8 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_
void publish_stream_info_locked(std::uint32_t slot, const CapturedFormat& cf, std::uint32_t state,
std::uint64_t frames)
{
if (g_ipc == nullptr)
IpcClient* ipc = g_ipc.load(std::memory_order_acquire);
if (ipc == nullptr)
{
return;
}
@@ -354,7 +406,7 @@ void publish_stream_info_locked(std::uint32_t slot, const CapturedFormat& cf, st
info.format_tag = cf.tag;
info.frames_rendered = frames;
info.format_state = state;
g_ipc->publish_audio_stream(slot, info);
ipc->publish_audio_stream(slot, info);
}
// Publish stream `slot`'s format to its ring, first deciding a guessed sample rate by
@@ -476,9 +528,9 @@ void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat&
}
const std::uint32_t seen = g_streams_seen.fetch_add(1, std::memory_order_relaxed) + 1;
if (g_ipc != nullptr)
if (IpcClient* ipc = g_ipc.load(std::memory_order_acquire))
{
g_ipc->set_audio_streams_seen(seen);
ipc->set_audio_streams_seen(seen);
}
logf("register_render_client: rc=%p seen=%u fmt=%uHz/%uch/%ubit tag=%u block=%u", rc, seen, cf.rate,
cf.channels, cf.bits, cf.tag, cf.block_align);
@@ -648,25 +700,18 @@ HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ct
} // namespace
bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
namespace
{
std::scoped_lock lock(g_setup_mutex);
g_ipc = &ipc;
g_rings[0].store(ring, std::memory_order_release);
if (g_vh_activate)
// Build the probe COM objects (enumerator -> device -> client -> render) and capture the
// device mix format. Created ONCE and kept for the DLL's lifetime: every instance of a
// coclass shares one vtable, so a toggle then only re-swaps vtable slots on these kept
// objects -- no COM create/destroy churn (which raced AudioSes). Caller holds g_setup_mutex.
bool build_probe_locked()
{
if (g_self_device != nullptr)
{
return true; // anchor already installed
return true; // already built
}
g_id_activate = hook_register("IMMDevice::Activate", HookSubsys_Audio);
g_id_initialize = hook_register("IAudioClient::Initialize", HookSubsys_Audio);
g_id_getservice = hook_register("IAudioClient::GetService", HookSubsys_Audio);
g_id_getbuffer = hook_register("IAudioRenderClient::GetBuffer", HookSubsys_Audio);
g_id_releasebuffer = hook_register("IAudioRenderClient::ReleaseBuffer", HookSubsys_Audio);
// Anchor: instantiate our own enumerator + default render device purely to
// read the shared IMMDevice vtable and hook Activate. Every IMMDevice in the
// process shares this vtable, so the game's Activate calls are intercepted.
IMMDeviceEnumerator* enumerator = nullptr;
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator))))
@@ -674,24 +719,18 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
return false;
}
IMMDevice* device = nullptr;
HRESULT hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device);
const HRESULT hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device);
enumerator->Release(); // only needed to reach the device
if (FAILED(hr) || device == nullptr)
{
enumerator->Release();
return false;
}
g_self_device = device; // kept alive (Activate hook re-installs from its vtable)
// Build our *own* client + render client first (no hook is live yet). We attach
// to a game that's usually already playing, so its IAudioClient /
// IAudioRenderClient predate us and we'll never see their Activate/GetService;
// but every instance of each coclass shares one vtable, so hooking the slots on
// *our* objects' vtables patches the shared vtables and intercepts the game's
// pre-existing objects too.
g_self_client = nullptr;
IAudioRenderClient* self_render = nullptr;
hr = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&g_self_client));
if (SUCCEEDED(hr) && g_self_client != nullptr)
HRESULT ah = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&g_self_client));
if (SUCCEEDED(ah) && g_self_client != nullptr)
{
WAVEFORMATEX* mix = nullptr;
if (SUCCEEDED(g_self_client->GetMixFormat(&mix)) && mix != nullptr)
@@ -705,7 +744,7 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
ih = g_self_client->GetService(__uuidof(IAudioRenderClient),
reinterpret_cast<void**>(&self_render));
}
logf("install_audio_hooks: probe client init=0x%08lX render=%p mix=%uHz/%uch/%ubit tag=%u",
logf("build_probe: client init=0x%08lX render=%p mix=%uHz/%uch/%ubit tag=%u",
static_cast<unsigned long>(ih), self_render, g_mix_format.rate, g_mix_format.channels,
g_mix_format.bits, g_mix_format.tag);
CoTaskMemFree(mix);
@@ -713,44 +752,70 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
}
else
{
logf("install_audio_hooks: probe Activate(IAudioClient) failed hr=0x%08lX",
static_cast<unsigned long>(hr));
logf("build_probe: Activate(IAudioClient) failed hr=0x%08lX", static_cast<unsigned long>(ah));
}
// Now install every hook by swapping vtable slots. Anchor Activate (idx 3)
// catches streams created after us; the inner hooks catch every render client
// on the shared vtables.
g_vh_activate.install(device, kIdx_IMMDevice_Activate, reinterpret_cast<void*>(&hk_Activate));
if (self_render != nullptr)
{
g_self_render.store(self_render, std::memory_order_release);
}
return true; // device built; render may be null on odd setups (Activate hook still works)
}
// Swap the detours into the shared vtables using the kept probe objects. Caller holds
// g_setup_mutex.
void install_detours_locked()
{
if (g_self_device == nullptr)
{
return;
}
g_hook_epoch.fetch_add(1, std::memory_order_release); // new epoch: invalidate any straddling GetBuffer
g_vh_activate.install(g_self_device, kIdx_IMMDevice_Activate, reinterpret_cast<void*>(&hk_Activate));
if (IAudioRenderClient* sr = g_self_render.load(std::memory_order_acquire))
{
g_vh_initialize.install(g_self_client, kIdx_IAudioClient_Initialize,
reinterpret_cast<void*>(&hk_Initialize));
g_vh_getservice.install(g_self_client, kIdx_IAudioClient_GetService,
reinterpret_cast<void*>(&hk_GetService));
g_vh_getbuffer.install(self_render, kIdx_IAudioRenderClient_GetBuffer,
reinterpret_cast<void*>(&hk_GetBuffer));
g_vh_releasebuffer.install(self_render, kIdx_IAudioRenderClient_ReleaseBuffer,
g_vh_getbuffer.install(sr, kIdx_IAudioRenderClient_GetBuffer, reinterpret_cast<void*>(&hk_GetBuffer));
g_vh_releasebuffer.install(sr, kIdx_IAudioRenderClient_ReleaseBuffer,
reinterpret_cast<void*>(&hk_ReleaseBuffer));
g_audioclient_hooked = (static_cast<bool>(g_vh_initialize) && static_cast<bool>(g_vh_getservice));
// Keep g_self_client + self_render alive (held in globals) so the vtables
// stay valid; they're released in remove_audio_hooks.
}
hook_set_installed(g_id_activate, static_cast<bool>(g_vh_activate));
hook_set_installed(g_id_initialize, static_cast<bool>(g_vh_initialize));
hook_set_installed(g_id_getservice, static_cast<bool>(g_vh_getservice));
hook_set_installed(g_id_getbuffer, static_cast<bool>(g_vh_getbuffer));
hook_set_installed(g_id_releasebuffer, static_cast<bool>(g_vh_releasebuffer));
logf("install_audio_hooks: activate=%d init=%d getsvc=%d getbuf=%d relbuf=%d (device=%p)",
logf("install_detours: activate=%d init=%d getsvc=%d getbuf=%d relbuf=%d",
static_cast<bool>(g_vh_activate) ? 1 : 0, static_cast<bool>(g_vh_initialize) ? 1 : 0,
static_cast<bool>(g_vh_getservice) ? 1 : 0, static_cast<bool>(g_vh_getbuffer) ? 1 : 0,
static_cast<bool>(g_vh_releasebuffer) ? 1 : 0, device);
static_cast<bool>(g_vh_releasebuffer) ? 1 : 0);
}
} // namespace
device->Release(); // vtable lives in the (still-loaded) audio COM module
enumerator->Release();
bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
{
std::scoped_lock lock(g_setup_mutex);
g_ipc.store(&ipc, std::memory_order_release);
g_rings[0].store(ring, std::memory_order_release);
if (g_vh_activate)
{
return true; // detours already installed
}
if (g_id_activate < 0) // register the hook-list ids once
{
g_id_activate = hook_register("IMMDevice::Activate", HookSubsys_Audio);
g_id_initialize = hook_register("IAudioClient::Initialize", HookSubsys_Audio);
g_id_getservice = hook_register("IAudioClient::GetService", HookSubsys_Audio);
g_id_getbuffer = hook_register("IAudioRenderClient::GetBuffer", HookSubsys_Audio);
g_id_releasebuffer = hook_register("IAudioRenderClient::ReleaseBuffer", HookSubsys_Audio);
}
if (!build_probe_locked()) // builds once; a re-enable reuses the kept probe
{
return false;
}
install_detours_locked();
return static_cast<bool>(g_vh_activate);
}
@@ -799,6 +864,11 @@ void set_audio_ring(unsigned index, AudioRingHeader* ring)
void remove_audio_hooks()
{
std::scoped_lock lock(g_setup_mutex);
// Restore the vtable slots (so the game's calls go direct again), but KEEP the probe
// objects alive -- a re-enable just re-swaps the slots, no COM churn. The probe is only
// released on detach (shutdown_audio_hooks). m_original stays valid (see VtableHook), so
// an in-flight detour on the audio thread completes safely after the restore.
g_hook_epoch.fetch_add(1, std::memory_order_release); // new epoch: a later capture won't trust a pre-toggle GetBuffer
g_vh_releasebuffer.remove();
g_vh_getbuffer.remove();
g_vh_getservice.remove();
@@ -811,18 +881,20 @@ void remove_audio_hooks()
hook_set_installed(g_id_getbuffer, false);
hook_set_installed(g_id_releasebuffer, false);
// Hooks are gone; safe to drop the probe objects that held the vtables.
if (IAudioRenderClient* sr = g_self_render.exchange(nullptr, std::memory_order_acq_rel))
// The slots are restored above, so no NEW detour will start. Drain any detour still
// in-flight on the audio thread before clearing the shared state it reads (an initial
// sleep covers a caller that read the old slot but hasn't entered the guard yet). Bounded
// so a wedged audio thread can't hang us. Detours are microseconds, so this is ~1-2 ms.
for (int spins = 0; spins < 200; ++spins)
{
sr->Release();
Sleep(1);
if (g_detours_active.load(std::memory_order_acquire) == 0)
{
break;
}
}
if (g_self_client != nullptr)
{
g_self_client->Release();
g_self_client = nullptr;
}
g_have_mix_format.store(0, std::memory_order_relaxed);
// Clear per-stream tracking (the game's streams re-register lazily on a re-enable).
g_registered = 0;
g_streams_seen.store(0, std::memory_order_relaxed);
g_frames_captured.store(0, std::memory_order_relaxed);
@@ -840,7 +912,29 @@ void remove_audio_hooks()
g_rings[i].store(nullptr, std::memory_order_release);
}
g_client_formats.clear();
g_ipc = nullptr;
g_ipc.store(nullptr, std::memory_order_release);
}
void shutdown_audio_hooks()
{
remove_audio_hooks(); // restore vtables + clear state (takes the lock)
// Now safe to release the kept probe objects (called only on DLL detach).
std::scoped_lock lock(g_setup_mutex);
if (IAudioRenderClient* sr = g_self_render.exchange(nullptr, std::memory_order_acq_rel))
{
sr->Release();
}
if (g_self_client != nullptr)
{
g_self_client->Release();
g_self_client = nullptr;
}
if (g_self_device != nullptr)
{
g_self_device->Release();
g_self_device = nullptr;
}
g_have_mix_format.store(0, std::memory_order_relaxed);
}
std::uint64_t audio_frames_captured()

View File

@@ -34,9 +34,14 @@ void set_audio_ring(unsigned index, AudioRingHeader* ring);
// earlier. No-op for rings that have no stream yet.
void republish_audio_format();
// Removes all installed render hooks (best effort; used on DLL detach).
// Removes the render-hook detours for an audio-subsystem toggle-off, but keeps the probe
// COM objects alive so a re-enable only re-swaps vtable slots (no COM churn -> no AudioSes
// race). Per-stream tracking is cleared (re-registers on re-enable).
void remove_audio_hooks();
// Full teardown for DLL detach: removes the detours AND releases the kept probe objects.
void shutdown_audio_hooks();
// --- Diagnostics (used by the self-test) -----------------------------------
// Cumulative frames the primary path copied to the ring and silenced locally.

View File

@@ -252,7 +252,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved)
coop::hook::set_log_ring(nullptr);
coop::hook::remove_focus_spoof();
coop::hook::remove_xinput_hooks();
coop::hook::remove_audio_hooks();
coop::hook::shutdown_audio_hooks(); // detach: remove detours + release the kept probe
coop::hook::remove_present_hooks();
coop::hook::remove_opengl_hooks();
coop::hook::remove_mkb_hooks();