Commit Graph

134 Commits

Author SHA1 Message Date
4985239222 Fix 32-bit FMOD audio crash: hook WASAPI COM methods via vtable swap
The stdcall() fix stopped the Present-hook crash but 32-bit games (Slaps
and Beans, FMOD) still crashed the instant audio init ran through the
hook. Root cause: SafetyHook's inline hook relocates the target's
overwritten prologue into a trampoline, but MMDevApi/AudioSes COM methods
on x86 open with `push ebp; mov ebp,esp; and esp,-8` (dynamic stack
alignment) and read arguments EBP-relative. The relocated copy leaves EBP
wrong, so the original runs with garbage arguments and faults (AV writing
*ppInterface inside CEndpointDevice::Activate+0x3d).

Switch all five WASAPI COM hooks (IMMDevice::Activate, IAudioClient::
Initialize/GetService, IAudioRenderClient::GetBuffer/ReleaseBuffer) from
safetyhook::create_inline to a small VtableHook helper: VirtualProtect the
shared vtable slot, overwrite the function pointer, call the saved original
directly. No code patching, no trampoline, pristine stack regardless of
prologue. One swap covers every instance (a coclass shares one vtable), so
the existing shared-vtable strategy is preserved. Inline hooking stays for
Present/SwapBuffers, whose prologues relocate cleanly.

Reproduced in-process with a new x86 build of the audio render-hook test
(audio_hook_test_x86): it installs the hooks, then drives a fresh
IAudioClient through them and renders -- segfaulted before, passes now.
The x64 audio_hook_test passes regardless of the bug, so the 32-bit build
is the regression guard.

ctest: x64 7/7, x86 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:39:29 +02:00
435ab9d30f Fix 32-bit game crash: call hooked __stdcall functions with stdcall()
SafetyHook's InlineHook::call() invokes the trampoline through a __cdecl
pointer (the compiler default on x86). The functions we hook are __stdcall
(IDXGISwapChain::Present/Present1, the WASAPI render interfaces, and the
WINAPI SwapBuffers/wglSwapBuffers), so on 32-bit both sides cleaned the
stack -> ESP imbalance -> Run-Time Check Failure #0 and an instant crash.
On x64 every convention collapses to one, so it only bit 32-bit games:
Slaps and Beans (Unity/Rewired, 32-bit D3D11) froze the moment the Present
hook ran. The user's "crashes as soon as a button is pressed" was the
Present, not the button.

Switch every __stdcall trampoline call to SafetyHook's stdcall() (a no-op
on x64). The XInput/focus hooks were unaffected because they never call
the trampoline -- they return synthesized data.

Reproduction + regression coverage:
- tools/input_probe (coop_input_probe): injects, reports a connected pad,
  toggles a button, and takes a disable_mask to bisect which subsystem
  affects a game. Isolated the freeze to the video subsystem live.
- hook_selftest_x86 + present_hook_test_x86: the x86 sub-build now builds
  and runs these (the x64 present_hook_test can't see a one-convention
  bug). present_hook_test_x86 drives a real swapchain through the
  trampoline -- it would hit RTC #0 before this fix.
- hook_selftest strengthened to exercise every loaded xinput DLL's full
  export set (GetState, ordinal-100 GetStateEx, GetCapabilities, rumble
  SetState) and to dump the SharedBlock layout.
- protocol.hpp: static_asserts lock the cross-bitness front-of-block
  offsets (verified byte-identical on x86 and x64).

README roadmap trimmed (this milestone done) and a lessons-learned note
added on the call()/stdcall() convention trap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 20:07:17 +02:00
9977ad5d5a Add OpenGL capture path for the hooked video mirror
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>
2026-06-20 14:33:47 +02:00
914dba4d25 Fix input forwarding: default to XInput, make Steam Input opt-in
Making Steam Input the default backend silently broke input forwarding. Merely
initializing Steam Input activates Steam's in-process XInput interception, which
hides controllers from XInputGetState unless they're bound to our action set for
the running appid. With no such binding (the normal case for a donor appid) Steam
Input reports zero controllers AND XInput now sees nothing -> no input at all.

Reproduced with coop_steam_input_probe: without Steam, XInput slot 0 is seen;
with Steam Input initialized, 0 Steam controllers and the XInput fallback goes
empty.

Default to XInput (RPT delivers guest pads there and it works) and make Steam
Input an opt-in Controllers-panel toggle that switches the backend at runtime;
turning it off restores XInput. All 6 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:25:43 +02:00
24d66020f6 Fix hooked video darkening for *_SRGB-backbuffer games
The host sampled the shared backbuffer copy through a view typed exactly like the
game's backbuffer. For games whose backbuffer is an *_SRGB format (e.g. Life is
Strange: Before the Storm -- confirmed R8G8B8A8_UNORM_SRGB / fmt 29 via the hook
log), the GPU decoded sRGB->linear on the sample, and the host then wrote those
linear values straight to its plain-UNORM swapchain with no re-encode, so the
mirror came out noticeably darker than the game.

