Commit Graph

85 Commits

Author SHA1 Message Date
bdb700ec56 Recover the video mirror from a WAIT_ABANDONED keyed mutex
SharedTextureSource::update() acquired the shared-texture keyed mutex with
`AcquireSync(...) == S_OK`. But WAIT_ABANDONED -- a prior owner (e.g. a host that
crashed mid-acquire, then reconnected) died holding it -- actually GRANTS us
ownership. Treating it as failure skipped the copy AND never released, so the
next AcquireSync blocked forever and the mirror froze permanently after a crash
+ reconnect (directly relevant to the new reconnect path).

Factor the decision into keyed_mutex_acquired(HRESULT) (capture/keyed_mutex.hpp):
S_OK or WAIT_ABANDONED -> copy + release; timeout/hard errors -> skip the frame.
update() now uses it.

Test-first: keyed_mutex_test asserts WAIT_ABANDONED is treated as acquired while
the genuine "didn't get it" cases (timeout, E_FAIL, device-removed) are not. The
full cross-process abandonment is keyed-mutex OS semantics, not re-tested with a
child process here -- the predicate is the regression surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:12:29 +02:00
8182389091 Detect and surface host device loss instead of spinning silently
D3D11Window ignored the HRESULTs from Present, ResizeBuffers, and
CreateRenderTargetView, so a host-side TDR / driver reset / GPU hang left the
render loop presenting to a dead device forever with no error.

Now note_device_loss() inspects those HRESULTs; on DXGI_ERROR_DEVICE_REMOVED/RESET
it captures GetDeviceRemovedReason() and sets device_lost(). The main loop checks
it after render_frame, shows a MessageBox with the reason, and stops cleanly.

