Commit Graph

35 Commits

Author SHA1 Message Date
1eee482165 Consolidate the host's UTF-8/wide conversions into util/utf8.hpp
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.
2026-07-12 09:23:40 +02:00
d73b43ad0d Make the host per-monitor DPI aware and scale the ImGui overlay
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>
2026-07-01 23:23:41 +02:00
b96a559500 Clean up a stale Vulkan-layer registration at host startup
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>
2026-06-24 02:22:06 +02:00
8182389091 Detect and surface host device loss instead of spinning silently
D3D11Window ignored the HRESULTs from Present, ResizeBuffers, and
CreateRenderTargetView, so a host-side TDR / driver reset / GPU hang left the
render loop presenting to a dead device forever with no error.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:05:47 +02:00
894285459c M2(Vulkan): too-late detection + red relaunch banner on the mirror window
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>
2026-06-22 12:51:34 +02:00
cf7e0c783a M1: UI-fit instrumentation + headless ui_fit_test + first overflow fixes
Add panel overflow instrumentation (record_panel_fit reading ImGui ScrollMax),
a forced layout-reference + debug-aware center split, and host harness commands
(uisize/uifit). New headless ui_fit_test drives the real Controllers + Audio
panels at reference resolutions with Debug details on and asserts no panel
overflows its assigned size; the Audio panel gains a demo mode so its richest
content renders without a live mirror.

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

15/15 ctest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:14:31 +02:00
b00b516cdb Injection: session-only auto re-attach on relaunch
Add an "Auto re-attach this game on relaunch" checkbox (default off, never
persisted, shown while attached or terminated). While on and the target has
terminated, the host polls the process list for the same image name and
re-injects the moment it reappears -- built on the existing
re-attach-by-image-name path, so there's no target to pick. The kill+relaunch
recovery for a wrong audio format: re-attaching early catches
IAudioClient::Initialize and reads the exact format.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 00:58:38 +02:00
a813465fd7 Quit via File -> Exit / Alt+F4 instead of Escape
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>
2026-06-22 00:07:28 +02:00
699ca31d31 Persist the "Debug details" toggle in the layout .ini
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>
2026-06-21 23:59:03 +02:00
4121ad4373 Move input polling to its own thread; fixed-width fast-changing UI numbers
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>
2026-06-21 12:51:00 +02:00
feb8fc5dae Add game-frame-synced flip toggle + multi-series colored perf graphs
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>
2026-06-21 11:55:10 +02:00
9f5b7c3272 Release the operator cursor for cursor-clipping games
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>
2026-06-21 05:20:17 +02:00
056a478e19 Forward rumble back to the guest controller (both backends)
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>
2026-06-21 05:11:33 +02:00
7673f186db Add mouse + keyboard forwarding (MKB subsystem, opt-in)
Forward the host window's clicks and keystrokes into the injected game so guests
can drive menus / "Press Start" / text entry that a pad can't.

Protocol (v7->v8): new HookSubsys_Mkb and an SPSC MkbRing of MkbEvents in
SharedBlock (host produces, hook consumes); push/pop helpers.

Hook (hook/src/mkb_hook.cpp, new subsystem): a worker-loop pump drains the ring at
~5 ms and PostMessageW's the matching window messages (WM_KEY*/WM_CHAR, mouse
buttons, WM_MOUSEWHEEL) to the game's main window; it also inline-hooks user32
GetAsyncKeyState / GetKeyboardState / GetCursorPos (stdcall trampolines per the x86
rule) to report a synthesized state so polling games react too. Removing the
subsystem clears all synthesized keys (no stuck input).

Host: the Injection panel gets a "Mouse + keyboard forwarding" subsystem toggle
(opt-in, default off -- the toggle is the hook). host/src/inject/mkb_forward.cpp
reads ImGui IO each frame and forwards only when the host window is focused and
ImGui isn't capturing the event; keyboard always, mouse only while mirroring (clicks
+ wheel, not movement). Mouse coords are mapped through the letterbox to game-client
space (host/src/inject/mkb_map.hpp), accounting for WGC-of-decorated-window vs
hooked/borderless. RawInput/DirectInput games are out of scope for this version.

