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>
344 lines
14 KiB
C++
344 lines
14 KiB
C++
// IPC contract shared between the host (coop_host.exe) and the injected hook
|
|
// DLL (coop_hook.dll). Both sides compile this identical header, so the memory
|
|
// layout must stay POD and version-locked.
|
|
#pragma once
|
|
|
|
#include <atomic>
|
|
#include <cstddef>
|
|
#include <cstdint>
|
|
|
|
namespace coop
|
|
{
|
|
|
|
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
|
// refuses to attach to a host with a mismatched version.
|
|
inline constexpr std::uint32_t kProtocolVersion = 13;
|
|
|
|
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
|
|
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
|
|
|
|
// XInput exposes four controller slots; we mirror that fixed count.
|
|
inline constexpr std::uint32_t kMaxPads = 4;
|
|
|
|
// The shared-memory section is named per host process id so multiple sessions
|
|
// can coexist. Format with the target game's pid: coop_ipc_<pid>.
|
|
inline constexpr wchar_t kSharedMemoryPrefix[] = L"Local\\coop_ipc_";
|
|
|
|
// One controller's state, laid out to map 1:1 onto XINPUT_GAMEPAD plus the
|
|
// metadata the hook needs. Field names/types match XINPUT_GAMEPAD so the hook
|
|
// can memcpy the trailing region straight into an XINPUT_STATE.
|
|
struct CoopPadState
|
|
{
|
|
std::uint8_t connected; // 1 if a guest/host pad is mapped to this slot
|
|
std::uint8_t reserved[3];
|
|
std::uint32_t packet; // bumps on change -> XINPUT_STATE::dwPacketNumber
|
|
std::uint16_t buttons; // XINPUT_GAMEPAD_* bitmask
|
|
std::uint8_t left_trigger;
|
|
std::uint8_t right_trigger;
|
|
std::int16_t thumb_lx;
|
|
std::int16_t thumb_ly;
|
|
std::int16_t thumb_rx;
|
|
std::int16_t thumb_ry;
|
|
};
|
|
|
|
static_assert(sizeof(CoopPadState) == 20, "CoopPadState layout must stay stable across both modules");
|
|
|
|
// Maximum render streams the diagnostics track. The hook captures only the
|
|
// first ("primary"); the rest are surfaced so a multi-stream game is visible.
|
|
inline constexpr std::uint32_t kMaxAudioStreams = 4;
|
|
|
|
// One render stream the hook observed, for the Audio panel's debug view. Plain
|
|
// POD (no atomics): diagnostics tolerate benign cross-process races like the
|
|
// other HookStatus counters. frames_rendered is cumulative; the host derives
|
|
// "live vs idle" from successive deltas.
|
|
struct AudioStreamInfo
|
|
{
|
|
std::uint32_t is_primary; // 1 = the stream the hook captures/silences
|
|
std::uint32_t sample_rate;
|
|
std::uint16_t channels;
|
|
std::uint16_t bits;
|
|
std::uint32_t format_tag; // WAVE_FORMAT_* of this stream
|
|
std::uint64_t frames_rendered;
|
|
};
|
|
|
|
// Orthogonal hook subsystems the host can install/remove independently.
|
|
enum HookSubsystem : std::uint32_t
|
|
{
|
|
HookSubsys_Input = 0, // XInput hooks (forward the guest pad)
|
|
HookSubsys_Focus = 1, // focus spoof (keep the game running unfocused)
|
|
HookSubsys_Audio = 2, // WASAPI render-hook (audio mirror without echo)
|
|
HookSubsys_Video = 3, // IDXGISwapChain::Present hook (shared-texture video mirror)
|
|
HookSubsys_Mkb = 4, // mouse+keyboard forwarding (PostMessage + polling-state hooks)
|
|
HookSubsys_Count = 5,
|
|
};
|
|
|
|
// Maximum individual hooks reported in the registry (a few per subsystem).
|
|
inline constexpr std::uint32_t kMaxHookEntries = 24;
|
|
|
|
// One installed hook, for the Injection panel's hook list. POD diagnostics, like
|
|
// AudioStreamInfo: the hook is the sole writer; benign cross-process races are ok.
|
|
struct HookEntry
|
|
{
|
|
char name[40]; // e.g. "XInputGetState"
|
|
std::uint32_t subsystem; // HookSubsystem
|
|
std::uint32_t installed; // 1 if currently hooked
|
|
std::uint64_t calls; // cumulative times the detour ran
|
|
};
|
|
|
|
// Indices into HookStatus::focus_query_calls.
|
|
enum FocusApi : std::uint32_t
|
|
{
|
|
FocusApi_Foreground = 0, // GetForegroundWindow
|
|
FocusApi_Active = 1, // GetActiveWindow
|
|
FocusApi_Focus = 2, // GetFocus
|
|
FocusApi_Count = 3,
|
|
};
|
|
|
|
// Hook -> host back-channel. The injected DLL is the sole writer; the host reads
|
|
// it for the diagnostics overlay: is the hook attached, which slots is the game
|
|
// polling, does it use the focus APIs, and does it read input through a
|
|
// focus-gated path (Raw Input / DirectInput)? Diagnostics only, so the non-atomic
|
|
// fields tolerate benign cross-process races.
|
|
struct HookStatus
|
|
{
|
|
std::atomic<std::uint32_t> heartbeat; // DLL bumps ~4x/sec while alive
|
|
std::atomic<std::uint64_t> get_state_calls[kMaxPads]; // XInputGetState/Ex per slot
|
|
std::atomic<std::uint64_t> get_caps_calls[kMaxPads]; // XInputGetCapabilities per slot
|
|
std::atomic<std::uint64_t> focus_query_calls[FocusApi_Count]; // focus API calls, see FocusApi
|
|
|
|
std::uint32_t attached; // 1 once XInput hooks are installed
|
|
std::uint32_t focus_spoof; // 1 once focus spoofing is active
|
|
std::uint32_t game_pid; // the DLL's own pid (sanity check)
|
|
std::uint64_t game_hwnd; // window the DLL subclassed (0 if none yet)
|
|
|
|
// Input-path diagnostics: which focus-gated mechanism (if any) the game uses.
|
|
std::uint32_t raw_input_registered; // process has any Raw Input registration
|
|
std::uint32_t raw_input_gamepad; // ... for a joystick/gamepad usage page
|
|
std::uint32_t raw_input_gamepad_sink; // ... and that usage has RIDEV_INPUTSINK (bg delivery)
|
|
std::uint32_t dinput_loaded; // dinput8.dll is present in the process
|
|
|
|
// Audio render-hook diagnostics. Stream counting runs whenever the DLL is
|
|
// injected, independent of whether audio mirroring is enabled, so a
|
|
// multi-stream game is visible before/without turning the mirror on.
|
|
std::uint32_t audio_streams_seen; // distinct render clients ever created
|
|
AudioStreamInfo audio_streams[kMaxAudioStreams]; // per-slot detail, [0] is primary
|
|
|
|
// Hook registry: every individual hook the DLL has installed, with a running
|
|
// call count. Lets the Injection panel list exactly what's hooked and how busy.
|
|
std::uint32_t hook_entry_count;
|
|
HookEntry hook_entries[kMaxHookEntries];
|
|
|
|
// Per-slot rumble the game last requested via XInputSetState (hook is sole
|
|
// writer). The host forwards it to the guest's controller. Plain POD like the
|
|
// other diagnostics -- benign cross-process races are fine.
|
|
std::uint16_t rumble_left[kMaxPads];
|
|
std::uint16_t rumble_right[kMaxPads];
|
|
|
|
// The last pad state the hook actually returned to the game per slot, so the host
|
|
// can show a true input round-trip (forwarded vs what the game read).
|
|
CoopPadState read_state[kMaxPads];
|
|
};
|
|
|
|
// Host -> hook control channel. The host requests which hook subsystems should be
|
|
// installed; the hook reconciles each tick. 0 = install (the zero-filled default,
|
|
// so a fresh mapping installs everything as before), 1 = remove.
|
|
struct HookControl
|
|
{
|
|
std::atomic<std::uint32_t> subsystem_disabled[HookSubsys_Count];
|
|
|
|
// Cursor handling for cursor-clipping games (part of the Focus subsystem).
|
|
// 0 = release the operator's mouse: the hook frees the game's ClipCursor and
|
|
// swallows its re-centering SetCursorPos (the default, so the operator can reach
|
|
// the overlay). 1 = let the game clip / position the cursor as normal.
|
|
std::atomic<std::uint32_t> allow_cursor_clip;
|
|
};
|
|
|
|
// Present-hook video channel. When the video subsystem is installed, the hook
|
|
// copies the game's swapchain backbuffer into a shared keyed-mutex texture named
|
|
// coop_video_<pid> and publishes its dimensions/format here; the host opens that
|
|
// texture by name and samples it (a lower-latency alternative to WGC). The hook
|
|
// is the sole writer. `generation` bumps on every published frame (0 = nothing
|
|
// shared yet); width/height/format describe the currently shared texture, so the
|
|
// host reopens it whenever they change. The keyed mutex uses key 0 on both sides.
|
|
struct VideoShare
|
|
{
|
|
std::atomic<std::uint32_t> generation; // bumps per published frame; 0 = none yet
|
|
std::uint32_t width; // shared texture dimensions / DXGI format
|
|
std::uint32_t height;
|
|
std::uint32_t format; // DXGI_FORMAT of the shared texture
|
|
std::uint64_t present_calls; // cumulative Present() detours (diagnostic)
|
|
std::int64_t present_qpc; // QueryPerformanceCounter at the last publish
|
|
std::uint64_t frames_dropped; // cumulative captures skipped because the shared
|
|
// keyed mutex was busy (host mid-copy) -- a frame
|
|
// the game produced that never reached the mirror
|
|
};
|
|
|
|
// --- Mouse + keyboard forwarding -------------------------------------------
|
|
// The host captures its own window's MKB input (when focused and ImGui doesn't
|
|
// want it) and pushes events here; the injected MKB subsystem drains them, posts
|
|
// the matching window messages to the game, and maintains a synthesized state the
|
|
// GetAsyncKeyState/GetKeyboardState/GetCursorPos hooks report to polling games.
|
|
|
|
enum MkbEventType : std::uint32_t
|
|
{
|
|
Mkb_KeyDown = 0, // code = Win32 virtual-key
|
|
Mkb_KeyUp = 1, // code = Win32 virtual-key
|
|
Mkb_Char = 2, // code = UTF-16 code unit (WM_CHAR)
|
|
Mkb_MouseDown = 3, // code = button (0=left,1=right,2=middle); x,y = game client px
|
|
Mkb_MouseUp = 4, // code = button; x,y = game client px
|
|
Mkb_Wheel = 5, // code = signed wheel delta (WHEEL_DELTA units); x,y = game client px
|
|
};
|
|
|
|
struct MkbEvent
|
|
{
|
|
std::uint32_t type; // MkbEventType
|
|
std::uint32_t code; // see per-type meaning above
|
|
std::int32_t x; // game-client x (mouse events)
|
|
std::int32_t y; // game-client y (mouse events)
|
|
};
|
|
|
|
static_assert(sizeof(MkbEvent) == 16, "MkbEvent must stay byte-identical across bitness");
|
|
|
|
// Power-of-two so the free-running indices mask cleanly.
|
|
inline constexpr std::uint32_t kMkbQueueSize = 128;
|
|
|
|
// Lock-free SPSC ring: host produces, hook consumes. Free-running 32-bit indices.
|
|
struct MkbRing
|
|
{
|
|
std::atomic<std::uint32_t> head; // producer (host) write position
|
|
std::atomic<std::uint32_t> tail; // consumer (hook) read position
|
|
MkbEvent events[kMkbQueueSize];
|
|
};
|
|
|
|
// The shared backbuffer texture is named per target pid, like the audio ring.
|
|
inline constexpr wchar_t kVideoSharePrefix[] = L"Local\\coop_video_";
|
|
|
|
// Keyed-mutex key both producer and consumer use (a plain cross-process mutex on
|
|
// the texture; the keyed mutex is created released at key 0).
|
|
inline constexpr std::uint64_t kVideoMutexKey = 0;
|
|
|
|
// Top-level shared block. The host is the sole writer of pad state; the hook is
|
|
// the sole reader. A seqlock (even = stable, odd = write in progress) lets the
|
|
// reader grab a torn-free snapshot without a kernel lock on the hot path.
|
|
struct SharedBlock
|
|
{
|
|
std::uint32_t magic;
|
|
std::uint32_t version;
|
|
std::uint32_t pad_count; // number of populated slots, <= kMaxPads
|
|
std::atomic<std::uint32_t> sequence;
|
|
CoopPadState pads[kMaxPads];
|
|
|
|
// Hook -> host diagnostics back-channel.
|
|
HookStatus status;
|
|
|
|
// Host -> hook control (which subsystems to install).
|
|
HookControl control;
|
|
|
|
// Hook -> host Present-hook video channel (shared-texture dimensions/format).
|
|
VideoShare video;
|
|
|
|
// Host -> hook mouse + keyboard event queue (when the MKB subsystem is on).
|
|
MkbRing mkb;
|
|
};
|
|
|
|
static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
|
|
"seqlock requires a lock-free 32-bit atomic for cross-process use");
|
|
static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
|
|
"status counters need a lock-free 64-bit atomic for cross-process use");
|
|
|
|
// The x64 host and the x86 hook map this same block, so its layout must be
|
|
// byte-identical across bitness. These offsets (verified equal on both arches)
|
|
// lock the front of the block -- the seqlock + pad state the input hot path reads;
|
|
// a future field reorder that diverges between x86 and x64 fails to compile on the
|
|
// arch that disagrees. (Fixed-width POD + no pointers is what keeps it stable.)
|
|
static_assert(offsetof(SharedBlock, sequence) == 12, "cross-bitness: sequence offset moved");
|
|
static_assert(offsetof(SharedBlock, pads) == 16, "cross-bitness: pad-state offset moved");
|
|
static_assert(offsetof(SharedBlock, status) == 96, "cross-bitness: status offset moved");
|
|
|
|
// --- Seqlock helpers -------------------------------------------------------
|
|
|
|
// Writer side: publish a fresh set of pad states. Called from the host.
|
|
inline void publish_pads(SharedBlock& block, const CoopPadState* pads, std::uint32_t count)
|
|
{
|
|
if (count > kMaxPads)
|
|
{
|
|
count = kMaxPads;
|
|
}
|
|
const std::uint32_t seq = block.sequence.load(std::memory_order_relaxed);
|
|
block.sequence.store(seq + 1, std::memory_order_release); // -> odd: write begins
|
|
std::atomic_thread_fence(std::memory_order_release);
|
|
block.pad_count = count;
|
|
for (std::uint32_t i = 0; i < count; ++i)
|
|
{
|
|
block.pads[i] = pads[i];
|
|
}
|
|
for (std::uint32_t i = count; i < kMaxPads; ++i)
|
|
{
|
|
block.pads[i] = CoopPadState{};
|
|
}
|
|
block.sequence.store(seq + 2, std::memory_order_release); // -> even: write done
|
|
}
|
|
|
|
// Reader side: copy a consistent snapshot. Called from the hook. Spins briefly
|
|
// if a write is in flight; bounded so a crashed writer can't hang the game.
|
|
inline bool read_pads(const SharedBlock& block, CoopPadState (&out)[kMaxPads], std::uint32_t& out_count)
|
|
{
|
|
for (int attempt = 0; attempt < 64; ++attempt)
|
|
{
|
|
const std::uint32_t before = block.sequence.load(std::memory_order_acquire);
|
|
if (before & 1u)
|
|
{
|
|
continue; // writer mid-update, retry
|
|
}
|
|
std::uint32_t count = block.pad_count;
|
|
if (count > kMaxPads)
|
|
{
|
|
count = kMaxPads;
|
|
}
|
|
for (std::uint32_t i = 0; i < kMaxPads; ++i)
|
|
{
|
|
out[i] = block.pads[i];
|
|
}
|
|
std::atomic_thread_fence(std::memory_order_acquire);
|
|
const std::uint32_t after = block.sequence.load(std::memory_order_acquire);
|
|
if (before == after)
|
|
{
|
|
out_count = count;
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// --- MKB ring helpers (SPSC: host pushes, hook pops) -----------------------
|
|
|
|
// Host side: enqueue an MKB event. Returns false (dropped) if the ring is full.
|
|
inline bool push_mkb_event(MkbRing& ring, const MkbEvent& ev)
|
|
{
|
|
const std::uint32_t head = ring.head.load(std::memory_order_relaxed);
|
|
const std::uint32_t tail = ring.tail.load(std::memory_order_acquire);
|
|
if (head - tail >= kMkbQueueSize)
|
|
{
|
|
return false; // full -> drop (host should always drain faster than it fills)
|
|
}
|
|
ring.events[head & (kMkbQueueSize - 1)] = ev;
|
|
ring.head.store(head + 1, std::memory_order_release);
|
|
return true;
|
|
}
|
|
|
|
// Hook side: dequeue the next MKB event. Returns false if the ring is empty.
|
|
inline bool pop_mkb_event(MkbRing& ring, MkbEvent& out)
|
|
{
|
|
const std::uint32_t tail = ring.tail.load(std::memory_order_relaxed);
|
|
const std::uint32_t head = ring.head.load(std::memory_order_acquire);
|
|
if (tail == head)
|
|
{
|
|
return false; // empty
|
|
}
|
|
out = ring.events[tail & (kMkbQueueSize - 1)];
|
|
ring.tail.store(tail + 1, std::memory_order_release);
|
|
return true;
|
|
}
|
|
|
|
} // namespace coop
|