Per the agreed scope this is detect-surface-halt, not full device re-creation
(which would have to re-init ImGui + the capture pipeline) -- that's future work.
Not unit-testable (TDR isn't deterministically reproducible); fixed by inspection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:08:47 +02:00
43d093405e Fix shutdown use-after-free of UiState via the ImGui settings handler
register_ui_settings installs an ImGui settings handler whose UserData points at
the run()-local UiState. ~ImGuiLayer calls DestroyContext, which flushes the .ini
through that handler (ui_settings_write_all dereferences UserData). But UiState
was declared after ImGuiLayer in run(), so it (and the panels between them) were
destroyed first -- the shutdown save read freed/clobbered stack every clean exit
(UB; could corrupt coop_layout.ini / the persisted debug-details flag).

Declare UiState before ImGuiLayer so it outlives the context and is destroyed
last. Not unit-testable (shutdown lifetime ordering); fixed by inspection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:05:47 +02:00
3f1ce0c78a Roadmap: add the review findings as Current work
From an in-depth review pass: confirmed bugs, correctness/robustness items to
verify-then-fix, the D3D9/OpenGL present-thread readback perf fixes, cross-process
ABI hardening, test-coverage gaps, the static-CRT + Vulkan-layer-cleanup features,
three UX fixes, and stale-comment/VtableHook cleanup. Each lands test-first as its
own commit and is removed from this list when done.

Also drop the inaccurate completed-work-lives-in-Lessons-learned line -- done
tasks live in git history; Lessons learned is only for genuinely important
lessons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:04:12 +02:00
0eb275daca Reconnect to an already-injected DLL (reuse it, survive a tool restart)
Disconnect -> reconnect now reuses the DLL already in the game instead of
injecting again, including across a tool restart or crash: a connected DLL keeps
its per-pid shared section (and worker) alive after the host goes away, so a
fresh host can find it and re-attach to the same section.

- hook_dll_alive(pid) (host/src/inject/dll_probe.cpp): detect a live DLL by
  opening the per-pid section and polling its heartbeat (returns as soon as a
  beat lands; a missing section or stalled worker reads as not-alive). It does
  not check magic -- a graceful disconnect zeroes magic but the DLL keeps
  beating and the worker never re-checks magic post-connect.
- InjectionPanel: the Inject and Connect button branches to reconnect_selected()
  when a live DLL is detected -- IpcServer::start() re-attaches to the SAME
  section the DLL still holds and re-publishes the subsystem state; no
  re-injection. Factored the shared post-connect setup (publish_subsystem_state
  / begin_liveness_tracking). The DLL needed no change -- it just resumes reading
  the re-attached section.
- A false not-alive is benign: the inject path still re-attaches an
  already-injected DLL (LoadLibrary no-ops), so the timeout only needs to clear
  the worker's ~250ms beat period with margin.

Test (mock_game_test test_reconnect): inject -> hooked -> graceful disconnect ->
drop the host handle (simulating a restart while the DLL keeps the section alive)
-> detect via heartbeat -> re-attach to the same section -> hooks re-install
without re-injecting -> and hook_dll_alive goes false once the game is gone.

Roadmap: both current tasks (graceful disconnect, reconnect) done -> removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:05:54 +02:00
6d96531ac7 Graceful disconnect: tell the DLL to unhook everything
On an explicit Disconnect and on graceful tool exit, the host now asks the
injected DLL to remove every subsystem so the game runs exactly as if it was
never hooked (each hook restores its original bytes). The DLL stays injected but
dormant, ready for a later reconnect -- we never eject it.

Before, both paths just dropped the IPC channel (IpcServer::stop) without telling
the DLL, leaving the hooks active with frozen forwarded state until the game
exited.

- IpcServer::request_unhook_all() sets every subsystem_disabled flag (the DLL
  reconciles to fully unhooked on its next tick); all_hooks_removed() reads the
  hook registry back so the host can confirm the game is vanilla.
- InjectionPanel::disconnect_graceful() requests the unhook, waits (bounded) for
  the registry to clear, then stops. Wired into the Disconnect button (700ms) and
  the destructor (300ms). The flags persist in the section the DLL keeps alive, so
  the unhook completes even if the host exits before confirming.

Tests (failing first):
- ipc_server_test: request_unhook_all() disables all subsystems; all_hooks_removed()
  tracks the registry. Deterministic, no game.
- mock_game_test test_graceful_disconnect: inject -> hooks installed -> request
  unhook-all -> every hook removed (game vanilla) while the DLL stays alive
  (heartbeat advancing). Full suite still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:46:51 +02:00
39558171e9 Add reconnect + graceful-disconnect tasks to the roadmap
Split the roadmap into Current tasks and Future work.

Current tasks (new, this session):
- Graceful disconnect asks the DLL to disable all subsystems (game runs vanilla,
  DLL stays injected/dormant), on an explicit Disconnect and on graceful exit.
- Reconnect to an already-injected DLL, including across a tool restart/crash,
  by detecting the live DLL via its IPC heartbeat and re-attaching to the same
  per-pid section without re-injecting. DLL self-cleanup on host crash is out of
  scope by design.

Future work: the pre-existing open directions (per-game profiles, multi-guest
pad mapping, raw-mouse movement forwarding, native-D3D12 capture path), moved
under their own subsection so they read as deferred rather than active.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:36:02 +02:00
0b017428b5 Trim completed work from the roadmap
The Roadmap section had grown into a recap of finished tasks (Vulkan perf fix,
inline-hook path, uncapped mock, hook-race family, etc.). Per the rule that done
work leaves the roadmap, drop the completed narrative -- it already lives in
Lessons learned and the test suite -- and keep only the open directions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:30:50 +02:00
51e2c4f4cd Preserve SafetyHook concurrency finding as a committed upstream repro
A multithreaded test that tight-looped InlineHook enable()/disable() while
other threads called the hooked function flaked ~1/10. Isolation proved this is
a SafetyHook limitation, not our code: with the hook created once (no install
race, no trampoline UAF), tight-loop toggling AVs ~1/3 of runs in Debug
(0xC0000005, faulting RIP in the target body), while a no-toggle control is
clean at ~60M calls. enable()/disable() re-patch the prologue in place under a
VEH page-trap that only relocates a thread parked ON the prologue; a thread in
the function body faults on the briefly-non-exec page and relies on instruction
retry, which under rapid toggling races a half-rewritten prologue.

Rather than silently drop the flaky test, preserve the finding:
- tools/sh_concurrency_repro/: minimal, committed, non-CI reproducer
  (coop_sh_concurrency_repro; --callonly is the control). Surfaces 5/16 AVs.
- docs/safetyhook-concurrency.md: upstream-ready write-up (mechanism + fix
  directions + why it does not affect us).
- README lessons-learned + memory updated; tests/CMakeLists cross-references it.

Our code stays in SafetyHook's safe envelope (install/remove reconciled from a
single tick-bounded worker thread, never a tight loop), so the mock_game_test
storm is reliably green; the persistent-trampoline contract is covered
deterministically by hook_install_test + detour_gate_test. Removes the temp
_sh_probe wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:23:06 +02:00
8a183902ad README: roadmap note reflects the final persistent-hook fix (not destroy)
The hook-removal fix evolved from disable->drain->destroy to the persistent-hook
model (never free the trampoline during the session); update the roadmap summary
to match the Lessons-learned detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:24:19 +02:00
79582f9fa6 Make inline-hook install AND remove safe to spam
The uncapped, input-polling mock_game_test storm (thousands of presents/s, now
also driving the input/focus/MKB hooks) drove out a family of install/remove races
the slow vsync'd mock had masked. Fixes (hook/src/hook_install.hpp + hook_guard.hpp):

- Persistent hooks. The old model created a hook on install and DESTROYED it on
  remove (= {}), freeing the trampoline; a detour about to call it (.stdcall) then
  hit freed memory -> 0xC0000005. drain() can't fully close that window (a thread
  can be inside the detour but not past its Guard ctor). So hooks are now created
  ONCE and only enable()/disable()d across install/remove cycles -- never destroyed
  during the session -- so a stale detour always calls a live trampoline (disabled,
  it just runs the original). Reused, so no churn and no leak. remove_* therefore
  disable()s + drain()s but does not destroy; install guards check .enabled().

- Install race. create_inline() enables the hook before the result is move-assigned
  into the global the detour reads; a call landing in the detour mid-assign reads a
  torn hook -> AV. install_inline() creates StartDisabled, assigns, then enable()s.

- drain() Sleep(1)s BEFORE each zero-check, so a thread that entered the detour but
  hasn't reached its Guard registers before we conclude zero.

- Focus: publish g_orig_proc before SetWindowLongPtr activates the subclass (and
  subclass_proc falls back to DefWindowProc if null); and disable the focus-query
  hooks in reverse install order, because GetForegroundWindow shares user32 code
  with GetActiveWindow (keep GFW hooked until GAW is unhooked).

- disable()/enable() [[nodiscard]] results are handled (logged), not (void)-discarded.

