The hook reports vk_too_late when vulkan-1.dll is loaded and the GPA hook has
been in for >4s but it never caught the app creating its device -- i.e. the app
resolved its Vulkan functions before we hooked (injected too late). Surfaced
through a new HookStatus.vk_too_late field (protocol 15->16) and shown by the
host as a red banner on the mirror window: "Detected a Vulkan game, but the
mirror hook attached too late... enable Auto re-attach (and Set up Vulkan layer
if it persists) and relaunch." WGC keeps mirroring meanwhile.
mock_game_test gains a too-late detection check (late-inject a Vulkan game ->
vk_too_late trips). Verified live: the banner shows over the running tool when a
Vulkan game is injected late with the video subsystem on (added a debug-harness
`video on/off` command to drive it). 15/15 ctest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New vk_hook.cpp inline-hooks the vulkan-1.dll vkGetInstanceProcAddr export and
hands back our wrappers for vkCreateInstance / vkCreateDevice / vkGetDeviceProcAddr
/ vkCreateSwapchainKHR / vkQueuePresentKHR, so a volk-using (loader-bypass) app
resolves our hooks. On present it reads the swap-chain image back with
vkCmdCopyImageToBuffer into a host-visible buffer (same read-back model as
D3D10/D3D9/OpenGL), swizzles BGRA->RGBA, and uploads it into the shared
keyed-mutex texture on a hook-owned D3D11 device. The read-back submit re-chains
the present's wait semaphores (consume the originals, signal our own that the
real present waits on) so capture orders after rendering without double-waiting.
Wired into the video subsystem with a lazy retry (vulkan-1.dll loads late). Hook
links the official Vulkan-Headers (headers only, VK_NO_PROTOTYPES) via the
include dir so the x86 sub-build builds too.
Because Vulkan caches its present pointer at init, late injection can't hook it:
mock_game_test launches the mock **suspended**, injects, resumes, and under
COOP_MOCK_VK_EARLY the mock loads Vulkan and waits so the hook arms first -- then
decodes frames through the hook like the other backends. 15/15 ctest (x64 +
x86); skips cleanly without a Vulkan driver.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New d3d9_hook.cpp (own file like opengl_hook): inline-hooks
IDirect3DDevice9::Present (vtable index 17, discovered from a throwaway device;
clean prologue so inline is safe, stdcall() on x86) and read-backs the backbuffer
with GetRenderTargetData into a D3DPOOL_SYSTEMMEM surface, swizzles BGRA->RGBA,
and uploads it into the shared keyed-mutex texture on a hook-owned D3D11 device
(a D3D9 surface isn't D3D11-shareable). Both plain D3D9 and D3D9Ex call this same
Present, so one read-back path serves both -- the GPU shared-surface fast path
the plan sketched for D3D9Ex wasn't worth it. Wired into the video subsystem
(install/remove in dllmain); hook links d3d9.
mock_game_test decodes DX9 + DX9Ex frames through the hook; 15/15 ctest (incl.
the x86 sub-build). Roadmap: DX9 milestone done and removed (remaining
renumbered); architecture + lessons updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Found by the mock-game capture/audio/hook stress test (toggling the audio
subsystem while a game renders):
1. Guessed-stream silence over-WRITE: hk_ReleaseBuffer zeroed num_frames *
guessed_block bytes, but a guess can be larger than the real per-frame size
(e.g. an 8ch device guess for a 2ch game), so the memset wrote past the real
buffer into adjacent audio memory -> intermittent access violation in the
game. Fix: capture but do NOT silence a guessed stream (it stays audible --
echo); only an exact/override format, whose frame size is known, gets the
no-echo silence.
2. VtableHook::remove nulled m_original, racing an in-flight detour into a null
call -> keep it valid (the original function stays mapped).
3. g_ipc was a non-atomic pointer read on the hot path while unhook nulled it
(TOCTOU) -> make it atomic, load once.
4. Stale GetBuffer/ReleaseBuffer pairing across a toggle -> epoch-stamp the
GetBuffer and only capture in the same hooked epoch.
5. COM-object churn: re-creating the probe client every enable raced AudioSes ->
build the probe once, keep it across toggles (only swap vtable slots);
release on detach (shutdown_audio_hooks). Plus drain in-flight detours before
tearing down state.
Stress test: 0 crashes in many repeated runs (was ~50%). Guessed streams now
echo (the no-echo path is reached via an exact/auto-attach format or override).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Games with several concurrent WASAPI render streams (e.g. Spider-Man: Miles
Morales) only had their first ("primary") stream mirrored; the rest kept playing
locally and never reached the guest. Now the render-hook captures + silences EVERY
tracked stream into its own ring (coop_audio_<pid>[_<index>]), each published with
that stream's own detected format (Initialize when caught, else GetMixFormat -- the
per-stream format detection, now actually used per ring rather than only for the
primary). The host creates a ring per stream and mixes the same-format streams with
a soft clip (host/src/audio/audio_mix.hpp); streams whose format differs from the
primary are still silenced (no echo) but skipped from the mix (would need
resampling).
The single-stream case is byte-for-byte unchanged: when only one stream is active
the host passes it through without the mixer, so the common path has no overhead or
fidelity change.
Verified: new audio_mix_test covers the decode/sum/soft-clip/encode math (float32 +
int16); audio_hook_test (x64 + x86) still passes, guarding the primary
capture+silence path against regression; full build x64 + x86 clean; ctest x64
11/11, x86 3/3. Multi-stream mixing against a real multi-stream game needs a live
session to fully confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Games that ClipCursor / re-center via SetCursorPos while focused trap the operator's
mouse (the focus spoof makes them think they're always focused), so the operator
can't reach the overlay. The Focus subsystem now inline-hooks ClipCursor and
SetCursorPos (stdcall trampolines): while "release" is requested it forces
ClipCursor(NULL) and swallows the re-centering SetCursorPos; otherwise it passes them
through. It frees any existing clip at install and re-frees each worker tick (covers
one-time clippers and a runtime clip->release toggle).
Host: protocol v10->v11 adds HookControl::allow_cursor_clip (0 = release, the
default). The Injection panel gets a "Release operator cursor" checkbox and an F2
hotkey (InjectionPanel::toggle_cursor_release); default released, since the guest
plays via the pad so the game's clip is operator-only.
Verified: full build x64 + x86 clean; ctest x64 9/9, x86 3/3. The cursor behavior
against a real clipping game (Trails through Daybreak) needs a live injected session
to confirm; logic reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The Present hook never fired in Phantom Brave because it's an OpenGL game
(OPENGL32.dll loaded, IDXGISwapChain::Present calls=0), so the hooked video source
showed no image. Add an OpenGL producer under the video subsystem: inline-hook
gdi32!SwapBuffers + opengl32!wglSwapBuffers (with a re-entrancy guard, since
SwapBuffers calls wglSwapBuffers), glReadPixels the backbuffer, flip it, and upload
it into the same shared keyed-mutex texture the host already samples -- so the host
is unchanged. DXGI games still hit the Present hook; both producers are installed
and whichever the game uses fills the texture.
Validated by opengl_hook_test (real GL context, clears to a known color, reads the
exact pixels back through the shared texture) and against Phantom Brave (SwapBuffers
~75/s, present=0, shared texture 1920x1080, generation advancing). Vulkan
(vkQueuePresentKHR) still needs WGC -- documented. All 7 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
End-to-end plumbing of the render-hook audio path.
Hook (coop_hook.dll):
- CMake: build audio_hook.cpp, link ole32/mmdevapi, NTDDI_WIN10_CO.
- dllmain: CoInitializeEx(MTA) on the worker thread; install the audio hooks
even before the ring exists (so streams are counted); open the host's
coop_audio_<pid> ring when it appears and attach it (enabling capture+silence);
remove_audio_hooks on clean detach.
Host (coop_host.exe):
- AudioMirror now creates the shared audio ring (owns capture_enabled) and tries
the Hooked path first: waits ~1s for the hook to publish a format, then
re-renders the game's frames from the ring with AUTOCONVERTPCM (no echo, since
the hook silences the game locally).
- Automatic fallback: if the ring can't be created, no format arrives in time,
or the render client won't initialize, it disables capture (so the game stays
audible) and reverts to the existing process-loopback path (echo, no regress).
- Exposes Source (Hooked/Loopback/None) for the upcoming panel indicator.
Loopback render loop kept intact as run_loopback. Manual end-to-end (M5) and the
panel UI (M4) are next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Surfaced by a game (Life is Strange: Before the Storm) that ignores
controller input when it lacks true OS focus even though it still polls
XInput. To find the focus-gated detection path, instrument the hook.
Protocol v3 status back-channel now reports:
- per-slot XInputGetState and XInputGetCapabilities counters (replacing the
single aggregate), so the overlay shows exactly which slots the game polls
and how fast;
- focus-API call counts (GetForegroundWindow/GetActiveWindow/GetFocus) to see
whether the game consults the APIs we spoof;
- input-path diagnostics: whether the process registered Raw Input for a
gamepad usage and whether it set RIDEV_INPUTSINK (background delivery), and
whether a DirectInput dll is loaded.
Host overlay gains a per-slot poll table and an "Input path" section. The DLL
refreshes input diagnostics each worker tick via GetRegisteredRawInputDevices.
hook_selftest updated for per-slot counters; passes.
This is diagnostic-only: once a real run shows which path LiS uses, the
targeted focus fix (e.g. forcing RIDEV_INPUTSINK or DI background coop) follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two problems surfaced in testing: (1) no way to tell whether the injected
hook was actually the input source, and (2) the final design needs the tool
window focused for Steam RPT capture, which would pause/silence games that
react to focus loss. Both are addressed here.
- Focus spoofing (hook/focus_spoof): find the game's main window, subclass it
to rewrite/swallow WM_ACTIVATE/ACTIVATEAPP/NCACTIVATE/KILLFOCUS, and inline-
hook GetForegroundWindow/GetActiveWindow/GetFocus to always report the game
as active. The game keeps running and polling while unfocused.
- Status back-channel (protocol v2): the DLL reports attached/focus-spoof
flags, game pid/hwnd, a heartbeat, and a cumulative XInputGetState counter.
The host overlay turns the counter into a live poll rate, so "is the hook
working" is directly observable.
- Synthetic test-input toggle in the host: forwards a known automated pattern
(stick circle + periodic A) to prove forwarding independent of the physical
pad.
- hook_selftest extended to assert the status channel; passes.
Documented the windowed/borderless requirement and the new observable test
flow in the README.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The host can now inject coop_hook.dll into a running game and forward
controller state to it over shared memory, so the game reads the host's
(eventually the guest's) input and nothing else.
- hook/: coop_hook.dll. DllMain spawns a worker that opens the shared-memory
channel (named by the game's pid) and installs SafetyHook inline hooks on
XInputGetState/GetStateEx/GetCapabilities/SetState. Detours synthesize state
from shared memory; unmanaged slots report disconnected, hiding physical pads.
- host/: process picker (Toolhelp32), CreateRemoteThread(LoadLibraryW) injector
with an IsWow64Process2 bitness guard, IPC server publishing pads each frame,
and an ImGui Injection panel wiring it together.
- tests/: hook_selftest exercises the IPC seqlock + hook detours in-process
(no game/controller needed); passes.
Build: SafetyHook wired in (COOP_BUILD_HOOK=ON), Zydis via FetchContent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>