// coop_hook.dll -- injected into the target game by the host. // // On load it opens the host's shared-memory channel (named by this process's // pid), hooks XInput so the game reads the forwarded controller state, and // spoofs focus so the game keeps running while the tool holds the real OS focus. // All real work happens on a worker thread; DllMain only kicks it off to stay // clear of the loader lock. #include #include #include #include "audio_hook.hpp" #include "coop/audio_ring.hpp" #include "coop/log_ring.hpp" #include "coop/shared_memory.hpp" #include "debug_log.hpp" #include "focus_spoof.hpp" #include "hook_registry.hpp" #include "ipc_client.hpp" #include "mkb_hook.hpp" #include "opengl_hook.hpp" #include "present_hook.hpp" #include "xinput_hook.hpp" namespace { coop::hook::IpcClient g_ipc; std::atomic g_running{true}; coop::SharedMemory g_audio_shm[coop::kMaxAudioStreams]; // per-stream audio rings, opened when present coop::SharedMemory g_log_shm; // the host's log 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; } // Attach the host's log ring first so the rest of bring-up streams to the Log // window. The host creates it at injection time; it's normally already there. { const std::wstring log_name = coop::log_ring_name(GetCurrentProcessId()); if (g_log_shm.open(log_name, coop::log_ring_total_size(coop::kLogCapacity))) { auto* lr = g_log_shm.as(); if (coop::log_ring_valid(*lr)) { coop::hook::set_log_ring(lr); } else { g_log_shm.reset(); } } } 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; bool audio_installed = false; bool audio_ring_open = false; bool video_installed = false; bool mkb_installed = false; // Each tick, reconcile each subsystem with the host's requested state: install // what's wanted but missing (modules / the game window may appear lazily) and // remove what's no longer wanted (the host toggled it off). Beat a heartbeat so // the host can see the hook is alive. while (g_running.load(std::memory_order_relaxed)) { // --- Input (XInput) --- const bool want_input = g_ipc.subsystem_install_requested(coop::HookSubsys_Input); if (want_input && !xinput_installed) { xinput_installed = coop::hook::install_xinput_hooks(g_ipc); } else if (!want_input && xinput_installed) { coop::hook::remove_xinput_hooks(); xinput_installed = false; } // --- Focus spoof --- const bool want_focus = g_ipc.subsystem_install_requested(coop::HookSubsys_Focus); if (want_focus && !focus_installed) { focus_installed = coop::hook::install_focus_spoof(g_ipc); } else if (!want_focus && focus_installed) { coop::hook::remove_focus_spoof(); focus_installed = false; } // --- Audio render-hook --- // Install even before the host's ring exists so render streams are counted // regardless; attach the ring (enabling capture+silence) once it appears. const bool want_audio = com_ok && g_ipc.subsystem_install_requested(coop::HookSubsys_Audio); if (want_audio && !audio_installed) { audio_installed = coop::hook::install_audio_hooks(g_ipc, nullptr); if (audio_installed) { coop::hook::logf("worker_thread: audio hooks installed"); audio_ring_open = false; // re-attach the ring below after a reinstall } } else if (!want_audio && audio_installed) { coop::hook::remove_audio_hooks(); audio_installed = false; audio_ring_open = false; coop::hook::logf("worker_thread: audio hooks removed (host request)"); } // --- Video (Present hook + OpenGL swap hook) --- // Opt-in alternative to the host's WGC path; the host enables it on request. // Install both producers: DXGI games hit the Present hook, OpenGL games hit // the SwapBuffers hook, whichever the game uses fills the shared texture. const bool want_video = g_ipc.subsystem_install_requested(coop::HookSubsys_Video); if (want_video && !video_installed) { const bool present_ok = coop::hook::install_present_hooks(g_ipc); const bool gl_ok = coop::hook::install_opengl_hooks(g_ipc); video_installed = present_ok || gl_ok; if (video_installed) { coop::hook::logf("worker_thread: video hooks installed (present=%d opengl=%d)", present_ok ? 1 : 0, gl_ok ? 1 : 0); } } else if (!want_video && video_installed) { coop::hook::remove_present_hooks(); coop::hook::remove_opengl_hooks(); video_installed = false; coop::hook::logf("worker_thread: video hooks removed (host request)"); } // --- Mouse + keyboard forwarding --- // Opt-in. When on, the host streams MKB events into the shared ring; we post // them to the game and synthesize polling state. Drained at high rate below. const bool want_mkb = g_ipc.subsystem_install_requested(coop::HookSubsys_Mkb); if (want_mkb && !mkb_installed) { mkb_installed = coop::hook::install_mkb_hooks(g_ipc); if (mkb_installed) { coop::hook::logf("worker_thread: MKB hooks installed"); } } else if (!want_mkb && mkb_installed) { coop::hook::remove_mkb_hooks(); mkb_installed = false; coop::hook::logf("worker_thread: MKB hooks removed (host request)"); } // Attach a ring per stream. The host creates up to kMaxAudioStreams rings // (coop_audio_[_]); we open each as it appears and (re)attach it so // every stream is captured + silenced into its own ring for the host to mix. if (audio_installed) { for (unsigned i = 0; i < coop::kMaxAudioStreams; ++i) { if (!g_audio_shm[i].valid()) { g_audio_shm[i].open(coop::audio_ring_name(GetCurrentProcessId(), i), coop::audio_ring_total_size(coop::kAudioRingCapacity)); } if (g_audio_shm[i].valid()) { auto* ring = g_audio_shm[i].as(); if (coop::audio_ring_valid(*ring)) { coop::hook::set_audio_ring(i, ring); // idempotent re-attach if (i == 0 && !audio_ring_open) { audio_ring_open = true; coop::hook::logf("worker_thread: audio ring 0 opened"); } } else { g_audio_shm[i].reset(); // present but not our contract; retry } } } } // A stream is often registered before its ring is attached (or the host re-inits // a ring on a mirror re-toggle, clearing its format); keep formats published so // the host consumes the rings instead of falling back to loopback. if (audio_ring_open) { coop::hook::republish_audio_format(); } coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change coop::hook::release_cursor_tick(); // free the operator's mouse if requested (no-op otherwise) coop::hook::hook_publish(g_ipc); // installed-hooks list + call counts g_ipc.heartbeat(); // Reconcile ~4x/s, but drain MKB events far more often (input must be // responsive). 50 slices x 5 ms ~= the old 250 ms reconcile period. for (int slice = 0; slice < 50 && g_running.load(std::memory_order_relaxed); ++slice) { if (mkb_installed) { coop::hook::mkb_pump(g_ipc); } Sleep(5); } } if (com_ok) { CoUninitialize(); } return 0; } } // namespace BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) { switch (reason) { case DLL_PROCESS_ATTACH: DisableThreadLibraryCalls(module); if (HANDLE thread = CreateThread(nullptr, 0, &worker_thread, nullptr, 0, nullptr)) { CloseHandle(thread); } break; case DLL_PROCESS_DETACH: // Skip cleanup when the process is tearing down (reserved != null): the // loader is already unwinding and touching other modules is unsafe. if (reserved == nullptr) { g_running.store(false, std::memory_order_relaxed); coop::hook::set_log_ring(nullptr); coop::hook::remove_focus_spoof(); coop::hook::remove_xinput_hooks(); coop::hook::remove_audio_hooks(); coop::hook::remove_present_hooks(); coop::hook::remove_opengl_hooks(); coop::hook::remove_mkb_hooks(); coop::hook::hook_registry_reset(); } break; default: break; } return TRUE; }