Audio render-hook M2: render hook + in-process self-test (concept proven)
Implements the WASAPI render-hook (hook/src/audio_hook.{hpp,cpp}) and an
in-process self-test that proves COM vtable discovery and GetBuffer/ReleaseBuffer
interception with no game and no second Steam account.
- audio_hook.cpp: anchors on IMMDevice::Activate (idx 3) off our own default
endpoint (shared vtable), then hooks IAudioClient::Initialize (3) /
GetService (14) and IAudioRenderClient::GetBuffer (3) / ReleaseBuffer (4) off
live game pointers. Copies primary-stream frames into the audio ring and
releases with AUDCLNT_BUFFERFLAGS_SILENT (+ memset belt-and-suspenders), only
while the host-owned capture_enabled flag is set. Stream counting runs always;
on a ring overrun it keeps playing locally rather than going silent.
- ipc_client.hpp: publish_audio_stream / note_audio_frames /
set_audio_streams_seen write the render-stream debug fields into HookStatus.
- tests/audio_hook_test.cpp: installs the hooks, renders a tone through WASAPI
in-process, and asserts exactly one stream, frames pushed to the ring, the
ring carries the non-silent tone, and the primary was silenced. PASS:
streams_seen=1, frames_captured=32640.
- plan doc: correct GetService vtable index 13 -> 14 (SetEventHandle is 13).
coop_hook DLL wiring + host consumer/fallback come next (M3).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -65,7 +65,7 @@ hooks):
|
|||||||
- **Everything else is hooked off the live pointers the game received** (no further
|
- **Everything else is hooked off the live pointers the game received** (no further
|
||||||
assumptions), install-once guarded:
|
assumptions), install-once guarded:
|
||||||
- `Activate` hook → if IID is `IAudioClient`/`2`/`3`, hook `Initialize` (idx 3)
|
- `Activate` hook → if IID is `IAudioClient`/`2`/`3`, hook `Initialize` (idx 3)
|
||||||
and `GetService` (idx 13) on that object.
|
and `GetService` (idx 14) on that object.
|
||||||
- `Initialize` hook → capture the `WAVEFORMATEX` (rate / channels / bits / tag).
|
- `Initialize` hook → capture the `WAVEFORMATEX` (rate / channels / bits / tag).
|
||||||
Fallback for games using `IAudioClient3::InitializeSharedAudioStream`: read the
|
Fallback for games using `IAudioClient3::InitializeSharedAudioStream`: read the
|
||||||
format via the original `GetMixFormat` in the `GetService` hook.
|
format via the original `GetMixFormat` in the `GetService` hook.
|
||||||
@@ -73,7 +73,8 @@ hooks):
|
|||||||
its `GetBuffer` (idx 3) and `ReleaseBuffer` (idx 4).
|
its `GetBuffer` (idx 3) and `ReleaseBuffer` (idx 4).
|
||||||
|
|
||||||
Vtable indices: `IMMDevice::Activate`=3; `IAudioClient::Initialize`=3,
|
Vtable indices: `IMMDevice::Activate`=3; `IAudioClient::Initialize`=3,
|
||||||
`GetService`=13; `IAudioRenderClient::GetBuffer`=3, `ReleaseBuffer`=4.
|
`GetService`=14 (after `SetEventHandle`=13); `IAudioRenderClient::GetBuffer`=3,
|
||||||
|
`ReleaseBuffer`=4.
|
||||||
|
|
||||||
The worker thread `CoInitializeEx(MTA)` for the lifetime of the DLL (needed for the
|
The worker thread `CoInitializeEx(MTA)` for the lifetime of the DLL (needed for the
|
||||||
enumerator instance); installs are retried on the existing 250 ms worker tick, like
|
enumerator instance); installs are retried on the existing 250 ms worker tick, like
|
||||||
|
|||||||
388
hook/src/audio_hook.cpp
Normal file
388
hook/src/audio_hook.cpp
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
#include "audio_hook.hpp"
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstring>
|
||||||
|
#include <mutex>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <audioclient.h>
|
||||||
|
#include <mmdeviceapi.h>
|
||||||
|
#include <mmreg.h>
|
||||||
|
|
||||||
|
#include <safetyhook.hpp>
|
||||||
|
|
||||||
|
namespace coop::hook
|
||||||
|
{
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// COM vtable indices (frozen ABI). IUnknown occupies 0..2.
|
||||||
|
// IMMDevice: Activate = 3
|
||||||
|
// IAudioClient: Initialize = 3, ... SetEventHandle = 13, GetService = 14
|
||||||
|
// IAudioRenderClient: GetBuffer = 3, ReleaseBuffer = 4
|
||||||
|
constexpr unsigned kIdx_IMMDevice_Activate = 3;
|
||||||
|
constexpr unsigned kIdx_IAudioClient_Initialize = 3;
|
||||||
|
constexpr unsigned kIdx_IAudioClient_GetService = 14;
|
||||||
|
constexpr unsigned kIdx_IAudioRenderClient_GetBuffer = 3;
|
||||||
|
constexpr unsigned kIdx_IAudioRenderClient_ReleaseBuffer = 4;
|
||||||
|
|
||||||
|
// The scalar audio format we forward; resolved from the game's WAVEFORMATEX.
|
||||||
|
struct CapturedFormat
|
||||||
|
{
|
||||||
|
std::uint32_t rate = 0;
|
||||||
|
std::uint32_t channels = 0;
|
||||||
|
std::uint32_t bits = 0;
|
||||||
|
std::uint32_t tag = 0; // WAVE_FORMAT_PCM / _IEEE_FLOAT (EXTENSIBLE resolved to its subformat)
|
||||||
|
std::uint32_t block_align = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Global hook state -----------------------------------------------------
|
||||||
|
|
||||||
|
IpcClient* g_ipc = nullptr;
|
||||||
|
std::atomic<AudioRingHeader*> g_ring{nullptr};
|
||||||
|
|
||||||
|
std::mutex g_setup_mutex; // guards installs + the format map + stream registration
|
||||||
|
|
||||||
|
safetyhook::InlineHook g_hk_activate;
|
||||||
|
safetyhook::InlineHook g_hk_initialize;
|
||||||
|
safetyhook::InlineHook g_hk_getservice;
|
||||||
|
safetyhook::InlineHook g_hk_getbuffer;
|
||||||
|
safetyhook::InlineHook g_hk_releasebuffer;
|
||||||
|
bool g_audioclient_hooked = false;
|
||||||
|
|
||||||
|
// Per IAudioClient, the format captured at Initialize, looked up when its render
|
||||||
|
// client is created. Setup-path only (never touched on the audio thread).
|
||||||
|
std::unordered_map<IAudioClient*, CapturedFormat> g_client_formats;
|
||||||
|
|
||||||
|
// Streams we track for the debug view (frame counting). Index 0 is primary.
|
||||||
|
struct TrackedStream
|
||||||
|
{
|
||||||
|
std::atomic<IAudioRenderClient*> client{nullptr};
|
||||||
|
std::atomic<std::uint64_t> frames{0};
|
||||||
|
};
|
||||||
|
TrackedStream g_streams[kMaxAudioStreams];
|
||||||
|
std::uint32_t g_registered = 0; // slots filled (<= kMaxAudioStreams), under mutex
|
||||||
|
std::atomic<std::uint32_t> g_streams_seen{0}; // total distinct clients ever seen
|
||||||
|
|
||||||
|
// Hot-path primary identity + frame size (avoids touching the map/mutex).
|
||||||
|
std::atomic<IAudioRenderClient*> g_primary{nullptr};
|
||||||
|
std::atomic<std::uint32_t> g_primary_block_align{0};
|
||||||
|
std::atomic<std::uint64_t> g_frames_captured{0};
|
||||||
|
|
||||||
|
// GetBuffer/ReleaseBuffer are paired on one thread, never nested: stash the
|
||||||
|
// pointer the game just got so ReleaseBuffer can copy it before releasing.
|
||||||
|
thread_local IAudioRenderClient* t_gb_client = nullptr;
|
||||||
|
thread_local BYTE* t_gb_data = nullptr;
|
||||||
|
thread_local UINT32 t_gb_frames = 0;
|
||||||
|
|
||||||
|
void* vtable_method(void* obj, unsigned index)
|
||||||
|
{
|
||||||
|
return (*reinterpret_cast<void***>(obj))[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
CapturedFormat capture_format(const WAVEFORMATEX* wfx)
|
||||||
|
{
|
||||||
|
CapturedFormat cf;
|
||||||
|
cf.rate = wfx->nSamplesPerSec;
|
||||||
|
cf.channels = wfx->nChannels;
|
||||||
|
cf.bits = wfx->wBitsPerSample;
|
||||||
|
cf.block_align = wfx->nBlockAlign;
|
||||||
|
cf.tag = wfx->wFormatTag;
|
||||||
|
if (wfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && wfx->cbSize >= 22)
|
||||||
|
{
|
||||||
|
const auto* ext = reinterpret_cast<const WAVEFORMATEXTENSIBLE*>(wfx);
|
||||||
|
if (ext->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT)
|
||||||
|
{
|
||||||
|
cf.tag = WAVE_FORMAT_IEEE_FLOAT;
|
||||||
|
}
|
||||||
|
else if (ext->SubFormat == KSDATAFORMAT_SUBTYPE_PCM)
|
||||||
|
{
|
||||||
|
cf.tag = WAVE_FORMAT_PCM;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cf.block_align == 0)
|
||||||
|
{
|
||||||
|
cf.block_align = cf.channels * (cf.bits / 8);
|
||||||
|
}
|
||||||
|
return cf;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Detours (declared before the installers that reference them) -----------
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data)
|
||||||
|
{
|
||||||
|
const HRESULT hr = g_hk_getbuffer.call<HRESULT>(self, num_frames, data);
|
||||||
|
if (SUCCEEDED(hr) && data != nullptr)
|
||||||
|
{
|
||||||
|
t_gb_client = self;
|
||||||
|
t_gb_data = *data;
|
||||||
|
t_gb_frames = num_frames;
|
||||||
|
}
|
||||||
|
return hr;
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_frames, DWORD flags)
|
||||||
|
{
|
||||||
|
// Frame counting for any tracked stream (drives the live/idle debug view).
|
||||||
|
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
||||||
|
{
|
||||||
|
if (g_streams[i].client.load(std::memory_order_acquire) == self)
|
||||||
|
{
|
||||||
|
const std::uint64_t total =
|
||||||
|
g_streams[i].frames.fetch_add(num_frames, std::memory_order_relaxed) + num_frames;
|
||||||
|
if (g_ipc != nullptr)
|
||||||
|
{
|
||||||
|
g_ipc->note_audio_frames(i, total);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture + silence only the primary stream, only while enabled.
|
||||||
|
if (self == g_primary.load(std::memory_order_acquire) && num_frames > 0 &&
|
||||||
|
(flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0)
|
||||||
|
{
|
||||||
|
AudioRingHeader* ring = g_ring.load(std::memory_order_acquire);
|
||||||
|
if (ring != nullptr && ring->capture_enabled.load(std::memory_order_relaxed) != 0 &&
|
||||||
|
t_gb_client == self && t_gb_data != nullptr && t_gb_frames == num_frames)
|
||||||
|
{
|
||||||
|
const std::uint32_t block = g_primary_block_align.load(std::memory_order_relaxed);
|
||||||
|
const std::uint32_t bytes = num_frames * block;
|
||||||
|
// Only silence if the frames made it into the ring; if the host has
|
||||||
|
// stalled (ring full) keep playing locally rather than going dead
|
||||||
|
// silent — degrades to today's echo, never to silence.
|
||||||
|
if (audio_ring_push(*ring, t_gb_data, bytes, num_frames))
|
||||||
|
{
|
||||||
|
std::memset(t_gb_data, 0, bytes); // belt-and-suspenders vs a driver ignoring SILENT
|
||||||
|
g_frames_captured.fetch_add(num_frames, std::memory_order_relaxed);
|
||||||
|
return g_hk_releasebuffer.call<HRESULT>(self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// 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.
|
||||||
|
void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat& cf)
|
||||||
|
{
|
||||||
|
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
||||||
|
{
|
||||||
|
if (g_streams[i].client.load(std::memory_order_relaxed) == rc)
|
||||||
|
{
|
||||||
|
return; // already tracked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::uint32_t seen = g_streams_seen.fetch_add(1, std::memory_order_relaxed) + 1;
|
||||||
|
if (g_ipc != nullptr)
|
||||||
|
{
|
||||||
|
g_ipc->set_audio_streams_seen(seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::uint32_t slot = g_registered;
|
||||||
|
if (slot >= kMaxAudioStreams)
|
||||||
|
{
|
||||||
|
return; // more streams than debug slots; counted above, not detailed
|
||||||
|
}
|
||||||
|
g_registered = slot + 1;
|
||||||
|
|
||||||
|
g_streams[slot].frames.store(0, std::memory_order_relaxed);
|
||||||
|
g_streams[slot].client.store(rc, std::memory_order_release);
|
||||||
|
|
||||||
|
AudioStreamInfo info{};
|
||||||
|
info.is_primary = (slot == 0) ? 1u : 0u;
|
||||||
|
info.sample_rate = cf.rate;
|
||||||
|
info.channels = static_cast<std::uint16_t>(cf.channels);
|
||||||
|
info.bits = static_cast<std::uint16_t>(cf.bits);
|
||||||
|
info.format_tag = cf.tag;
|
||||||
|
info.frames_rendered = 0;
|
||||||
|
if (g_ipc != nullptr)
|
||||||
|
{
|
||||||
|
g_ipc->publish_audio_stream(slot, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slot == 0)
|
||||||
|
{
|
||||||
|
g_primary_block_align.store(cf.block_align, std::memory_order_relaxed);
|
||||||
|
g_primary.store(rc, std::memory_order_release);
|
||||||
|
if (AudioRingHeader* ring = g_ring.load(std::memory_order_acquire))
|
||||||
|
{
|
||||||
|
audio_ring_set_format(*ring, cf.rate, cf.channels, cf.bits, cf.tag, cf.block_align);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
install_render_client_hooks_once(rc);
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mode, DWORD flags,
|
||||||
|
REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity,
|
||||||
|
const WAVEFORMATEX* format, LPCGUID session)
|
||||||
|
{
|
||||||
|
const HRESULT hr =
|
||||||
|
g_hk_initialize.call<HRESULT>(self, mode, flags, buffer_duration, periodicity, format, session);
|
||||||
|
if (SUCCEEDED(hr) && format != nullptr)
|
||||||
|
{
|
||||||
|
std::scoped_lock lock(g_setup_mutex);
|
||||||
|
g_client_formats[self] = capture_format(format);
|
||||||
|
}
|
||||||
|
return hr;
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE hk_GetService(IAudioClient* self, REFIID riid, void** ppv)
|
||||||
|
{
|
||||||
|
const HRESULT hr = g_hk_getservice.call<HRESULT>(self, riid, ppv);
|
||||||
|
if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr && riid == __uuidof(IAudioRenderClient))
|
||||||
|
{
|
||||||
|
CapturedFormat cf;
|
||||||
|
bool have = false;
|
||||||
|
{
|
||||||
|
std::scoped_lock lock(g_setup_mutex);
|
||||||
|
auto it = g_client_formats.find(self);
|
||||||
|
if (it != g_client_formats.end())
|
||||||
|
{
|
||||||
|
cf = it->second;
|
||||||
|
have = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback for IAudioClient3::InitializeSharedAudioStream (no Initialize
|
||||||
|
// format): the shared-mode format is the device mix format.
|
||||||
|
if (!have)
|
||||||
|
{
|
||||||
|
WAVEFORMATEX* mix = nullptr;
|
||||||
|
if (SUCCEEDED(self->GetMixFormat(&mix)) && mix != nullptr)
|
||||||
|
{
|
||||||
|
cf = capture_format(mix);
|
||||||
|
have = true;
|
||||||
|
CoTaskMemFree(mix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (have)
|
||||||
|
{
|
||||||
|
std::scoped_lock lock(g_setup_mutex);
|
||||||
|
register_render_client_locked(static_cast<IAudioRenderClient*>(*ppv), cf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void install_audioclient_hooks(IAudioClient* ac)
|
||||||
|
{
|
||||||
|
std::scoped_lock lock(g_setup_mutex);
|
||||||
|
if (g_audioclient_hooked)
|
||||||
|
{
|
||||||
|
return; // shared vtable: hook the first IAudioClient we see, covers all
|
||||||
|
}
|
||||||
|
g_hk_initialize = safetyhook::create_inline(
|
||||||
|
vtable_method(ac, kIdx_IAudioClient_Initialize), reinterpret_cast<void*>(&hk_Initialize));
|
||||||
|
g_hk_getservice = safetyhook::create_inline(
|
||||||
|
vtable_method(ac, kIdx_IAudioClient_GetService), reinterpret_cast<void*>(&hk_GetService));
|
||||||
|
g_audioclient_hooked = (g_hk_initialize && g_hk_getservice);
|
||||||
|
}
|
||||||
|
|
||||||
|
HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ctx, PROPVARIANT* params,
|
||||||
|
void** ppv)
|
||||||
|
{
|
||||||
|
const HRESULT hr = g_hk_activate.call<HRESULT>(self, riid, cls_ctx, params, ppv);
|
||||||
|
if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr &&
|
||||||
|
(riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) ||
|
||||||
|
riid == __uuidof(IAudioClient3)))
|
||||||
|
{
|
||||||
|
install_audioclient_hooks(static_cast<IAudioClient*>(*ppv));
|
||||||
|
}
|
||||||
|
return hr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
|
||||||
|
{
|
||||||
|
std::scoped_lock lock(g_setup_mutex);
|
||||||
|
g_ipc = &ipc;
|
||||||
|
g_ring.store(ring, std::memory_order_release);
|
||||||
|
if (g_hk_activate)
|
||||||
|
{
|
||||||
|
return true; // anchor already installed
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
IMMDevice* device = nullptr;
|
||||||
|
HRESULT hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device);
|
||||||
|
if (FAILED(hr) || device == nullptr)
|
||||||
|
{
|
||||||
|
enumerator->Release();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_hk_activate = safetyhook::create_inline(
|
||||||
|
vtable_method(device, kIdx_IMMDevice_Activate), reinterpret_cast<void*>(&hk_Activate));
|
||||||
|
|
||||||
|
device->Release(); // vtable lives in the (still-loaded) audio COM module
|
||||||
|
enumerator->Release();
|
||||||
|
return static_cast<bool>(g_hk_activate);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_audio_ring(AudioRingHeader* ring)
|
||||||
|
{
|
||||||
|
g_ring.store(ring, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
void remove_audio_hooks()
|
||||||
|
{
|
||||||
|
std::scoped_lock lock(g_setup_mutex);
|
||||||
|
g_hk_releasebuffer = {};
|
||||||
|
g_hk_getbuffer = {};
|
||||||
|
g_hk_getservice = {};
|
||||||
|
g_hk_initialize = {};
|
||||||
|
g_hk_activate = {};
|
||||||
|
g_audioclient_hooked = false;
|
||||||
|
g_registered = 0;
|
||||||
|
g_streams_seen.store(0, std::memory_order_relaxed);
|
||||||
|
g_primary.store(nullptr, std::memory_order_release);
|
||||||
|
g_primary_block_align.store(0, std::memory_order_relaxed);
|
||||||
|
g_frames_captured.store(0, std::memory_order_relaxed);
|
||||||
|
for (auto& s : g_streams)
|
||||||
|
{
|
||||||
|
s.client.store(nullptr, std::memory_order_relaxed);
|
||||||
|
s.frames.store(0, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
g_client_formats.clear();
|
||||||
|
g_ring.store(nullptr, std::memory_order_release);
|
||||||
|
g_ipc = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t audio_frames_captured()
|
||||||
|
{
|
||||||
|
return g_frames_captured.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t audio_streams_seen()
|
||||||
|
{
|
||||||
|
return g_streams_seen.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace coop::hook
|
||||||
40
hook/src/audio_hook.hpp
Normal file
40
hook/src/audio_hook.hpp
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Injected WASAPI render-hook: captures the game's audio render frames into the
|
||||||
|
// shared audio ring and silences the game's local playback, so the host can
|
||||||
|
// re-render the audio for Steam Remote Play Together without the operator
|
||||||
|
// hearing it twice (the "local audio echo"). See docs/audio-render-hook-plan.md.
|
||||||
|
//
|
||||||
|
// The hooks always install (so render streams are counted for the debug view
|
||||||
|
// even with mirroring off); the copy+silence behavior is gated by the ring's
|
||||||
|
// host-owned capture_enabled flag.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include "coop/audio_ring.hpp"
|
||||||
|
#include "ipc_client.hpp"
|
||||||
|
|
||||||
|
namespace coop::hook
|
||||||
|
{
|
||||||
|
|
||||||
|
// Installs the render-path hooks. `ipc` must outlive the hooks (used for the
|
||||||
|
// stream-count diagnostics in HookStatus). `ring` may be null — counting still
|
||||||
|
// works; capture/silence only runs once a ring with capture_enabled is attached.
|
||||||
|
// Requires COM initialized (MTA) on the calling thread. Returns true once the
|
||||||
|
// anchor hook is in place; safe to call repeatedly (install-once internally).
|
||||||
|
bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring);
|
||||||
|
|
||||||
|
// Attach/replace the producer ring after install (e.g. host created it late).
|
||||||
|
void set_audio_ring(AudioRingHeader* ring);
|
||||||
|
|
||||||
|
// Removes all installed render hooks (best effort; used on DLL detach).
|
||||||
|
void remove_audio_hooks();
|
||||||
|
|
||||||
|
// --- Diagnostics (used by the self-test) -----------------------------------
|
||||||
|
|
||||||
|
// Cumulative frames the primary path copied to the ring and silenced locally.
|
||||||
|
std::uint64_t audio_frames_captured();
|
||||||
|
|
||||||
|
// Distinct render streams ever observed (may exceed kMaxAudioStreams).
|
||||||
|
std::uint32_t audio_streams_seen();
|
||||||
|
|
||||||
|
} // namespace coop::hook
|
||||||
@@ -119,6 +119,35 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Audio render-hook diagnostics -------------------------------------
|
||||||
|
|
||||||
|
// Total distinct render streams the audio hook has observed.
|
||||||
|
void set_audio_streams_seen(std::uint32_t count)
|
||||||
|
{
|
||||||
|
if (block_ != nullptr)
|
||||||
|
{
|
||||||
|
block_->status.audio_streams_seen = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish a tracked stream's format/role into its debug slot.
|
||||||
|
void publish_audio_stream(std::uint32_t slot, const AudioStreamInfo& info)
|
||||||
|
{
|
||||||
|
if (block_ != nullptr && slot < kMaxAudioStreams)
|
||||||
|
{
|
||||||
|
block_->status.audio_streams[slot] = info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a tracked stream's cumulative frame count (host derives live/idle).
|
||||||
|
void note_audio_frames(std::uint32_t slot, std::uint64_t frames)
|
||||||
|
{
|
||||||
|
if (block_ != nullptr && slot < kMaxAudioStreams)
|
||||||
|
{
|
||||||
|
block_->status.audio_streams[slot].frames_rendered = frames;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
SharedMemory shm_;
|
SharedMemory shm_;
|
||||||
SharedBlock* block_ = nullptr;
|
SharedBlock* block_ = nullptr;
|
||||||
|
|||||||
@@ -34,3 +34,24 @@ target_link_libraries(audio_loopback_test PRIVATE mmdevapi ole32)
|
|||||||
|
|
||||||
add_dependencies(audio_loopback_test coop_tone)
|
add_dependencies(audio_loopback_test coop_tone)
|
||||||
add_test(NAME audio_loopback_test COMMAND audio_loopback_test)
|
add_test(NAME audio_loopback_test COMMAND audio_loopback_test)
|
||||||
|
|
||||||
|
# In-process self-test for the WASAPI render-hook. Reuses the shipping
|
||||||
|
# audio_hook.cpp and drives a real WASAPI render path in the same process, so it
|
||||||
|
# exercises COM vtable discovery + GetBuffer/ReleaseBuffer interception with no
|
||||||
|
# game and no second Steam account (the audio analogue of hook_selftest).
|
||||||
|
add_executable(audio_hook_test
|
||||||
|
audio_hook_test.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/hook/src/audio_hook.cpp)
|
||||||
|
|
||||||
|
target_include_directories(audio_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src)
|
||||||
|
|
||||||
|
# IAudioClient3 / process-audio APIs want the Windows 10 20H1 (NTDDI_WIN10_CO) headers.
|
||||||
|
target_compile_definitions(audio_hook_test PRIVATE NTDDI_VERSION=0x0A00000B)
|
||||||
|
|
||||||
|
target_link_libraries(audio_hook_test PRIVATE
|
||||||
|
coop_common
|
||||||
|
safetyhook::safetyhook
|
||||||
|
ole32
|
||||||
|
mmdevapi)
|
||||||
|
|
||||||
|
add_test(NAME audio_hook_test COMMAND audio_hook_test)
|
||||||
|
|||||||
266
tests/audio_hook_test.cpp
Normal file
266
tests/audio_hook_test.cpp
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
// In-process self-test for the WASAPI render-hook (hook/src/audio_hook.cpp).
|
||||||
|
// This process plays both "game" and "hook": it installs the audio hooks, then
|
||||||
|
// renders a sine tone through WASAPI exactly like a game would. With the hooks
|
||||||
|
// live, that render path must (1) be discovered via the COM vtables, (2) copy
|
||||||
|
// the rendered frames into the shared audio ring (non-silent), (3) silence the
|
||||||
|
// local output, and (4) report exactly one render stream. No second Steam
|
||||||
|
// account, no real game. Exits 0 on pass, 1 on failure.
|
||||||
|
//
|
||||||
|
// Requires a working default render endpoint; on a headless machine it reports
|
||||||
|
// SKIP and exits 0 (mirrors audio_loopback_test).
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <audioclient.h>
|
||||||
|
#include <mmdeviceapi.h>
|
||||||
|
#include <mmreg.h>
|
||||||
|
|
||||||
|
#include "audio_hook.hpp"
|
||||||
|
#include "coop/audio_ring.hpp"
|
||||||
|
#include "coop/protocol.hpp"
|
||||||
|
#include "coop/shared_memory.hpp"
|
||||||
|
#include "ipc_client.hpp"
|
||||||
|
|
||||||
|
using namespace coop;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
|
||||||
|
int g_failures = 0;
|
||||||
|
|
||||||
|
void check(bool ok, const char* what)
|
||||||
|
{
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
std::printf(" FAIL: %s\n", what);
|
||||||
|
++g_failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T>
|
||||||
|
void release(T*& p)
|
||||||
|
{
|
||||||
|
if (p)
|
||||||
|
{
|
||||||
|
p->Release();
|
||||||
|
p = nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)))
|
||||||
|
{
|
||||||
|
std::printf("FAIL: CoInitializeEx\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Host side: create the IPC SharedBlock (named by our pid) so the hook's
|
||||||
|
// IpcClient can connect, and a producer audio ring with capture enabled.
|
||||||
|
SharedMemory shm;
|
||||||
|
if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock)))
|
||||||
|
{
|
||||||
|
std::printf("FAIL: create shared memory\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
auto* block = shm.as<SharedBlock>(); // mapping is zero-initialized by the OS
|
||||||
|
block->version = kProtocolVersion;
|
||||||
|
block->sequence.store(0, std::memory_order_relaxed);
|
||||||
|
block->magic = kProtocolMagic;
|
||||||
|
|
||||||
|
std::vector<std::uint8_t> ring_storage(audio_ring_total_size(kAudioRingCapacity), 0);
|
||||||
|
auto* ring = new (ring_storage.data()) AudioRingHeader();
|
||||||
|
audio_ring_init(*ring, kAudioRingCapacity);
|
||||||
|
ring->capture_enabled.store(1, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
hook::IpcClient ipc;
|
||||||
|
check(ipc.connect(10, 5), "IPC client connect");
|
||||||
|
|
||||||
|
// --- Install the render hooks BEFORE any audio client is created. ---
|
||||||
|
if (!hook::install_audio_hooks(ipc, ring))
|
||||||
|
{
|
||||||
|
std::printf("SKIP: could not install audio hooks (no default render endpoint?)\n");
|
||||||
|
CoUninitialize();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Game side: render a tone through WASAPI (the coop_tone render path). ---
|
||||||
|
IMMDeviceEnumerator* enumerator = nullptr;
|
||||||
|
IMMDevice* endpoint = nullptr;
|
||||||
|
IAudioClient* client = nullptr;
|
||||||
|
IAudioRenderClient* render = nullptr;
|
||||||
|
WAVEFORMATEX* fmt = nullptr;
|
||||||
|
HANDLE buffer_event = nullptr;
|
||||||
|
bool rendered = false;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
|
||||||
|
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator))))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (FAILED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint)))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (FAILED(endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
|
||||||
|
reinterpret_cast<void**>(&client))))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (FAILED(client->GetMixFormat(&fmt)))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
buffer_event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
|
||||||
|
constexpr REFERENCE_TIME kBuffer = 30 * 10000; // 30 ms
|
||||||
|
if (FAILED(client->Initialize(AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_EVENTCALLBACK, kBuffer,
|
||||||
|
0, fmt, nullptr)))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
client->SetEventHandle(buffer_event);
|
||||||
|
if (FAILED(client->GetService(__uuidof(IAudioRenderClient), reinterpret_cast<void**>(&render))))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
UINT32 buffer_frames = 0;
|
||||||
|
client->GetBufferSize(&buffer_frames);
|
||||||
|
|
||||||
|
const bool is_float =
|
||||||
|
fmt->wFormatTag == WAVE_FORMAT_IEEE_FLOAT ||
|
||||||
|
(fmt->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
|
||||||
|
reinterpret_cast<WAVEFORMATEXTENSIBLE*>(fmt)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT);
|
||||||
|
const unsigned channels = fmt->nChannels;
|
||||||
|
const double rate = fmt->nSamplesPerSec;
|
||||||
|
const double step = 2.0 * kPi * 440.0 / rate;
|
||||||
|
|
||||||
|
auto write_frames = [&](UINT32 frames, double& phase) {
|
||||||
|
BYTE* data = nullptr;
|
||||||
|
if (frames == 0 || FAILED(render->GetBuffer(frames, &data)))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (UINT32 i = 0; i < frames; ++i)
|
||||||
|
{
|
||||||
|
const double s = std::sin(phase) * 0.25;
|
||||||
|
phase += step;
|
||||||
|
if (phase > 2.0 * kPi)
|
||||||
|
{
|
||||||
|
phase -= 2.0 * kPi;
|
||||||
|
}
|
||||||
|
for (unsigned c = 0; c < channels; ++c)
|
||||||
|
{
|
||||||
|
if (is_float)
|
||||||
|
{
|
||||||
|
reinterpret_cast<float*>(data)[i * channels + c] = static_cast<float>(s);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
reinterpret_cast<INT16*>(data)[i * channels + c] =
|
||||||
|
static_cast<INT16>(s * 32767.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
render->ReleaseBuffer(frames, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
double phase = 0.0;
|
||||||
|
write_frames(buffer_frames, phase); // pre-roll
|
||||||
|
client->Start();
|
||||||
|
const DWORD end_tick = GetTickCount() + 800; // ~0.8 s of rendering
|
||||||
|
while (GetTickCount() < end_tick)
|
||||||
|
{
|
||||||
|
if (WaitForSingleObject(buffer_event, 200) != WAIT_OBJECT_0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
UINT32 padding = 0;
|
||||||
|
if (FAILED(client->GetCurrentPadding(&padding)))
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
write_frames(buffer_frames - padding, phase);
|
||||||
|
}
|
||||||
|
client->Stop();
|
||||||
|
rendered = true;
|
||||||
|
} while (false);
|
||||||
|
|
||||||
|
if (!rendered)
|
||||||
|
{
|
||||||
|
std::printf("SKIP: could not render through WASAPI on this machine\n");
|
||||||
|
release(render);
|
||||||
|
release(client);
|
||||||
|
release(endpoint);
|
||||||
|
release(enumerator);
|
||||||
|
if (fmt)
|
||||||
|
{
|
||||||
|
CoTaskMemFree(fmt);
|
||||||
|
}
|
||||||
|
if (buffer_event)
|
||||||
|
{
|
||||||
|
CloseHandle(buffer_event);
|
||||||
|
}
|
||||||
|
hook::remove_audio_hooks();
|
||||||
|
CoUninitialize();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Assertions: the hook discovered and intercepted the render path. ---
|
||||||
|
std::printf("streams_seen=%u, frames_captured=%llu, ring frames_produced=%llu\n",
|
||||||
|
hook::audio_streams_seen(),
|
||||||
|
static_cast<unsigned long long>(hook::audio_frames_captured()),
|
||||||
|
static_cast<unsigned long long>(ring->frames_produced.load()));
|
||||||
|
|
||||||
|
check(hook::audio_streams_seen() == 1, "exactly one render stream observed");
|
||||||
|
check(block->status.audio_streams_seen == 1, "stream count published to HookStatus");
|
||||||
|
check(block->status.audio_streams[0].is_primary == 1, "slot 0 marked primary");
|
||||||
|
check(block->status.audio_streams[0].sample_rate == fmt->nSamplesPerSec, "primary sample rate published");
|
||||||
|
check(block->status.audio_streams[0].frames_rendered > 0, "primary frames_rendered advancing");
|
||||||
|
check(ring->frames_produced.load() > 0, "frames pushed to the audio ring");
|
||||||
|
check(hook::audio_frames_captured() > 0, "frames captured + silenced");
|
||||||
|
|
||||||
|
// The ring must hold the actual (non-silent) tone we rendered.
|
||||||
|
{
|
||||||
|
std::vector<std::uint8_t> buf(64 * 1024, 0);
|
||||||
|
const std::uint32_t got = audio_ring_pop(*ring, buf.data(), static_cast<std::uint32_t>(buf.size()));
|
||||||
|
bool nonsilent = false;
|
||||||
|
for (std::uint32_t i = 0; i < got; ++i)
|
||||||
|
{
|
||||||
|
if (buf[i] != 0)
|
||||||
|
{
|
||||||
|
nonsilent = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(got > 0 && nonsilent, "ring carries non-silent captured audio");
|
||||||
|
}
|
||||||
|
|
||||||
|
release(render);
|
||||||
|
release(client);
|
||||||
|
release(endpoint);
|
||||||
|
release(enumerator);
|
||||||
|
if (fmt)
|
||||||
|
{
|
||||||
|
CoTaskMemFree(fmt);
|
||||||
|
}
|
||||||
|
if (buffer_event)
|
||||||
|
{
|
||||||
|
CloseHandle(buffer_event);
|
||||||
|
}
|
||||||
|
hook::remove_audio_hooks();
|
||||||
|
CoUninitialize();
|
||||||
|
|
||||||
|
std::printf(g_failures == 0 ? "AUDIO HOOK TEST PASS\n" : "AUDIO HOOK TEST FAILED (%d)\n", g_failures);
|
||||||
|
return g_failures == 0 ? 0 : 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user