diff --git a/CMakeLists.txt b/CMakeLists.txt index c737c04..976fbba 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,6 +44,7 @@ if(COOP_BUILD_HOOK) add_subdirectory(third_party/safetyhook) add_subdirectory(hook) 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) endif() diff --git a/hook/CMakeLists.txt b/hook/CMakeLists.txt index b294e41..105cdd2 100644 --- a/hook/CMakeLists.txt +++ b/hook/CMakeLists.txt @@ -2,7 +2,8 @@ add_library(coop_hook SHARED src/dllmain.cpp src/xinput_hook.cpp src/focus_spoof.cpp - src/audio_hook.cpp) + src/audio_hook.cpp + src/debug_log.cpp) target_include_directories(coop_hook PRIVATE src) diff --git a/hook/src/audio_hook.cpp b/hook/src/audio_hook.cpp index 1a4f259..f31069b 100644 --- a/hook/src/audio_hook.cpp +++ b/hook/src/audio_hook.cpp @@ -13,6 +13,8 @@ #include +#include "debug_log.hpp" + namespace coop::hook { @@ -53,6 +55,21 @@ safetyhook::InlineHook g_hk_getbuffer; safetyhook::InlineHook g_hk_releasebuffer; 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 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 g_have_mix_format{0}; + // 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 g_client_formats; @@ -112,6 +129,9 @@ CapturedFormat capture_format(const WAVEFORMATEX* wfx) // --- 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) { const HRESULT hr = g_hk_getbuffer.call(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) { + // 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). 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(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(&hk_GetBuffer)); - g_hk_releasebuffer = safetyhook::create_inline( - vtable_method(rc, kIdx_IAudioRenderClient_ReleaseBuffer), reinterpret_cast(&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. @@ -195,6 +211,8 @@ void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat& { 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) @@ -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.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); } } + // 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 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, @@ -237,6 +293,8 @@ HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mo { const HRESULT hr = g_hk_initialize.call(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(flags), static_cast(hr), format ? "yes" : "null"); if (SUCCEEDED(hr) && format != nullptr) { 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) { const HRESULT hr = g_hk_getservice.call(self, riid, ppv); + const bool is_render = (riid == __uuidof(IAudioRenderClient)); + logf("hk_GetService: client=%p hr=0x%08lX render_client=%d", self, static_cast(hr), + is_render ? 1 : 0); if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr && riid == __uuidof(IAudioRenderClient)) { CapturedFormat cf; @@ -294,15 +355,19 @@ void install_audioclient_hooks(IAudioClient* ac) g_hk_getservice = safetyhook::create_inline( vtable_method(ac, kIdx_IAudioClient_GetService), reinterpret_cast(&hk_GetService)); g_audioclient_hooked = (g_hk_initialize && g_hk_getservice); + logf("install_audioclient_hooks: ac=%p initialize=%d getservice=%d", ac, + static_cast(g_hk_initialize) ? 1 : 0, static_cast(g_hk_getservice) ? 1 : 0); } HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ctx, PROPVARIANT* params, void** ppv) { const HRESULT hr = g_hk_activate.call(self, riid, cls_ctx, params, ppv); - if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr && - (riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) || - riid == __uuidof(IAudioClient3))) + 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(hr), + is_audioclient ? 1 : 0); + if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr && is_audioclient) { install_audioclient_hooks(static_cast(*ppv)); } @@ -338,9 +403,70 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring) 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(&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(&self_render)); + } + logf("install_audio_hooks: probe client init=0x%08lX render=%p mix=%uHz/%uch/%ubit tag=%u", + static_cast(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(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( vtable_method(device, kIdx_IMMDevice_Activate), reinterpret_cast(&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(&hk_Initialize)); + g_hk_getservice = safetyhook::create_inline( + vtable_method(g_self_client, kIdx_IAudioClient_GetService), reinterpret_cast(&hk_GetService)); + g_hk_getbuffer = safetyhook::create_inline( + vtable_method(self_render, kIdx_IAudioRenderClient_GetBuffer), reinterpret_cast(&hk_GetBuffer)); + g_hk_releasebuffer = safetyhook::create_inline( + vtable_method(self_render, kIdx_IAudioRenderClient_ReleaseBuffer), + reinterpret_cast(&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(g_hk_activate) ? 1 : 0, static_cast(g_hk_initialize) ? 1 : 0, + static_cast(g_hk_getservice) ? 1 : 0, static_cast(g_hk_getbuffer) ? 1 : 0, + static_cast(g_hk_releasebuffer) ? 1 : 0, device); + device->Release(); // vtable lives in the (still-loaded) audio COM module enumerator->Release(); return static_cast(g_hk_activate); @@ -349,6 +475,8 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring) void set_audio_ring(AudioRingHeader* ring) { 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() @@ -360,6 +488,19 @@ void remove_audio_hooks() g_hk_initialize = {}; g_hk_activate = {}; 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_streams_seen.store(0, std::memory_order_relaxed); g_primary.store(nullptr, std::memory_order_release); diff --git a/hook/src/debug_log.cpp b/hook/src/debug_log.cpp new file mode 100644 index 0000000..486c1a4 --- /dev/null +++ b/hook/src/debug_log.cpp @@ -0,0 +1,86 @@ +#define _CRT_SECURE_NO_WARNINGS +#include "debug_log.hpp" + +#include +#include +#include + +#include + +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 diff --git a/hook/src/debug_log.hpp b/hook/src/debug_log.hpp new file mode 100644 index 0000000..fba4141 --- /dev/null +++ b/hook/src/debug_log.hpp @@ -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 diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index 6155ddf..ca897ae 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -15,6 +15,7 @@ #include "audio_hook.hpp" #include "coop/audio_ring.hpp" #include "coop/shared_memory.hpp" +#include "debug_log.hpp" #include "focus_spoof.hpp" #include "ipc_client.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) { + coop::hook::logf("worker_thread: started"); + // The host creates the mapping around injection time; give it a few seconds. if (!g_ipc.connect(/*attempts=*/200, /*delay_ms=*/25)) { + coop::hook::logf("worker_thread: IPC connect FAILED (no host mapping); exiting"); return 0; } + coop::hook::logf("worker_thread: IPC connected"); // The audio render-hook instantiates a COM enumerator on this thread. 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 focus_installed = false; @@ -60,6 +66,10 @@ DWORD WINAPI worker_thread(LPVOID) if (com_ok && !audio_installed) { 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) { @@ -71,6 +81,7 @@ DWORD WINAPI worker_thread(LPVOID) { coop::hook::set_audio_ring(ring); audio_ring_open = true; + coop::hook::logf("worker_thread: audio ring opened"); } else { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 953571b..45c94d3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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). add_executable(audio_hook_test 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) diff --git a/tools/audio_probe/CMakeLists.txt b/tools/audio_probe/CMakeLists.txt new file mode 100644 index 0000000..cf3c566 --- /dev/null +++ b/tools/audio_probe/CMakeLists.txt @@ -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") diff --git a/tools/audio_probe/main.cpp b/tools/audio_probe/main.cpp new file mode 100644 index 0000000..9285c41 --- /dev/null +++ b/tools/audio_probe/main.cpp @@ -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 [seconds] +// +// Run from the same directory as coop_hook.dll (i.e. bin//). + +#include +#include +#include +#include +#include +#include + +#include + +#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( + 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 [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(); + 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::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 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(drain.size()))) > 0) + { + if (ring->format_tag == 3 /*IEEE_FLOAT*/ && ring->bits == 32) + { + const auto* f = reinterpret_cast(drain.data()); + for (std::uint32_t i = 0; i < got / 4; ++i) + { + peak = std::max(peak, static_cast(std::abs(f[i]))); + } + } + else if (ring->bits == 16) + { + const auto* s = reinterpret_cast(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(produced), + static_cast(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(s.frames_rendered), + 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; +}