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:
2026-06-19 11:57:28 +02:00
parent 8e93312925
commit 4e3814a072
6 changed files with 747 additions and 2 deletions

388
hook/src/audio_hook.cpp Normal file
View 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
View 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

View File

@@ -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:
SharedMemory shm_;
SharedBlock* block_ = nullptr;