Commit Graph

9 Commits

Author SHA1 Message Date
7673f186db Add mouse + keyboard forwarding (MKB subsystem, opt-in)
Forward the host window's clicks and keystrokes into the injected game so guests
can drive menus / "Press Start" / text entry that a pad can't.

Protocol (v7->v8): new HookSubsys_Mkb and an SPSC MkbRing of MkbEvents in
SharedBlock (host produces, hook consumes); push/pop helpers.

Hook (hook/src/mkb_hook.cpp, new subsystem): a worker-loop pump drains the ring at
~5 ms and PostMessageW's the matching window messages (WM_KEY*/WM_CHAR, mouse
buttons, WM_MOUSEWHEEL) to the game's main window; it also inline-hooks user32
GetAsyncKeyState / GetKeyboardState / GetCursorPos (stdcall trampolines per the x86
rule) to report a synthesized state so polling games react too. Removing the
subsystem clears all synthesized keys (no stuck input).

Host: the Injection panel gets a "Mouse + keyboard forwarding" subsystem toggle
(opt-in, default off -- the toggle is the hook). host/src/inject/mkb_forward.cpp
reads ImGui IO each frame and forwards only when the host window is focused and
ImGui isn't capturing the event; keyboard always, mouse only while mirroring (clicks
+ wheel, not movement). Mouse coords are mapped through the letterbox to game-client
space (host/src/inject/mkb_map.hpp), accounting for WGC-of-decorated-window vs
hooked/borderless. RawInput/DirectInput games are out of scope for this version.

Verified: new mkb_ring_test + mkb_map_test pass; full build x64 + x86 clean; ctest
x64 9/9 and x86 3/3 green (no regression from the protocol bump). The subsystem is
opt-in, so it can't affect existing behavior unless enabled; the end-to-end
click-into-game path needs live Remote Play + a real game to confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:06:38 +02:00
61049a620c Restructure bin/: deployables at root, tests and tools in subfolders
Only the shipping artifacts (coop_host.exe, coop_hook.dll, coop_hook_x86.dll,
coop_inject_x86.exe, steam_api64.dll, steam_input_actions.vdf) now land in the
bin/<config>/ root, so it can be copied wholesale into a donor game folder. Test
exes (plus the coop_tone fixture) build into bin/<config>/tests/ and the dev probes
into bin/<config>/tools/, via a new coop_output_subdir() CMake helper.

The probes resolve coop_hook.dll / the x86 injector from the deployable root one
level up (new common/coop/tool_paths.hpp: deployed_artifact_path checks next-to-exe
then parent). coop_tone is co-located with the tests so audio_loopback_test's
"spawn coop_tone.exe next to me" lookup is unchanged.

Verified from a clean bin/: root holds only deployables; ctest x64 7/7 and x86 3/3
green (incl. audio_loopback_test driving coop_tone from tests/).

Also convert the roadmap Planned list to bullets and drop this (now-done) item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 00:59:03 +02:00
b1f8783321 Phase 3: x86 (32-bit) game support via injector helper
Drive a nested Win32 sub-build (CMake ExternalProject, re-entrant via
COOP_X86_HELPER_BUILD) from the normal x64 build to produce coop_hook_x86.dll and
a 32-bit coop_inject_x86.exe, staged next to the x64 binaries. The host detects a
WOW64 target with IsWow64Process2 and spawns the helper to load the x86 DLL, since
a 64-bit process can't cleanly inject a 32-bit one. The shared-memory IPC is
fixed-width / bitness-stable, so the x64 host and x86 hook interoperate.

Validated end-to-end against Slaps and Beans (32-bit D3D11): all 15 hooks
installed, heartbeat advancing, the Present hook engaged (shared a 1920x1080
backbuffer -- the real-game video-hook proof Phantom Brave's D3D9 couldn't give),
and status/audio/video/log IPC all crossed the x64<->x86 boundary. coop_audio_probe
now also delegates to the helper for WOW64 targets. All 5 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:42:42 +02:00
36b861d167 Phase 2: Present-hook video path (shared-texture mirror)
Add an injected IDXGISwapChain::Present / Present1 hook as a lower-latency,
border-free alternative to WGC. The hook copies the swapchain backbuffer into a
shared keyed-mutex texture (coop_video_<pid>); the host opens it by name and
samples it. New opt-in HookSubsys_Video (protocol v6 -> v7); the Video mirror
panel gains a WGC vs Hooked source toggle that installs/removes the subsystem.

Verified by present_hook_test (drives a real D3D11 swapchain end-to-end and reads
the rendered pixels back through the shared texture) and against Phantom Brave
(D3D9: hook installs cleanly and stays idle, WGC fallback). All 5 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:34:12 +02:00
9557b9ca69 Log window: stream the hook's logs over IPC into an in-app Log panel
Add a shared log ring (common/coop/log_ring.hpp): a lossy multi-producer /
single-consumer ring named coop_log_<pid>. The hook logs from several threads,
so producers claim a slot with fetch_add and publish each record with a
release store of its sequence; the consumer reads in order and tolerates
losing the oldest lines if it falls a whole ring behind.

