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:
23
README.md
23
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
|
||||
|
||||
@@ -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,13 +367,22 @@ 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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
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,
|
||||
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()
|
||||
{
|
||||
return true; // anchor already installed
|
||||
if (g_self_device != nullptr)
|
||||
{
|
||||
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,
|
||||
HRESULT ah = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
|
||||
reinterpret_cast<void**>(&g_self_client));
|
||||
if (SUCCEEDED(hr) && g_self_client != nullptr)
|
||||
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();
|
||||
}
|
||||
if (g_self_client != nullptr)
|
||||
Sleep(1);
|
||||
if (g_detours_active.load(std::memory_order_acquire) == 0)
|
||||
{
|
||||
g_self_client->Release();
|
||||
g_self_client = nullptr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user