diff --git a/README.md b/README.md index cc8a2d1..9d4fa28 100644 --- a/README.md +++ b/README.md @@ -112,10 +112,6 @@ Completed work lives in **Lessons learned** + the test suite, not here. ### Current tasks -- **Graceful disconnect unhooks everything.** On an explicit *Disconnect* and on a graceful tool - exit, ask the injected DLL to disable *all* subsystems so the game runs exactly as if it was never - hooked (every hook's original bytes restored). The DLL stays injected but dormant — ready for a - later reconnect. Today neither path tells the DLL anything, so the hooks are left active/frozen. - **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, diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index a4410c8..d62af8a 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -98,6 +98,10 @@ void InjectionPanel::refresh_targets() InjectionPanel::~InjectionPanel() { + // Graceful tool exit: unhook everything so a still-running game returns to normal (the DLL + // stays injected, dormant). Short timeout -- the flags persist in the section the DLL keeps + // alive, so the unhook completes even if the process exits before it confirms. + disconnect_graceful(/*timeout_ms=*/300); if (vk_layer_enabled_) { unregister_vk_layer(); // don't leave the implicit layer registered after the tool closes @@ -105,6 +109,25 @@ InjectionPanel::~InjectionPanel() close_target_handle(); } +void InjectionPanel::disconnect_graceful(int timeout_ms) +{ + // Tell the DLL to remove every hook so the game runs as if it was never touched, then wait + // (bounded) for it to confirm before we drop the channel. The flags persist in the section the + // DLL keeps alive, so it unhooks even if we time out or exit first -- the wait just lets us + // observe a clean game. The DLL is left injected (dormant) for a later reconnect; we never eject. + if (server_.running()) + { + server_.request_unhook_all(); + for (int waited = 0; waited < timeout_ms && !server_.all_hooks_removed(); waited += 10) + { + Sleep(10); + } + } + server_.stop(); + injected_ = false; + close_target_handle(); +} + void InjectionPanel::close_target_handle() { if (target_process_ != nullptr) @@ -557,10 +580,10 @@ void InjectionPanel::draw(bool debug_details) } if (ImGui::Button("Disconnect")) { - server_.stop(); - injected_ = false; - close_target_handle(); - status_ = "Stopped."; + // Leave the game vanilla: unhook everything before dropping the channel. The DLL stays + // injected (dormant), so it can be reconnected later without re-injecting. + disconnect_graceful(/*timeout_ms=*/700); + status_ = "Disconnected (game unhooked; DLL left injected)."; status_color_ = kGrey; } // A relaunched game has a new pid; re-attach by image name without hunting for diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index ce65129..fea5747 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -165,6 +165,10 @@ private: void refresh_targets(); // refresh both the window list and the process list void refresh_processes(); void inject_selected(); + // 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. + void disconnect_graceful(int timeout_ms); void reattach(); // re-inject a relaunched same-name target (Terminated state) void auto_reattach_tick(); // poll for the same game relaunching while auto-reattach is on void update_liveness(); // recompute target_state_ from process + heartbeat diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index 3e1049d..b98a430 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -127,6 +127,42 @@ void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled) } } +void IpcServer::request_unhook_all() +{ + std::scoped_lock lock(mutex_); + if (block_ == nullptr) + { + return; + } + for (std::uint32_t s = 0; s < HookSubsys_Count; ++s) + { + block_->control.subsystem_disabled[s].store(1u, std::memory_order_release); // 1 = remove + } +} + +bool IpcServer::all_hooks_removed() const +{ + std::scoped_lock lock(mutex_); + if (block_ == nullptr) + { + return true; // not connected -> nothing of ours is hooked + } + const HookStatus& s = block_->status; + std::uint32_t count = s.hook_entry_count; + if (count > kMaxHookEntries) + { + count = kMaxHookEntries; + } + for (std::uint32_t i = 0; i < count; ++i) + { + if (s.hook_entries[i].installed != 0) + { + return false; + } + } + return true; +} + void IpcServer::host_log(std::uint32_t level, const char* text) { std::scoped_lock lock(mutex_); diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index 6da50d1..9c169d5 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -81,6 +81,18 @@ public: // reconciles on its next tick. No-op if not started. void set_subsystem_enabled(std::uint32_t subsystem, bool enabled); + // Ask the injected DLL to remove EVERY subsystem, so the game runs exactly as if it was + // never hooked (every hook's original bytes restored). The DLL stays loaded but dormant -- + // only the hooks are disabled -- ready for a later reconnect. Used by graceful Disconnect + // and graceful tool exit. No-op if not started. + void request_unhook_all(); + + // True once the DLL's hook registry reports nothing is currently installed (every subsystem + // removed). Also true when not started or before the DLL has published a registry -- i.e. + // "there is nothing hooked right now" -- so the host can confirm a graceful disconnect took + // effect (poll this after request_unhook_all()). + [[nodiscard]] bool all_hooks_removed() const; + // Enqueue a mouse/keyboard event for the hook's MKB subsystem to forward. Drops // silently if not started or the ring is full. void push_mkb(const MkbEvent& ev) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5ef47ef..c5db245 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -38,6 +38,16 @@ add_executable(audio_ring_test audio_ring_test.cpp) target_link_libraries(audio_ring_test PRIVATE coop_common) add_test(NAME audio_ring_test COMMAND audio_ring_test) +# Unit test for the host-side IpcServer control ops behind graceful disconnect: +# request_unhook_all() disables every subsystem and all_hooks_removed() reads the hook +# registry back. Opens a second view of the section to stand in for the hook. No game. +add_executable(ipc_server_test + ipc_server_test.cpp + ${CMAKE_SOURCE_DIR}/host/src/ipc/ipc_server.cpp) +target_include_directories(ipc_server_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src) +target_link_libraries(ipc_server_test PRIVATE coop_common) +add_test(NAME ipc_server_test COMMAND ipc_server_test) + # Unit test for the DetourGate safe-unhook coordinator (hook/src/hook_guard.hpp): drain() must block # while a detour Guard is in flight and return promptly otherwise. Fast/deterministic complement to # the mock_game_test hook/unhook storm. Header-only (just needs the hook include dir + threads). @@ -299,6 +309,7 @@ coop_output_subdir(tests hook_selftest dinput_hook_test audio_ring_test + ipc_server_test detour_gate_test hook_install_test mkb_ring_test diff --git a/tests/ipc_server_test.cpp b/tests/ipc_server_test.cpp new file mode 100644 index 0000000..c41bb39 --- /dev/null +++ b/tests/ipc_server_test.cpp @@ -0,0 +1,103 @@ +// Unit test for the host-side IpcServer control operations that drive graceful disconnect +// (hook/src reconciles against these): request_unhook_all() must set every subsystem's +// "disabled" flag so the injected DLL removes all hooks, and all_hooks_removed() must report +// whether the DLL's hook registry currently has anything installed. No game / no DLL -- the +// test opens a second view of the same section to act as the hook would (publish a registry, +// read the control flags). Deterministic, no threads. +#include +#include + +#include + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" +#include "ipc/ipc_server.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +void check(bool ok, const char* what) +{ + std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what); + if (!ok) + { + ++g_failures; + } +} + +bool all_disabled(const SharedBlock* b) +{ + for (std::uint32_t s = 0; s < HookSubsys_Count; ++s) + { + if (b->control.subsystem_disabled[s].load(std::memory_order_acquire) != 1u) + { + return false; + } + } + return true; +} + +bool none_disabled(const SharedBlock* b) +{ + for (std::uint32_t s = 0; s < HookSubsys_Count; ++s) + { + if (b->control.subsystem_disabled[s].load(std::memory_order_acquire) != 0u) + { + return false; + } + } + return true; +} +} // namespace + +int main() +{ + const unsigned long pid = GetCurrentProcessId(); // section name is per-pid; no real game needed + + IpcServer server; + if (!server.start(pid)) + { + std::printf("FAIL: IpcServer::start\n"); + return 1; + } + + // A second view of the same section, standing in for the injected hook: it reads the control + // flags the host writes and publishes the hook registry the host reads back. + SharedMemory hook_view; + if (!hook_view.open(shared_memory_name(pid), sizeof(SharedBlock))) + { + std::printf("FAIL: open hook view\n"); + return 1; + } + auto* block = hook_view.as(); + + // Fresh section: nothing disabled (0 = install), so the game gets every subsystem by default. + check(none_disabled(block), "fresh section requests all subsystems installed"); + + // Graceful disconnect step 1: ask the DLL to remove every hook. + server.request_unhook_all(); + check(all_disabled(block), "request_unhook_all() disables every subsystem"); + + // all_hooks_removed() reflects the DLL's registry. Empty registry => nothing installed. + check(server.all_hooks_removed(), "all_hooks_removed() true when the registry is empty"); + + // Hook publishes one installed entry => something is still hooked. + block->status.hook_entry_count = 1; + block->status.hook_entries[0].installed = 1u; + check(!server.all_hooks_removed(), "all_hooks_removed() false while a hook is still installed"); + + // Hook finishes removing it => back to "nothing installed". + block->status.hook_entries[0].installed = 0u; + check(server.all_hooks_removed(), "all_hooks_removed() true once every entry is uninstalled"); + + // Reconnect re-enables: clearing a subsystem's disabled flag requests it back (symmetric op). + server.set_subsystem_enabled(HookSubsys_Input, true); + check(block->control.subsystem_disabled[HookSubsys_Input].load(std::memory_order_acquire) == 0u, + "re-enabling a subsystem clears its disabled flag (reconnect path)"); + + server.stop(); + std::printf(g_failures == 0 ? "PASS ipc_server_test\n" : "FAILED ipc_server_test (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +} diff --git a/tests/mock_game_test.cpp b/tests/mock_game_test.cpp index f439cd6..6f19409 100644 --- a/tests/mock_game_test.cpp +++ b/tests/mock_game_test.cpp @@ -958,6 +958,87 @@ void test_hook_storm(const char* backend, ID3D11Device* device, bool vk_early) } } // namespace +// Number of hooks the DLL currently reports as installed (across all subsystems). +std::uint32_t installed_hook_count(const SharedBlock* block) +{ + std::uint32_t count = block->status.hook_entry_count; + if (count > kMaxHookEntries) + { + count = kMaxHookEntries; + } + std::uint32_t installed = 0; + for (std::uint32_t i = 0; i < count; ++i) + { + installed += block->status.hook_entries[i].installed != 0 ? 1u : 0u; + } + return installed; +} + +// Graceful disconnect contract (the host-side IpcServer::request_unhook_all path): once the host +// asks for every subsystem off, the DLL must remove ALL its hooks -- the game runs as if it was +// never touched -- yet stay alive (heartbeat advancing) so it can be reconnected later. Models what +// the Disconnect button / tool-exit does (set all subsystem_disabled = 1) and asserts the outcome. +void test_graceful_disconnect(const char* backend) +{ + std::printf("== graceful disconnect: %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 (graceful disconnect)"); + return; + } + Sleep(800); + + // Enable input + focus + video + MKB (all of which the mock exercises); leave audio off so the + // test doesn't depend on an audio endpoint. Then inject. + SharedMemory shm; + SharedBlock* block = make_ipc(shm, game.pid(), 1u << HookSubsys_Audio); + if (block == nullptr || !inject_retry(game.pid())) + { + check(false, "inject mock game (graceful disconnect)"); + game.kill(); + return; + } + + // Wait for the DLL to install its hooks. + bool installed = false; + for (int i = 0; i < 100 && game.alive() && !installed; ++i) // up to ~5 s + { + Sleep(50); + installed = installed_hook_count(block) > 0; + } + check(installed, "hooks installed after inject (something to unhook)"); + const std::uint32_t hb0 = block->status.heartbeat.load(std::memory_order_relaxed); + + // Graceful disconnect: request every subsystem removed (what request_unhook_all writes). + for (std::uint32_t s = 0; s < HookSubsys_Count; ++s) + { + block->control.subsystem_disabled[s].store(1u, std::memory_order_release); + } + + // The DLL must remove every hook -> game vanilla. + bool vanilla = false; + for (int i = 0; i < 100 && game.alive() && !vanilla; ++i) // up to ~5 s + { + Sleep(50); + vanilla = installed_hook_count(block) == 0; + } + check(vanilla, "DLL removed every hook on request (game runs vanilla)"); + + // ... and stay alive (dormant), so a later reconnect can re-enable it. + check(game.alive(), "game still alive after graceful disconnect"); + Sleep(400); + const std::uint32_t hb1 = block->status.heartbeat.load(std::memory_order_relaxed); + check(hb1 != hb0, "DLL heartbeat still advancing (injected but dormant)"); + + game.kill(); +} + int main() { kill_stray_mock_games(); // clean slate: no leftover game holding coop_hook.dll @@ -988,6 +1069,10 @@ int main() test_av_and_hook_cycles(device); + // Graceful disconnect: the host asks the DLL to unhook everything; the game must return to + // vanilla while the DLL stays injected/dormant (the reconnect-friendly teardown). + test_graceful_disconnect("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.