Per-subsystem hook control: install/remove input, focus, audio at runtime
Add a host->hook control channel (protocol v5 -> v6: HookControl in SharedBlock, per-subsystem "disabled" flags, 0 = install so the zero-filled default is unchanged). The worker now reconciles each subsystem every tick: install what's requested-and-missing, remove what's no longer wanted -- so the audio hooks re-attach the ring and republish format on a reinstall, and XInput/focus clear their stale status flags on removal. Injection panel: a checkbox per subsystem (input forwarding / focus spoof / audio render-hook) toggles it at runtime, showing the requested vs actual installed state from the registry, plus DLL heartbeat liveness. The hook-status section now keys off whether a DLL was injected (host-side) rather than the input-hook "attached" flag, so it stays visible with input unhooked. Guards for dependent features: the synthetic-input control is disabled when input forwarding is off, and the Audio panel explains that mirroring uses loopback (echo) when the render-hook is off. Verified against Phantom Brave via coop_audio_probe: starting with audio requested off installs only input+focus (8 hooks, no capture); re-enabling at runtime installs the audio hooks (13) and capture starts immediately. All four tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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<std::uint32_t> 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.
|
||||
};
|
||||
|
||||
@@ -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<coop::AudioRingHeader>();
|
||||
if (coop::audio_ring_valid(*ring))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -----------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user