contains_ci and narrow/widen were centralized into shared headers
(ui/text_match.hpp, util/utf8.hpp) during the cleanup but had no
focused test, unlike the project's other pure-logic helpers. Cover the
filter contract the Log/Injection panels depend on (empty/null needle
matches all, case-insensitivity both directions, boundary substrings,
non-matches) and a narrow<->widen round-trip.
The non-ASCII round-trips (cafe.exe, CJK) would fail against the old
lossy `c & 0x7F` narrowing this cleanup replaced, so the test guards
that fix, not just the happy path. Registered as test #34; suite green.
The WideCharToMultiByte call fits in one line under the 120-column
limit (119 cols) -- the form the code it was extracted from used.
Comment/whitespace only.
The animated pattern math (background sweep, moving 24px bar, frame-
counter block) was copy-pasted across all five clear-based backends
(dx9/dx10/dx11/dx12/gl). The backends MUST render an identical image --
the capture test decodes the same frame-counter block from each -- so a
drift between copies would be a silent test hazard.
Factor it into one frame_pattern() helper (plus kBarWidth) in
render_backend.hpp; each backend now consumes the computed bg/bar_x/code
values with its own API's clear/fill. mock_game_test still decodes every
backend correctly.
Five files each carried their own copy of the WideCharToMultiByte /
MultiByteToWideChar UTF-8 conversion (injection_panel, audio_overrides,
imgui_layer's to_utf8, main's harness widen, and vk_layer_setup's inline
form). Replace them all with coop::narrow / coop::widen from one header.
audio_panel's image_basename dropped its lossy `c & 0x7F` ASCII mask for
the proper narrow(), so a non-ASCII game exe name is no longer mangled
in log lines.
The vk_capture_perf_test comments described its synchronous reference
arm as "the OLD code"; it is a deliberately-constructed slow baseline
the test compares against, not removed history -- reworded to say so.
Replace the last std::getenv (tone_source.hpp) with
GetEnvironmentVariableA, dropping the C4996 deprecation warning from
every audio-tone build, and drop the now-unused cstdint/cstdlib
includes. Generalize a game-name aside in the header comment.
Extract a RAII RenderEndpoint that owns the enumerator/endpoint/client/
render-event/buffer for both AudioMirror::run_hooked and run_loopback,
replacing two hand-rolled setup+teardown blocks and their two identical
fail lambdas with one set_error helper; every COM object now frees on
each exit path automatically.
Route audio_format_verifier's mono decode through the shared
correlate_detail::decode_layout instead of a second copy, and read the
debug env var via GetEnvironmentVariableA (drops the getenv C4996).
Fold the near-identical read_frame/read_pixel staging-copy setup in
SharedTextureSource into one map_staging_copy, and the three separate
case-insensitive filter helpers (log/injection panels) into
ui/text_match.hpp.
Comments: drop game-name anecdotes and "the old code"/version phrasing;
genericize the override-file example; fix a stale heartbeat-interval
note. Behavior unchanged (host tests + mock_game_test pass).
Consolidate four copies of the keyed-mutex shared-texture setup
(present/opengl/d3d9/vk_capture) into one RAII SharedVideoTexture,
two copies of find_main_window into find_window.hpp, audio_hook's
hand-rolled detour guard into the shared DetourGate, the duplicated
vtable_method into vtable_hook.hpp, and the near-identical
Present/Present1 and SwapBuffers/wglSwapBuffers detour pairs into one
shared body each. The vk_layer and vk_capture_perf_test targets now
compile debug_log.cpp since the shared texture code logs.
Comments no longer narrate the past: drop stress-test/game anecdotes,
"used to"/"the old model" phrasing, plan-step labels, and pointers to
docs that do not exist; fix present_hook.hpp/opengl_hook.hpp claims
that predate the D3D12/D3D9/Vulkan backends. Net -266 lines, no
behavior change (full x64 + x86 suites pass, including the mock-game
hook/unhook storm).
Comments must not document the past or reference plan circumstances:
drop the stale pointer to a never-created audio_correlate_layout.hpp,
the "step b" plan labels, the "carved out of reserved space" history
note, and a README pointer; reword a past-tense seqlock comment to
describe the failure mode in the present.
Replace the strncpy in log_ring_push with a bounded memcpy: same
semantics (truncate + NUL), but without the C4996 deprecation warning
on every host build.
The window sized itself to GetSystemMetrics(SM_CXSCREEN/CYSCREEN) but the
process was DPI-unaware, so on a scaled display (e.g. 4K @ 150%) Windows
handed us a virtualized resolution and bitmap-stretched the whole window up
to native -- softening the mirror, which is the tool's entire point.
Declare per-monitor-v2 awareness at startup so GetSystemMetrics/GetDpiForWindow
report true pixels. That alone would shrink the fixed-13px ImGui overlay to
crisp-but-tiny, so pair it with UI scaling: rebuild the default-font atlas at a
DPI-scaled SizePixels (crisp at the target size, unlike FontGlobalScale's
bitmap stretch) and ScaleAllSizes() the style. Net: same physical size as
before, now sharp.
- common/include/coop/dpi.hpp: pure DPI->scale math (uses USER_DEFAULT_SCREEN_DPI
and a named kBaseFontPx, not bare 96/13 literals), with a zero fallback and
clamping. Unit-tested by tests/dpi_test.cpp.
- imgui_layer: apply_dpi() at init from GetDpiForWindow; set_dpi() for runtime
changes (rebuild atlas + reset/scale style + invalidate the DX11 font texture).
- d3d11_window: latch WM_DPICHANGED (honor the suggested rect), expose
take_dpi_change(); main loop polls it and calls imgui.set_dpi() between frames.
The DPI math is unit-tested; the actual awareness + font rasterization + live
WM_DPICHANGED rescale are verified by hand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
audio_hook.cpp carried its own VtableHook class identical to the shared
hook/src/vtable_hook.hpp -- two copies of the same delicate vtable-swap unhook
logic to keep in sync. Drop the audio copy and use the shared one (it's in
namespace coop::hook, so the in-file references resolve to it), leaving a short
note on why WASAPI methods are vtable-swapped rather than inline-hooked. Update
the shared header's comment to name both users (audio + DirectInput).
Last item from the review pass -- the Roadmap's Current-work section is now empty
(done work lives in git history); only Future work remains. Validated by
audio_hook_test (the vtable swap still hooks GetService/GetBuffer/ReleaseBuffer
end to end).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- hook_guard.hpp top block: described removal as `hook = {}` (destroy/reset); the
model is now persistent disable_for_removal (never destroyed mid-session, the
trampoline stays alive). Updated to match.
- input_source.hpp: SteamInputSource is no longer "future" -- it exists and is
opt-in; reworded.
- audio_ring.hpp: format_generation actually bumps on every set_format (not
"reserved, v1 sets once"); verify_capture is a 4-byte atomic guarded by the
version gate (not "repurposed from a reserved byte old builds saw"); and the
SharedBlock is no longer "20-byte pads".
- audio_format_verifier.cpp: dropped a dead `(void)recover_layout;` with a stale
"step (a) only" comment -- the parameter is actually used.
Comment-only except the dead (void) cast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- The "Auto re-attach this game on relaunch" checkbox (and the Vulkan-layer
checkbox) lived inside the connected-only block, so after a disconnect the
control vanished while auto_reattach_ could stay enabled -- an active,
invisible flag. Move both to render whenever a target is selected, connected or
not, so they can be set up pre-launch and toggled off after disconnect.
- Explain why controls are greyed out: an inline hint under the subsystem toggles
when no live game is connected, and a hover tooltip (AllowWhenDisabled) on the
disabled "Inject & Connect" button telling the operator to pick a target.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Log window's filter used a case-sensitive substring match, so "error" missed
"ERROR" -- inconsistent with the rest of the app. Match case-insensitively.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
register_vk_layer writes an HKCU implicit-layer entry that makes the loader pull
our DLL into every Vulkan app; it's session-scoped (unregistered on clean exit).
If the host crashed or was killed while registered, the entry leaked and kept
loading our DLL into every Vulkan process until the next clean run.
Add cleanup_stale_vk_layer(), called once at the top of run(): since registration
is opt-in per session, anything registered at startup is a crash leftover, so it
removes it (delegates to unregister_vk_layer). No-op when nothing is registered.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The injected coop_hook.dll links the dynamic CRT, so it failed to load into games
on machines without the matching VC++ redistributable -- a real field failure of
the core feature. It was deferred because SafetyHook + Zydis (linked into the DLL)
default to /MD, so a per-target /MT would mismatch.
Set CMAKE_MSVC_RUNTIME_LIBRARY to MultiThreaded[Debug] project-wide (CMP0091 NEW,
available at our 3.21 minimum). Every target -- the DLL, the vendored deps, the
host, tools, and tests, on both x64 and the x86 sub-build -- now shares one static
CRT, so there's no mismatch and the whole tool ships redist-free.
Verified: x64 + x86 full builds are clean; dumpbin shows coop_hook.dll and
coop_hook_x86.dll import only system DLLs (USER32/ole32/d3d11/KERNEL32) -- no
VCRUNTIME/MSVCP -- and the /MT test binaries run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
focus_spoof_test plays game + host: it creates a top-level window (which
find_main_window picks up), installs the spoof, and checks that
GetForegroundWindow / GetActiveWindow / GetFocus now report that window, that the
status flag flips on install and off on removal, and that a ClipCursor request is
swallowed while cursor release is the default. Skips if the window can't be
enumerated.
With this and d3d9_hook_test, the dedicated-hook-tests item is done. vk_hook's
present path is intentionally not given a separate in-process test: it's exercised
end to end by mock_game_test's vk-inject path ("hook captured Vulkan present
calls"), and the off-thread VkCapture it drives has vk_capture_perf_test -- a
standalone in-process Vulkan swapchain harness would just duplicate that at high
cost.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>