Storm now survives on every backend across repeated runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 11:17:13 +02:00
8a43d2f568 Mock game: render every backend UNCAPPED + assert a healthy present rate
The mock backends presented with vsync ("a game-like cadence") -- wrong for a
perf/stress fixture: it does trivial work on an RTX 4090, so it must run as fast
as it can. Vsync capped them to tens of fps (dx9 30, dx10 23, dx11 63, dx12 126),
which hid both capture-induced slowdowns and the hook-removal race. Uncapped now:

  dx9/dx10 INTERVAL_IMMEDIATE / Present(0,0) (BLT), dx11/dx12 ALLOW_TEARING +
  Present(0, ALLOW_TEARING) (flip), gl wglSwapIntervalEXT(0), vk IMMEDIATE/MAILBOX.

Measured no-hook: dx9 ~21000, dx10 ~2800, dx11 ~17000, dx12 ~12000, gl ~26000, vk
~24000 fps.

mock_game_test now adds a present-rate floor per backend (>= 300/s while
capturing): with the hook live every backend stays in the hundreds-thousands
(vk 13500, dx11 9000+, gl 1800, dx9/10 ~1000-1600, dx12 2500). This is the
dimension the frame-advance checks missed -- the Vulkan 144->3 FPS stall still
advanced frames -- so it catches a present-thread stall OR an accidental vsync.