Verified: new mkb_ring_test + mkb_map_test pass; full build x64 + x86 clean; ctest
x64 9/9 and x86 3/3 green (no regression from the protocol bump). The subsystem is
opt-in, so it can't affect existing behavior unless enabled; the end-to-end
click-into-game path needs live Remote Play + a real game to confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:06:38 +02:00
a32e78ffef Move the synthetic-input toggle to Controllers under Debug details
"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>
2026-06-21 01:23:41 +02:00
37d0f64e23 Lay out the overlay panels in a fixed 3-column scheme
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>
2026-06-21 01:19:45 +02:00
b3e8a1adc6 Detect terminated / hung target and reflect it in the UI
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>
2026-06-21 01:05:49 +02:00
914dba4d25 Fix input forwarding: default to XInput, make Steam Input opt-in
Making Steam Input the default backend silently broke input forwarding. Merely
initializing Steam Input activates Steam's in-process XInput interception, which
hides controllers from XInputGetState unless they're bound to our action set for
the running appid. With no such binding (the normal case for a donor appid) Steam
Input reports zero controllers AND XInput now sees nothing -> no input at all.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:51:58 +02:00
36b861d167 Phase 2: Present-hook video path (shared-texture mirror)
Add an injected IDXGISwapChain::Present / Present1 hook as a lower-latency,
border-free alternative to WGC. The hook copies the swapchain backbuffer into a
shared keyed-mutex texture (coop_video_<pid>); the host opens it by name and
samples it. New opt-in HookSubsys_Video (protocol v6 -> v7); the Video mirror
panel gains a WGC vs Hooked source toggle that installs/removes the subsystem.

Verified by present_hook_test (drives a real D3D11 swapchain end-to-end and reads
the rendered pixels back through the shared texture) and against Phantom Brave
(D3D9: hook installs cleanly and stays idle, WGC fallback). All 5 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 11:34:12 +02:00
9557b9ca69 Log window: stream the hook's logs over IPC into an in-app Log panel
Add a shared log ring (common/coop/log_ring.hpp): a lossy multi-producer /
single-consumer ring named coop_log_<pid>. The hook logs from several threads,
so producers claim a slot with fetch_add and publish each record with a
release store of its sequence; the consumer reads in order and tolerates
losing the oldest lines if it falls a whole ring behind.

The DLL's logf() now formats once and pushes every line to the ring (the file
trace stays as an opt-in mirror); the worker attaches the ring right after IPC
connect so bring-up is captured. The host (IpcServer) creates the ring at
injection time and exposes drain_logs(); a new LogPanel pulls new lines each
frame into a bounded rolling buffer and renders them with auto-scroll, a
filter, and clear. Added to the View menu (and UiState.show_log).

Verified against Phantom Brave via coop_audio_probe, which now also creates the
ring and drains it: the full hook bring-up trace streamed over IPC. All four
tests pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 01:03:59 +02:00
e370c8dcc5 Phase 1a: input forwarding via DLL injection + XInput hook
The host can now inject coop_hook.dll into a running game and forward
controller state to it over shared memory, so the game reads the host's
(eventually the guest's) input and nothing else.

- hook/: coop_hook.dll. DllMain spawns a worker that opens the shared-memory
  channel (named by the game's pid) and installs SafetyHook inline hooks on
  XInputGetState/GetStateEx/GetCapabilities/SetState. Detours synthesize state
  from shared memory; unmanaged slots report disconnected, hiding physical pads.
- host/: process picker (Toolhelp32), CreateRemoteThread(LoadLibraryW) injector
  with an IsWow64Process2 bitness guard, IPC server publishing pads each frame,
  and an ImGui Injection panel wiring it together.
- tests/: hook_selftest exercises the IPC seqlock + hook detours in-process
  (no game/controller needed); passes.

Build: SafetyHook wired in (COOP_BUILD_HOOK=ON), Zydis via FetchContent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:21:20 +02:00
cf058aecfa Phase 0: donor-launch spike foundation
Scaffold CoopAllTheThings: Remote Play Together for any XInput game via a
mirror app under a donor appid (real game keeps its own appid, so DRM,
achievements, and playtime stay intact).

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

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

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