From e2562ac63c00f7e6a9b080d97f6885d51855f0b3 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Mon, 22 Jun 2026 05:21:17 +0200 Subject: [PATCH] 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 --- README.md | 23 +++- hook/src/audio_hook.cpp | 242 ++++++++++++++++++++++++++++------------ hook/src/audio_hook.hpp | 7 +- hook/src/dllmain.cpp | 2 +- 4 files changed, 195 insertions(+), 79 deletions(-) diff --git a/README.md b/README.md index 06e17a3..84c0452 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/hook/src/audio_hook.cpp b/hook/src/audio_hook.cpp index f59d840..17e3d29 100644 --- a/hook/src/audio_hook.cpp +++ b/hook/src/audio_hook.cpp @@ -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 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 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 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 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 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()(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()( - 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()( + 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(&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(&g_self_client)); - if (SUCCEEDED(hr) && g_self_client != nullptr) + HRESULT ah = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, + reinterpret_cast(&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(&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(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(hr)); + logf("build_probe: Activate(IAudioClient) failed hr=0x%08lX", static_cast(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(&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(&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(&hk_Initialize)); g_vh_getservice.install(g_self_client, kIdx_IAudioClient_GetService, reinterpret_cast(&hk_GetService)); - g_vh_getbuffer.install(self_render, kIdx_IAudioRenderClient_GetBuffer, - reinterpret_cast(&hk_GetBuffer)); - g_vh_releasebuffer.install(self_render, kIdx_IAudioRenderClient_ReleaseBuffer, + g_vh_getbuffer.install(sr, kIdx_IAudioRenderClient_GetBuffer, reinterpret_cast(&hk_GetBuffer)); + g_vh_releasebuffer.install(sr, kIdx_IAudioRenderClient_ReleaseBuffer, reinterpret_cast(&hk_ReleaseBuffer)); g_audioclient_hooked = (static_cast(g_vh_initialize) && static_cast(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(g_vh_activate)); hook_set_installed(g_id_initialize, static_cast(g_vh_initialize)); hook_set_installed(g_id_getservice, static_cast(g_vh_getservice)); hook_set_installed(g_id_getbuffer, static_cast(g_vh_getbuffer)); hook_set_installed(g_id_releasebuffer, static_cast(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(g_vh_activate) ? 1 : 0, static_cast(g_vh_initialize) ? 1 : 0, static_cast(g_vh_getservice) ? 1 : 0, static_cast(g_vh_getbuffer) ? 1 : 0, - static_cast(g_vh_releasebuffer) ? 1 : 0, device); + static_cast(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(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() diff --git a/hook/src/audio_hook.hpp b/hook/src/audio_hook.hpp index d0bf0fa..c195c8e 100644 --- a/hook/src/audio_hook.hpp +++ b/hook/src/audio_hook.hpp @@ -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. diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index 3b2f7cb..0a6b0d7 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -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();