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>
267 lines
7.3 KiB
C++
267 lines
7.3 KiB
C++
// 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;
|
|
}
|