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
|
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
|
||||||
|
|||||||
@@ -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,10 +367,19 @@ 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);
|
||||||
return g_vh_releasebuffer.original<ReleaseBufferFn>()(
|
// Only silence (zero the buffer) for an EXACT/override format, where `block`
|
||||||
self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT);
|
// 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,
|
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)
|
||||||
|
{
|
||||||
|
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_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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user