The faster storm exposed the hook-removal UAF fixed in the previous commit.
README roadmap + lessons-learned updated (incl. correcting the old "reset makes
in-flight trampoline calls safe" claim). Full suite 21/21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:45:00 +02:00
9fee13789d Vulkan capture: preserve the game's sync mode, drop the capture throttle
The capture must never change vsync, and must not frame-limit itself.

- Removed the ~150 Hz capture throttle from VkCapture. It was wrong: vsync already
  paces capture (a 144 Hz FIFO game presents 144x/s, so we mirror 144x/s). The only
  limiter left is ring backpressure (skip a frame if the reaper is behind), which is
  correctness, not a cap, and never touches the game's present thread or sync mode.
- The layer/hook already pass VkSwapchainCreateInfoKHR straight through, so the
  present mode (= the sync mode) is untouched. Added a presentMode log to prove it.

Measured on Sphere Spectacle (direct launch, layer attached): presentMode=2 (FIFO),
steady 144.0 fps, and with the throttle gone the mirror now tracks it at 144/s
(was capped ~130). The earlier 400-600 fps reading was a direct-launch artifact --
a non-foreground windowed FIFO app isn't throttled by DWM -- not the layer, and not
the case through Steam (144). vk_validate now states the mirror follows the present
rate (no throttle) and still asserts a present-rate floor.

Full suite 21/21.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:48:44 +02:00
d4412ff610 Roadmap: add vsync-preservation, DX12 perf, and mock-game-all-backends tasks
Follow-ups from real-game testing:
1. The Vulkan capture must not change the swapchain sync mode, and must not
   throttle/frame-limit capture (that's vsync's job). Remove the ~150 Hz throttle
   and prove the present mode/rate is unchanged with vs without the layer.
2. DX12 capture overhead (~0.34 ms) is higher than DX11 (~0.05) / GL (~0.09) due
   to the D3D11On12 bridge -- investigate and improve.
3. The mock game only drives D3D11/D3D12; add Vulkan/OpenGL/D3D9 renderers so every
   capture path has game-driven coverage (this gap is why the Vulkan stall slipped
   through). Make the mock game a real-world test across all backends.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:38:46 +02:00
15d6da92bc Make the inline-hook (suspended-inject) Vulkan path work on Sphere Spectacle
The earlier validation concluded suspended-inject was "not applicable -- the
title requires launching through Steam." That was wrong; it was two bugs:

1. coop_vk_validate's inject mode launched the exe with CreateProcessW and a
   null working directory, so the game couldn't load steam_api64.dll / resources/
   (loaded relative to cwd) and never rendered -> no presents. Launch with the
   game's own folder as cwd and it runs fine directly, no Steam needed.
2. The game resolves vkQueuePresentKHR / vkCreateSwapchainKHR via
   vkGetInstanceProcAddr (volk's volkLoadInstance does this), but vk_hook only
   substituted our detours when they were resolved via vkGetDeviceProcAddr -- so
   the present bypassed the hook. Intercept those names in hk_vkGetInstanceProcAddr
   too (our detours already gate on g_capture_enabled/g_device, so handing them out
   before the device exists is safe).

With both fixed, inject mode captures Sphere Spectacle correctly: 1920x1080,
correct colors/orientation (screenshot), ~480 fps present while mirroring at the
~150 Hz throttle -- no present-thread impact (the VkCapture fix is shared).

Also makes the validator ASSERT a present-rate floor while capturing (it used to
report the rate and rationalize it, which is exactly what hid the 144->3 FPS
stall), and reports the true mirror rate from video.generation. Division of labor
is about who launches the game: layer for Steam-launched (can't suspend), inject
when we control the launch. README lessons-learned corrected accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:31:11 +02:00
304857dcf0 Fix Vulkan capture perf collapse: read back off the present thread
Against Sphere Spectacle (144 FPS, runs without Steam) the implicit-layer
capture dropped the game to ~3 FPS. Measured cause (per-stage trace in the
layer): the read-back ran on the game's PRESENT THREAD and spent ~370 ms per
1080p frame -- not the GPU copy (~2 ms) but the CPU swizzle, because the staging
buffer was a plain HOST_VISIBLE|HOST_COHERENT type (write-combined / uncached on
a discrete GPU), where a scattered CPU read runs at PCIe latency. 3 captures/s =
the 3 FPS the user saw.

Test-first: tests/vk_capture_perf_test reproduces the stall as a deterministic
unit test (372 ms/present, ratio 1.0 -> FAIL via `--sync`), then proves the fix
(0.02 ms/present, byte-correct BGRA->RGBA, ratio ~0 -> PASS).

Fix: extract the near-identical read-back from vk_hook.cpp and coop_vk_layer.cpp
into one shared coop::hook::VkCapture that:
  * has the present thread only record + submit the copy (sub-ms) and return;
  * runs a dedicated reaper thread for the fence wait + swizzle + D3D upload, off
    the critical path, with a ring of in-flight slots (game never waits);
  * allocates HOST_CACHED staging (fast CPU read), invalidating when non-coherent;
  * throttles capture to ~150 Hz (a guest stream is <= the host refresh; no point
    mirroring an uncapped 400+ FPS game and burning reaper CPU).

Real-game A/B: present rate now matches the no-capture baseline (605->470 vs
593->405 over the same ramp) with the mirror at ~130 fps -- no measurable impact.

Also adds present-thread overhead guards to the other GPU backends' hook tests
(present_overhead.hpp): DX11 0.05 ms, DX12 0.34 ms, OpenGL 0.09 ms overhead, all
asserted < one 60 Hz frame, so any future synchronous-stall regression fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:19:47 +02:00
60957775b7 Roadmap: add the two Vulkan tasks (perf collapse + suspended-inject)
Real-game testing of Sphere Spectacle (144 FPS, no focus throttle, runs without
Steam) surfaced two genuine defects the earlier "validation" missed:

1. The implicit-layer capture drops the game to ~3 FPS -- it stalls the present
   thread on a full GPU readback every frame. Vulkan support is meaningless until
   capture is (near-)free.
2. The inline-hook (suspended-inject) path must actually work on this title, or
   be proven a true limitation -- Steam is not the reason it currently doesn't.

Final verification of both paths is to be done with the game launched through
Steam. Tracked as Current tasks; will move to Done as each lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:40:49 +02:00
82a6328b1b Validate the Vulkan backend against a real game (Sphere Spectacle)
Adds coop_vk_validate, a harness that drives the Vulkan capture path end-to-end
against a shipping title (default Sphere Spectacle, a pure-Vulkan game) and asserts
frames reach the shared texture and advance, the captured resolution/colors are
sane, saves a BMP screenshot for visual confirmation, and reports the present rate.

Findings:
- Layer method WORKS: coop_vk_layer mirrors the game correctly at 1920x1080 --
  right colors/brightness, no BGRA/RGBA swizzle, no sRGB darkening (screenshot
  confirmed). The layer captures every present (no drops).
- Suspended-inject "Auto-attach" is NOT applicable to a Steam title that must launch
  through Steam: its exe renders nothing when launched directly, so there's no Vulkan
  present to catch. The layer is the method for Steam Vulkan games (the early-inject
  mechanism itself is covered by mock_game_test's suspended-launch path).
- The layer does video, but the game needs coop_hook.dll co-injected for focus-spoof
  or an unfocused, event-driven game throttles itself to a few fps (looks like a
  capture slowdown but isn't). A present-rate number alone can't prove "no FPS impact"
  -- it's the game's own cadence; capture stays off the critical path (every present
  copied, read-back on its own queue + present-semaphore re-chain).

Also adds SharedTextureSource::read_frame (bulk RGBA readback) for the screenshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:34:22 +02:00
911b543d98 Forward mouse + keyboard to DirectInput and Raw Input games
The MKB subsystem covered message-loop (PostMessage) and polling
(GetAsyncKeyState/GetKeyboardState/GetCursorPos) games. Add the two remaining
read paths, both fed from the same synthesized state:

- DirectInput: vtable-swap IDirectInputDevice8::GetDeviceState (a COM method ->
  vtable swap, not inline, per the x86 COM-prologue trap), reading the shared
  vtable from a kept-alive probe device (the game made its devices before we
  injected). Dispatch on cbData: 256 = keyboard BYTE[256] indexed by DIK
  scan-code (map VK->DIK via MapVirtualKey VK_TO_VSC); DIMOUSESTATE = mouse
  buttons + relative deltas from the forwarded cursor.
- Raw Input: games get no WM_INPUT while unfocused, so mkb_pump synthesizes it
  (PostMessage WM_INPUT with lParam = one of our RAWINPUT slots) and the hooked
  GetRawInputData serves that slot back (RID_HEADER + RID_INPUT). Covers keys +
  buttons; relative raw-mouse movement isn't in the position-based MKB stream.

Both detours use the DetourGate safe-unhook guard, and the mock_game_test storm
exercises their install/remove. dinput_hook_test drives the real path end-to-end:
forward a key + mouse button through the ring, then a real DI keyboard/mouse
device's GetDeviceState returns them. The Raw Input round-trip is operator-
validated against a real game (too brittle to assert in-process). vtable_hook.hpp
factors the COM vtable-swap helper out for reuse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 03:14:59 +02:00
7ada550930 Recover a guessed stream's channels + bit depth by correlation too (step b)
Extends the two-path correlation from rate-only to the full layout, removing the
"channels/bit-depth assumed = device" limitation. correlate_format tries each
candidate de-interleaving (float32 / int16; mono..7.1) of the hook capture, runs
the rate correlation per layout, and keeps whichever aligns with the loopback;
a wrong de-interleaving is noise and won't.

The catch: the hook can't know a guessed stream's real frame size, so its verify
tap pads each render buffer to the device block -- which over-reads stale staging
bytes for a stream with fewer channels/bits, scrambling the audio. So the tap is
now self-describing: it prefixes each buffer with its frame count
([count][count*device_block bytes]), and the host strips the padding per candidate
layout (take the real count*real_block of each chunk) before de-interleaving.

- audio_correlate.hpp: ChunkedCapture + chunk-aware correlate_format + candidate
  layouts; absolute-margin confidence gate (the true layout scores ~1.0, a truly
  ambiguous alternative within ~0.001 -- 2ch@R == 1ch@2R for identical channels --
  is correctly left unconfident).
- audio_hook.cpp: chunked verify tap (free-space-checked so framing can't tear).
- audio_format_verifier: parse chunks; recover_layout path. AudioMirror now corrects
  the full format.
- audio_correlation_test: layout recovery from padded chunks (stereo float, 16-bit
  PCM, 5.1, mono). audio_verify_test gains scenario (b): 2ch on a multichannel
  endpoint with distinct per-channel content (new env-gated ToneSource mode) ->
  recovers ch=2/32-bit float end-to-end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:50:59 +02:00
00244bcfd7 Recover a guessed audio stream's rate by correlating hook vs loopback (step a)
When the host attaches to an already-running game it never saw the stream's
Initialize, so the render-hook assumes the device mix format and measures only
the sample rate from the render cadence -- which a jittery game can make wrong
(intermittent pitch shift). But during the measurement window the game is still
audible, so we have the same audio twice: the hook (pre-mix, unknown format) and
a process-loopback (post-mix, the known device format). Cross-correlating them
pins the true rate from ground truth.

- common/include/coop/audio_correlate.hpp: the pure correlator. Resample the hook
  by each candidate standard rate up to the device rate and score how well it
  aligns with the loopback across the window (drift-detecting). audio_correlation_test
  recovers every rate (score ~1.0 vs ~0.01 for wrong ones), incl. 44100-vs-48000,
  and rejects unrelated signals.
- Hook measurement tap: a host-set verify_capture ring flag makes the hook push a
  still-being-measured (guessed) stream's raw pre-mix bytes WITHOUT silencing, so
  the host can co-capture both signals (a silenced game's loopback is silent).
  Inert by default -- the shipping no-echo path is untouched.
- host/src/audio/audio_format_verifier: co-captures hook + loopback and correlates,
  feeding a correction into the existing override channel. Wired into AudioMirror's
  measurement window (hidden in the gap loopback already covers, so exact streams
  pay nothing). audio_verify_test drives it end-to-end against coop_mock_game.

Rate vs layout are coupled (correlating the waveform needs the right channel
de-interleaving), so this step assumes the hook layout matches the device (the
common stereo-on-stereo case); recovering a different channel count / bit depth
is step b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:26:06 +02:00
21c15b162b Harden every hook against the install/remove use-after-free
Spamming a subsystem toggle (the "Mirror video" button) could crash the game:
remove_*_hooks freed a hook's shared D3D / Vulkan / IPC state immediately, while a
capture detour was still mid-flight on the game's render thread -> use-after-free.
Only the audio hooks had the safe-unhook drain; the video (Present/D3D9/D3D10/GL/
Vulkan) and XInput/focus/MKB hooks did not.

Test-first: mock_game_test now runs an aggressive hook/unhook storm -- a separate
thread thrashes every subsystem on/off while the game presents, across all backends.
It crashed gl + vk (0xC0000005) and failed dx9 capture-resume before the fix.

Fix (hook/src/hook_guard.hpp, DetourGate): each detour wraps its body in an RAII
active-count Guard; remove_* restores the hook first (so no new detour starts),
drains the in-flight detours to zero, and only then frees the shared state. Vulkan
is special-cased -- the game caches hk_vkQueuePresentKHR, so removal closes an
atomic capture gate (detours then pass through to the real present), drains, then
frees the read-back resources. Storm now passes on every backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:56:36 +02:00
79399e88f1 Add roadmap task: validate Vulkan backend against a real game
Adds a fourth Current Task: exercise the Vulkan capture path end-to-end on
Sphere Spectacle (Steam appid 1123040) via both early-presence methods
(Auto-attach and the implicit coop_vk_layer), verifying capture, image
correctness, and no performance regression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 01:16:12 +02:00
7d29abf60e Add Current Tasks roadmap: injection hardening + audio-format correlation
Replace the Future-work section with a single "Current Tasks" list:
- Injection hardening: toggling "Mirror video" has crashed a real game
  (Brotato) via a hook install/remove race. Test-first task -- make
  mock_game_test's hook/unhook stress aggressive enough to reproduce the
  crash (tight video-subsystem toggles from a separate thread while the game
  presents, across all backends), then apply the audio hooks' safe-unhook
  guard (epoch bump + restore-then-drain in-flight detours) to the Present /
  D3D9 / D3D10 / OpenGL / Vulkan hooks.
- Audio format by correlating the loopback (known device format) and
  render-hook (unknown format) captures instead of guessing: (a) rate
  verification/correction, (b) channel + bit-depth recovery.
- Mouse + keyboard forwarding (Raw Input / DirectInput) folded in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:55:23 +02:00
62c65c7438 Fix Brotato hooked-audio double-play: mute guessed streams via SILENT flag
The hooked path must capture the game's frames AND mute its local playback
("no echo"). The mute was implemented as zero-the-buffer (memset) + release
with AUDCLNT_BUFFERFLAGS_SILENT. Zeroing num_frames*block is only safe when
block is the real frame size; for a guessed format (late attach -- the
Brotato case, where we never saw Initialize) the guessed block can exceed
the real buffer, so the conservative code skipped the whole mute for guessed
streams. That left the game audible: it played locally AND the mirror
re-rendered the same audio a few ms later = a metallic, out-of-sync double.

Fix: AUDCLNT_BUFFERFLAGS_SILENT already makes WASAPI ignore the buffer
contents and play silence -- it mutes with no write at all, so it's safe for
any format. Decouple the two: always mute via the flag; keep the memset only
for an exact/override format (belt-and-suspenders). One-line behavior change;
the byte-incompatible cases confirm the flag-mute never over-writes.

Test-first (now a documented rule, README "Tests"): added an
audio_frames_silenced() counter and a mute assertion to audio_hook_test for
both the exact and guessed paths. The guessed assertion FAILS on the unfixed
code (3 rates) and passes after the fix -- the regression guard for this bug.
README also updates the now-correct no-echo limitation and adds a
lessons-learned writeup.

ctest 17/17. Brotato confirmed fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 00:41:54 +02:00
04dcd0f41e Docs/memory: Vulkan opt-in layer done; remove the completed Current-work roadmap
All of M1 (UI-fit + live inspection) and M2 (every backend mock + capture, incl.
the Vulkan implicit layer + checkbox) are done, so the "Current work" roadmap
section is removed -- only Future work (Raw Input / DirectInput MKB) remains.
Architecture lists the Vulkan layer as the implemented early-presence path;
lessons-learned add the chain-dispatch + loader sType 47/48 gotcha; build/test
docs note the layer artifact + coverage; submodule list updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:34:22 +02:00
945d7fbf78 Docs: note the Vulkan layer needs vk_layer.h (not in Vulkan-Headers)
The opt-in implicit-layer piece requires the loader/layer interface header
vk_layer.h, which Vulkan-Headers doesn't ship (it's in Vulkan-Loader / the SDK)
-- so it needs a new submodule or hand-declared link structs, confirming it's a
separate, higher-risk component rather than a quick add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:01:25 +02:00
36ccdcc3ad Docs: Vulkan hooked capture in architecture + lessons; scope the opt-in layer
Architecture now lists Vulkan as a hooked producer (GPA interception + read-back,
early-presence required). Adds a lessons-learned bullet (can't late-hook Vulkan;
the present-semaphore re-chaining trap; the too-late heuristic; early-load
testing). mock_game_test docs note the suspended-launch capture + too-late check.
The Vulkan milestone is trimmed to its one remaining piece -- the opt-in implicit
layer for immediate-init games -- with the capture/banner/best-effort marked done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:56:26 +02:00
d4a83e75bb Roadmap: Vulkan mock done; capture remaining (read-back approach + test caveat)
Reflect that the Vulkan mock backend + submodules are done, and scope the
remaining Vulkan capture honestly: hook vkGetInstance/DeviceProcAddr +
intercept device/swapchain creation, and read the presented image back with
vkCmdCopyImageToBuffer (the same read-back pattern as D3D10/D3D9/OpenGL --
simpler/robuster than a VK_KHR_external_memory keyed-mutex blit). Note testing
needs the early-load path (late-inject can't catch a Vulkan present), and Vulkan
games mirror via WGC meanwhile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:19:16 +02:00
502eb83f46 Docs: require a real-screenshot visual inspection to close out M1
The headless ui_fit_test proves panels *fit* their windows but not that the
overlay *looks* right. Document that closing M1 requires running the actual
coop_host.exe (-DCOOP_TEST_HARNESS=ON), driving it to maximum info, taking an
F10 screenshot, and eyeballing the real overlay -- a green unit test is not a
substitute for looking at the product. Verified live: every panel fits and reads
correctly at the monitor resolution, and the live `uifit` reports "fit".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:16:34 +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
67c1940d52 M2(DX9): D3D9 capture via Present read-back (covers plain D3D9 + D3D9Ex)
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>
2026-06-22 11:55:06 +02:00
68b9aabc8c M2: DX10 capture via D3D10 read-back; mock_game_test covers DX10
A pure-D3D10 game's backbuffer QIs to ID3D11Texture2D but that view reads back
empty (content lives on the game's D3D10 device), and a D3D11 backbuffer also
QIs to ID3D10Texture2D -- so GetBuffer can't discriminate. The reliable signal
is that a feature-level-10 device rejects CreateTexture2D with the NT-handle
keyed-mutex share flags (E_INVALIDARG). The present hook now tries the D3D11
fast path and, on that failure, switches (sticky) to reading the backbuffer
through the game's own D3D10 device into a staging texture and uploading it into
the shared texture on a hook-owned D3D11 device (Map blocks until the GPU copy
completes -> no cross-device race). The host reader is unchanged.

mock_game_test now decodes DX10 frames through the hook (monotonic/advancing)
alongside DX11/DX12; 15/15 ctest. Roadmap: DX10 milestone done and removed
(remaining renumbered); architecture + lessons updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:43:25 +02:00
cf7e0c783a M1: UI-fit instrumentation + headless ui_fit_test + first overflow fixes
Add panel overflow instrumentation (record_panel_fit reading ImGui ScrollMax),
a forced layout-reference + debug-aware center split, and host harness commands
(uisize/uifit). New headless ui_fit_test drives the real Controllers + Audio
panels at reference resolutions with Debug details on and asserts no panel
overflows its assigned size; the Audio panel gains a demo mode so its richest
content renders without a live mirror.

Fixes from the measured overflow: widen the center column (was too narrow ->
horizontal overflow), merge the Controllers poll/round-trip tables and fold the
trigger line into the slot line, and make the center height split
Debug-details-aware (Video's height is mirroring-driven, not debug-driven, so a
static split can't serve both modes). Controllers + Audio now fit at 1920x1080
with max info. Video/Injection/Log fit is finalized at the end via the live
uifit harness (M1 stays open until then).

15/15 ctest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:14:31 +02:00
fc43355db2 Roadmap: sequence backend work per-API (mock + capture together) + UI-fit pass
Restructure the roadmap into one "Current work" section where each rendering
API is a single milestone that builds its mock-game backend and the injected
capture for that same API side by side, so each API reaches verified
end-to-end before the next. Adds an M1 end-user UI fit pass (drive the overlay
to maximum info via the harness, screenshot, assert every panel fits its
window; resize/rearrange otherwise) kept green by every later milestone.
Folds in DX10, DX9 (D3D9Ex + plain non-Ex), OpenGL, and Vulkan (best-effort
inject + opt-in implicit layer, too-late red prompt). Drops the old Vulkan/
D3D9 future-work bullets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:47:53 +02:00
2d65961bf2 Test: cover audio format variants in the mock-game stress suite
mock_game_test now launches coop_mock_game at 44100/48000/96000 (PCM + float)
and asserts the hook measures each variant's rate through the full inject path
(+ non-silent capture) -- the "all audio variants work" part of the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 05:34:50 +02:00
e608c6fb38 Add mock-game capture/audio/hook stress test
mock_game_test launches coop_mock_game (DX11/DX12, frame-numbered A/V), injects
coop_hook.dll, opens the hook's shared video texture, and decodes the frame
number from the captured pixels to assert a monotonic, advancing mirror for
both backends (catches dropped/stale/out-of-order frames). Then it runs an
A/V + hook/unhook stress pass and confirms no crash + capture resumes. Adds a
read_pixel() readback to the shipping SharedTextureSource for the decode.
Robust to repeated/CI runs (kills stray games, retries inject). Trim the
roadmap (the mock-game item is done) and document the test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 05:22:20 +02:00
e2562ac63c Audio: fix five hook/unhook concurrency + over-write bugs
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>
2026-06-22 05:21:17 +02:00
b00b516cdb Injection: session-only auto re-attach on relaunch
Add an "Auto re-attach this game on relaunch" checkbox (default off, never
persisted, shown while attached or terminated). While on and the target has
terminated, the host polls the process list for the same image name and
re-injects the moment it reappears -- built on the existing
re-attach-by-image-name path, so there's no target to pick. The kill+relaunch
recovery for a wrong audio format: re-attaching early catches
IAudioClient::Initialize and reads the exact format.

Validated live (harness): inject coop_tone -> kill -> relaunch -> auto
re-injected into the new pid, and early enough that the format came back as
Exact. Trim README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 02:03:13 +02:00
32028cb286 Audio: per-game persisted format overrides + auto-learn
Persist audio overrides keyed by game image name (coop_audio_overrides.ini
next to the exe) so a known-bad game is auto-corrected on its next launch:
on attach to a guessed stream the host applies any saved override, and a
manual Override now saves too. A format the hook catches exactly at
IAudioClient::Initialize is auto-saved as that game's override (ground truth);
if it overwrites a differing stored value, a warning is logged. Host-originated
log lines now reach the Log window via IpcServer::host_log (color-coded).

Validated live (harness): a pre-seeded override for coop_tone is auto-applied
over the guess (state -> manual override, 48000/2/16). audio_overrides_test
covers persist/reload/case-insensitive lookup/differing-overwrite. Trim README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:59:11 +02:00
fe6f8462ab docs: lessons learned for rate-measurement hardening + the test harness
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:48:40 +02:00
90f40ae479 Audio: operator re-measure + format override (host<->hook op channel)
Add a per-stream op channel in AudioRingHeader (op_seq + op_* fields, version
2): the host posts re-measure / override commands, the hook applies them and
re-publishes (bumping format_generation), and the host rebuilds its render
client live on the change. The Audio panel (under Debug details) gains a
"Re-measure rate" button and a rate/channels/bit-depth/format override -- for
when detection is wrong or the channels/bit-depth were unrecoverable.

Also add a debug-only IPC test harness (-DCOOP_TEST_HARNESS, off by default,
absent from the shipped host): a file-based command channel that drives the
host's real UI code paths (inject / audio / re-measure / override / screenshot
/ status) for scripted validation, instead of unreliable synthetic mouse input.
Used to validate live: late-attach to coop_tone@44100 -> measured 44100,
promoted to hooked; override -> 2ch state, re-measure -> reconverge.

Trim the README roadmap to what's left; document the harness + rate_estimator_test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 01:46:59 +02:00
f72da74f78 Add F10 back-buffer screenshot (PNG, focus-independent)
Capture the rendered back buffer to a timestamped PNG next to the exe via
WIC, triggered by F10 (delivered even when unfocused). The capture runs in
render_frame just before Present so it includes the ImGui overlay, and reads
off the GPU so it works regardless of window focus, z-order, or occlusion. A
brief toast confirms the save (drawn the next frame, so it's never in the shot).
F10 chosen to avoid Steam's F12; its WM_SYSKEYDOWN is swallowed so Windows
doesn't enter menu mode. Documented in the Help menu + README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:58:38 +02:00
31db2d82c6 Roadmap: plan audio format reliability work
Document the diagnosis and plan for the unreliable hooked-audio format
detection: robust rate measurement (longer window, atomic endpoints,
consensus, reject non-standard rates), visible + red-flagged loopback
fallback with auto-promote, a re-measure button and per-game persisted
format overrides via an AudioRingHeader op channel, session-only
auto-re-attach on relaunch, and color-coded log levels. Drop the stale
"previous backlog is all shipped" note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:41:43 +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
ffaad6c4ae DX12 capture: fence the copy off the game's queue + add drop detection
Resolves the DX12 mirror stutter and makes dropped frames observable.

Decouple the D3D11On12 copy from the game's present queue. Submitting the
copy on the game's own present queue (the prior approach) ordered it
correctly but stalled the game's presents: GPU back-pressure, plus the
shared keyed-mutex AcquireSync is a CPU-blocking call on the render
thread. Running it on an independent queue avoids the stall but races the
game's render -> stale frames. Do both: run the copy on our own queue and
order it after the frame with an ID3D12Fence the game's present queue
signals (near-free) and our queue waits on. The present queue is still
recovered for late injection via the ExecuteCommandLists hook (now used to
signal the fence, not host the copy). Producer AcquireSync stays
non-blocking (timeout 0) so a busy mutex drops a mirror frame instead of
stalling the game.

Add drop detection (protocol v12 -> v13). The hook counts captures skipped
because the keyed mutex was busy (VideoShare.frames_dropped); the host
counts published frames it never displayed (generation gaps). The Video
panel shows "Frames lost: N/s capture  N/s display", red when nonzero.
This confirmed the game-window-vs-mirror behavior is a display-path
artifact (unfocused windows lose VRR/independent flip), not a capture loss.

Add a one-shot present-pattern log: per distinct swapchain (size/format/
buffer index) and per distinct present-flags value, with DXGI_PRESENT_TEST
spelled out as an occlusion probe that draws nothing -- which is why
Miles Morales shows ~2 presents per captured frame (the test present is
counted but produces no frame).

Docs: add the DX12 capture lessons to the README (rotating back buffer,
fence/own-queue, capture-at-Present decoupling from DWM) and drop the now
-moot DX12 overhead future-work item.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:55:27 +02:00
86905a1f35 Tune center-column heights; note RawInput/DirectInput MKB + DX12 cost as future work
- Center stack heights set to Controllers 45% / Video 32% / Audio 23% (measured from
  the operator's preferred layout): Controllers has the most content, Audio the least.
- Future work: MKB forwarding for Raw Input / DirectInput games (the message +
  polling-state path doesn't reach them -- e.g. Trails through Daybreak uses Raw
  Input), and reducing the D3D11On12 (DX12) per-frame capture overhead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:05:52 +02:00
784c31a9b5 Capture every audio stream into its own ring and mix them on the host
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>
2026-06-21 05:45:47 +02:00
dfd2be8c17 Add DX12 hooked capture via a D3D11On12 bridge
D3D12 games (e.g. Spider-Man: Miles Morales) fire the Present hook -- the DXGI
swapchain's Present is the same vtable function for D3D11 and D3D12 -- but
GetBuffer(0) as ID3D11Texture2D fails, so the hook used to idle. Now, when the D3D11
GetBuffer fails, present_hook bridges via D3D11On12: it gets the game's ID3D12Device
from the backbuffer, creates its own DIRECT command queue on it (no need to hook the
game's ExecuteCommandLists), builds an ID3D11On12Device, CreateWrappedResource's the
D3D12 backbuffer, and CopyResource's it into the existing shared keyed-mutex texture
-- so the host side is unchanged. The bridge is created lazily and torn down with the
hook.

Verified with a new in-process dx12_present_hook_test: it drives a real D3D12
swapchain (clears a backbuffer, Presents) and asserts present fired, the backbuffer
was bridged into the shared texture, and a second device reads the exact color back
by name -- {51,102,153,255}. Full build x64 + x86 clean (the x86 hook compiles the
D3D12 path too); ctest x64 10/10, x86 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:32:15 +02:00
5e05d38be8 Add capture pipeline rate + latency metrics to the Video panel
The FPS readout only measured the host's own render rate, hiding capture stutter.
The Video panel now shows a "Pipeline rates" section:
- Tool render (host FPS / frametime, as before);
- hooked source: Game present (/s, from VideoShare.present_calls deltas), Hook
  publish (/s, generation deltas), and capture->display latency avg/min/max ms;
- WGC source: WGC capture (/s) from a new WindowCapture frame-arrival counter
  (game present + latency are n/a, since WGC frames aren't game-timestamped).

Latency uses a system-wide clock: protocol v11->v12 adds VideoShare.present_qpc,
stamped by the hook at publish (publish_video_frame); the host measures
now_qpc - present_qpc per newly published frame, windowed to min/avg/max each second.

Verified: present_hook_test (x64 + x86) now asserts present_qpc is stamped; full
build x64 + x86 clean; ctest x64 9/9, x86 3/3. The live rate/latency numbers need a
real game mirroring to read meaningfully; wiring validated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:25:17 +02:00