diff --git a/README.md b/README.md index c661435..56a641e 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,14 @@ Done: reported over IPC. The Injection panel shows it grouped by subsystem (input / focus / audio) so you can see exactly what's hooked and how busy each hook is. `coop_audio_probe` prints the same table headless. +- **Per-subsystem hook control. ✅** The three injectable subsystems — input + forwarding (XInput), focus spoof, and the audio render-hook — are independently + controllable. The Injection panel has a checkbox per subsystem that + installs/removes its hooks at runtime over a host→hook control channel; the hook + reconciles each tick. Dependent features are guarded: the synthetic-input + control is disabled when input forwarding is off, and the Audio panel notes when + the render-hook is off (mirroring then uses loopback). Defaults to all-on so + behavior is unchanged unless you toggle something. Future work, roughly in priority order: diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index 18d86d4..a6d96f2 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -11,7 +11,7 @@ namespace coop // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // refuses to attach to a host with a mismatched version. -inline constexpr std::uint32_t kProtocolVersion = 5; +inline constexpr std::uint32_t kProtocolVersion = 6; // 'COOP' little-endian, used to sanity-check the mapping before trusting it. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -126,6 +126,14 @@ struct HookStatus HookEntry hook_entries[kMaxHookEntries]; }; +// Host -> hook control channel. The host requests which hook subsystems should be +// installed; the hook reconciles each tick. 0 = install (the zero-filled default, +// so a fresh mapping installs everything as before), 1 = remove. +struct HookControl +{ + std::atomic subsystem_disabled[HookSubsys_Count]; +}; + // Top-level shared block. The host is the sole writer of pad state; the hook is // the sole reader. A seqlock (even = stable, odd = write in progress) lets the // reader grab a torn-free snapshot without a kernel lock on the hot path. @@ -140,6 +148,9 @@ struct SharedBlock // Hook -> host diagnostics back-channel. HookStatus status; + // Host -> hook control (which subsystems to install). + HookControl control; + // Phase 2 appends the shared-texture handle/dimensions control fields here; // keep new members at the end so existing offsets never shift. }; diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index 86799aa..b5cc9cc 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -49,33 +49,65 @@ DWORD WINAPI worker_thread(LPVOID) bool audio_installed = false; bool audio_ring_open = false; - // Keep retrying the installs (XInput and the game window may both appear - // lazily) and beat a heartbeat so the host can show the hook is alive. + // 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)) { - if (!xinput_installed) + // --- 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); } - if (!focus_installed) + 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); } - // Install the audio render-hook even before the host's ring exists, so - // render streams are counted for the debug view regardless; attach the - // ring (enabling capture+silence) once the host creates it. - if (com_ok && !audio_installed) + 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)"); + } + if (audio_installed && !audio_ring_open) { const std::wstring name = coop::audio_ring_name(GetCurrentProcessId()); - if (g_audio_shm.open(name, coop::audio_ring_total_size(coop::kAudioRingCapacity))) + if (!g_audio_shm.valid()) + { + g_audio_shm.open(name, coop::audio_ring_total_size(coop::kAudioRingCapacity)); + } + if (g_audio_shm.valid()) { auto* ring = g_audio_shm.as(); if (coop::audio_ring_valid(*ring)) diff --git a/hook/src/focus_spoof.cpp b/hook/src/focus_spoof.cpp index f0f5699..9abc38e 100644 --- a/hook/src/focus_spoof.cpp +++ b/hook/src/focus_spoof.cpp @@ -230,6 +230,10 @@ void remove_focus_spoof() hook_set_installed(g_id_active, false); hook_set_installed(g_id_focus, false); hook_set_installed(g_id_wndproc, false); + if (g_focus_ipc != nullptr) + { + g_focus_ipc->mark_focus_spoof(false, 0); + } g_game_hwnd = nullptr; g_orig_proc = nullptr; g_focus_ipc = nullptr; diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index 88f6dc9..ccd3f48 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -44,6 +44,17 @@ public: return block_ != nullptr; } + // Host-requested install state for a subsystem (default = install, since the + // mapping is zero-filled and 0 means "disabled flag clear" = install). + [[nodiscard]] bool subsystem_install_requested(std::uint32_t subsystem) const + { + if (block_ == nullptr || subsystem >= HookSubsys_Count) + { + return true; + } + return block_->control.subsystem_disabled[subsystem].load(std::memory_order_acquire) == 0; + } + // Copies a torn-free snapshot of all slots. Returns false only if the host // was mid-write for the whole spin window (caller should reuse its cache). bool snapshot(CoopPadState (&out)[kMaxPads], std::uint32_t& count) const @@ -91,6 +102,16 @@ public: } } + // XInput hooks were removed (host unhooked input): clear the attached flag so + // the Controllers panel stops showing stale poll rates. + void mark_detached() + { + if (block_ != nullptr) + { + block_->status.attached = 0; + } + } + void mark_focus_spoof(bool active, std::uint64_t game_hwnd) { if (block_ != nullptr) diff --git a/hook/src/xinput_hook.cpp b/hook/src/xinput_hook.cpp index 3d5bea0..05849ef 100644 --- a/hook/src/xinput_hook.cpp +++ b/hook/src/xinput_hook.cpp @@ -224,6 +224,10 @@ void remove_xinput_hooks() hook_set_installed(g_id_getstateex, false); hook_set_installed(g_id_getcaps, false); hook_set_installed(g_id_setstate, false); + if (g_ipc != nullptr) + { + g_ipc->mark_detached(); + } g_ipc = nullptr; } diff --git a/host/src/audio_panel.cpp b/host/src/audio_panel.cpp index 915074f..2fe70b3 100644 --- a/host/src/audio_panel.cpp +++ b/host/src/audio_panel.cpp @@ -88,7 +88,25 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details) // hooked path silences it, so don't warn there. if (mirror_.source() == AudioMirror::Source::Loopback) { - ImGui::TextDisabled("Game audio also plays locally (echo). Inject the hook to remove it."); + bool audio_hook_on = false; + const std::uint32_t hn = + status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries; + for (std::uint32_t i = 0; i < hn; ++i) + { + if (status.hook_entries[i].subsystem == HookSubsys_Audio && status.hook_entries[i].installed) + { + audio_hook_on = true; + break; + } + } + if (audio_hook_on) + { + ImGui::TextDisabled("Game audio also plays locally (echo)."); + } + else + { + ImGui::TextDisabled("Audio render-hook is off -> loopback (echo). Enable it in the Injection panel."); + } } // --- Render-stream view ----------------------------------------------- diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index 34da7a0..e1737bc 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -92,9 +92,15 @@ 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_); + const InjectResult result = inject_dll(selected_pid_, hook_dll_path()); if (result.status == InjectStatus::Ok) { + injected_ = true; status_ = "Injected into " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) + ")."; status_color_ = kGreen; } @@ -200,6 +206,62 @@ void InjectionPanel::draw_hook_list(const HookStatusView& status) } } +// True if any hook of `subsystem` is currently installed in the game. +static bool subsystem_installed(const HookStatusView& status, std::uint32_t subsystem) +{ + const std::uint32_t n = status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries; + for (std::uint32_t i = 0; i < n; ++i) + { + if (status.hook_entries[i].subsystem == subsystem && status.hook_entries[i].installed) + { + return true; + } + } + return false; +} + +void InjectionPanel::draw_subsystem_controls(const HookStatusView& status) +{ + ImGui::SeparatorText("Subsystems (hook / unhook)"); + + struct Row + { + const char* label; + std::uint32_t subsystem; + bool* want; + const char* depends; + }; + const Row rows[] = { + {"Input forwarding (XInput)", HookSubsys_Input, &want_input_, "controller input reaches the game"}, + {"Focus spoof", HookSubsys_Focus, &want_focus_, "the game keeps running unfocused"}, + {"Audio render-hook", HookSubsys_Audio, &want_audio_, "audio mirror without echo"}, + }; + + for (const Row& r : rows) + { + ImGui::PushID(r.label); + if (ImGui::Checkbox(r.label, r.want)) + { + server_.set_subsystem_enabled(r.subsystem, *r.want); + } + ImGui::SameLine(); + const bool on = subsystem_installed(status, r.subsystem); + if (*r.want != on) + { + ImGui::TextColored(kGrey, "(%s...)", *r.want ? "installing" : "removing"); + } + else + { + ImGui::TextColored(on ? kGreen : kGrey, on ? "installed" : "off"); + } + if (!*r.want) + { + ImGui::TextDisabled(" off: %s won't work", r.depends); + } + ImGui::PopID(); + } +} + void InjectionPanel::draw_hook_status(bool debug_details) { if (!server_.running()) @@ -209,16 +271,37 @@ void InjectionPanel::draw_hook_status(bool debug_details) const HookStatusView status = server_.hook_status(); - ImGui::SeparatorText("Hook status"); - if (!status.attached) + // DLL liveness from the heartbeat (advances ~4x/s while the worker runs). + const double now = ImGui::GetTime(); + if (status.heartbeat != last_heartbeat_) { - ImGui::TextColored(kGrey, "Waiting for hook to attach in the game..."); + last_heartbeat_ = status.heartbeat; + last_heartbeat_time_ = now; + dll_alive_ = true; + } + else if (now - last_heartbeat_time_ > 1.5) + { + dll_alive_ = false; + } + + ImGui::SeparatorText("Hook status"); + if (!injected_) + { + ImGui::TextColored(kGrey, "Not injected."); return; } - ImGui::TextColored(kGreen, "Attached (game pid %u)", status.game_pid); - ImGui::TextColored(status.focus_spoof ? kGreen : kGrey, "Focus spoof: %s", - status.focus_spoof ? "active" : "inactive"); + if (dll_alive_) + { + ImGui::TextColored(kGreen, "Hook DLL loaded in pid %lu (heartbeat %u)", server_.target_pid(), + status.heartbeat); + } + else + { + ImGui::TextColored(kRed, "Hook DLL not responding (no heartbeat)."); + } + + draw_subsystem_controls(status); ImGui::TextDisabled("Controller poll rates are in the Controllers panel."); draw_hook_list(status); @@ -263,10 +346,11 @@ void InjectionPanel::draw(bool debug_details) if (server_.running()) { - ImGui::TextColored(kGreen, "Forwarding input to pid %lu", server_.target_pid()); - if (ImGui::Button("Stop forwarding")) + ImGui::TextColored(kGreen, "Connected to pid %lu", server_.target_pid()); + if (ImGui::Button("Disconnect")) { server_.stop(); + injected_ = false; status_ = "Stopped."; status_color_ = kGrey; } @@ -315,7 +399,10 @@ void InjectionPanel::draw(bool debug_details) ImGui::TextColored(status_color_, "%s", status_.c_str()); } + // Synthetic input only reaches the game if the XInput hook is installed. + ImGui::BeginDisabled(injected_ && !want_input_); ImGui::Checkbox("Forward synthetic test input", &test_input_); + ImGui::EndDisabled(); if (test_input_) { ImGui::SameLine(); diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index c3332e6..1b7997b 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -45,6 +45,7 @@ public: private: void refresh_processes(); void inject_selected(); + void draw_subsystem_controls(const HookStatusView& status); void draw_hook_list(const HookStatusView& status); void draw_hook_status(bool debug_details); @@ -58,6 +59,18 @@ private: ImVec4 status_color_; bool test_input_ = false; + + // Host-requested per-subsystem install state (default on). Written to the hook + // over the control channel; the hook reconciles each tick. + bool want_input_ = true; + bool want_focus_ = true; + bool want_audio_ = true; + bool injected_ = false; // a hook DLL is loaded in the target + + // Heartbeat liveness tracking (is the injected DLL responding?). + std::uint32_t last_heartbeat_ = 0; + double last_heartbeat_time_ = 0.0; + bool dll_alive_ = false; }; } // namespace coop diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index 767e881..03250be 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -79,6 +79,15 @@ HookStatusView IpcServer::hook_status() const return view; } +void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled) +{ + if (block_ != nullptr && subsystem < HookSubsys_Count) + { + // 0 = install, 1 = remove. + block_->control.subsystem_disabled[subsystem].store(enabled ? 0u : 1u, std::memory_order_release); + } +} + void IpcServer::stop() { if (block_ != nullptr) diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index 4508263..4b06585 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -52,6 +52,10 @@ public: // Reads the hook's diagnostics back-channel (zeroed if not started). [[nodiscard]] HookStatusView hook_status() const; + // Request a hook subsystem be installed (true) or removed (false). The hook + // reconciles on its next tick. No-op if not started. + void set_subsystem_enabled(std::uint32_t subsystem, bool enabled); + [[nodiscard]] bool running() const { return block_ != nullptr; diff --git a/tools/audio_probe/main.cpp b/tools/audio_probe/main.cpp index 086b073..dcffebe 100644 --- a/tools/audio_probe/main.cpp +++ b/tools/audio_probe/main.cpp @@ -98,6 +98,7 @@ int wmain(int argc, wchar_t** argv) 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"); @@ -115,6 +116,12 @@ int wmain(int argc, wchar_t** argv) 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; // Enable the hook's file trace (%TEMP%\coop_hook.log) for this debug session. @@ -183,6 +190,15 @@ int wmain(int argc, wchar_t** argv) { 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;