diff --git a/README.md b/README.md index 9d4fa28..8708557 100644 --- a/README.md +++ b/README.md @@ -110,14 +110,6 @@ default** and covers anything the hooked path doesn't. Completed work lives in **Lessons learned** + the test suite, not here. -### Current tasks - -- **Reconnect to an already-injected DLL.** Support disconnect → reconnect reusing the DLL that's - already in the game, *including across a tool restart or crash*: the host detects the live DLL - (via its advancing IPC heartbeat on the per-pid section), re-attaches to the same shared section, - and resumes control without re-injecting. (DLL self-cleanup on host *crash* is explicitly **not** - required — relaunch, reconnect, then disconnect gracefully to clean up.) - ### Future work - **Per-game profiles** — persist each game's subsystem / capture-mode / audio choices and re-apply @@ -397,6 +389,14 @@ person/account to receive the stream. sees the mirrored video, hears the audio, and that their controller drives the real game. +**Disconnect / reconnect.** **Disconnect** asks the injected DLL to remove every hook so the game +behaves exactly as if it was never touched, then drops the channel — but leaves the DLL injected +(dormant). Clicking **Inject & Connect** on a game that still has a live DLL (left dormant, or +surviving a tool restart/crash — a connected DLL keeps its shared section alive) **reconnects** to it +and resumes, without injecting again. Closing the host also unhooks the game on the way out. So a +clean cycle is: connect → play → disconnect (game back to normal, DLL parked) → reconnect later. The +DLL is never force-unloaded; it goes away when the game exits. + Useful checks while developing without RPT: tick **Forward synthetic test input** in the Injection panel to make the game move on its own (proving forwarding is the source), and click away from the game to confirm focus spoofing keeps it running. diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index d26a542..62d369f 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -13,6 +13,7 @@ add_executable(coop_host WIN32 src/inject/process_list.cpp src/inject/window_list.cpp src/inject/injector.cpp + src/inject/dll_probe.cpp src/inject/mkb_forward.cpp src/ipc/ipc_server.cpp src/capture/frame_renderer.cpp diff --git a/host/src/inject/dll_probe.cpp b/host/src/inject/dll_probe.cpp new file mode 100644 index 0000000..4c8ea9e --- /dev/null +++ b/host/src/inject/dll_probe.cpp @@ -0,0 +1,38 @@ +#include "inject/dll_probe.hpp" + +#include + +#include + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" + +namespace coop +{ + +bool hook_dll_alive(unsigned long pid, int timeout_ms) +{ + // The per-pid section exists only while someone holds it; a connected DLL keeps it alive across + // a host restart. Open it (don't create), then confirm the DLL's worker is actually beating -- + // a stale section with a dead worker (heartbeat frozen) must read as not-alive so we inject fresh. + // Poll rather than sample once: the worker only beats ~every 250 ms, so a single short read can + // straddle a gap and miss it; return the instant a beat lands, and give up after the timeout. + SharedMemory shm; + if (!shm.open(shared_memory_name(pid), sizeof(SharedBlock))) + { + return false; + } + auto* block = shm.as(); + const std::uint32_t h0 = block->status.heartbeat.load(std::memory_order_acquire); + for (int waited = 0; waited < timeout_ms; waited += 25) + { + Sleep(25); + if (block->status.heartbeat.load(std::memory_order_acquire) != h0) + { + return true; + } + } + return false; +} + +} // namespace coop diff --git a/host/src/inject/dll_probe.hpp b/host/src/inject/dll_probe.hpp new file mode 100644 index 0000000..aff5446 --- /dev/null +++ b/host/src/inject/dll_probe.hpp @@ -0,0 +1,18 @@ +// Detect whether our hook DLL is already injected and alive in a target process, so the host can +// reconnect to it (reusing the DLL) instead of injecting again -- including after a tool restart or +// crash, since a connected DLL keeps the per-pid IPC section alive. +#pragma once + +namespace coop +{ + +// True if `pid` already hosts a live coop_hook DLL: the per-pid IPC section exists and its heartbeat +// advances within `timeout_ms` (the DLL's worker is still beating). Returns as soon as a beat lands, +// so it's fast when the DLL is healthy; the timeout must clear the worker's beat period (~250 ms), so +// the default leaves margin. A missing section, or one whose heartbeat has stalled (dead worker), +// reads as not-alive -> the caller should inject fresh (which still re-attaches an already-injected +// DLL, so a false negative is benign). The default clears several beat periods for margin; a healthy +// DLL is detected as soon as the first beat lands, so this returns quickly in the normal case. +bool hook_dll_alive(unsigned long pid, int timeout_ms = 1000); + +} // namespace coop diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index d62af8a..2f07075 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -6,6 +6,7 @@ #include +#include "inject/dll_probe.hpp" #include "inject/injector.hpp" #include "ui/app_chrome.hpp" @@ -217,6 +218,48 @@ void InjectionPanel::refresh_processes() processes_ = list_processes(); } +void InjectionPanel::publish_subsystem_state() +{ + // Publish the desired subsystem state before the hook's next reconcile tick. + server_.set_subsystem_enabled(HookSubsys_Input, want_input_); + server_.set_subsystem_enabled(HookSubsys_Focus, want_focus_); + server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_); + server_.set_subsystem_enabled(HookSubsys_Video, want_video_); + server_.set_subsystem_enabled(HookSubsys_Mkb, want_mkb_); + server_.set_cursor_clip_allowed(!release_cursor_); +} + +void InjectionPanel::begin_liveness_tracking() +{ + injected_ = true; + // Track liveness: a SYNCHRONIZE|QUERY handle lets us notice the game exiting, + // and seeding the heartbeat clock avoids a spurious "hung" before the first beat. + close_target_handle(); // drop any handle from a previous target + target_process_ = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, selected_pid_); + target_state_ = TargetState::Alive; + last_heartbeat_ = 0; + last_heartbeat_time_ = ImGui::GetTime(); + dll_alive_ = true; +} + +void InjectionPanel::reconnect_selected() +{ + // Re-attach to a DLL that's already injected and alive (a prior session left it dormant after a + // graceful disconnect, or the tool restarted): bring the channel back up on the SAME per-pid + // section the DLL still holds and re-publish the desired subsystem state -- no re-injection. + if (!server_.start(selected_pid_)) + { + status_ = "Failed to re-attach shared memory."; + status_color_ = kRed; + return; + } + publish_subsystem_state(); + begin_liveness_tracking(); + status_ = "Reconnected to " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) + + ") -- reused the injected DLL."; + status_color_ = kGreen; +} + void InjectionPanel::inject_selected() { if (selected_pid_ == 0) @@ -226,6 +269,15 @@ void InjectionPanel::inject_selected() return; } + // If our DLL is already injected and alive in this target (left dormant by a graceful disconnect, + // or surviving a tool restart -- it keeps the section alive), reconnect to it instead of + // injecting a second time. + if (hook_dll_alive(selected_pid_)) + { + reconnect_selected(); + return; + } + // Bring up the shared-memory channel before injecting so the hook finds it // immediately on load. if (!server_.start(selected_pid_)) @@ -235,26 +287,12 @@ void InjectionPanel::inject_selected() return; } - // Publish the desired subsystem state before the hook's first reconcile tick. - server_.set_subsystem_enabled(HookSubsys_Input, want_input_); - server_.set_subsystem_enabled(HookSubsys_Focus, want_focus_); - server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_); - server_.set_subsystem_enabled(HookSubsys_Video, want_video_); - server_.set_subsystem_enabled(HookSubsys_Mkb, want_mkb_); - server_.set_cursor_clip_allowed(!release_cursor_); + publish_subsystem_state(); const InjectResult result = inject_dll(selected_pid_, hook_dll_path()); if (result.status == InjectStatus::Ok) { - injected_ = true; - // Track liveness: a SYNCHRONIZE|QUERY handle lets us notice the game exiting, - // and seeding the heartbeat clock avoids a spurious "hung" before the first beat. - close_target_handle(); // drop any handle from a previous target - target_process_ = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, selected_pid_); - target_state_ = TargetState::Alive; - last_heartbeat_ = 0; - last_heartbeat_time_ = ImGui::GetTime(); - dll_alive_ = true; + begin_liveness_tracking(); status_ = "Injected into " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) + ")."; status_color_ = kGreen; } diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index fea5747..d603dc4 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -164,7 +164,10 @@ public: private: void refresh_targets(); // refresh both the window list and the process list void refresh_processes(); - void inject_selected(); + void inject_selected(); // inject fresh, OR reconnect if a live DLL is already in the target + void reconnect_selected(); // re-attach to an already-injected, live DLL (no re-inject) + void publish_subsystem_state(); // push the desired per-subsystem install state + cursor policy + void begin_liveness_tracking(); // mark connected: open the process handle, seed the heartbeat clock // Graceful disconnect: ask the DLL to remove every hook (game returns to vanilla), wait // (bounded) for it to take effect, then drop the channel. The DLL stays injected/dormant for a // later reconnect; we never eject it. Used by the Disconnect button and the destructor. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c5db245..9ac302b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -221,7 +221,8 @@ add_test(NAME dx12_present_hook_test COMMAND dx12_present_hook_test) # stresses hook/unhook cycles. Reuses the shipping shared-texture reader. Skips without D3D11. add_executable(mock_game_test mock_game_test.cpp - ${CMAKE_SOURCE_DIR}/host/src/capture/shared_texture.cpp) + ${CMAKE_SOURCE_DIR}/host/src/capture/shared_texture.cpp + ${CMAKE_SOURCE_DIR}/host/src/inject/dll_probe.cpp) target_include_directories(mock_game_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src ${CMAKE_SOURCE_DIR}/tools/mock_game) diff --git a/tests/mock_game_test.cpp b/tests/mock_game_test.cpp index 6f19409..568bc29 100644 --- a/tests/mock_game_test.cpp +++ b/tests/mock_game_test.cpp @@ -24,6 +24,7 @@ #include #include "capture/shared_texture.hpp" +#include "inject/dll_probe.hpp" #include "coop/audio_ring.hpp" #include "coop/log_ring.hpp" #include "coop/protocol.hpp" @@ -1039,6 +1040,89 @@ void test_graceful_disconnect(const char* backend) game.kill(); } +// Reconnect contract: after a graceful disconnect (and even a simulated tool restart -- the host +// drops its section handle while the DLL keeps it alive), the host can detect the live DLL via its +// heartbeat (hook_dll_alive) and re-attach to the SAME per-pid section to resume control, without +// re-injecting. The DLL, still connected to that section, re-installs its hooks when the reconnected +// host re-enables them. +void test_reconnect(const char* backend) +{ + std::printf("== reconnect to an already-injected DLL: %s ==\n", backend); + std::wstring wbackend; + for (const char* p = backend; *p != '\0'; ++p) + { + wbackend.push_back(static_cast(*p)); + } + MockGame game = MockGame::launch(wbackend + L" 30"); + if (!game.ok) + { + check(false, "launch mock game (reconnect)"); + return; + } + Sleep(800); + + const std::uint32_t disabled = 1u << HookSubsys_Audio; // input+focus+video+mkb on; audio off + SharedMemory shm_a; + SharedBlock* block_a = make_ipc(shm_a, game.pid(), disabled); + if (block_a == nullptr || !inject_retry(game.pid())) + { + check(false, "inject mock game (reconnect)"); + game.kill(); + return; + } + + bool installed = false; + for (int i = 0; i < 100 && game.alive() && !installed; ++i) + { + Sleep(50); + installed = installed_hook_count(block_a) > 0; + } + check(installed, "first connection installed hooks"); + check(hook_dll_alive(game.pid()), "hook_dll_alive() detects the live DLL"); + + // Graceful disconnect: unhook everything, then simulate the host going away (drop our handle; + // the DLL keeps the section alive). This stands in for both an explicit disconnect and a restart. + for (std::uint32_t s = 0; s < HookSubsys_Count; ++s) + { + block_a->control.subsystem_disabled[s].store(1u, std::memory_order_release); + } + for (int i = 0; i < 100 && installed_hook_count(block_a) != 0; ++i) + { + Sleep(50); + } + check(installed_hook_count(block_a) == 0, "graceful disconnect unhooked the game"); + // Let the DLL settle back to steady heartbeating: the unhook tick runs several bounded drains, so + // the worker can briefly not beat right after it. A real reconnect targets an already-dormant DLL, + // not one in the microsecond after a mass-unhook. + Sleep(500); + shm_a.reset(); // host A "exits" -- only the DLL holds the section now + + // The DLL is still alive and holding the section: the restarted host can find it... + check(hook_dll_alive(game.pid()), "DLL still detectable after the host dropped its handle"); + + // ...and reconnect by re-attaching to the SAME section (no re-inject) and re-enabling subsystems. + SharedMemory shm_b; + SharedBlock* block_b = make_ipc(shm_b, game.pid(), 0u); // re-attach, all subsystems on + if (block_b == nullptr) + { + check(false, "reconnect: re-attach to the section"); + game.kill(); + return; + } + bool reinstalled = false; + for (int i = 0; i < 100 && game.alive() && !reinstalled; ++i) + { + Sleep(50); + reinstalled = installed_hook_count(block_b) > 0; + } + check(reinstalled, "reconnect re-installed the hooks via the existing DLL (no re-inject)"); + check(game.alive(), "game alive after reconnect"); + + game.kill(); + Sleep(200); + check(!hook_dll_alive(game.pid()), "hook_dll_alive() false once the game (and DLL) is gone"); +} + int main() { kill_stray_mock_games(); // clean slate: no leftover game holding coop_hook.dll @@ -1073,6 +1157,10 @@ int main() // vanilla while the DLL stays injected/dormant (the reconnect-friendly teardown). test_graceful_disconnect("dx11"); + // Reconnect: detect the live DLL and re-attach to the same section (even across a simulated host + // restart) to resume control without re-injecting. + test_reconnect("dx11"); + // Aggressive hook/unhook storm across every backend: a separate thread thrashes every // subsystem on/off while the game presents, to catch an unsafe install/remove race (the // "spamming Mirror video crashed Brotato" use-after-free). vk uses the early-load path.