// 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/log_ring.hpp" #include "coop/protocol.hpp" #include "coop/shared_memory.hpp" #include "coop/tool_paths.hpp" namespace { // coop_hook.dll ships in the deployable bin// root; this probe runs from // bin//tools/, so resolve next-to-self first, then one level up. std::wstring dll_path_next_to_self() { return coop::deployed_artifact_path(L"coop_hook.dll"); } std::wstring sibling_of(const std::wstring& path, const wchar_t* name) { const size_t slash = path.find_last_of(L"\\/"); return (slash == std::wstring::npos ? std::wstring() : path.substr(0, slash + 1)) + name; } // Inject a 32-bit (WOW64) target via the x86 helper, mirroring the host. Lets the // probe exercise the x86 hook end-to-end (the IPC channels are bitness-agnostic). bool inject_via_helper(unsigned long pid, const std::wstring& dll_path) { const std::wstring helper = sibling_of(dll_path, L"coop_inject_x86.exe"); const std::wstring x86_dll = sibling_of(dll_path, L"coop_hook_x86.dll"); if (GetFileAttributesW(helper.c_str()) == INVALID_FILE_ATTRIBUTES || GetFileAttributesW(x86_dll.c_str()) == INVALID_FILE_ATTRIBUTES) { std::printf("ERROR: x86 helper/dll missing next to the probe.\n"); return false; } std::wstring cmd = L"\"" + helper + L"\" " + std::to_wstring(pid) + L" \"" + x86_dll + L"\""; std::printf("32-bit target: injecting coop_hook_x86.dll via coop_inject_x86.exe ...\n"); STARTUPINFOW si{}; si.cb = sizeof(si); PROCESS_INFORMATION pi{}; if (!CreateProcessW(helper.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &si, &pi)) { std::printf("ERROR: CreateProcess(coop_inject_x86) failed (%lu).\n", GetLastError()); return false; } WaitForSingleObject(pi.hProcess, INFINITE); DWORD code = 1; GetExitCodeProcess(pi.hProcess, &code); CloseHandle(pi.hThread); CloseHandle(pi.hProcess); if (code != 0) { std::printf("ERROR: coop_inject_x86 reported failure (exit %lu).\n", code); return false; } return true; } 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; } // 32-bit target -> delegate to the x86 helper (a 64-bit process can't inject it). USHORT proc_machine = IMAGE_FILE_MACHINE_UNKNOWN, native_machine = IMAGE_FILE_MACHINE_UNKNOWN; if (IsWow64Process2(process, &proc_machine, &native_machine) && proc_machine != IMAGE_FILE_MACHINE_UNKNOWN) { CloseHandle(process); return inject_via_helper(pid, dll_path); } 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] [ring_delay_ms]\n" " ring_delay_ms: how long after injecting to create the audio ring\n" " (default 1500 = reproduces the real app, which creates the ring\n" " only when audio mirroring is toggled on; 0 = ring before inject).\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; const int ring_delay_ms = (argc >= 4) ? std::max(0, _wtoi(argv[3])) : 1500; const bool audio_enabled = (argc >= 5) ? _wtoi(argv[4]) != 0 : true; // arg5=0 tests unhooking audio 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); if (!audio_enabled) { // Request the hook NOT install the audio subsystem (control-channel test). block->control.subsystem_disabled[coop::HookSubsys_Audio].store(1, std::memory_order_release); std::printf("Audio subsystem requested OFF (control channel test).\n"); } block->magic = coop::kProtocolMagic; // Log ring (host's role): the hook opens this and streams its log lines back. coop::SharedMemory log_shm; coop::LogRing* log_ring = nullptr; std::uint64_t log_cursor = 0; if (log_shm.create(coop::log_ring_name(pid), coop::log_ring_total_size(coop::kLogCapacity))) { log_ring = log_shm.as(); coop::log_ring_init(*log_ring, coop::kLogCapacity); } // 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); } } } // Create the audio ring (capture enabled), mirroring AudioMirror::thread_main. // By default we do this *after* injecting so the ordering matches the real app // (the host creates the ring only when audio mirroring is toggled on, which is // after the hook has already been injected and the game's stream registered). coop::SharedMemory ring_shm; coop::AudioRingHeader* ring = nullptr; auto create_ring = [&]() -> bool { 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 false; } ring = ring_shm.as(); coop::audio_ring_init(*ring, coop::kAudioRingCapacity); ring->capture_enabled.store(1, std::memory_order_release); return true; }; if (ring_delay_ms == 0 && !create_ring()) { return 1; } // Inject. std::printf("Injecting coop_hook.dll into pid %lu ...\n", pid); if (!inject(pid, dll_path_next_to_self())) { return 1; } if (ring_delay_ms > 0) { std::printf("Injected. Creating audio ring %d ms later (app-ordering)...\n", ring_delay_ms); Sleep(static_cast(ring_delay_ms)); if (!create_ring()) { return 1; } } std::printf("Polling for %d s. Hook trace: %%TEMP%%\\coop_hook.log\n\n", seconds); // 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); // If audio started disabled, re-enable it at the midpoint to demonstrate // runtime hooking ("hook with a button press"): the worker should install // the audio hooks and capture should start within a tick or two. if (!audio_enabled && t == seconds) { block->control.subsystem_disabled[coop::HookSubsys_Audio].store(0, std::memory_order_release); std::printf(">>> re-enabling audio subsystem at runtime <<<\n"); } // 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); const std::uint32_t vgen = block->video.generation.load(std::memory_order_acquire); const std::uint64_t vpresent = block->video.present_calls; std::printf("[%4.1fs] hb=%u streams=%u peak=%.4f ring{fmt=%d %uHz/%uch/%ubit produced=%llu " "overruns=%llu} video{present=%llu gen=%u %ux%u}\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), static_cast(vpresent), vgen, block->video.width, block->video.height); 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 ? "" : ""); } } // Dump the hook registry so the installed-hooks list can be verified headless. static const char* kSubsys[] = {"Input", "Focus", "Audio", "Video", "MKB"}; std::printf("\nInstalled hooks (%u):\n", status.hook_entry_count); for (std::uint32_t i = 0; i < status.hook_entry_count && i < coop::kMaxHookEntries; ++i) { const coop::HookEntry& e = status.hook_entries[i]; std::printf(" [%-5s] %-34s %s calls=%llu\n", e.subsystem < 5 ? kSubsys[e.subsystem] : "?", e.name, e.installed ? "ON " : "off", static_cast(e.calls)); } // Drain the IPC log ring to verify the hook streams its logs to the host. if (log_ring != nullptr) { std::printf("\nHook log (streamed over IPC):\n"); coop::log_ring_drain(*log_ring, log_cursor, [](const coop::LogRecord& rec) { std::printf(" %s\n", rec.text); }); } 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; }