Sample the copy as the plain-UNORM sibling of the format (srgb_to_unorm) so the
bytes pass through unchanged -- matching what WGC already does. The UNORM and
*_SRGB formats share a typeless group, so CopyResource from the producer's sRGB
texture into the host's UNORM copy is allowed. Non-sRGB formats are unaffected.

Adds srgb_format_test locking the mapping. All 6 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:22:31 +02:00
edf2da69d8 Steam Input as the primary guest-input path (optional, SDK-gated)
Add SteamInputSource: initializes SteamAPI + Steam Input, loads a bundled action
manifest via SetInputActionManifestFilePath (no partner-backend config needed),
and reads the GameControls action set into CoopPadState -- falling back to XInput
per slot, and to pure XInput if Steam isn't available, so the host always runs.

Enabled automatically when the Steamworks SDK is vendored at
third_party/steamworks_sdk/ (auto-detected by CMake; gitignored and never
committed -- the build is XInput-only without it). Stages steam_api64.dll + the
manifest next to the host and builds coop_steam_input_probe (a console smoke test).

Verified: the probe initializes against the live Steam client and enumerates
controllers; the host degrades gracefully when launched standalone. All 5 tests
pass. Reading actual controller state needs a pad bound through Steam Input for
the running (donor) appid, which XInput otherwise covers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:51:58 +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
565934e8cf Move controller-poll info from Injection to Controllers panel
The per-slot XInput poll rates and the "game reading controller" summary
describe the controller, not the injection mechanism, so move them to the
Controllers panel (renamed from debug_overlay -> ControllersPanel, now a class
that owns the poll-rate sampling). The panel now shows both directions: the
guest pads the host receives from RPT, and what the injected game reads back.

The Injection panel keeps the hook attach state, focus spoof, focus-API
counts, and input-path diagnostics, and points to the Controllers panel for
poll rates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:00:30 +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
a1b7578165 Video mirror: frametime + FPS graphs with min/max/avg stats
The mirror renders into the host window, so the host's render frame timing is
the mirror's performance. FrameStats now retains a ~2 s ring of frame-time
samples; the Video mirror panel plots them as a frametime graph (0-33 ms
scale) and an FPS graph (0-144 scale), each with an avg overlay, and prints
avg/min/max for both frametime and FPS underneath.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:19:00 +02:00
e07a89c869 docs: mark overlay-toggle + UI-generalize roadmap items done
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:08:07 +02:00
d19974b17c Generalize UI: general status by default, diagnostics behind Debug details
Rework the spike-era debug readouts into general-purpose status, with the
verbose diagnostics gated behind the menu bar's "Debug details" switch:

- Controllers (was "Phase 0 spike"): always shows slot/source + live
  buttons; the raw stick/trigger numbers are debug-only.
- Injection hook status: general view is attached + focus spoof + a single
  "Game reading controller: N polls/s" summary; the per-slot poll table,
  focus-API counts, and input-path detection are debug-only.
- Audio: general view adds a "Buffered: N ms" health/latency proxy
  (AudioMirror now tracks buffered audio in both render paths) and keeps the
  render-stream count; the per-stream table is debug-only.

Default overlay is now clean general status; flip Debug details for the full
diagnostics. Completes the "generalize the UI" roadmap task. All tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:07:42 +02:00
b48cdd6515 Generalize UI: main menu bar with panel toggles + frame stats
Add a top menu bar (host/src/ui/app_chrome) as the overlay shell:
- View menu toggles each panel (Controllers / Injection / Video / Audio)
  and a global "Debug details" switch (consumed by panels in a follow-up).
- A right-aligned performance readout: stable 1 s FPS plus the frame-time
  spread (avg, min-max) so stutter is visible; it turns amber on a >25 ms
  spike. FrameStats keeps the rolling window.

Consolidates the FPS that was duplicated in the controllers and video
panels into this one readout.

First structural step of the "generalize the UI" roadmap task; content
rework (debug-vs-general split, renaming the spike-era readouts) follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:03:50 +02:00
2df5a3752e Overlay toggle: F1 hides the UI for a clean RPT mirror
The ImGui overlay was always visible, so the mirror window Steam Remote Play
Together captures always had debug panels drawn over the game. F1 now toggles
the whole overlay off; the input/video/audio pipelines keep running
underneath. When hidden, a small "F1: show overlay" hint fades out over ~4s so
the operator can find the way back, then the window is fully clean.

