The hook reports vk_too_late when vulkan-1.dll is loaded and the GPA hook has been in for >4s but it never caught the app creating its device -- i.e. the app resolved its Vulkan functions before we hooked (injected too late). Surfaced through a new HookStatus.vk_too_late field (protocol 15->16) and shown by the host as a red banner on the mirror window: "Detected a Vulkan game, but the mirror hook attached too late... enable Auto re-attach (and Set up Vulkan layer if it persists) and relaunch." WGC keeps mirroring meanwhile. mock_game_test gains a too-late detection check (late-inject a Vulkan game -> vk_too_late trips). Verified live: the banner shows over the running tool when a Vulkan game is injected late with the video subsystem on (added a debug-harness `video on/off` command to drive it). 15/15 ctest. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
288 lines
9.5 KiB
C++
288 lines
9.5 KiB
C++
// 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 <atomic>
|
|
|
|
#include <windows.h>
|
|
|
|
#include <objbase.h>
|
|
|
|
#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 "d3d9_hook.hpp"
|
|
#include "opengl_hook.hpp"
|
|
#include "present_hook.hpp"
|
|
#include "vk_hook.hpp"
|
|
#include "xinput_hook.hpp"
|
|
|
|
namespace
|
|
{
|
|
|
|
coop::hook::IpcClient g_ipc;
|
|
std::atomic<bool> 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<coop::LogRing>();
|
|
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 vk_installed = false; // tracked separately: vulkan-1.dll loads lazily, so retry until present
|
|
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);
|
|
const bool d3d9_ok = coop::hook::install_d3d9_hooks(g_ipc);
|
|
video_installed = present_ok || gl_ok || d3d9_ok;
|
|
if (video_installed)
|
|
{
|
|
coop::hook::logf("worker_thread: video hooks installed (present=%d opengl=%d d3d9=%d)",
|
|
present_ok ? 1 : 0, gl_ok ? 1 : 0, d3d9_ok ? 1 : 0);
|
|
}
|
|
}
|
|
// Vulkan separately: vulkan-1.dll loads lazily (volk dlopens it after start), so the DXGI/
|
|
// GL/D3D9 hooks above may install before it exists. Keep trying each tick until it appears.
|
|
if (want_video && !vk_installed)
|
|
{
|
|
vk_installed = coop::hook::install_vk_hooks(g_ipc);
|
|
if (vk_installed)
|
|
{
|
|
video_installed = true; // a Vulkan-only game otherwise has no video hook installed
|
|
coop::hook::logf("worker_thread: vulkan video hook installed");
|
|
}
|
|
}
|
|
else if (!want_video && video_installed)
|
|
{
|
|
coop::hook::remove_present_hooks();
|
|
coop::hook::remove_opengl_hooks();
|
|
coop::hook::remove_d3d9_hooks();
|
|
coop::hook::remove_vk_hooks();
|
|
vk_installed = false;
|
|
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_<pid>[_<index>]); 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<coop::AudioRingHeader>();
|
|
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.set_vk_too_late(coop::hook::vk_injected_too_late()); // Vulkan attached-too-late banner
|
|
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::shutdown_audio_hooks(); // detach: remove detours + release the kept probe
|
|
coop::hook::remove_present_hooks();
|
|
coop::hook::remove_opengl_hooks();
|
|
coop::hook::remove_d3d9_hooks();
|
|
coop::hook::remove_vk_hooks();
|
|
coop::hook::remove_mkb_hooks();
|
|
coop::hook::hook_registry_reset();
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
return TRUE;
|
|
}
|