The DLL's logf() now formats once and pushes every line to the ring (the file
trace stays as an opt-in mirror); the worker attaches the ring right after IPC
connect so bring-up is captured. The host (IpcServer) creates the ring at
injection time and exposes drain_logs(); a new LogPanel pulls new lines each
frame into a bounded rolling buffer and renders them with auto-scroll, a
filter, and clear. Added to the View menu (and UiState.show_log).

Verified against Phantom Brave via coop_audio_probe, which now also creates the
ring and drains it: the full hook bring-up trace streamed over IPC. All four
tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:25:04 +02:00
0935dccfc4 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>
2026-06-19 20:17:48 +02:00
1f905940ef Hook registry: list installed hooks + call counts in Injection panel
Add a process-wide hook registry (hook/src/hook_registry) that every hook
module registers its hooks with and bumps a counter from each detour. The
XInput, focus-spoof, and audio render-hooks now register their individual
hooks (XInputGetState/Ex/Caps/SetState; GetForegroundWindow/GetActiveWindow/
GetFocus/WndProc guard; IMMDevice::Activate, IAudioClient::Initialize/
GetService, IAudioRenderClient::GetBuffer/ReleaseBuffer) and count calls.

The worker publishes the table to the host each tick over a new HookStatus
field (protocol v4 -> v5: HookEntry[] + count). The Injection panel shows it
as a collapsible table grouped by subsystem with an installed flag and call
count per hook; coop_audio_probe prints the same table headless.

Verified against Phantom Brave: 13 hooks listed with live counts (focus APIs
polled heavily, GetBuffer/ReleaseBuffer ticking with the audio render loop).
All four tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:09:39 +02:00
4e076420bf Fix audio falling back to echo in the full app (format not published)
In the full app the host creates the audio ring only when the operator toggles
audio mirroring on -- after injection. So the hook registers the game's primary
render stream while the ring is still null, and register_render_client_locked
skips publishing the format (nothing to publish to). When the ring later
attaches via set_audio_ring, the already-registered stream's format was never
re-published: format_valid stayed 0, the host's wait_for_format timed out, and
it fell back to loopback (the echo) -- on every game, including Phantom Brave.
The in-process probe created the ring before injecting, so it never reproduced
this.

Fix: the hook stores the primary stream's format and republish_audio_format()
publishes it whenever a ring is attached but has no format yet -- called from
set_audio_ring and once per worker tick (the tick also covers the host
re-initializing the ring on a mirror re-toggle, which clears format_valid).

coop_audio_probe now creates the ring ~1.5 s AFTER injecting by default
(ring_delay_ms arg) to match the app's ordering. Verified against Phantom
Brave: the log shows "primary stream set ... no ring yet" at inject, then
"republish_audio_format: published 48000Hz/2ch/32bit" when the ring attaches,
and the host-shaped consumer then drains real audio with zero overruns.

All four tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:23:30 +02:00
c7be4eeb9a Fix audio render-hook missing already-playing streams (late injection)
The render-hook only installed IAudioClient/IAudioRenderClient hooks
reactively, when it saw the game call IMMDevice::Activate -> GetService.
But we attach to a game that is already running and playing audio, so its
render client was created before injection: those calls never fire again,
no primary stream is ever registered, nothing is captured, and the host
always falls back to process loopback (the echo). Every game tested did so.

Fix: at anchor time, build our own probe IAudioClient + IAudioRenderClient
with raw calls and hook GetBuffer/ReleaseBuffer (plus Initialize/GetService)
on their vtables. Every instance of a COM coclass shares one vtable, so this
patches the shared vtables and intercepts the game's pre-existing render
client too. The first render client seen actively releasing buffers is
adopted as primary on the audio thread (try-lock, one-time) using the device
mix format as its assumed format (we never saw its Initialize). Streams
created after injection still register via the reactive path with their real
format.

Also fixes a self-deadlock: installing the Activate hook before the probe's
own device->Activate call re-entered hk_Activate -> install_audioclient_hooks,
which blocked on the setup mutex the installer already held, freezing the
worker (and any game thread that later called Activate -> crash). The probe
objects are now created raw, before any hook is installed.

Validated against Phantom Brave (injected while already playing): the
pre-existing 48 kHz/2ch/float render client is detected and registered as
primary, real non-silent audio reaches the ring (peak tracks the game's
levels), and a draining consumer sees zero overruns.

Tooling for iterating on real games without Steam/RPT/the host UI:
- tools/audio_probe: creates the IPC block + audio ring, injects the hook,
  drains the ring and prints stream/format/peak/overrun diagnostics by pid.
- hook/src/debug_log: opt-in file trace (%TEMP%\coop_hook.log), enabled by
  the COOP_HOOK_LOG env var or the %TEMP%\coop_hook.log.on sentinel the probe
  drops; off in normal use.

All four tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:53:52 +02:00