First item of the "make the tool usable" roadmap task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 15:01:23 +02:00
88db914914 docs: record remaining verification checks for the audio render-hook
List the human/full-setup checks that producer-side capture can't prove on
its own: hooked audio over RPT end-to-end, the mix-format assumption for
streams that predate injection, and multi-stream games.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:58:39 +02:00
365043c3da docs: record the late-injection render-hook fix + probe tool
Update the plan's status (validated against a real already-playing game),
add the "reactive-only hooking fails on late injection" lesson and the
self-deadlock ordering sub-lesson, and document tools/audio_probe in the
README. Mirrors commit c7be4ee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:55:11 +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
2f1c036320 Audio render-hook: document the echo fix (M1-M4 done, M5 manual)
- README: audio mirror is now the injected render-hook with process-loopback as
  automatic fallback; update Architecture/Limitations/Status/Roadmap and the
  manual run step (Source indicator + render-stream table). Add tests
  (audio_ring_test, audio_hook_test) and a lessons-learned note on COM vtable
  indices (IAudioClient::GetService is 14, not 13).
- plan doc: mark M1-M4 implemented/committed, M5 (real-game end-to-end) pending,
  and record the GetService index correction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:21:38 +02:00
31390d8c15 Audio render-hook M4: audio panel source indicator + stream-count debug view
Surfaces the render-hook diagnostics in the Audio panel.

- HookStatusView / IpcServer::hook_status(): carry audio_streams_seen +
  audio_streams[] from the hook's back-channel.
- InjectionPanel: expose hook_status() so the audio panel can read the counts.
- AudioPanel::draw_ui(HookStatusView): show the active Source (green Hooked
  vs amber Loopback); only warn about the local echo on the loopback path. Add
  the required render-stream table: one row per stream with format, cumulative
  frames, the primary tagged, and a live/idle dot derived from frame-count
  deltas. Notes overflow when the game exceeds kMaxAudioStreams slots.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:05:28 +02:00
679b243974 Audio render-hook M3: wire DLL + host hooked mode + loopback fallback
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>
2026-06-19 12:02:29 +02:00
4e3814a072 Audio render-hook M2: render hook + in-process self-test (concept proven)
Implements the WASAPI render-hook (hook/src/audio_hook.{hpp,cpp}) and an
in-process self-test that proves COM vtable discovery and GetBuffer/ReleaseBuffer
interception with no game and no second Steam account.

- audio_hook.cpp: anchors on IMMDevice::Activate (idx 3) off our own default
  endpoint (shared vtable), then hooks IAudioClient::Initialize (3) /
  GetService (14) and IAudioRenderClient::GetBuffer (3) / ReleaseBuffer (4) off
  live game pointers. Copies primary-stream frames into the audio ring and
  releases with AUDCLNT_BUFFERFLAGS_SILENT (+ memset belt-and-suspenders), only
  while the host-owned capture_enabled flag is set. Stream counting runs always;
  on a ring overrun it keeps playing locally rather than going silent.
- ipc_client.hpp: publish_audio_stream / note_audio_frames /
  set_audio_streams_seen write the render-stream debug fields into HookStatus.
- tests/audio_hook_test.cpp: installs the hooks, renders a tone through WASAPI
  in-process, and asserts exactly one stream, frames pushed to the ring, the
  ring carries the non-silent tone, and the primary was silenced. PASS:
  streams_seen=1, frames_captured=32640.
- plan doc: correct GetService vtable index 13 -> 14 (SetEventHandle is 13).

