The D3D9 capture path had no dedicated test. d3d9_hook_test plays game + host:
installs the IDirect3DDevice9::Present hook, drives a real D3D9 device (Clear +
Present), and verifies the full GetRenderTargetData -> BGRA->RGBA swizzle ->
shared keyed-mutex texture path by opening coop_video_<pid> from a D3D11 device
and reading the rendered color back ({51,102,153}).
It also carries the present-thread overhead guard the perf section deferred here,
at a realistic 1280x720: measured capture overhead is ~0.39 ms -- far under one
frame, confirming the cached-memory D3D9 read-back is not a stall (and tripping
the budget if a write-combined-class regression ever lands on the present thread).
Skips cleanly without a D3D9/D3D11 device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fills small coverage gaps:
- shared_memory_test: SharedMemory create-or-open aliasing, move (steal + empty
the source, no double-free), reset, open-missing.
- wav_test: malformed input -- truncation, bad magic, missing data chunk,
over-long data size (clamps), odd-sized chunk (word-align skip), and a corrupt
~4 GB chunk_size. The reader gains an advance guard so that last case can't wrap
pos on a 32-bit size_t (x86) or spin the walk; it stops cleanly.
- tool_paths_test: deployed_artifact_path resolution -- next-to-exe, one-dir-up,
and the not-found fallback -- with real marker files.
- audio_ring_test: an overrun-at-the-seam case (write head near the end: a
wrapping push that fits vs. an over-capacity wrapping push dropped whole),
exercising the wrap split + overrun together, not just at offset 0.
The injector bitness check isn't added as a unit test: is_wow64_process is
file-local and the real WOW64 path needs a 32-bit target, so it stays
inspection-covered (and exercised by the x86 injection path).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New protocol_test covers two previously-untested cross-process contracts:
- publish_pads/read_pads seqlock: a concurrent writer + reader run for ~700 ms;
the reader (2.5M snapshots) never observes a torn snapshot, and read_pads
returns false (bounded, no hang) when the sequence is stuck odd mid-write.
- IpcClient::connect refuses a section with a mismatched version or a bad magic,
and accepts a matching header -- the ABI safety net that only had happy-path
coverage before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The lossy MPSC log ring published each record by writing its text and THEN storing
the slot's sequence. A consumer that passed the seq==generation check could then
read text while a producer 'capacity' generations later overwrote that same slot
(it wrote text before bumping seq), yielding a torn line. Diagnostics-only and
practically unreachable (it needs the consumer a full ring behind -- ~60k lines/s
between two host drains), but a real data race.
Make it a proper seqlock: the producer stores seq 0 (in-progress) and fences
BEFORE touching the record, then publishes the generation after the text; the
consumer copies the record out and re-checks seq, dropping the line if it changed.
The ring stays lossy, never torn.
Adds log_ring_test (previously zero coverage): a deterministic wrap-drop case plus
a threaded torn-read guard (4 producers + a slow consumer on a 32-slot ring) that
emits 0 torn lines out of ~300k produced. Closes both the cross-process item and
the log_ring coverage gap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-game override store keyed and persisted image names through narrow(), which
masked each character with & 0x7F, and widen() used the full byte -- not a true
inverse. So a non-ASCII exe name was corrupted on reload, and two names differing
only in their high bits collapsed onto the same key (e.g. U+00E9 'é' masked to
'i', so "café.exe" collided with "cafi.exe").
Use real WideCharToMultiByte/MultiByteToWideChar(CP_UTF8) so the round-trip is
lossless for any Unicode name. ASCII names are byte-identical under UTF-8, so
existing override files stay compatible.
audio_overrides_test gains a high-bit-collision case (café vs cafi, built from a
code point to keep the source ASCII) plus a non-ASCII persist/reload check -- both
of which the old 7-bit mask failed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The cross-process block's layout is a wire protocol shared by the x64 host and the
x86 hook, but only three front offsets were asserted, and hook_selftest's
dump_layout merely printed the rest. A field reordered/resized inside HookStatus
(which precedes control/video/mkb) would silently shift everything with no
compile-time tripwire and, if the developer forgot to bump kProtocolVersion, ship
a silent host<->DLL mismatch.
- protocol.hpp now static_asserts sizeof(SharedBlock) and every sub-channel offset
(status/control/video/mkb) plus each sub-struct size (HookStatus/HookControl/
VideoShare/AudioStreamInfo/HookEntry/MkbRing). protocol.hpp is compiled for both
arches, so a cross-bitness divergence fails to compile on the one that disagrees.
- hook_selftest's dump_layout now ASSERTS the same numbers instead of only
printing, so hook_selftest_x86 confirms the x86 layout at runtime too.
Verified: x64 and x86 builds both compile (identical layout) and both selftests
pass; sizeof(SharedBlock)=3936 on both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- injector bitness gate: IsWow64Process2 failure was treated as "native", which
would send the x64 DLL into a 32-bit target. Fall back to the legacy
IsWow64Process before giving up to permissive.
- injector helper exit: GetExitCodeProcess's BOOL was ignored; on a failed query
surface GetLastError instead of a misleading exit code.
- window_capture: CreateShaderResourceView's HRESULT was ignored, and width_/
height_ were committed even on failure, so the (latest_ == nullptr) recreate
guard never retried -- a silently black mirror until the next resize. Only
commit the dims on success; otherwise drop latest_ so the next frame retries.
- xinput rumble: make the best-effort XInputSetState ignore explicit (a
disconnected pad re-syncs on the next refresh; the result isn't actionable).
The GetClientRect/ClientToScreen reads in mkb_forward are left as-is: a failure
there is a single self-correcting frame (the mapping is rejected and reused next
frame), so checking them adds no actionable behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
verify_stream_format ignored ProcessLoopbackCapture::start()'s bool. A failed
loopback activation then produced an empty ground-truth signal, so the result was
ok=false -- indistinguishable from "captured fine but the two paths didn't
correlate" -- after burning the whole measurement window capturing only the hook
side for nothing.
Now it checks start(): on failure it restores the ring tap, emits a clear
OutputDebugString diagnostic, and returns immediately (ok=false) instead of
wasting the window. The caller still falls back to the measured guess, but the
cause is now visible.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
forward_mkb_frame early-returned the whole mouse block when mirroring was off or
ImGui wanted the mouse, and the top-level gates returned when the subsystem was
off / focus was lost / the game died. A key or mouse button held at that moment
never got its KeyUp/MouseUp, so it stuck DOWN in the guest -- a held mouse button
fires continuously, a held key walks forever -- contradicting the "send the up so
nothing sticks" intent.
Track what we've forwarded as held (g_mouse_down / g_key_down) and release it
whenever we stop forwarding for any reason: the can't-forward gate, ImGui grabbing
the keyboard/mouse, or the mouse-not-mirroring path all now release held inputs
before returning. Normal down/up still flips the held state.
Fix by inspection: forward_mkb_frame needs a live ImGui context + injection panel,
so it isn't unit-tested; the logic is a straightforward held-state release.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Doing the "failing perf test first": measured the OpenGL capture's present-thread
overhead at a real resolution instead of the test's 64x64 toy. It is ~0.68 ms at
1280x720 (and ~0.06 ms when it overlaps a busy present at 1080p) -- well under one
frame, even at 144 Hz. No budget makes it fail, so the off-thread / async-PBO
refactor is NOT warranted.
The Vulkan 144->3 FPS stall was catastrophic specifically because it read
WRITE-COMBINED staging memory (~370 ms/frame), not because read-back is
synchronous. D3D9 GetRenderTargetData (a D3DPOOL_SYSTEMMEM surface) and glReadPixels
(normal CPU memory) read CACHED memory, so there is no comparable stall.
What changed instead:
- opengl_hook_test now runs the present-overhead guard at 1280x720 (not 64x64), so
it is meaningful -- a future write-combined-class regression trips the budget.
- README Lessons learned records the cached-vs-write-combined distinction so nobody
needlessly off-threads the other backends.
The matching D3D9 present-overhead guard ships with the new d3d9_hook_test (test
coverage). Drops both Performance items from the roadmap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
present_calls, frames_dropped (VideoShare) and frames_rendered (AudioStreamInfo)
were plain `+= 1` / stores in the DLL, read by the host cross-process. On an x86
DLL a 64-bit store is two halves, so the x64 host could read a torn value during
a carry. Benign (display-only), but a real data race.
Use std::atomic_ref at the access sites rather than changing the field types:
the structs stay plain POD so the layout/offset asserts are unchanged and
AudioStreamInfo stays trivially copyable (it's published/read as a whole struct).
The DLL writers (note_present / note_video_dropped / note_audio_frames) and the
host readers (IpcServer::video_share / hook_status) now use relaxed atomic_ref;
hook_status reloads frames_rendered atomically after the wholesale struct copy.
The dev-tool readers (vk_validate, audio_probe) keep plain reads -- diagnostics of
diagnostics, and same-bitness in practice.
Validated by present_hook_test (present_calls via atomic_ref) and audio_hook_test
(frames_rendered) -- also confirms no atomic_ref alignment fault.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each forwarded raw-input event is written to g_raw_slots[head++ % kRawSlots] and
a WM_INPUT carrying that slot's ADDRESS is posted to the game, which reads it back
through hk_GetRawInputData. With only 64 slots, a burst that queues more than 64
WM_INPUTs before the game pumps could overwrite a slot before the game reads it,
so it would decode a newer event for a stale message. No memory unsafety (the
address stays in-bounds), but wrong event data under backlog.
Grow the ring to 512 (a few tens of KB) so realistic input rates can't lap it.
Deliberately not per-slot consume-tracking: that would permanently exhaust slots
and silently stop forwarding for a game that ignores WM_INPUT, whereas a large
ring always forwards and only risks a rare stale read under extreme backlog.
Also drops the "publish() synthetic-input timing" review item: verified it uses
GetTickCount64() (thread-safe), not ImGui state -- not a bug, no change needed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
g_swaps (vk_hook.cpp and the Vulkan layer) is pushed from the create-swapchain
detour and iterated by the present detour, which can run on different game
threads (Vulkan external sync is per-object, not global), and cleared on removal
from another thread -- all with no mutex. A push_back realloc could dangle the
SwapInfo* a concurrent find_swap/present is using. It was also never pruned, so a
game that recreates its swapchain each resize grew it without bound and could
match a recycled handle's stale images.
Add g_swaps_mutex around every access; the present detour now copies the matched
swapchain's fields out under the lock and captures without holding it (no GPU
submit under the lock, no dangling pointer). Create de-dups by handle and an LRU
cap (8) bounds growth -- the active swapchain is the newest, so it's never
evicted. Deliberately NOT hooking vkDestroySwapchainKHR: forwarding a destroy
incorrectly could break the game, and the de-dup + cap already bound growth and
defeat handle recycling.
Verified real by inspection (a concurrent-create+present Vulkan race isn't
deterministically reproducible in a test); validated by the full mock_game_test
Vulkan paths (capture + layer + too-late) staying green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- The remove_* comments still described the superseded "disable -> drain -> destroy"
flow; the code keeps hooks alive (persistent) and re-enables on re-install. Updated
the comments to match, and corrected the XInput note (its detours return synthesized
state and never call the trampoline, so destroying its vector is safe -- unlike the
trampoline-calling present/MKB/focus-cursor hooks).
- hook_install_test: a fast, single-threaded contract test for hook_install.hpp --
install_inline creates the hook once and reuses the SAME trampoline across 50
install/remove cycles (never freed -> no stale-detour UAF), toggling enable/disable
cleanly. Fills the guard the removed (flaky, concurrency-bound) reproducer left, with
no threads so it can't flake on SafetyHook's enable/disable atomicity.
x64 23/23, x86 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
- poll_input() each frame (XInputGetState / GetAsyncKeyState / GetKeyboardState /
GetForegroundWindow), like a real game, so mock_game_test's hook/unhook storm
actually exercises removing the input/focus/MKB hooks while their detours are in
flight -- the coverage gap that let those removal races go untested.
- Window title shows the backend + a once-per-second-smoothed fps.
- A vectored-exception crash logger prints the faulting module+offset (named the
storm's intermittent crashes during this work; inert otherwise).
- detour_gate_test: fast, deterministic guard for DetourGate -- drain() must block
while a Guard is in flight and return promptly otherwise, plus a concurrency
stress that asserts no body runs against freed state. (A synthetic install-race
unit test was tried but flaked on SafetyHook's own enable/disable atomicity under
~30M calls/s, unrelated to our code, so the storm is the install/remove guard.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Uncapping the mock game (next commit) turned mock_game_test's hook/unhook storm
into a real stress test (thousands of presents/s instead of tens), which reliably
crashed the game on remove (0xC0000005) for dx9/dx11/dx12. Two races the slow
vsync'd mock had masked:
1. Trampoline use-after-free. remove_*_hooks did `hook = {}` (destroy) BEFORE the
DetourGate drain. Destroying a SafetyHook InlineHook frees its trampoline
immediately, but an in-flight detour about to call the original via .stdcall()
(the trampoline) then used freed memory. Fix: disable() first (restores the
original bytes under thread suspension, but KEEPS the trampoline alive) -> drain
-> only then destroy. Applied to present/d3d9/opengl/vk/xinput/mkb/focus.
2. Entry-window race in DetourGate::drain(). It returned the instant the active
count read zero, but a thread can be inside the detour yet not have reached its
Guard constructor (the prologue is unguarded), so the count reads zero while a
detour is about to run -- and the freed state is then used. Fix: Sleep(1) BEFORE
each zero-check; with the hook disabled no new detour starts, so any
already-entered thread registers within that window. This alone fixed dx11 (the
highest present rate, ~11000/s, which hit the window every storm).
Audio is unaffected (it uses vtable swaps, which keep a real original pointer, not
a trampoline). Full suite 21/21, and the storm now survives on every backend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Investigated the DX12 present-thread overhead (~0.38 ms vs DX11 ~0.05 / GL ~0.09).
Per-stage timing of the D3D11On12 path showed the cost is NOT where you'd assume:
fence 0.005 + wrap(CreateWrappedResource+Acquire) 0.012 + copy 0.133 + flush 0.057 ms
CreateWrappedResource is cheap. The cost is the CopyResource issued on the 11On12
immediate context plus the mandatory Flush to make the shared copy visible to the
host -- both inherent to the bridge and not paid by the native-D3D11 path. The
per-frame GetDevice can't be skipped either (it's how device recreation is
detected). Documented this in the capture path.
Improving it means a native-D3D12 copy-queue path into a D3D12-shared texture, but
the host consumes the shared surface via IDXGIKeyedMutex (a D3D11 concept), so that
also requires switching the DX12 producer<->host sync to a shared ID3D12Fence -- a
cross-API rewrite. Deferred: the overhead is ~5% of a 144 Hz frame and correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Build coop_audio_validate, a tool that turns "the mirror audio sounds off"
into numbers. It plays a known sine (coop_tone, 44.1 kHz on a 48 kHz
endpoint -- the Godot/Brotato case), injects the hook as the host does, and
runs a fidelity analyzer (coop/tone_analysis.hpp: pitch error in cents,
SNR/THD, click + dropout counts), dumping a .wav to listen to. Modes:
--render drives the real AudioMirror and measures its rendered output;
--baseline/--selfcheck give the measurement floor; --listen <pid> records a
live coop_host's output; --wav analyzes a recording. Analyzer + WAV I/O are
unit-tested (tone_analysis_test) against synthesized defects.
Using it, the capture ring measures pristine (~68 dB, 0 gaps) while the
render path dropped to ~18 dB with gaps -- localizing a real defect in
AudioMirror::run_hooked: it re-primed (withheld the feed until ~30 ms had
rebuffered) on any partial fill (to_write < avail). A partial fill is normal
producer jitter, and withholding the feed drains the device, so a one-frame
ring dip became a full ~30 ms drop-out; on a jittery game it fired
constantly. Fix: feed whatever is available each tick and re-prime only on a
genuine starvation (device empty AND ring empty). The policy is factored
into a pure RenderPacer reused by run_hooked + run_loopback and proven by
render_pacer_test (the old policy withholds available data ~168x and drains
to one period from silence on a jittery schedule; the new one never
withholds).
ctest 17/17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Host helper register_vk_layer/unregister_vk_layer registers coop_vk_layer's
manifest as a per-user (HKCU, no admin) implicit Vulkan layer and writes the
scoping file (%TEMP%\coop_vk_target.txt) naming the target game's exe, so the
layer captures only that game and stays inert for every other Vulkan app. The
Injection panel gains an opt-in "Set up Vulkan layer" checkbox (next to Auto
re-attach, which the too-late banner points at); it registers on tick,
unregisters on untick, and the panel destructor unregisters on host exit so no
stale registration is left behind.
Verified live: a registry-registered layer is loaded by the loader for a fresh
vk launch (no env vars) and the scoping matches (active=1), then cleans up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A real chain-aware Vulkan implicit layer the loader inserts at vkCreateInstance
-- the reliable early-presence path for games that init Vulkan immediately,
which the inline-hook vk_hook can't catch. It intercepts vkCreateInstance /
Device / CreateSwapchainKHR / QueuePresentKHR via proper layer-chain dispatch
and does the same read-back capture (vkCmdCopyImageToBuffer -> swizzle ->
hook-owned D3D11 shared texture, with present-semaphore re-chaining) as vk_hook.
The loader/layer link structs (VkLayer*CreateInfo, VkNegotiateLayerInterface)
aren't in Vulkan-Headers, so they're hand-declared to interface version 2. Key
gotcha found via tracing: the loader tags those link structs with small internal
sType values (LOADER_INSTANCE_CREATE_INFO=47, _DEVICE=48), not the 1000000000
range -- matching the wrong value made the device-chain walk fail.
Scoping: an implicit layer loads into every Vulkan app, so it only *captures*
when COOP_VK_LAYER_FORCE is set (tests) or this process's image matches
%TEMP%\coop_vk_target.txt (the host writes it); otherwise pure pass-through.
mock_game_test registers it via VK_LAYER_PATH/VK_INSTANCE_LAYERS and decodes
frames through it. Ships at the bin root with its JSON manifest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
The hook reports vk_too_late when vulkan-1.dll is loaded and the GPA hook has
been in for >4s but it never caught the app creating its device -- i.e. the app
resolved its Vulkan functions before we hooked (injected too late). Surfaced
through a new HookStatus.vk_too_late field (protocol 15->16) and shown by the
host as a red banner on the mirror window: "Detected a Vulkan game, but the
mirror hook attached too late... enable Auto re-attach (and Set up Vulkan layer
if it persists) and relaunch." WGC keeps mirroring meanwhile.
mock_game_test gains a too-late detection check (late-inject a Vulkan game ->
vk_too_late trips). Verified live: the banner shows over the running tool when a
Vulkan game is injected late with the video subsystem on (added a debug-harness
`video on/off` command to drive it). 15/15 ctest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New vk_hook.cpp inline-hooks the vulkan-1.dll vkGetInstanceProcAddr export and
hands back our wrappers for vkCreateInstance / vkCreateDevice / vkGetDeviceProcAddr
/ vkCreateSwapchainKHR / vkQueuePresentKHR, so a volk-using (loader-bypass) app
resolves our hooks. On present it reads the swap-chain image back with
vkCmdCopyImageToBuffer into a host-visible buffer (same read-back model as
D3D10/D3D9/OpenGL), swizzles BGRA->RGBA, and uploads it into the shared
keyed-mutex texture on a hook-owned D3D11 device. The read-back submit re-chains
the present's wait semaphores (consume the originals, signal our own that the
real present waits on) so capture orders after rendering without double-waiting.
Wired into the video subsystem with a lazy retry (vulkan-1.dll loads late). Hook
links the official Vulkan-Headers (headers only, VK_NO_PROTOTYPES) via the
include dir so the x86 sub-build builds too.
Because Vulkan caches its present pointer at init, late injection can't hook it:
mock_game_test launches the mock **suspended**, injects, resumes, and under
COOP_MOCK_VK_EARLY the mock loads Vulkan and waits so the hook arms first -- then
decodes frames through the hook like the other backends. 15/15 ctest (x64 +
x86); skips cleanly without a Vulkan driver.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Since guessed streams are now captured-but-not-silenced (the over-write fix),
the hooked path is only no-echo for an exact/override format. The panel
inferred "Hooked (no echo)" unconditionally, which was misleading. Show
"Hooked (echo -- guessed format)" (amber) for a guessed primary stream and
"Hooked (no echo)" (green) only for exact/override; drop the unconditional
"no echo" from source_name() and the mirror status string.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
The host used to wait a fixed 1 s for the hook to publish a format and, on
timeout, fall to loopback permanently with no explanation -- which the now
slower (consensus) measurement made common. Restructure the audio thread to
keep the rings live the whole session and alternate: prefer hooked, and while
it isn't ready run loopback (echo) so guests still hear audio, watching the
ring to promote to the no-echo hooked path the instant the hook publishes a
format. Surface the concrete reason loopback is active (no format yet / not
renderable / no ring) in the Audio panel, in amber.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Harden the guessed-stream sample-rate measurement that produced wrong rates
(e.g. 44100 read as ~46205). New rate_estimator.hpp measures over longer
~0.5 s windows, rejects any window that doesn't snap to a standard rate
(standard rates are >8% apart, so a quantization/burst error big enough to
miss one lands in no-man's-land, never on a wrong neighbour), and requires
consensus across windows before committing. If consensus isn't reached it
commits a low-confidence estimate (new AudioFormat_LowConfidence, shown red)
rather than spinning or publishing garbage. Pure logic, unit-tested with
adversarial cadences (rate_estimator_test) incl. the real 46205 bug value.
Add log severity levels: hook logw/loge set LogRecord.level; the host Log
window colors warnings amber and errors red. The low-confidence rate logs a
warning. Protocol -> v15 (new format states); also reserves AudioFormat_Override.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
The inline hint still claimed Esc quits (no longer true) and duplicated the
F1 shortcut + RPT-capture note that the Help menu already lists. Remove the
redundant block; the Help menu is the single source for those.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Escape was a leftover spike convenience that quit the tool. Esc is a common
in-game key, so dropping it from a borderless mirror was too easy to do by
accident. Remove the Escape-quit handler and add a File -> Exit menu item
(shown with its Alt+F4 shortcut). Alt+F4 already worked via DefWindowProc ->
WM_CLOSE -> WM_DESTROY; the menu item sets a one-shot flag the main loop reads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The View-menu verbosity switch reset to off on every launch. Register a
custom ImGui settings handler (a [CoopUI][State] section in coop_layout.ini,
alongside the window layout) so the operator's choice survives restarts.
Toggling it marks settings dirty so ImGui's auto-save writes it back.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
A game that used the Hooked source with "Sync flip to game frames" left
the overlay sluggish (~5 FPS) after it terminated or was detached: with no
live game the generation never bumps, so wait_for_hooked_frame waited out
its full timeout every iteration.
Gate frame_sync_active() on InjectionPanel::target_state() == Alive, so a
terminated/hung/detached target falls back to normal vsync. Alive means the
process is running and the hook heartbeat is advancing, so this also covers
a frozen game (which would stall the generation the same way). The checkbox
preference is preserved -- frame-sync auto-resumes when a new game is
injected rather than silently unchecking.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
Even copying the correct back buffer, the DX12 path occasionally showed a
several-frames-old frame under GPU load (visible when the game window has
true focus and the camera is whipped around with the mouse). Cause: the
On12 bridge submitted CopyResource on a command queue of our own, which
knows nothing about the game's queue. With frames in flight, our copy
could race ahead of the game's render of that buffer and capture its
previous (rotated) contents. The DX11 path never had this because it
copies on the game's immediate context, ordered after the frame.
Fix: submit the copy on the game's actual present queue so it's ordered
after the frame's rendering, matching the DX11 path. Recover the queue by
inline-hooking ID3D12CommandQueue::ExecuteCommandLists (the per-frame
method, not swapchain/queue creation) so it works for late injection --
the queue already exists when we attach. Record the last DIRECT queue
seen, preferring the one on the render thread (Present and its queue's
ExecuteCommandLists share that thread); the atomic is a cross-thread
fallback. Thread it into the On12 bridge, rebuilding if the captured
queue changes, and fall back to our own queue until it's captured.
Resolves D3D12CreateDevice dynamically from an already-loaded d3d12.dll,
so it adds no link dependency and no-ops for D3D11 / x86 games.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The D3D12 capture path grabbed GetBuffer(0) every Present. Unlike D3D11
flip-model -- where DXGI keeps GetBuffer(0) pointing at the live back
buffer -- D3D12 rotates buffers explicitly: the game renders into the
buffer at GetCurrentBackBufferIndex(), which advances each Present. So
buffer 0 only holds fresh content every Nth frame; the rest copied a
stale buffer, and the mirror silently ran at refresh/N with duplicate
frames in between.
Every metric read full rate (Present counter, published FPS, generation
bump, capture->display latency) because they count Presents, not unique
content -- which is why it looked fine but felt like missing frames,
especially on high-refresh DX12 games (DMC5/Myst at 144, Miles Morales).
Query IDXGISwapChain3::GetCurrentBackBufferIndex() before the trampoline
Present (so it's the just-rendered buffer) and copy that one; fall back
to 0 only if the interface is unavailable. The DX11 path is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Input thread: controller polling, pad publishing, and rumble forwarding were
driven by the render loop, so a low/synced frame rate throttled how often guest
input reached the game. New InputWorker owns the InputSource and runs poll +
IPC publish + rumble on a dedicated ~1 kHz thread, independent of rendering. The
UI thread reads a copy-safe InputSnapshot for the Controllers panel and relays the
Steam-Input request/active/failed state to/from the worker (Steam init/shutdown now
happen on the worker thread). IpcServer gained a mutex so the worker's publish() /
hook_status() can't race the UI thread starting/stopping the shared-memory channel
(use-after-unmap); InjectionPanel::test_input_ is now atomic. ControllersPanel::draw
takes an InputSnapshot instead of the live InputSource.
Fixed-width numbers: fast-changing readouts (menu-bar FPS/ms, Video pipeline rates +
latency + graph legend, controller poll rates + round-trip sticks, audio buffered ms
+ frames/s) printed with %.0f etc., so they shifted/blurred as values crossed digit
thresholds (99 -> 100) each frame. Padded them to fixed field widths so they stay put.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Frame sync: new "Sync flip to game frames" toggle in the Video mirror panel
(Hooked source only -- WGC frames are delivered by the compositor at monitor
refresh and don't carry the game's true present cadence, so it's disabled there).
When on, the main loop waits for the hook's next published frame (its generation
bump) before rendering and presents with sync interval 0, so the tool flips in
lockstep with the game instead of vsync. The wait pumps messages to stay
responsive and times out after 200 ms so a paused/stalled game can't hang the
overlay. timeBeginPeriod(1) keeps the wait's Sleep(1) granular; links winmm.
render_frame() gained a sync_interval parameter (default 1 = vsync).
Perf graphs: the old graphs drew the tool's frametime and FPS as single same-color
lines. Replaced with a custom multi-series plotter (ImDrawList polylines) that
overlays Tool (blue), Game present (green), and Hook publish (orange) -- or Tool +
WGC capture in WGC mode -- in distinct colors with a colored legend, for both an
FPS (0-144) and a frametime (0-33 ms) view. Game/hook rates come from an EdgeRate
tracker that measures the instantaneous rate the moment each counter advances, so
the lines have real per-frame resolution rather than 0.5 s stair-steps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Layout persistence: re-enable io.IniFilename (was nullptr "for the spike"),
anchored to a coop_layout.ini next to the exe so window positions/sizes survive
restarts even when Steam launches us under the donor appid (CWD is unreliable).
Path is UTF-8 for ImGui's file IO. When a saved layout is restored at startup,
suppress the computed-default force so it does not clobber the user's positions;
Reset layout (and a fresh install with no .ini) still applies the default.
Log spam: the worker thread re-attaches every audio ring every tick (idempotent),
and set_audio_ring logged unconditionally, flooding the log. Only log when the
ring pointer actually changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 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>
Feedback from real use: the center column was far too wide and Injection/Log too
narrow; Audio was too tall while Controllers/Video were too short; and the layout
overlapped on launch until "Reset layout" was pressed.
- Columns are now proportional: Injection (left) and Log (right) take 36% each of the
usable width, the center control column 28% -- so the wide panels are wide and the
control panels narrow.
- The center stack heights are evened out (Controllers 30% / Video 38% / Audio 32% of
the column), so Audio no longer hogs it.
- The default layout is now forced (ImGuiCond_Always) for the first few frames after
launch, because the viewport WorkSize isn't trustworthy on frame 0 -- FirstUseEver
was locking in those wrong (overlapping) positions until a manual Reset layout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Games with several concurrent WASAPI render streams (e.g. Spider-Man: Miles
Morales) only had their first ("primary") stream mirrored; the rest kept playing
locally and never reached the guest. Now the render-hook captures + silences EVERY
tracked stream into its own ring (coop_audio_<pid>[_<index>]), each published with
that stream's own detected format (Initialize when caught, else GetMixFormat -- the
per-stream format detection, now actually used per ring rather than only for the
primary). The host creates a ring per stream and mixes the same-format streams with
a soft clip (host/src/audio/audio_mix.hpp); streams whose format differs from the
primary are still silenced (no echo) but skipped from the mix (would need
resampling).
The single-stream case is byte-for-byte unchanged: when only one stream is active
the host passes it through without the mixer, so the common path has no overhead or
fidelity change.
Verified: new audio_mix_test covers the decode/sum/soft-clip/encode math (float32 +
int16); audio_hook_test (x64 + x86) still passes, guarding the primary
capture+silence path against regression; full build x64 + x86 clean; ctest x64
11/11, x86 3/3. Multi-stream mixing against a real multi-stream game needs a live
session to fully confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
Games that ClipCursor / re-center via SetCursorPos while focused trap the operator's
mouse (the focus spoof makes them think they're always focused), so the operator
can't reach the overlay. The Focus subsystem now inline-hooks ClipCursor and
SetCursorPos (stdcall trampolines): while "release" is requested it forces
ClipCursor(NULL) and swallows the re-centering SetCursorPos; otherwise it passes them
through. It frees any existing clip at install and re-frees each worker tick (covers
one-time clippers and a runtime clip->release toggle).
Host: protocol v10->v11 adds HookControl::allow_cursor_clip (0 = release, the
default). The Injection panel gets a "Release operator cursor" checkbox and an F2
hotkey (InjectionPanel::toggle_cursor_release); default released, since the guest
plays via the pad so the game's clip is operator-only.
Verified: full build x64 + x86 clean; ctest x64 9/9, x86 3/3. The cursor behavior
against a real clipping game (Trails through Daybreak) needs a live injected session
to confirm; logic reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes it possible to isolate an input->tool problem from a tool->game one in the
Controllers panel (under Debug details):
- which backend fed each slot is already shown via the per-pad source tag
("XInput #0" / "Steam Input") in the Incoming section;
- a new "Round-trip" table shows, per slot, the state we forwarded (the active
backend's pad) next to what the game actually read back, highlighting matches.
For the round-trip, the XInput hook now echoes the state it returns to the game into
the status (protocol v9->v10: per-slot read_state in HookStatus).
Verified: hook_selftest (x64 + x86) asserts the hook records the game-read state;
full build x64 + x86 clean; ctest x64 9/9, x86 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The XInput hook used to swallow XInputSetState; now it records the requested
left/right motor speeds into the status back-channel (protocol v8->v9: per-slot
rumble_left/right in HookStatus). Each frame the host reads them and, only on
change, drives the guest's actuator via the active backend:
- XInput: XInputSetState on the guest's slot (the open question is whether Steam's
RPT virtual pad accepts vibration and routes it to the guest -- needs live RPT);
- Steam Input: SteamInput TriggerVibration on the slot's controller handle, with the
XInput fallback for slots Steam isn't driving.
InputSource gains a set_rumble(slot,left,right) hook (default no-op) implemented by
both backends; SteamInputSource now tracks per-slot controller handles + which slots
it drives.
Verified: hook_selftest (x64 + x86) now asserts the hook records the rumble from
XInputSetState into the status; full build x64 + x86 clean; ctest x64 9/9, x86 3/3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Forward the host window's clicks and keystrokes into the injected game so guests
can drive menus / "Press Start" / text entry that a pad can't.
Protocol (v7->v8): new HookSubsys_Mkb and an SPSC MkbRing of MkbEvents in
SharedBlock (host produces, hook consumes); push/pop helpers.
Hook (hook/src/mkb_hook.cpp, new subsystem): a worker-loop pump drains the ring at
~5 ms and PostMessageW's the matching window messages (WM_KEY*/WM_CHAR, mouse
buttons, WM_MOUSEWHEEL) to the game's main window; it also inline-hooks user32
GetAsyncKeyState / GetKeyboardState / GetCursorPos (stdcall trampolines per the x86
rule) to report a synthesized state so polling games react too. Removing the
subsystem clears all synthesized keys (no stuck input).
Host: the Injection panel gets a "Mouse + keyboard forwarding" subsystem toggle
(opt-in, default off -- the toggle is the hook). host/src/inject/mkb_forward.cpp
reads ImGui IO each frame and forwards only when the host window is focused and
ImGui isn't capturing the event; keyboard always, mouse only while mirroring (clicks
+ wheel, not movement). Mouse coords are mapped through the letterbox to game-client
space (host/src/inject/mkb_map.hpp), accounting for WGC-of-decorated-window vs
hooked/borderless. RawInput/DirectInput games are out of scope for this version.
Verified: new mkb_ring_test + mkb_map_test pass; full build x64 + x86 clean; ctest
x64 9/9 and x86 3/3 green (no regression from the protocol bump). The subsystem is
opt-in, so it can't affect existing behavior unless enabled; the end-to-end
click-into-game path needs live Remote Play + a real game to confirm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Forward synthetic test input" is a controller-debug aid, so it now lives in the
Controllers panel (gated behind Debug details, disabled until the XInput hook is
attached) instead of the Injection panel. ControllersPanel owns the flag and exposes
test_input(); main feeds it into InjectionPanel::set_test_input each frame, so the
existing synthetic-pad substitution in publish() is unchanged.
Verified: x64 build green; review (default view no longer shows it in Injection;
appears in Controllers under Debug details).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The live indicator compared frames_rendered to the previous UI frame's value, but
audio buffers release in bursts so most frames saw no change -- the cell flickered
between a green dot and grey "idle". Now each stream remembers when it last advanced
and reads "live" for a short window (0.4 s) afterwards, with a ~2 Hz frames/s
estimate next to it; otherwise "idle". Steady and readable for multi-stream games.
Verified: x64 build green. Full visual confirmation needs an injected, audio-playing
game with the per-stream table open (Debug details); logic reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Panels used to Begin at cascade positions, so they overlapped and clipped. A new
apply_panel_layout() in app_chrome positions/sizes each panel from the main
viewport work area (ImGuiCond_FirstUseEver, still movable): Injection left column
full height (room for hook diagnostics), Controllers/Video/Audio stacked in the
center column, Log right edge full height (max room for the log stream). Added a
"View -> Reset layout" menu item (request_layout_reset / apply_layout_end_frame
re-apply the defaults once via ImGuiCond_Always). Each panel now calls
apply_panel_layout(Panel::X) instead of its own ad-hoc SetNextWindowPos/Size.
Verified live: captured the host overlay -- Injection (left, full height),
Controllers/Video/Audio (center stack), Log (right, full height), no overlap among
the panels. x64 build + ctest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New host/src/inject/window_list.{hpp,cpp} enumerates visible, titled, non-tool
top-level (alt-tab-style) windows via EnumWindows -- root-owner only, our own
process excluded -- and maps each to its owning pid + image name. The Injection
panel now defaults to this window list (each row "title [process.exe pid]", with a
filter over title or process), since there are far fewer windows than processes and
a window maps straight to the HWND the capturer wants. The full process list stays
as the advanced picker under Debug details.
Verified live: launched the host and captured its window -- the picker lists real
windows (Discord/Firefox/Explorer/...) in "title [exe pid]" form, filter present,
and the host's own window correctly excluded. x64 build green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In the Terminated state the Injection panel now offers a Re-attach button: it
refreshes the process list and finds live processes whose image name matches the
original target's (case-insensitive). Exactly one match -> tear down the stale IPC
channel and inject into the new pid via the normal path; several matches -> don't
guess, filter the picker to the name and prompt the operator to pick one; none ->
report it. Saves hunting for a relaunched game's new pid in the list.
Verified: x64 build + ctest 7/7 green. Re-attach is a Terminated-state button flow,
so its end-to-end behavior is best confirmed live (ImGui clicks can't be scripted);
logic reviewed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Injection panel kept showing "Attached" after the game exited. Now it tracks
target liveness each frame (InjectionPanel::tick from the main loop, independent of
panel visibility):
- a SYNCHRONIZE|QUERY process handle taken at inject time -> WaitForSingleObject
detects the process exiting (Terminated);
- the hook heartbeat stalling for ~2 s while the process still exists flags a
distinct Hung state (games here can freeze without exiting).
The panel shows a clear colored banner per state and disables the subsystem
hook/unhook controls and the synthetic-input toggle when the target isn't alive.
game_hwnd() returns null once Terminated, so the Video and Audio panels drop to
idle instead of chasing a dead window.
Verified: x64 build + ctest 7/7 green; host launches and renders the panels without
regression (screenshot smoke test). The interactive terminated/hung visual against a
real game is best confirmed in a live session (ImGui injection can't be GUI-scripted).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>