Fix audio render-hook missing already-playing streams (late injection)
The render-hook only installed IAudioClient/IAudioRenderClient hooks reactively, when it saw the game call IMMDevice::Activate -> GetService. But we attach to a game that is already running and playing audio, so its render client was created before injection: those calls never fire again, no primary stream is ever registered, nothing is captured, and the host always falls back to process loopback (the echo). Every game tested did so. Fix: at anchor time, build our own probe IAudioClient + IAudioRenderClient with raw calls and hook GetBuffer/ReleaseBuffer (plus Initialize/GetService) on their vtables. Every instance of a COM coclass shares one vtable, so this patches the shared vtables and intercepts the game's pre-existing render client too. The first render client seen actively releasing buffers is adopted as primary on the audio thread (try-lock, one-time) using the device mix format as its assumed format (we never saw its Initialize). Streams created after injection still register via the reactive path with their real format. Also fixes a self-deadlock: installing the Activate hook before the probe's own device->Activate call re-entered hk_Activate -> install_audioclient_hooks, which blocked on the setup mutex the installer already held, freezing the worker (and any game thread that later called Activate -> crash). The probe objects are now created raw, before any hook is installed. Validated against Phantom Brave (injected while already playing): the pre-existing 48 kHz/2ch/float render client is detected and registered as primary, real non-silent audio reaches the ring (peak tracks the game's levels), and a draining consumer sees zero overruns. Tooling for iterating on real games without Steam/RPT/the host UI: - tools/audio_probe: creates the IPC block + audio ring, injects the hook, drains the ring and prints stream/format/peak/overrun diagnostics by pid. - hook/src/debug_log: opt-in file trace (%TEMP%\coop_hook.log), enabled by the COOP_HOOK_LOG env var or the %TEMP%\coop_hook.log.on sentinel the probe drops; off in normal use. All four tests still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,7 @@ if(COOP_BUILD_HOOK)
|
|||||||
add_subdirectory(third_party/safetyhook)
|
add_subdirectory(third_party/safetyhook)
|
||||||
add_subdirectory(hook)
|
add_subdirectory(hook)
|
||||||
enable_testing()
|
enable_testing()
|
||||||
add_subdirectory(tools/audio_tone) # coop_tone: audio source for the loopback test
|
add_subdirectory(tools/audio_tone) # coop_tone: audio source for the loopback test
|
||||||
|
add_subdirectory(tools/audio_probe) # coop_audio_probe: inject + diagnose the render-hook
|
||||||
add_subdirectory(tests)
|
add_subdirectory(tests)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ add_library(coop_hook SHARED
|
|||||||
src/dllmain.cpp
|
src/dllmain.cpp
|
||||||
src/xinput_hook.cpp
|
src/xinput_hook.cpp
|
||||||
src/focus_spoof.cpp
|
src/focus_spoof.cpp
|
||||||
src/audio_hook.cpp)
|
src/audio_hook.cpp
|
||||||
|
src/debug_log.cpp)
|
||||||
|
|
||||||
target_include_directories(coop_hook PRIVATE src)
|
target_include_directories(coop_hook PRIVATE src)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
|
|
||||||
#include <safetyhook.hpp>
|
#include <safetyhook.hpp>
|
||||||
|
|
||||||
|
#include "debug_log.hpp"
|
||||||
|
|
||||||
namespace coop::hook
|
namespace coop::hook
|
||||||
{
|
{
|
||||||
|
|
||||||
@@ -53,6 +55,21 @@ safetyhook::InlineHook g_hk_getbuffer;
|
|||||||
safetyhook::InlineHook g_hk_releasebuffer;
|
safetyhook::InlineHook g_hk_releasebuffer;
|
||||||
bool g_audioclient_hooked = false;
|
bool g_audioclient_hooked = false;
|
||||||
|
|
||||||
|
// Our own probe COM objects, created at anchor time purely to read the shared
|
||||||
|
// IAudioClient / IAudioRenderClient vtables and hook GetBuffer/ReleaseBuffer
|
||||||
|
// *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).
|
||||||
|
IAudioClient* g_self_client = nullptr;
|
||||||
|
std::atomic<IAudioRenderClient*> g_self_render{nullptr};
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// can't know its real format; shared-mode clients overwhelmingly use the mix
|
||||||
|
// format). Written once at install before any hook is live.
|
||||||
|
CapturedFormat g_mix_format;
|
||||||
|
std::atomic<std::uint32_t> g_have_mix_format{0};
|
||||||
|
|
||||||
// Per IAudioClient, the format captured at Initialize, looked up when its render
|
// Per IAudioClient, the format captured at Initialize, looked up when its render
|
||||||
// client is created. Setup-path only (never touched on the audio thread).
|
// client is created. Setup-path only (never touched on the audio thread).
|
||||||
std::unordered_map<IAudioClient*, CapturedFormat> g_client_formats;
|
std::unordered_map<IAudioClient*, CapturedFormat> g_client_formats;
|
||||||
@@ -112,6 +129,9 @@ CapturedFormat capture_format(const WAVEFORMATEX* wfx)
|
|||||||
|
|
||||||
// --- Detours (declared before the installers that reference them) -----------
|
// --- Detours (declared before the installers that reference them) -----------
|
||||||
|
|
||||||
|
bool stream_tracked(IAudioRenderClient* rc);
|
||||||
|
void try_register_lazy(IAudioRenderClient* rc);
|
||||||
|
|
||||||
HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data)
|
HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data)
|
||||||
{
|
{
|
||||||
const HRESULT hr = g_hk_getbuffer.call<HRESULT>(self, num_frames, data);
|
const HRESULT hr = g_hk_getbuffer.call<HRESULT>(self, num_frames, data);
|
||||||
@@ -126,6 +146,14 @@ HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_fram
|
|||||||
|
|
||||||
HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_frames, DWORD flags)
|
HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_frames, DWORD flags)
|
||||||
{
|
{
|
||||||
|
// 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
|
||||||
|
// primary we capture). Skip our own silent probe client.
|
||||||
|
if (self != g_self_render.load(std::memory_order_acquire) && num_frames > 0 && !stream_tracked(self))
|
||||||
|
{
|
||||||
|
try_register_lazy(self);
|
||||||
|
}
|
||||||
|
|
||||||
// Frame counting for any tracked stream (drives the live/idle debug view).
|
// Frame counting for any tracked stream (drives the live/idle debug view).
|
||||||
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
||||||
{
|
{
|
||||||
@@ -165,18 +193,6 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_
|
|||||||
return g_hk_releasebuffer.call<HRESULT>(self, num_frames, flags);
|
return g_hk_releasebuffer.call<HRESULT>(self, num_frames, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
void install_render_client_hooks_once(IAudioRenderClient* rc)
|
|
||||||
{
|
|
||||||
if (g_hk_releasebuffer)
|
|
||||||
{
|
|
||||||
return; // shared vtable: one install covers every render client
|
|
||||||
}
|
|
||||||
g_hk_getbuffer = safetyhook::create_inline(
|
|
||||||
vtable_method(rc, kIdx_IAudioRenderClient_GetBuffer), reinterpret_cast<void*>(&hk_GetBuffer));
|
|
||||||
g_hk_releasebuffer = safetyhook::create_inline(
|
|
||||||
vtable_method(rc, kIdx_IAudioRenderClient_ReleaseBuffer), reinterpret_cast<void*>(&hk_ReleaseBuffer));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registers a newly created render client: assigns it a debug slot, marks the
|
// Registers a newly created render client: assigns it a debug slot, marks the
|
||||||
// first as primary (the one we capture), publishes it to HookStatus, and hooks
|
// first as primary (the one we capture), publishes it to HookStatus, and hooks
|
||||||
// the render-client vtable on first sight. Caller holds g_setup_mutex.
|
// the render-client vtable on first sight. Caller holds g_setup_mutex.
|
||||||
@@ -195,6 +211,8 @@ void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat&
|
|||||||
{
|
{
|
||||||
g_ipc->set_audio_streams_seen(seen);
|
g_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);
|
||||||
|
|
||||||
const std::uint32_t slot = g_registered;
|
const std::uint32_t slot = g_registered;
|
||||||
if (slot >= kMaxAudioStreams)
|
if (slot >= kMaxAudioStreams)
|
||||||
@@ -222,13 +240,51 @@ void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat&
|
|||||||
{
|
{
|
||||||
g_primary_block_align.store(cf.block_align, std::memory_order_relaxed);
|
g_primary_block_align.store(cf.block_align, std::memory_order_relaxed);
|
||||||
g_primary.store(rc, std::memory_order_release);
|
g_primary.store(rc, std::memory_order_release);
|
||||||
if (AudioRingHeader* ring = g_ring.load(std::memory_order_acquire))
|
AudioRingHeader* ring = g_ring.load(std::memory_order_acquire);
|
||||||
|
logf("primary stream set: rc=%p ring=%p (format %s)", rc, ring,
|
||||||
|
ring ? "published" : "no ring yet");
|
||||||
|
if (ring)
|
||||||
{
|
{
|
||||||
audio_ring_set_format(*ring, cf.rate, cf.channels, cf.bits, cf.tag, cf.block_align);
|
audio_ring_set_format(*ring, cf.rate, cf.channels, cf.bits, cf.tag, cf.block_align);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// GetBuffer/ReleaseBuffer are hooked proactively at install time (the shared
|
||||||
|
// vtable covers every render client), so nothing to install per-stream here.
|
||||||
|
}
|
||||||
|
|
||||||
install_render_client_hooks_once(rc);
|
// True if `rc` already occupies a tracked debug slot (lock-free scan).
|
||||||
|
bool stream_tracked(IAudioRenderClient* rc)
|
||||||
|
{
|
||||||
|
for (auto& s : g_streams)
|
||||||
|
{
|
||||||
|
if (s.client.load(std::memory_order_acquire) == rc)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a render client discovered on the audio thread (we never saw its
|
||||||
|
// Initialize/GetService — it predates our injection). Uses the device mix format
|
||||||
|
// as a best guess. Non-blocking: if setup is momentarily busy, retry next call.
|
||||||
|
void try_register_lazy(IAudioRenderClient* rc)
|
||||||
|
{
|
||||||
|
if (g_have_mix_format.load(std::memory_order_acquire) == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
std::unique_lock<std::mutex> lock(g_setup_mutex, std::try_to_lock);
|
||||||
|
if (!lock.owns_lock())
|
||||||
|
{
|
||||||
|
return; // another thread is in setup; try again on the next buffer
|
||||||
|
}
|
||||||
|
if (stream_tracked(rc))
|
||||||
|
{
|
||||||
|
return; // a concurrent path registered it first
|
||||||
|
}
|
||||||
|
logf("try_register_lazy: discovered pre-existing render client rc=%p", rc);
|
||||||
|
register_render_client_locked(rc, g_mix_format);
|
||||||
}
|
}
|
||||||
|
|
||||||
HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mode, DWORD flags,
|
HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mode, DWORD flags,
|
||||||
@@ -237,6 +293,8 @@ HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mo
|
|||||||
{
|
{
|
||||||
const HRESULT hr =
|
const HRESULT hr =
|
||||||
g_hk_initialize.call<HRESULT>(self, mode, flags, buffer_duration, periodicity, format, session);
|
g_hk_initialize.call<HRESULT>(self, mode, flags, buffer_duration, periodicity, format, session);
|
||||||
|
logf("hk_Initialize: client=%p mode=%d flags=0x%lX hr=0x%08lX fmt=%s", self, mode,
|
||||||
|
static_cast<unsigned long>(flags), static_cast<unsigned long>(hr), format ? "yes" : "null");
|
||||||
if (SUCCEEDED(hr) && format != nullptr)
|
if (SUCCEEDED(hr) && format != nullptr)
|
||||||
{
|
{
|
||||||
std::scoped_lock lock(g_setup_mutex);
|
std::scoped_lock lock(g_setup_mutex);
|
||||||
@@ -248,6 +306,9 @@ HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mo
|
|||||||
HRESULT STDMETHODCALLTYPE hk_GetService(IAudioClient* self, REFIID riid, void** ppv)
|
HRESULT STDMETHODCALLTYPE hk_GetService(IAudioClient* self, REFIID riid, void** ppv)
|
||||||
{
|
{
|
||||||
const HRESULT hr = g_hk_getservice.call<HRESULT>(self, riid, ppv);
|
const HRESULT hr = g_hk_getservice.call<HRESULT>(self, riid, ppv);
|
||||||
|
const bool is_render = (riid == __uuidof(IAudioRenderClient));
|
||||||
|
logf("hk_GetService: client=%p hr=0x%08lX render_client=%d", self, static_cast<unsigned long>(hr),
|
||||||
|
is_render ? 1 : 0);
|
||||||
if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr && riid == __uuidof(IAudioRenderClient))
|
if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr && riid == __uuidof(IAudioRenderClient))
|
||||||
{
|
{
|
||||||
CapturedFormat cf;
|
CapturedFormat cf;
|
||||||
@@ -294,15 +355,19 @@ void install_audioclient_hooks(IAudioClient* ac)
|
|||||||
g_hk_getservice = safetyhook::create_inline(
|
g_hk_getservice = safetyhook::create_inline(
|
||||||
vtable_method(ac, kIdx_IAudioClient_GetService), reinterpret_cast<void*>(&hk_GetService));
|
vtable_method(ac, kIdx_IAudioClient_GetService), reinterpret_cast<void*>(&hk_GetService));
|
||||||
g_audioclient_hooked = (g_hk_initialize && g_hk_getservice);
|
g_audioclient_hooked = (g_hk_initialize && g_hk_getservice);
|
||||||
|
logf("install_audioclient_hooks: ac=%p initialize=%d getservice=%d", ac,
|
||||||
|
static_cast<bool>(g_hk_initialize) ? 1 : 0, static_cast<bool>(g_hk_getservice) ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ctx, PROPVARIANT* params,
|
HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ctx, PROPVARIANT* params,
|
||||||
void** ppv)
|
void** ppv)
|
||||||
{
|
{
|
||||||
const HRESULT hr = g_hk_activate.call<HRESULT>(self, riid, cls_ctx, params, ppv);
|
const HRESULT hr = g_hk_activate.call<HRESULT>(self, riid, cls_ctx, params, ppv);
|
||||||
if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr &&
|
const bool is_audioclient = (riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) ||
|
||||||
(riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) ||
|
riid == __uuidof(IAudioClient3));
|
||||||
riid == __uuidof(IAudioClient3)))
|
logf("hk_Activate: device=%p hr=0x%08lX audioclient=%d", self, static_cast<unsigned long>(hr),
|
||||||
|
is_audioclient ? 1 : 0);
|
||||||
|
if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr && is_audioclient)
|
||||||
{
|
{
|
||||||
install_audioclient_hooks(static_cast<IAudioClient*>(*ppv));
|
install_audioclient_hooks(static_cast<IAudioClient*>(*ppv));
|
||||||
}
|
}
|
||||||
@@ -338,9 +403,70 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build our *own* client + render client with RAW calls first (no hook is
|
||||||
|
// live yet, so these don't re-enter our detours — which would deadlock on the
|
||||||
|
// setup mutex we already hold). 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 *our* objects' vtable slots patches the shared vtables and
|
||||||
|
// intercepts the game's pre-existing objects too.
|
||||||
|
g_self_client = nullptr;
|
||||||
|
IAudioRenderClient* self_render = nullptr;
|
||||||
|
hr = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
|
||||||
|
reinterpret_cast<void**>(&g_self_client));
|
||||||
|
if (SUCCEEDED(hr) && g_self_client != nullptr)
|
||||||
|
{
|
||||||
|
WAVEFORMATEX* mix = nullptr;
|
||||||
|
if (SUCCEEDED(g_self_client->GetMixFormat(&mix)) && mix != nullptr)
|
||||||
|
{
|
||||||
|
g_mix_format = capture_format(mix);
|
||||||
|
g_have_mix_format.store(1, std::memory_order_release);
|
||||||
|
constexpr REFERENCE_TIME kBuf = 10 * 10000; // 10 ms; never started
|
||||||
|
HRESULT ih = g_self_client->Initialize(AUDCLNT_SHAREMODE_SHARED, 0, kBuf, 0, mix, nullptr);
|
||||||
|
if (SUCCEEDED(ih))
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logf("install_audio_hooks: probe Activate(IAudioClient) failed hr=0x%08lX",
|
||||||
|
static_cast<unsigned long>(hr));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now install every hook. Anchor Activate (idx 3) catches streams created
|
||||||
|
// after us; the inner hooks catch every render client on the shared vtables.
|
||||||
g_hk_activate = safetyhook::create_inline(
|
g_hk_activate = safetyhook::create_inline(
|
||||||
vtable_method(device, kIdx_IMMDevice_Activate), reinterpret_cast<void*>(&hk_Activate));
|
vtable_method(device, kIdx_IMMDevice_Activate), reinterpret_cast<void*>(&hk_Activate));
|
||||||
|
|
||||||
|
if (self_render != nullptr)
|
||||||
|
{
|
||||||
|
g_self_render.store(self_render, std::memory_order_release);
|
||||||
|
g_hk_initialize = safetyhook::create_inline(
|
||||||
|
vtable_method(g_self_client, kIdx_IAudioClient_Initialize), reinterpret_cast<void*>(&hk_Initialize));
|
||||||
|
g_hk_getservice = safetyhook::create_inline(
|
||||||
|
vtable_method(g_self_client, kIdx_IAudioClient_GetService), reinterpret_cast<void*>(&hk_GetService));
|
||||||
|
g_hk_getbuffer = safetyhook::create_inline(
|
||||||
|
vtable_method(self_render, kIdx_IAudioRenderClient_GetBuffer), reinterpret_cast<void*>(&hk_GetBuffer));
|
||||||
|
g_hk_releasebuffer = safetyhook::create_inline(
|
||||||
|
vtable_method(self_render, kIdx_IAudioRenderClient_ReleaseBuffer),
|
||||||
|
reinterpret_cast<void*>(&hk_ReleaseBuffer));
|
||||||
|
g_audioclient_hooked = (g_hk_initialize && g_hk_getservice);
|
||||||
|
// Keep g_self_client + self_render alive (held in globals) so the vtables
|
||||||
|
// stay valid; they're released in remove_audio_hooks.
|
||||||
|
}
|
||||||
|
|
||||||
|
logf("install_audio_hooks: activate=%d init=%d getsvc=%d getbuf=%d relbuf=%d (device=%p)",
|
||||||
|
static_cast<bool>(g_hk_activate) ? 1 : 0, static_cast<bool>(g_hk_initialize) ? 1 : 0,
|
||||||
|
static_cast<bool>(g_hk_getservice) ? 1 : 0, static_cast<bool>(g_hk_getbuffer) ? 1 : 0,
|
||||||
|
static_cast<bool>(g_hk_releasebuffer) ? 1 : 0, device);
|
||||||
|
|
||||||
device->Release(); // vtable lives in the (still-loaded) audio COM module
|
device->Release(); // vtable lives in the (still-loaded) audio COM module
|
||||||
enumerator->Release();
|
enumerator->Release();
|
||||||
return static_cast<bool>(g_hk_activate);
|
return static_cast<bool>(g_hk_activate);
|
||||||
@@ -349,6 +475,8 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
|
|||||||
void set_audio_ring(AudioRingHeader* ring)
|
void set_audio_ring(AudioRingHeader* ring)
|
||||||
{
|
{
|
||||||
g_ring.store(ring, std::memory_order_release);
|
g_ring.store(ring, std::memory_order_release);
|
||||||
|
logf("set_audio_ring: ring=%p capture_enabled=%u", ring,
|
||||||
|
ring ? ring->capture_enabled.load(std::memory_order_relaxed) : 0u);
|
||||||
}
|
}
|
||||||
|
|
||||||
void remove_audio_hooks()
|
void remove_audio_hooks()
|
||||||
@@ -360,6 +488,19 @@ void remove_audio_hooks()
|
|||||||
g_hk_initialize = {};
|
g_hk_initialize = {};
|
||||||
g_hk_activate = {};
|
g_hk_activate = {};
|
||||||
g_audioclient_hooked = false;
|
g_audioclient_hooked = 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))
|
||||||
|
{
|
||||||
|
sr->Release();
|
||||||
|
}
|
||||||
|
if (g_self_client != nullptr)
|
||||||
|
{
|
||||||
|
g_self_client->Release();
|
||||||
|
g_self_client = nullptr;
|
||||||
|
}
|
||||||
|
g_have_mix_format.store(0, std::memory_order_relaxed);
|
||||||
|
|
||||||
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_primary.store(nullptr, std::memory_order_release);
|
g_primary.store(nullptr, std::memory_order_release);
|
||||||
|
|||||||
86
hook/src/debug_log.cpp
Normal file
86
hook/src/debug_log.cpp
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#define _CRT_SECURE_NO_WARNINGS
|
||||||
|
#include "debug_log.hpp"
|
||||||
|
|
||||||
|
#include <cstdarg>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
namespace coop::hook
|
||||||
|
{
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
std::mutex g_log_mutex;
|
||||||
|
FILE* g_log_file = nullptr;
|
||||||
|
bool g_log_tried = false;
|
||||||
|
|
||||||
|
// Logging is opt-in so an injected DLL doesn't write to disk in normal use.
|
||||||
|
// Enable it by setting the COOP_HOOK_LOG environment variable for the target, or
|
||||||
|
// (more practical for a Steam-launched game we can't set env on) by creating the
|
||||||
|
// sentinel file %TEMP%\coop_hook.log.on — every process sees the same %TEMP%, so
|
||||||
|
// the probe / debugger can flip it without touching the game's environment.
|
||||||
|
bool logging_enabled()
|
||||||
|
{
|
||||||
|
wchar_t buf[8] = {};
|
||||||
|
if (GetEnvironmentVariableW(L"COOP_HOOK_LOG", buf, 8) > 0)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
wchar_t dir[MAX_PATH] = {};
|
||||||
|
const DWORD n = GetTempPathW(MAX_PATH, dir);
|
||||||
|
if (n != 0 && n < MAX_PATH)
|
||||||
|
{
|
||||||
|
const std::wstring sentinel = std::wstring(dir) + L"coop_hook.log.on";
|
||||||
|
return GetFileAttributesW(sentinel.c_str()) != INVALID_FILE_ATTRIBUTES;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
FILE* log_file_locked()
|
||||||
|
{
|
||||||
|
if (!g_log_tried)
|
||||||
|
{
|
||||||
|
g_log_tried = true;
|
||||||
|
if (logging_enabled())
|
||||||
|
{
|
||||||
|
wchar_t dir[MAX_PATH] = {};
|
||||||
|
const DWORD n = GetTempPathW(MAX_PATH, dir);
|
||||||
|
if (n != 0 && n < MAX_PATH)
|
||||||
|
{
|
||||||
|
std::wstring path = std::wstring(dir) + L"coop_hook.log";
|
||||||
|
g_log_file = _wfopen(path.c_str(), L"a");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g_log_file;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void logf(const char* fmt, ...)
|
||||||
|
{
|
||||||
|
std::scoped_lock lock(g_log_mutex);
|
||||||
|
FILE* f = log_file_locked();
|
||||||
|
if (f == nullptr)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SYSTEMTIME st;
|
||||||
|
GetLocalTime(&st);
|
||||||
|
std::fprintf(f, "[%02u:%02u:%02u.%03u pid=%lu] ", st.wHour, st.wMinute, st.wSecond, st.wMilliseconds,
|
||||||
|
GetCurrentProcessId());
|
||||||
|
|
||||||
|
va_list args;
|
||||||
|
va_start(args, fmt);
|
||||||
|
std::vfprintf(f, fmt, args);
|
||||||
|
va_end(args);
|
||||||
|
|
||||||
|
std::fputc('\n', f);
|
||||||
|
std::fflush(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace coop::hook
|
||||||
14
hook/src/debug_log.hpp
Normal file
14
hook/src/debug_log.hpp
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// Lightweight file logger for diagnosing the injected hook from inside a game.
|
||||||
|
//
|
||||||
|
// We can't see stdout from an injected DLL, so route diagnostics to a file in
|
||||||
|
// %TEMP%\coop_hook.log. Thread-safe, opened lazily, append-only. Intended for
|
||||||
|
// development / bring-up; cheap enough to leave compiled in.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace coop::hook
|
||||||
|
{
|
||||||
|
|
||||||
|
// Append a printf-style line to %TEMP%\coop_hook.log (prefixed with pid + time).
|
||||||
|
void logf(const char* fmt, ...);
|
||||||
|
|
||||||
|
} // namespace coop::hook
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
#include "audio_hook.hpp"
|
#include "audio_hook.hpp"
|
||||||
#include "coop/audio_ring.hpp"
|
#include "coop/audio_ring.hpp"
|
||||||
#include "coop/shared_memory.hpp"
|
#include "coop/shared_memory.hpp"
|
||||||
|
#include "debug_log.hpp"
|
||||||
#include "focus_spoof.hpp"
|
#include "focus_spoof.hpp"
|
||||||
#include "ipc_client.hpp"
|
#include "ipc_client.hpp"
|
||||||
#include "xinput_hook.hpp"
|
#include "xinput_hook.hpp"
|
||||||
@@ -28,14 +29,19 @@ coop::SharedMemory g_audio_shm; // the host's audio ring, opened when present
|
|||||||
|
|
||||||
DWORD WINAPI worker_thread(LPVOID)
|
DWORD WINAPI worker_thread(LPVOID)
|
||||||
{
|
{
|
||||||
|
coop::hook::logf("worker_thread: started");
|
||||||
|
|
||||||
// The host creates the mapping around injection time; give it a few seconds.
|
// The host creates the mapping around injection time; give it a few seconds.
|
||||||
if (!g_ipc.connect(/*attempts=*/200, /*delay_ms=*/25))
|
if (!g_ipc.connect(/*attempts=*/200, /*delay_ms=*/25))
|
||||||
{
|
{
|
||||||
|
coop::hook::logf("worker_thread: IPC connect FAILED (no host mapping); exiting");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
coop::hook::logf("worker_thread: IPC connected");
|
||||||
|
|
||||||
// The audio render-hook instantiates a COM enumerator on this thread.
|
// The audio render-hook instantiates a COM enumerator on this thread.
|
||||||
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
|
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
|
||||||
|
coop::hook::logf("worker_thread: CoInitializeEx com_ok=%d", com_ok ? 1 : 0);
|
||||||
|
|
||||||
bool xinput_installed = false;
|
bool xinput_installed = false;
|
||||||
bool focus_installed = false;
|
bool focus_installed = false;
|
||||||
@@ -60,6 +66,10 @@ DWORD WINAPI worker_thread(LPVOID)
|
|||||||
if (com_ok && !audio_installed)
|
if (com_ok && !audio_installed)
|
||||||
{
|
{
|
||||||
audio_installed = coop::hook::install_audio_hooks(g_ipc, nullptr);
|
audio_installed = coop::hook::install_audio_hooks(g_ipc, nullptr);
|
||||||
|
if (audio_installed)
|
||||||
|
{
|
||||||
|
coop::hook::logf("worker_thread: audio hooks installed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (audio_installed && !audio_ring_open)
|
if (audio_installed && !audio_ring_open)
|
||||||
{
|
{
|
||||||
@@ -71,6 +81,7 @@ DWORD WINAPI worker_thread(LPVOID)
|
|||||||
{
|
{
|
||||||
coop::hook::set_audio_ring(ring);
|
coop::hook::set_audio_ring(ring);
|
||||||
audio_ring_open = true;
|
audio_ring_open = true;
|
||||||
|
coop::hook::logf("worker_thread: audio ring opened");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ add_test(NAME audio_loopback_test COMMAND audio_loopback_test)
|
|||||||
# game and no second Steam account (the audio analogue of hook_selftest).
|
# game and no second Steam account (the audio analogue of hook_selftest).
|
||||||
add_executable(audio_hook_test
|
add_executable(audio_hook_test
|
||||||
audio_hook_test.cpp
|
audio_hook_test.cpp
|
||||||
${CMAKE_SOURCE_DIR}/hook/src/audio_hook.cpp)
|
${CMAKE_SOURCE_DIR}/hook/src/audio_hook.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp)
|
||||||
|
|
||||||
target_include_directories(audio_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src)
|
target_include_directories(audio_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src)
|
||||||
|
|
||||||
|
|||||||
6
tools/audio_probe/CMakeLists.txt
Normal file
6
tools/audio_probe/CMakeLists.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Dev harness: creates the hook's IPC block + audio ring, injects coop_hook.dll
|
||||||
|
# into a target game by pid, and prints the audio render-hook diagnostics. Lets
|
||||||
|
# the audio path be brought up against a real game without Steam / RPT / the host.
|
||||||
|
add_executable(coop_audio_probe main.cpp)
|
||||||
|
target_link_libraries(coop_audio_probe PRIVATE coop_common)
|
||||||
|
set_target_properties(coop_audio_probe PROPERTIES OUTPUT_NAME "coop_audio_probe")
|
||||||
212
tools/audio_probe/main.cpp
Normal file
212
tools/audio_probe/main.cpp
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// coop_audio_probe — standalone harness to bring up the injected audio
|
||||||
|
// render-hook against a real game without Steam / RPT / the host UI.
|
||||||
|
//
|
||||||
|
// Given a target pid it: creates the input SharedBlock and the audio ring the
|
||||||
|
// hook expects (named by that pid), enables capture, injects coop_hook.dll, then
|
||||||
|
// polls and prints the hook's status back-channel and the audio ring counters
|
||||||
|
// for a while. The hook writes a detailed trace to %TEMP%\coop_hook.log.
|
||||||
|
//
|
||||||
|
// coop_audio_probe <pid> [seconds]
|
||||||
|
//
|
||||||
|
// Run from the same directory as coop_hook.dll (i.e. bin/<config>/).
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include "coop/audio_ring.hpp"
|
||||||
|
#include "coop/protocol.hpp"
|
||||||
|
#include "coop/shared_memory.hpp"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
std::wstring dll_path_next_to_self()
|
||||||
|
{
|
||||||
|
wchar_t exe[MAX_PATH] = {};
|
||||||
|
GetModuleFileNameW(nullptr, exe, MAX_PATH);
|
||||||
|
std::wstring path(exe);
|
||||||
|
const size_t slash = path.find_last_of(L"\\/");
|
||||||
|
if (slash != std::wstring::npos)
|
||||||
|
{
|
||||||
|
path.resize(slash + 1);
|
||||||
|
}
|
||||||
|
return path + L"coop_hook.dll";
|
||||||
|
}
|
||||||
|
|
||||||
|
bool inject(unsigned long pid, const std::wstring& dll_path)
|
||||||
|
{
|
||||||
|
if (GetFileAttributesW(dll_path.c_str()) == INVALID_FILE_ATTRIBUTES)
|
||||||
|
{
|
||||||
|
std::printf("ERROR: coop_hook.dll not found at the probe's directory.\n");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const DWORD access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION |
|
||||||
|
PROCESS_VM_WRITE | PROCESS_VM_READ;
|
||||||
|
HANDLE process = OpenProcess(access, FALSE, pid);
|
||||||
|
if (process == nullptr)
|
||||||
|
{
|
||||||
|
std::printf("ERROR: OpenProcess(%lu) failed (%lu). Run as administrator?\n", pid, GetLastError());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const SIZE_T bytes = (dll_path.size() + 1) * sizeof(wchar_t);
|
||||||
|
void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
|
||||||
|
bool ok = false;
|
||||||
|
if (remote != nullptr && WriteProcessMemory(process, remote, dll_path.c_str(), bytes, nullptr))
|
||||||
|
{
|
||||||
|
auto load_library = reinterpret_cast<LPTHREAD_START_ROUTINE>(
|
||||||
|
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"));
|
||||||
|
HANDLE thread = CreateRemoteThread(process, nullptr, 0, load_library, remote, 0, nullptr);
|
||||||
|
if (thread != nullptr)
|
||||||
|
{
|
||||||
|
WaitForSingleObject(thread, INFINITE);
|
||||||
|
DWORD exit_code = 0;
|
||||||
|
GetExitCodeThread(thread, &exit_code);
|
||||||
|
CloseHandle(thread);
|
||||||
|
ok = (exit_code != 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (remote != nullptr)
|
||||||
|
{
|
||||||
|
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
|
||||||
|
}
|
||||||
|
CloseHandle(process);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
std::printf("ERROR: injection failed (%lu).\n", GetLastError());
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int wmain(int argc, wchar_t** argv)
|
||||||
|
{
|
||||||
|
if (argc < 2)
|
||||||
|
{
|
||||||
|
std::printf("usage: coop_audio_probe <pid> [seconds]\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const unsigned long pid = std::wcstoul(argv[1], nullptr, 10);
|
||||||
|
const int seconds = (argc >= 3) ? std::max(1, _wtoi(argv[2])) : 20;
|
||||||
|
if (pid == 0)
|
||||||
|
{
|
||||||
|
std::printf("ERROR: invalid pid.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Input SharedBlock (the hook's worker exits if it can't connect to this).
|
||||||
|
coop::SharedMemory ipc;
|
||||||
|
if (!ipc.create(coop::shared_memory_name(pid), sizeof(coop::SharedBlock)))
|
||||||
|
{
|
||||||
|
std::printf("ERROR: create input mapping failed (%lu).\n", GetLastError());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
auto* block = ipc.as<coop::SharedBlock>();
|
||||||
|
block->version = coop::kProtocolVersion;
|
||||||
|
block->pad_count = 0;
|
||||||
|
block->sequence.store(0, std::memory_order_relaxed);
|
||||||
|
block->magic = coop::kProtocolMagic;
|
||||||
|
|
||||||
|
// 2) Audio ring, capture enabled (mirrors AudioMirror::thread_main).
|
||||||
|
coop::SharedMemory ring_shm;
|
||||||
|
if (!ring_shm.create(coop::audio_ring_name(pid), coop::audio_ring_total_size(coop::kAudioRingCapacity)))
|
||||||
|
{
|
||||||
|
std::printf("ERROR: create audio ring mapping failed (%lu).\n", GetLastError());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
auto* ring = ring_shm.as<coop::AudioRingHeader>();
|
||||||
|
coop::audio_ring_init(*ring, coop::kAudioRingCapacity);
|
||||||
|
ring->capture_enabled.store(1, std::memory_order_release);
|
||||||
|
|
||||||
|
// Enable the hook's file trace (%TEMP%\coop_hook.log) for this debug session.
|
||||||
|
{
|
||||||
|
wchar_t dir[MAX_PATH] = {};
|
||||||
|
if (GetTempPathW(MAX_PATH, dir) != 0)
|
||||||
|
{
|
||||||
|
const std::wstring sentinel = std::wstring(dir) + L"coop_hook.log.on";
|
||||||
|
HANDLE h = CreateFileW(sentinel.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr,
|
||||||
|
OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||||
|
if (h != INVALID_HANDLE_VALUE)
|
||||||
|
{
|
||||||
|
CloseHandle(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Inject.
|
||||||
|
std::printf("Injecting coop_hook.dll into pid %lu ...\n", pid);
|
||||||
|
if (!inject(pid, dll_path_next_to_self()))
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::printf("Injected. Polling for %d s. Hook trace: %%TEMP%%\\coop_hook.log\n\n", seconds);
|
||||||
|
|
||||||
|
// 4) Poll + print. Drain the ring like the real host would (so it doesn't
|
||||||
|
// overrun) and measure peak amplitude to prove we captured real audio.
|
||||||
|
const coop::HookStatus& status = block->status;
|
||||||
|
std::uint64_t prev_frames[coop::kMaxAudioStreams] = {};
|
||||||
|
std::vector<std::uint8_t> drain(coop::kAudioRingCapacity);
|
||||||
|
for (int t = 0; t < seconds * 2; ++t)
|
||||||
|
{
|
||||||
|
Sleep(500);
|
||||||
|
|
||||||
|
// Consume everything available and find the peak sample magnitude.
|
||||||
|
double peak = 0.0;
|
||||||
|
std::uint32_t got = 0;
|
||||||
|
while ((got = coop::audio_ring_pop(*ring, drain.data(), static_cast<std::uint32_t>(drain.size()))) > 0)
|
||||||
|
{
|
||||||
|
if (ring->format_tag == 3 /*IEEE_FLOAT*/ && ring->bits == 32)
|
||||||
|
{
|
||||||
|
const auto* f = reinterpret_cast<const float*>(drain.data());
|
||||||
|
for (std::uint32_t i = 0; i < got / 4; ++i)
|
||||||
|
{
|
||||||
|
peak = std::max(peak, static_cast<double>(std::abs(f[i])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (ring->bits == 16)
|
||||||
|
{
|
||||||
|
const auto* s = reinterpret_cast<const std::int16_t*>(drain.data());
|
||||||
|
for (std::uint32_t i = 0; i < got / 2; ++i)
|
||||||
|
{
|
||||||
|
peak = std::max(peak, std::abs(s[i]) / 32768.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (got < drain.size())
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::uint32_t streams = status.audio_streams_seen;
|
||||||
|
const std::uint32_t heartbeat = status.heartbeat.load(std::memory_order_relaxed);
|
||||||
|
const std::uint64_t produced = ring->frames_produced.load(std::memory_order_relaxed);
|
||||||
|
const std::uint64_t overruns = ring->overruns.load(std::memory_order_relaxed);
|
||||||
|
const bool fmt_ready = coop::audio_ring_format_ready(*ring);
|
||||||
|
|
||||||
|
std::printf("[%4.1fs] hb=%u streams=%u peak=%.4f ring{fmt=%d %uHz/%uch/%ubit produced=%llu "
|
||||||
|
"overruns=%llu}\n",
|
||||||
|
(t + 1) * 0.5, heartbeat, streams, peak, fmt_ready ? 1 : 0, ring->sample_rate,
|
||||||
|
ring->channels, ring->bits, static_cast<unsigned long long>(produced),
|
||||||
|
static_cast<unsigned long long>(overruns));
|
||||||
|
for (std::uint32_t i = 0; i < coop::kMaxAudioStreams && i < streams; ++i)
|
||||||
|
{
|
||||||
|
const coop::AudioStreamInfo& s = status.audio_streams[i];
|
||||||
|
const bool live = s.frames_rendered > prev_frames[i];
|
||||||
|
prev_frames[i] = s.frames_rendered;
|
||||||
|
std::printf(" stream %u %s %uHz/%uch/%ubit tag=%u frames=%llu %s\n", i,
|
||||||
|
s.is_primary ? "PRIMARY" : "extra ", s.sample_rate, s.channels, s.bits,
|
||||||
|
s.format_tag, static_cast<unsigned long long>(s.frames_rendered),
|
||||||
|
live ? "<live>" : "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("\nDone. Leaving the hook loaded in the game.\n");
|
||||||
|
block->magic = 0; // invalidate so a late hook read won't trust stale data
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user