coop_hook DLL wiring + host consumer/fallback come next (M3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:57:28 +02:00
8e93312925 Audio render-hook M1: shared audio ring + protocol diag fields
First milestone of the injection render-hook audio path (see
docs/audio-render-hook-plan.md) that fixes the local audio echo without a
virtual device.

- common/include/coop/audio_ring.hpp: new lock-free SPSC shared-memory ring
  for PCM, separate from the input/status SharedBlock. Free-running 64-bit
  positions (release/acquire), format handshake, host-owned capture_enabled
  gate, drop-whole-packet overrun policy.
- common/include/coop/protocol.hpp: add AudioStreamInfo + audio_streams_seen /
  audio_streams[] to the always-present HookStatus for the render-stream-count
  debug view; bump kProtocolVersion 3->4 (new members appended).
- tests/audio_ring_test.cpp: in-process unit test (push/pop integrity,
  wrap-around, format handshake, overrun/drop). No hook or audio device.
- docs/audio-render-hook-plan.md: the green-lit design this implements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:49:18 +02:00
8059f0e488 docs: update README for Phases 0-2 done, add roadmap + lessons
- Reflect audio mirror (Phase 2) and video mirror (Phase 1b) as implemented;
  drop stale "(current)"/"still ahead" framing and the broken docs/ link.
- Add a Roadmap section for the planned work: toggle/generalize the debug UI,
  fix the local audio echo, Present-hook video path, Steam Input, x86 support.
- Add Lessons learned (RPT focus requirement, no exclusive fullscreen, the
  ActivateAudioInterfaceAsync agile-handler gotcha, WGC occlusion, audio echo).
- Document clangd/compile_commands setup and the CTest suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:27:57 +02:00
663d86e6ec Phase 2: audio mirror via WASAPI process loopback
Mirror the real game's audio so Steam Remote Play Together (which streams the
host's own audio session) carries it to the guest. The host captures the game
by PID via WASAPI process loopback and re-renders it on the default endpoint;
the game still plays locally too (accepted "double audio" for now).

- ProcessLoopbackCapture: process-loopback capture client, frame-sink + stats.
  The completion handler must be agile (IAgileObject) or
  ActivateAudioInterfaceAsync rejects every call with E_ILLEGAL_METHOD_CALL.
- AudioMirror: wraps capture with an event-driven render client and a primed
  ring buffer; AudioPanel drives it from the injected game's window/PID.
- coop_tone: standalone WASAPI sine-wave process used as a known audio source.
- audio_loopback_test (CTest): captures coop_tone by PID and asserts non-silent
  audio arrives, so the path is verifiable without a second Steam account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:21:27 +02:00
ba545ba64a Tooling: generate compile_commands.json for clangd
The Visual Studio CMake generator does not emit compile_commands.json, so
clangd had no include paths or flags and reported hundreds of false errors.
Add a parallel Ninja config (build-clangd/) that produces the database:

- CMAKE_EXPORT_COMPILE_COMMANDS ON (honored by Ninja, ignored by VS)
- gen-compile-commands.bat configures build-clangd/ under vcvars64
- .clangd points clangd's CompilationDatabase at build-clangd/
- ignore /build-clangd

clangd's clang-cl driver auto-resolves the MSVC/Windows SDK system includes,
so the cl.exe-based database is sufficient.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:21:10 +02:00
e38df6cf88 Phase 1b: video mirror via Windows Graphics Capture
Mirror the injected game's window into the host's borderless window so Steam
RPT streams a live copy of the game. No injection needed for video.

- capture/window_capture: WGC capture of a target HWND. Wraps our D3D11 device
  as an IDirect3DDevice, creates a free-threaded frame pool + capture session,
  hides the cursor and (best-effort) the capture border. Frames arrive on a WGC
  thread and are handed to the render thread, which copies the newest into a
  shader-resource texture and draws it -- keeping all D3D11 context use on one
  thread. Handles window resize via frame-pool Recreate.
- capture/frame_renderer: fullscreen-triangle shader that blits the captured
  texture letterboxed (aspect-preserved) into the window.
- capture_panel: "Mirror game window" toggle + lifecycle; target HWND comes
  from the hook's reported game window.
- main: winrt apartment init; mirrored frame drawn as background, ImGui on top.
- CMake: link windowsapp + d3dcompiler.

Verified: builds clean; host starts (apartment init + shader compile succeed).
Visual capture quality/latency to be judged on a real game.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 01:03:59 +02:00
a0a12d69fe Phase 1a: per-slot poll viz + input-path diagnostics
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>
2026-06-19 00:38:07 +02:00
df4325d21b Phase 1a: focus spoofing + hook observability
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>
2026-06-19 00:13:06 +02:00
e370c8dcc5 Phase 1a: input forwarding via DLL injection + XInput hook
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>
2026-06-18 23:21:20 +02:00
cf058aecfa Phase 0: donor-launch spike foundation
Scaffold CoopAllTheThings: Remote Play Together for any XInput game via a
mirror app under a donor appid (real game keeps its own appid, so DRM,
achievements, and playtime stay intact).

- Build: CMake skeleton, ImGui + SafetyHook submodules (no vcpkg)
- common/: host<->hook IPC contract (seqlock pad state, shared-memory RAII)
- host/: borderless D3D11 window + ImGui overlay listing visible XInput pads,
  behind an InputSource interface (Steam Input slots in later)
- README documents the Phase 0 donor-launch validation procedure, anti-cheat
  limitation, and XInput/bitness constraints

Phase 0 validates the riskiest assumption (Steam RPT streams an arbitrary
window under a donor appid and routes guest input to it) before capture and
injection are built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 22:48:31 +02:00