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 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 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 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 — device is mirrored with the wrong layout (garbled audio) on the hooked path, but never
but never an over-read/crash (a `VirtualQuery` clamp guards the copy), and the an over-read/crash: a guessed stream is **captured but not silenced** (so it stays
loopback fallback is always format-correct. The Audio panel shows each stream's 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* / format provenance (*known* / *measuring* / *measured rate* / *low-confidence* /
*override*) so the assumption is visible, and (under Debug details) lets the operator *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 **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- 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 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. 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 - **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 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 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); VirtualProtect(&m_vtable[m_index], sizeof(void*), old_protect, &old_protect);
} }
m_vtable = nullptr; 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; m_index = 0;
} }
@@ -113,7 +117,11 @@ struct CapturedFormat
// --- Global hook state ----------------------------------------------------- // --- 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 // 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. // primary; the host creates a ring per stream and mixes them.
std::atomic<AudioRingHeader*> g_rings[kMaxAudioStreams]{}; std::atomic<AudioRingHeader*> g_rings[kMaxAudioStreams]{};
@@ -126,6 +134,29 @@ VtableHook g_vh_getservice;
VtableHook g_vh_getbuffer; VtableHook g_vh_getbuffer;
VtableHook g_vh_releasebuffer; VtableHook g_vh_releasebuffer;
bool g_audioclient_hooked = false; 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. // Registry ids for the hook list.
int g_id_activate = -1; 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 // *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. // 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). // 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; IAudioClient* g_self_client = nullptr;
std::atomic<IAudioRenderClient*> g_self_render{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 // 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 // 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 IAudioRenderClient* t_gb_client = nullptr;
thread_local BYTE* t_gb_data = nullptr; thread_local BYTE* t_gb_data = nullptr;
thread_local UINT32 t_gb_frames = 0; 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) 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) 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); hook_note_call(g_id_getbuffer);
const HRESULT hr = g_vh_getbuffer.original<GetBufferFn>()(self, num_frames, data); const HRESULT hr = g_vh_getbuffer.original<GetBufferFn>()(self, num_frames, data);
if (SUCCEEDED(hr) && data != nullptr) if (SUCCEEDED(hr) && data != nullptr)
@@ -270,12 +308,14 @@ HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_fram
t_gb_client = self; t_gb_client = self;
t_gb_data = *data; t_gb_data = *data;
t_gb_frames = num_frames; t_gb_frames = num_frames;
t_gb_epoch = g_hook_epoch.load(std::memory_order_acquire);
} }
return hr; return hr;
} }
HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_frames, DWORD flags) 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); hook_note_call(g_id_releasebuffer);
// A render client we've never seen actively rendering is almost certainly one // 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 // 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 = const std::uint64_t total =
g_streams[i].frames.fetch_add(num_frames, std::memory_order_relaxed) + num_frames; 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) 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). // 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 && 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 && 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); const std::uint32_t block = g_streams[i].block_align.load(std::memory_order_relaxed);
std::uint32_t bytes = num_frames * block; std::uint32_t bytes = num_frames * block;
// If this stream's channels/bits were guessed (pre-existing client), the block // A guessed (pre-existing client) stream's block may be larger than the real
// may be too large for the real buffer; clamp to what's actually readable so the // per-frame size, so clamp the COPY to what's actually readable -- never over-read
// copy can never over-read the game's buffer (no-op when the guess is right). // the game's buffer (no-op when the guess is right).
if (g_streams[i].assumed_format.load(std::memory_order_relaxed) != 0) if (guessed)
{ {
bytes = readable_bytes(t_gb_data, bytes); bytes = readable_bytes(t_gb_data, bytes);
} }
@@ -325,13 +367,22 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_
// silent — degrades to today's echo, never to silence. // silent — degrades to today's echo, never to silence.
if (block != 0 && audio_ring_push(*ring, t_gb_data, bytes, num_frames)) 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); g_frames_captured.fetch_add(num_frames, std::memory_order_relaxed);
// 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>()( return g_vh_releasebuffer.original<ReleaseBufferFn>()(
self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT); self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT);
} }
} }
} }
}
break; break;
} }
return g_vh_releasebuffer.original<ReleaseBufferFn>()(self, num_frames, flags); return g_vh_releasebuffer.original<ReleaseBufferFn>()(self, num_frames, flags);
@@ -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, void publish_stream_info_locked(std::uint32_t slot, const CapturedFormat& cf, std::uint32_t state,
std::uint64_t frames) std::uint64_t frames)
{ {
if (g_ipc == nullptr) IpcClient* ipc = g_ipc.load(std::memory_order_acquire);
if (ipc == nullptr)
{ {
return; return;
} }
@@ -354,7 +406,7 @@ void publish_stream_info_locked(std::uint32_t slot, const CapturedFormat& cf, st
info.format_tag = cf.tag; info.format_tag = cf.tag;
info.frames_rendered = frames; info.frames_rendered = frames;
info.format_state = state; 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 // 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; 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, 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); 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 } // namespace
bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring) namespace
{ {
std::scoped_lock lock(g_setup_mutex); // Build the probe COM objects (enumerator -> device -> client -> render) and capture the
g_ipc = &ipc; // device mix format. Created ONCE and kept for the DLL's lifetime: every instance of a
g_rings[0].store(ring, std::memory_order_release); // coclass shares one vtable, so a toggle then only re-swaps vtable slots on these kept
if (g_vh_activate) // 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; IMMDeviceEnumerator* enumerator = nullptr;
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator)))) __uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator))))
@@ -674,24 +719,18 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
return false; return false;
} }
IMMDevice* device = nullptr; 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) if (FAILED(hr) || device == nullptr)
{ {
enumerator->Release();
return false; 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; IAudioRenderClient* self_render = nullptr;
hr = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, HRESULT ah = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&g_self_client)); reinterpret_cast<void**>(&g_self_client));
if (SUCCEEDED(hr) && g_self_client != nullptr) if (SUCCEEDED(ah) && g_self_client != nullptr)
{ {
WAVEFORMATEX* mix = nullptr; WAVEFORMATEX* mix = nullptr;
if (SUCCEEDED(g_self_client->GetMixFormat(&mix)) && 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), ih = g_self_client->GetService(__uuidof(IAudioRenderClient),
reinterpret_cast<void**>(&self_render)); 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, static_cast<unsigned long>(ih), self_render, g_mix_format.rate, g_mix_format.channels,
g_mix_format.bits, g_mix_format.tag); g_mix_format.bits, g_mix_format.tag);
CoTaskMemFree(mix); CoTaskMemFree(mix);
@@ -713,44 +752,70 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
} }
else else
{ {
logf("install_audio_hooks: probe Activate(IAudioClient) failed hr=0x%08lX", logf("build_probe: Activate(IAudioClient) failed hr=0x%08lX", static_cast<unsigned long>(ah));
static_cast<unsigned long>(hr));
} }
// 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) if (self_render != nullptr)
{ {
g_self_render.store(self_render, std::memory_order_release); 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, g_vh_initialize.install(g_self_client, kIdx_IAudioClient_Initialize,
reinterpret_cast<void*>(&hk_Initialize)); reinterpret_cast<void*>(&hk_Initialize));
g_vh_getservice.install(g_self_client, kIdx_IAudioClient_GetService, g_vh_getservice.install(g_self_client, kIdx_IAudioClient_GetService,
reinterpret_cast<void*>(&hk_GetService)); reinterpret_cast<void*>(&hk_GetService));
g_vh_getbuffer.install(self_render, kIdx_IAudioRenderClient_GetBuffer, g_vh_getbuffer.install(sr, kIdx_IAudioRenderClient_GetBuffer, reinterpret_cast<void*>(&hk_GetBuffer));
reinterpret_cast<void*>(&hk_GetBuffer)); g_vh_releasebuffer.install(sr, kIdx_IAudioRenderClient_ReleaseBuffer,
g_vh_releasebuffer.install(self_render, kIdx_IAudioRenderClient_ReleaseBuffer,
reinterpret_cast<void*>(&hk_ReleaseBuffer)); reinterpret_cast<void*>(&hk_ReleaseBuffer));
g_audioclient_hooked = (static_cast<bool>(g_vh_initialize) && static_cast<bool>(g_vh_getservice)); 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_activate, static_cast<bool>(g_vh_activate));
hook_set_installed(g_id_initialize, static_cast<bool>(g_vh_initialize)); 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_getservice, static_cast<bool>(g_vh_getservice));
hook_set_installed(g_id_getbuffer, static_cast<bool>(g_vh_getbuffer)); hook_set_installed(g_id_getbuffer, static_cast<bool>(g_vh_getbuffer));
hook_set_installed(g_id_releasebuffer, static_cast<bool>(g_vh_releasebuffer)); hook_set_installed(g_id_releasebuffer, static_cast<bool>(g_vh_releasebuffer));
logf("install_detours: activate=%d init=%d getsvc=%d getbuf=%d relbuf=%d",
logf("install_audio_hooks: activate=%d init=%d getsvc=%d getbuf=%d relbuf=%d (device=%p)",
static_cast<bool>(g_vh_activate) ? 1 : 0, static_cast<bool>(g_vh_initialize) ? 1 : 0, 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_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 bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
enumerator->Release(); {
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); return static_cast<bool>(g_vh_activate);
} }
@@ -799,6 +864,11 @@ void set_audio_ring(unsigned index, AudioRingHeader* ring)
void remove_audio_hooks() void remove_audio_hooks()
{ {
std::scoped_lock lock(g_setup_mutex); 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_releasebuffer.remove();
g_vh_getbuffer.remove(); g_vh_getbuffer.remove();
g_vh_getservice.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_getbuffer, false);
hook_set_installed(g_id_releasebuffer, false); hook_set_installed(g_id_releasebuffer, false);
// Hooks are gone; safe to drop the probe objects that held the vtables. // The slots are restored above, so no NEW detour will start. Drain any detour still
if (IAudioRenderClient* sr = g_self_render.exchange(nullptr, std::memory_order_acq_rel)) // 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)
if (g_self_client != nullptr)
{ {
g_self_client->Release(); break;
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_registered = 0;
g_streams_seen.store(0, std::memory_order_relaxed); g_streams_seen.store(0, std::memory_order_relaxed);
g_frames_captured.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_rings[i].store(nullptr, std::memory_order_release);
} }
g_client_formats.clear(); 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() 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. // earlier. No-op for rings that have no stream yet.
void republish_audio_format(); 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(); 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) ----------------------------------- // --- Diagnostics (used by the self-test) -----------------------------------
// Cumulative frames the primary path copied to the ring and silenced locally. // 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::set_log_ring(nullptr);
coop::hook::remove_focus_spoof(); coop::hook::remove_focus_spoof();
coop::hook::remove_xinput_hooks(); 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_present_hooks();
coop::hook::remove_opengl_hooks(); coop::hook::remove_opengl_hooks();
coop::hook::remove_mkb_hooks(); coop::hook::remove_mkb_hooks();