// 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 #include #include 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 = 10; // '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_. 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 heartbeat; // DLL bumps ~4x/sec while alive std::atomic get_state_calls[kMaxPads]; // XInputGetState/Ex per slot std::atomic get_caps_calls[kMaxPads]; // XInputGetCapabilities per slot std::atomic 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 subsystem_disabled[HookSubsys_Count]; }; // 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_ 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 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) }; // --- 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 head; // producer (host) write position std::atomic 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 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::is_always_lock_free, "seqlock requires a lock-free 32-bit atomic for cross-process use"); static_assert(std::atomic::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