Commit Graph

18 Commits

Author SHA1 Message Date
49daa1d81f M2(Vulkan): Vulkan mock-game backend + volk/Vulkan-Headers submodules
Add render_vk.cpp (selectable as `vk`): brings up a real Vulkan
instance/device/swap chain via volk (which dlopens vulkan-1.dll -- the
loader-bypass case the capture hook must handle) and clears the swap-chain image
to the frame-counter colour each frame with vkCmdClearColorImage (no pipeline,
no shaders, no SPIR-V) and presents. The whole image encodes the frame number,
so it animates and stale frames are detectable.

Adds the official Khronos Vulkan-Headers + zeux/volk submodules and a
coop_require_submodule() CMake helper that fails with a clear "git submodule
update --init --recursive" message rather than auto-cloning. volk is pinned to
the project's dynamic CRT (no LNK4098).

mock_game_test gets a vk liveness check (its present pointer is cached at init,
so late injection can't hook it -- the capture path needs the early-load path,
to come). 16/16 ctest, skips cleanly without a Vulkan driver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:12:55 +02:00
e996188aec M2(OpenGL): GL mock backend (no loader) + mock-backed capture coverage
Add render_gl.cpp (selectable as `gl`): renders the animated pattern with
scissored clears (glClear + glScissor -- GL 1.1, exported straight from
opengl32) and presents with SwapBuffers. No glad/submodule needed: a legacy
wglCreateContext + <GL/gl.h> suffices, so the planned loader dependency was
dropped. GL is bottom-left origin and the capture flips top-down, so the
frame-counter block is drawn at the GL top to land top-left in the captured
image. Window class gains CS_OWNDC for a stable GL DC; best-effort vsync via a
runtime wglSwapIntervalEXT lookup.

The GL SwapBuffers/wglSwapBuffers hook now bumps the shared present counter
(it's the GL present), so present_calls works for GL games too. mock_game_test
decodes GL frames through the existing glReadPixels capture path; 15/15 ctest.
Roadmap: OpenGL milestone done and removed (Vulkan renumbered to M2);
architecture/lessons/test docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:03:43 +02:00
5bccfd8b86 M2(DX9): dual-mode DX9 mock-game render backend
Add render_dx09.cpp (selectable as `dx9ex` / `dx9`): renders the animated
pattern with Clear + ColorFill (D3D9's built-in rect fill -- no shaders) and
presents through a real D3D9 / D3D9Ex device. Two modes so the two D3D9 capture
paths each have a matching game. Smoke-verified: both present frames and exit
cleanly. The capture hook + frame-decode test land next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:46:13 +02:00
a8de75b2f6 M2: DX10 mock-game render backend
Add render_dx10.cpp (selectable as `dx10`): composes the animated pattern
(background + moving bar + top-left frame-counter block) on the CPU each frame
and CopyResource's it into a real D3D10 DXGI swap-chain back buffer (DX10 has no
rect-clear; no shaders). Smoke-verified: `coop_mock_game dx10 2` presents frames
and exits cleanly. The frame-accurate decode-through-the-hook assertion lands
with the DX10 capture commit next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:19:38 +02:00
549b91a9ce Add coop_mock_game: animated, frame-numbered A/V test game (DX11 + DX12)
A tiny test "game" for exercising the capture/audio/hook paths. Opens a normal
window and renders an animated pattern -- moving bar + per-frame background so
motion (and dropped frames) are obvious -- with a top-left block whose RGB
encodes the exact frame number, so a capture test can decode it and detect
dropped / duplicated / stale frames. Selectable backend (dx11 / dx12 today),
behind a RenderBackend interface so OpenGL/Vulkan can be added. With audio args
it also plays a configurable WASAPI tone (shared ToneSource), so it's a full A/V
source with a window (unlike coop_tone).

  coop_mock_game.exe [dx11|dx12] [seconds] [rate] [channels] [bits] [pcm|float]

Milestone 1 of the mock-game roadmap item (the stress-test suite follows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:09:41 +02:00
7264cb2ef4 Audio: detect a pre-existing render stream's true sample rate (fix pitch)
Hooked audio mirroring played back pitch-shifted on games we inject into
that render at a non-device sample rate (e.g. Godot/Brotato render 44100 Hz
on a 48000 Hz endpoint via WASAPI AUTOCONVERTPCM). We attach to an
already-running game, so the render-hook never saw its IAudioClient::
Initialize and assumed the device mix format -- right channels/bits, wrong
rate -- so 44100 audio was rendered as 48000 (+~1.5 semitones).

Fix: treat a pre-existing client's format as a guess and measure its true
sample rate from the render cadence (frames/sec over a steady-state window,
snapped to the nearest standard rate) before publishing it, deferring
capture until verified. Discard the first measurement window so the
buffer-fill burst at attach time doesn't over-count. Streams created after
we inject still carry their exact Initialize format.

Channels/bit-depth genuinely can't be recovered for a pre-existing client:
AUTOCONVERTPCM hands GetBuffer a fixed staging buffer (no buffer stride to
measure -- confirmed empirically) and WASAPI exposes no API for the format.
They stay the device-mix guess, which is correct for the common case
(engines render stereo float, matching the endpoint). To keep a wrong guess
safe, a VirtualQuery clamp stops the capture copy from ever over-reading the
source buffer when the guessed bytes/frame is too large.

Surface all of this: a per-stream AudioFormatState (known / measuring /
measured rate (ch/bits assumed)) in HookStatus, shown in the Audio panel for
the hooked path and as "device endpoint (known)" for loopback; clear hook
logs; and enriched mirror status strings. Documented in README (Limitations
+ Lessons learned). The loopback fallback was always correct (post-mix at
the device format).

Tests: extract a shared, configurable ToneSource (used by coop_tone and the
hook self-test); coop_tone takes rate/channels/bits/format args. Rewrite
audio_hook_test to a format matrix x both code paths -- see-init (exact) and
guess (rate measured) -- plus a byte-incompatible guess that asserts the
clamp keeps capture safe. The matrix caught the attach-burst over-count.
audio_loopback_test now spawns coop_tone at several source formats to
confirm loopback is format-agnostic. 11/11 x64 + 3/3 x86 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:41:57 +02:00
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
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
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
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
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