Layout persistence: re-enable io.IniFilename (was nullptr "for the spike"), anchored to a coop_layout.ini next to the exe so window positions/sizes survive restarts even when Steam launches us under the donor appid (CWD is unreliable). Path is UTF-8 for ImGui's file IO. When a saved layout is restored at startup, suppress the computed-default force so it does not clobber the user's positions; Reset layout (and a fresh install with no .ini) still applies the default. Log spam: the worker thread re-attaches every audio ring every tick (idempotent), and set_audio_ring logged unconditionally, flooding the log. Only log when the ring pointer actually changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
670 lines
25 KiB
C++
670 lines
25 KiB
C++
#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 "debug_log.hpp"
|
|
#include "hook_registry.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;
|
|
|
|
// Original COM method signatures (all __stdcall via STDMETHODCALLTYPE). We call
|
|
// the originals through the saved vtable pointers, so these types must match the
|
|
// real interfaces exactly.
|
|
using ActivateFn = HRESULT(STDMETHODCALLTYPE*)(IMMDevice*, REFIID, DWORD, PROPVARIANT*, void**);
|
|
using InitializeFn = HRESULT(STDMETHODCALLTYPE*)(IAudioClient*, AUDCLNT_SHAREMODE, DWORD, REFERENCE_TIME,
|
|
REFERENCE_TIME, const WAVEFORMATEX*, LPCGUID);
|
|
using GetServiceFn = HRESULT(STDMETHODCALLTYPE*)(IAudioClient*, REFIID, void**);
|
|
using GetBufferFn = HRESULT(STDMETHODCALLTYPE*)(IAudioRenderClient*, UINT32, BYTE**);
|
|
using ReleaseBufferFn = HRESULT(STDMETHODCALLTYPE*)(IAudioRenderClient*, UINT32, DWORD);
|
|
|
|
// Hooks one COM vtable slot by overwriting its function pointer; the original is
|
|
// called through the saved pointer. We use this instead of SafetyHook's inline
|
|
// hooks for the WASAPI COM methods because, on x86, MMDevApi/AudioSes prologues
|
|
// use dynamic stack alignment (`and esp,-8`) with EBP-relative argument access,
|
|
// which SafetyHook's trampoline relocation mishandles: the relocated prologue
|
|
// leaves EBP wrong, so the original reads garbage arguments and faults (it froze
|
|
// 32-bit FMOD games the instant audio init ran through the hook). Swapping the
|
|
// vtable entry leaves the original code untouched, so it runs with a pristine
|
|
// stack regardless of prologue shape. Every instance of a COM coclass shares one
|
|
// vtable, so a single swap intercepts all of them (the same property the old
|
|
// inline approach relied on). See the project's stdcall-x86 note.
|
|
class VtableHook
|
|
{
|
|
public:
|
|
bool install(void* com_object, unsigned index, void* detour)
|
|
{
|
|
if (m_vtable != nullptr)
|
|
{
|
|
return true; // already installed (shared vtable covers every instance)
|
|
}
|
|
auto** vtable = *reinterpret_cast<void***>(com_object);
|
|
DWORD old_protect = 0;
|
|
if (!VirtualProtect(&vtable[index], sizeof(void*), PAGE_READWRITE, &old_protect))
|
|
{
|
|
return false;
|
|
}
|
|
m_original = vtable[index];
|
|
vtable[index] = detour; // aligned pointer store -> atomic vs. a concurrent caller
|
|
VirtualProtect(&vtable[index], sizeof(void*), old_protect, &old_protect);
|
|
m_vtable = vtable;
|
|
m_index = index;
|
|
return true;
|
|
}
|
|
|
|
void remove()
|
|
{
|
|
if (m_vtable == nullptr)
|
|
{
|
|
return;
|
|
}
|
|
DWORD old_protect = 0;
|
|
if (VirtualProtect(&m_vtable[m_index], sizeof(void*), PAGE_READWRITE, &old_protect))
|
|
{
|
|
m_vtable[m_index] = m_original;
|
|
VirtualProtect(&m_vtable[m_index], sizeof(void*), old_protect, &old_protect);
|
|
}
|
|
m_vtable = nullptr;
|
|
m_original = nullptr;
|
|
m_index = 0;
|
|
}
|
|
|
|
template <typename Fn> Fn original() const { return reinterpret_cast<Fn>(m_original); }
|
|
explicit operator bool() const { return m_vtable != nullptr; }
|
|
|
|
private:
|
|
void** m_vtable = nullptr;
|
|
unsigned m_index = 0;
|
|
void* m_original = nullptr;
|
|
};
|
|
|
|
// 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;
|
|
// One ring per tracked stream (index = the stream's debug slot). Stream 0 is the
|
|
// primary; the host creates a ring per stream and mixes them.
|
|
std::atomic<AudioRingHeader*> g_rings[kMaxAudioStreams]{};
|
|
|
|
std::mutex g_setup_mutex; // guards installs + the format map + stream registration
|
|
|
|
VtableHook g_vh_activate;
|
|
VtableHook g_vh_initialize;
|
|
VtableHook g_vh_getservice;
|
|
VtableHook g_vh_getbuffer;
|
|
VtableHook g_vh_releasebuffer;
|
|
bool g_audioclient_hooked = false;
|
|
|
|
// Registry ids for the hook list.
|
|
int g_id_activate = -1;
|
|
int g_id_initialize = -1;
|
|
int g_id_getservice = -1;
|
|
int g_id_getbuffer = -1;
|
|
int g_id_releasebuffer = -1;
|
|
|
|
// 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};
|
|
|
|
// Each tracked stream's actual format, captured when it's registered. The host
|
|
// creates the rings only when audio mirroring is toggled on — typically *after* a
|
|
// stream was already registered — so the format must be (re)published to a ring
|
|
// whenever it attaches. Guarded by g_setup_mutex.
|
|
CapturedFormat g_stream_formats[kMaxAudioStreams];
|
|
|
|
// 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 (frame counting + per-stream capture). Index 0 is primary.
|
|
struct TrackedStream
|
|
{
|
|
std::atomic<IAudioRenderClient*> client{nullptr};
|
|
std::atomic<std::uint64_t> frames{0};
|
|
std::atomic<std::uint32_t> block_align{0}; // hot-path frame size for this stream
|
|
};
|
|
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
|
|
|
|
std::atomic<std::uint64_t> g_frames_captured{0}; // total frames captured across streams
|
|
|
|
// 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;
|
|
|
|
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) -----------
|
|
|
|
bool stream_tracked(IAudioRenderClient* rc);
|
|
void try_register_lazy(IAudioRenderClient* rc);
|
|
|
|
HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data)
|
|
{
|
|
hook_note_call(g_id_getbuffer);
|
|
const HRESULT hr = g_vh_getbuffer.original<GetBufferFn>()(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)
|
|
{
|
|
hook_note_call(g_id_releasebuffer);
|
|
// A render client we've never seen actively rendering is almost certainly one
|
|
// the game created before we injected; adopt it now (the first becomes the
|
|
// 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);
|
|
}
|
|
|
|
// Per tracked stream: count frames (debug view) and, into the stream's own ring,
|
|
// capture + silence its buffer while capture is enabled. Every stream is captured
|
|
// into its own ring; the host mixes them.
|
|
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
|
{
|
|
if (g_streams[i].client.load(std::memory_order_acquire) != self)
|
|
{
|
|
continue;
|
|
}
|
|
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);
|
|
}
|
|
|
|
if (num_frames > 0 && (flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0)
|
|
{
|
|
AudioRingHeader* ring = g_rings[i].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_streams[i].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 (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);
|
|
return g_vh_releasebuffer.original<ReleaseBufferFn>()(
|
|
self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
return g_vh_releasebuffer.original<ReleaseBufferFn>()(self, num_frames, flags);
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
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;
|
|
if (slot >= kMaxAudioStreams)
|
|
{
|
|
return; // more streams than debug slots; counted above, not detailed
|
|
}
|
|
g_registered = slot + 1;
|
|
|
|
g_stream_formats[slot] = cf;
|
|
g_streams[slot].frames.store(0, std::memory_order_relaxed);
|
|
g_streams[slot].block_align.store(cf.block_align, std::memory_order_relaxed); // before client (hot path)
|
|
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);
|
|
}
|
|
|
|
// Publish this stream's format to its own ring if the host has attached one yet.
|
|
AudioRingHeader* ring = g_rings[slot].load(std::memory_order_acquire);
|
|
logf("stream %u set: rc=%p ring=%p fmt=%uHz/%uch/%ubit (%s)", slot, rc, ring, cf.rate, cf.channels, cf.bits,
|
|
ring ? "published" : "no ring yet");
|
|
if (ring != nullptr)
|
|
{
|
|
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.
|
|
}
|
|
|
|
// 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,
|
|
REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity,
|
|
const WAVEFORMATEX* format, LPCGUID session)
|
|
{
|
|
hook_note_call(g_id_initialize);
|
|
const HRESULT hr = g_vh_initialize.original<InitializeFn>()(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)
|
|
{
|
|
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)
|
|
{
|
|
hook_note_call(g_id_getservice);
|
|
const HRESULT hr = g_vh_getservice.original<GetServiceFn>()(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))
|
|
{
|
|
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_vh_initialize.install(ac, kIdx_IAudioClient_Initialize, reinterpret_cast<void*>(&hk_Initialize));
|
|
g_vh_getservice.install(ac, kIdx_IAudioClient_GetService, reinterpret_cast<void*>(&hk_GetService));
|
|
g_audioclient_hooked = (static_cast<bool>(g_vh_initialize) && static_cast<bool>(g_vh_getservice));
|
|
logf("install_audioclient_hooks: ac=%p initialize=%d getservice=%d", ac,
|
|
static_cast<bool>(g_vh_initialize) ? 1 : 0, static_cast<bool>(g_vh_getservice) ? 1 : 0);
|
|
}
|
|
|
|
HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ctx, PROPVARIANT* params,
|
|
void** ppv)
|
|
{
|
|
hook_note_call(g_id_activate);
|
|
const HRESULT hr = g_vh_activate.original<ActivateFn>()(self, riid, cls_ctx, params, ppv);
|
|
const bool is_audioclient = (riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) ||
|
|
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));
|
|
}
|
|
return hr;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
|
|
{
|
|
std::scoped_lock lock(g_setup_mutex);
|
|
g_ipc = &ipc;
|
|
g_rings[0].store(ring, std::memory_order_release);
|
|
if (g_vh_activate)
|
|
{
|
|
return true; // anchor already installed
|
|
}
|
|
|
|
g_id_activate = hook_register("IMMDevice::Activate", HookSubsys_Audio);
|
|
g_id_initialize = hook_register("IAudioClient::Initialize", HookSubsys_Audio);
|
|
g_id_getservice = hook_register("IAudioClient::GetService", HookSubsys_Audio);
|
|
g_id_getbuffer = hook_register("IAudioRenderClient::GetBuffer", HookSubsys_Audio);
|
|
g_id_releasebuffer = hook_register("IAudioRenderClient::ReleaseBuffer", HookSubsys_Audio);
|
|
|
|
// Anchor: instantiate our own enumerator + default render device purely to
|
|
// read the shared IMMDevice vtable and hook Activate. Every IMMDevice in the
|
|
// process shares this vtable, so the game's Activate calls are intercepted.
|
|
IMMDeviceEnumerator* enumerator = nullptr;
|
|
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
|
|
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator))))
|
|
{
|
|
return false;
|
|
}
|
|
IMMDevice* device = nullptr;
|
|
HRESULT hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device);
|
|
if (FAILED(hr) || device == nullptr)
|
|
{
|
|
enumerator->Release();
|
|
return false;
|
|
}
|
|
|
|
// Build our *own* client + render client first (no hook is live yet). We attach
|
|
// to a game that's usually already playing, so its IAudioClient /
|
|
// IAudioRenderClient predate us and we'll never see their Activate/GetService;
|
|
// but every instance of each coclass shares one vtable, so hooking the slots on
|
|
// *our* objects' vtables patches the shared vtables and intercepts the game's
|
|
// pre-existing objects too.
|
|
g_self_client = nullptr;
|
|
IAudioRenderClient* self_render = nullptr;
|
|
hr = device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
|
|
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 by swapping vtable slots. Anchor Activate (idx 3)
|
|
// catches streams created after us; the inner hooks catch every render client
|
|
// on the shared vtables.
|
|
g_vh_activate.install(device, kIdx_IMMDevice_Activate, reinterpret_cast<void*>(&hk_Activate));
|
|
|
|
if (self_render != nullptr)
|
|
{
|
|
g_self_render.store(self_render, std::memory_order_release);
|
|
g_vh_initialize.install(g_self_client, kIdx_IAudioClient_Initialize,
|
|
reinterpret_cast<void*>(&hk_Initialize));
|
|
g_vh_getservice.install(g_self_client, kIdx_IAudioClient_GetService,
|
|
reinterpret_cast<void*>(&hk_GetService));
|
|
g_vh_getbuffer.install(self_render, kIdx_IAudioRenderClient_GetBuffer,
|
|
reinterpret_cast<void*>(&hk_GetBuffer));
|
|
g_vh_releasebuffer.install(self_render, kIdx_IAudioRenderClient_ReleaseBuffer,
|
|
reinterpret_cast<void*>(&hk_ReleaseBuffer));
|
|
g_audioclient_hooked = (static_cast<bool>(g_vh_initialize) && static_cast<bool>(g_vh_getservice));
|
|
// Keep g_self_client + self_render alive (held in globals) so the vtables
|
|
// stay valid; they're released in remove_audio_hooks.
|
|
}
|
|
|
|
hook_set_installed(g_id_activate, static_cast<bool>(g_vh_activate));
|
|
hook_set_installed(g_id_initialize, static_cast<bool>(g_vh_initialize));
|
|
hook_set_installed(g_id_getservice, static_cast<bool>(g_vh_getservice));
|
|
hook_set_installed(g_id_getbuffer, static_cast<bool>(g_vh_getbuffer));
|
|
hook_set_installed(g_id_releasebuffer, static_cast<bool>(g_vh_releasebuffer));
|
|
|
|
logf("install_audio_hooks: activate=%d init=%d getsvc=%d getbuf=%d relbuf=%d (device=%p)",
|
|
static_cast<bool>(g_vh_activate) ? 1 : 0, static_cast<bool>(g_vh_initialize) ? 1 : 0,
|
|
static_cast<bool>(g_vh_getservice) ? 1 : 0, static_cast<bool>(g_vh_getbuffer) ? 1 : 0,
|
|
static_cast<bool>(g_vh_releasebuffer) ? 1 : 0, device);
|
|
|
|
device->Release(); // vtable lives in the (still-loaded) audio COM module
|
|
enumerator->Release();
|
|
return static_cast<bool>(g_vh_activate);
|
|
}
|
|
|
|
void republish_audio_format()
|
|
{
|
|
// Nothing to do if no ring needs a format yet (cheap pre-check, no lock).
|
|
bool any_pending = false;
|
|
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
|
{
|
|
AudioRingHeader* ring = g_rings[i].load(std::memory_order_acquire);
|
|
if (ring != nullptr && !audio_ring_format_ready(*ring))
|
|
{
|
|
any_pending = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!any_pending)
|
|
{
|
|
return;
|
|
}
|
|
std::scoped_lock lock(g_setup_mutex);
|
|
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
|
{
|
|
AudioRingHeader* ring = g_rings[i].load(std::memory_order_acquire);
|
|
if (ring == nullptr || audio_ring_format_ready(*ring) || g_stream_formats[i].rate == 0)
|
|
{
|
|
continue; // no ring, already published, or this slot has no stream yet
|
|
}
|
|
const CapturedFormat& cf = g_stream_formats[i];
|
|
audio_ring_set_format(*ring, cf.rate, cf.channels, cf.bits, cf.tag, cf.block_align);
|
|
logf("republish_audio_format: stream %u -> %uHz/%uch/%ubit ring %p", i, cf.rate, cf.channels, cf.bits,
|
|
ring);
|
|
}
|
|
}
|
|
|
|
void set_audio_ring(unsigned index, AudioRingHeader* ring)
|
|
{
|
|
if (index >= kMaxAudioStreams)
|
|
{
|
|
return;
|
|
}
|
|
// The worker thread re-attaches every tick (idempotent); only log when the ring
|
|
// pointer actually changes so the log isn't flooded with identical lines.
|
|
AudioRingHeader* const prev = g_rings[index].exchange(ring, std::memory_order_acq_rel);
|
|
if (prev != ring)
|
|
{
|
|
logf("set_audio_ring: index=%u ring=%p capture_enabled=%u", index, ring,
|
|
ring ? ring->capture_enabled.load(std::memory_order_relaxed) : 0u);
|
|
}
|
|
// The stream may already be registered (game was playing before we injected and
|
|
// before the host created the ring); publish its format so the host stops waiting
|
|
// and consumes the ring instead of falling back to loopback.
|
|
republish_audio_format();
|
|
}
|
|
|
|
void remove_audio_hooks()
|
|
{
|
|
std::scoped_lock lock(g_setup_mutex);
|
|
g_vh_releasebuffer.remove();
|
|
g_vh_getbuffer.remove();
|
|
g_vh_getservice.remove();
|
|
g_vh_initialize.remove();
|
|
g_vh_activate.remove();
|
|
g_audioclient_hooked = false;
|
|
hook_set_installed(g_id_activate, false);
|
|
hook_set_installed(g_id_initialize, false);
|
|
hook_set_installed(g_id_getservice, false);
|
|
hook_set_installed(g_id_getbuffer, false);
|
|
hook_set_installed(g_id_releasebuffer, false);
|
|
|
|
// Hooks are gone; safe to drop the probe objects that held the vtables.
|
|
if (IAudioRenderClient* sr = g_self_render.exchange(nullptr, std::memory_order_acq_rel))
|
|
{
|
|
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_streams_seen.store(0, std::memory_order_relaxed);
|
|
g_frames_captured.store(0, std::memory_order_relaxed);
|
|
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
|
{
|
|
g_streams[i].client.store(nullptr, std::memory_order_relaxed);
|
|
g_streams[i].frames.store(0, std::memory_order_relaxed);
|
|
g_streams[i].block_align.store(0, std::memory_order_relaxed);
|
|
g_stream_formats[i] = CapturedFormat{};
|
|
g_rings[i].store(nullptr, std::memory_order_release);
|
|
}
|
|
g_client_formats.clear();
|
|
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
|