commit cf058aecfa933cd1d748376e6e3abc1ea03794bd Author: BlackMark Date: Thu Jun 18 22:48:31 2026 +0200 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 diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..5518a39 --- /dev/null +++ b/.clang-format @@ -0,0 +1,19 @@ +--- +BasedOnStyle: LLVM +ColumnLimit: 120 +IndentWidth: 4 +TabWidth: 4 +UseTab: ForIndentation + +BreakBeforeBraces: Custom +BraceWrapping: + AfterFunction: true + SplitEmptyRecord: false + +AlignEscapedNewlines: DontAlign +AllowShortFunctionsOnASingleLine: Inline +AlwaysBreakTemplateDeclarations: true +BreakBeforeBinaryOperators: NonAssignment +ConstructorInitializerAllOnOneLineOrOnePerLine: true +PointerAlignment: Left +... diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1110e45 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{c,cpp,h,hpp,inc}] +indent_style = tab +indent_size = 4 + +[{CMakeLists.txt,*.cmake}] +indent_style = tab +indent_size = 4 + +[*.{sln,vcxproj,props,filters}] +end_of_line = crlf +insert_final_newline = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a43d6ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.vs +*.vcxproj.user +*.args.json + +/bin +/build +CMakeUserPresets.json + +x64 +Debug +Release diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b077a4f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "third_party/imgui"] + path = third_party/imgui + url = https://github.com/ocornut/imgui.git +[submodule "third_party/safetyhook"] + path = third_party/safetyhook + url = https://github.com/cursey/safetyhook.git diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..b3b0e1c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,36 @@ +cmake_minimum_required(VERSION 3.21) + +project(CoopAllTheThings + VERSION 0.0.1 + DESCRIPTION "Steam Remote Play Together for any XInput game" + LANGUAGES CXX) + +if(NOT WIN32) + message(FATAL_ERROR "CoopAllTheThings targets Windows only.") +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Single output dir keeps the donor-launch / inject workflow simple: host exe and +# hook dll land next to each other under /bin (already in .gitignore). +set(COOP_OUTPUT_DIR "${CMAKE_SOURCE_DIR}/bin/$") +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${COOP_OUTPUT_DIR}") +set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${COOP_OUTPUT_DIR}") + +if(MSVC) + add_compile_options(/W4 /permissive- /Zc:__cplusplus /utf-8 /MP) + add_compile_definitions(UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX) +endif() + +add_subdirectory(third_party) +add_subdirectory(common) +add_subdirectory(host) + +# The injected hook DLL pulls in SafetyHook (+ Zydis). It is built in Phase 1; +# enable once the dependency is wired so Phase 0 stays minimal. +option(COOP_BUILD_HOOK "Build the injected game-side hook DLL" OFF) +if(COOP_BUILD_HOOK) + add_subdirectory(hook) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..4dc72ab --- /dev/null +++ b/README.md @@ -0,0 +1,109 @@ +# CoopAllTheThings + +Steam **Remote Play Together (RPT)** for any XInput game — without breaking DRM, +achievements, or playtime. + +Existing "donor game" tools (e.g. RemotePlayWhatever) copy a target game's files +into a donor game's folder and rename the executable so Steam streams the target +under the donor's appid. That breaks DRM-protected games, breaks achievements, +and credits playtime to the donor. + +CoopAllTheThings takes a different approach: the **real game runs normally under +its own appid** (so DRM, achievements, and playtime all work), while a lightweight +**mirror app runs under the donor appid**. The mirror presents a borderless +window that is a live copy of the game's video + audio, and forwards the guests' +input back into the real game. Steam's RPT captures the mirror window — so any +XInput game becomes Remote-Play-Together-able. + +See [`docs`](docs) and the in-repo plan for the full design. + +## Architecture (target) + +| Concern | Mechanism | Where | +| --- | --- | --- | +| Receive guest input | Steam Input / XInput (RPT delivers guests to the focused window) | `coop_host.exe` | +| Forward input to game | DLL injection + XInput hook (SafetyHook) — game sees *only* our pad | `coop_hook.dll` | +| Mirror video | Windows Graphics Capture first; `IDXGISwapChain::Present` hook as the low-latency upgrade | host (+ hook) | +| Mirror audio | WASAPI process-loopback capture of the game, re-rendered | `coop_host.exe` | +| Host ↔ hook IPC | Named shared memory (seqlock for input, shared D3D11 texture for video) | `common/` | + +## Limitations + +- **Anti-cheat:** the input path injects `coop_hook.dll` into the target game. + Games protected by kernel-level anti-cheat (Easy Anti-Cheat, BattlEye, + Vanguard, etc.) will detect the injected module and may **kick the player or + issue a ban**. Such games are explicitly **out of scope and unsupported** — do + not use CoopAllTheThings with them. The tool targets single-player and + co-op/local-multiplayer titles without active anti-cheat. +- **XInput only:** the game must read controllers via XInput (the common case). + DirectInput-only / RawInput-only games are not handled. +- **Architecture match:** the host and hook DLL must match the game's bitness. + x64 is supported first; x86 support is a later phase (see the plan). + +## Status + +**Phase 0 — donor spike (current).** `coop_host.exe` is a borderless D3D11 window +with an ImGui overlay that lists every controller it can see. It exists to +validate the riskiest assumption before anything else is built: *can Steam RPT +stream an arbitrary window launched under a donor appid, and route a guest's +gamepad into it?* + +Later phases (capture, injection, audio) are scoped in the plan and gated on +Phase 0 passing. + +## Building + +Requirements: Windows 10/11, Visual Studio 2022 (MSVC + C++ workload), CMake ≥ 3.21. + +```sh +git clone --recurse-submodules +# or, if already cloned: +git submodule update --init --recursive + +cmake -S . -B build -G "Visual Studio 17 2022" -A x64 +cmake --build build --config Debug +# output: bin/Debug/coop_host.exe +``` + +Third-party dependencies (Dear ImGui, SafetyHook) are git submodules under +`third_party/`; the Steamworks SDK is vendored manually there when wired. No +vcpkg / package manager is used. + +## Phase 0: validating the donor-launch assumption + +This is a manual test — it needs Steam, a second person (or second account), and +a donor game that supports Remote Play Together. + +1. **Pick a donor game** you own that supports Remote Play Together (check the + "Remote Play Together" tag on its store page). The donor only needs RPT + support; it is never actually played. + +2. **Launch the host under the donor's appid.** Find the donor's appid (the + number in its store URL), then run: + + ```text + "C:\Program Files (x86)\Steam\steam.exe" -applaunch "D:\dev\CoopAllTheThings\bin\Debug\coop_host.exe" + ``` + + The borderless overlay window should appear and Steam should consider the + donor "running" (green status / "Stop" button in the library). + + > If the donor ignores the trailing path, set the host as the donor's + > **Launch Options** instead (`"D:\...\coop_host.exe" %command%` variants), + > or use a launcher such as RemotePlayDetached. Recording which method makes + > Steam attribute our window to the donor is the main deliverable of Phase 0. + +3. **Start Remote Play Together** from the Steam friends list / overlay and invite + a friend (or a second machine/account). + +4. **Verify on the guest side:** + - The guest sees the borderless overlay window streamed (not a black screen). + - The guest presses buttons on their controller and the corresponding slot in + the overlay lights up. This proves RPT routes guest input into our window + as XInput — the foundation the whole tool relies on. + +5. Press **Esc** in the host window to quit. + +**If steps 2 and 4 both work, the core premise holds** and we proceed to Phase 1 +(window capture + input injection). If not, we revisit the donor-attribution +approach before building further. diff --git a/common/CMakeLists.txt b/common/CMakeLists.txt new file mode 100644 index 0000000..55202d1 --- /dev/null +++ b/common/CMakeLists.txt @@ -0,0 +1,6 @@ +# Header-only IPC contract shared by the host and the injected hook DLL. +add_library(coop_common INTERFACE) + +target_include_directories(coop_common INTERFACE include) + +target_compile_features(coop_common INTERFACE cxx_std_20) diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp new file mode 100644 index 0000000..673ef7d --- /dev/null +++ b/common/include/coop/protocol.hpp @@ -0,0 +1,118 @@ +// 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 + +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 = 1; + +// '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"); + +// 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]; + + // Phase 2 appends the shared-texture handle/dimensions control fields here; + // keep new members at the end so existing offsets never shift. +}; + +static_assert(std::atomic::is_always_lock_free, + "seqlock requires a lock-free 32-bit atomic for cross-process use"); + +// --- 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; +} + +} // namespace coop diff --git a/common/include/coop/shared_memory.hpp b/common/include/coop/shared_memory.hpp new file mode 100644 index 0000000..8617631 --- /dev/null +++ b/common/include/coop/shared_memory.hpp @@ -0,0 +1,131 @@ +// Thin RAII wrapper over a Win32 file-mapping section used as the host<->hook +// IPC transport. Header-only so both modules share one implementation. +#pragma once + +#include +#include + +#include + +#include "coop/protocol.hpp" + +namespace coop +{ + +class SharedMemory +{ +public: + SharedMemory() = default; + + SharedMemory(const SharedMemory&) = delete; + SharedMemory& operator=(const SharedMemory&) = delete; + + SharedMemory(SharedMemory&& other) noexcept + { + *this = std::move(other); + } + + SharedMemory& operator=(SharedMemory&& other) noexcept + { + if (this != &other) + { + reset(); + mapping_ = std::exchange(other.mapping_, nullptr); + view_ = std::exchange(other.view_, nullptr); + size_ = std::exchange(other.size_, 0); + } + return *this; + } + + ~SharedMemory() + { + reset(); + } + + // Host side: create (or open if it already exists) the named section. + bool create(const std::wstring& name, std::size_t size) + { + reset(); + mapping_ = CreateFileMappingW(INVALID_HANDLE_VALUE, nullptr, PAGE_READWRITE, 0, + static_cast(size), name.c_str()); + if (mapping_ == nullptr) + { + return false; + } + return map(size); + } + + // Hook side: open an existing section created by the host. + bool open(const std::wstring& name, std::size_t size) + { + reset(); + mapping_ = OpenFileMappingW(FILE_MAP_ALL_ACCESS, FALSE, name.c_str()); + if (mapping_ == nullptr) + { + return false; + } + return map(size); + } + + void reset() + { + if (view_ != nullptr) + { + UnmapViewOfFile(view_); + view_ = nullptr; + } + if (mapping_ != nullptr) + { + CloseHandle(mapping_); + mapping_ = nullptr; + } + size_ = 0; + } + + [[nodiscard]] bool valid() const + { + return view_ != nullptr; + } + + template + [[nodiscard]] T* as() const + { + return static_cast(view_); + } + + [[nodiscard]] void* data() const + { + return view_; + } + + [[nodiscard]] std::size_t size() const + { + return size_; + } + +private: + bool map(std::size_t size) + { + view_ = MapViewOfFile(mapping_, FILE_MAP_ALL_ACCESS, 0, 0, size); + if (view_ == nullptr) + { + CloseHandle(mapping_); + mapping_ = nullptr; + return false; + } + size_ = size; + return true; + } + + HANDLE mapping_ = nullptr; + void* view_ = nullptr; + std::size_t size_ = 0; +}; + +// Build the per-pid section name both sides agree on. +inline std::wstring shared_memory_name(unsigned long target_pid) +{ + return std::wstring(kSharedMemoryPrefix) + std::to_wstring(target_pid); +} + +} // namespace coop diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt new file mode 100644 index 0000000..23e3f89 --- /dev/null +++ b/host/CMakeLists.txt @@ -0,0 +1,18 @@ +add_executable(coop_host WIN32 + src/main.cpp + src/d3d11_window.cpp + src/imgui_layer.cpp + src/debug_overlay.cpp + src/input/xinput_source.cpp) + +target_include_directories(coop_host PRIVATE src) + +target_link_libraries(coop_host PRIVATE + coop_common + imgui + d3d11 + dxgi + dwmapi + xinput) + +set_target_properties(coop_host PROPERTIES OUTPUT_NAME "coop_host") diff --git a/host/src/d3d11_window.cpp b/host/src/d3d11_window.cpp new file mode 100644 index 0000000..cd44b89 --- /dev/null +++ b/host/src/d3d11_window.cpp @@ -0,0 +1,222 @@ +#include "d3d11_window.hpp" + +#include + +// Forward declared in the ImGui Win32 backend; lets ImGui consume input first. +extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam); + +using Microsoft::WRL::ComPtr; + +namespace coop +{ + +namespace +{ +constexpr wchar_t kWindowClass[] = L"CoopAllTheThingsWindow"; +} + +D3D11Window::~D3D11Window() +{ + release_render_target(); + if (hwnd_ != nullptr) + { + DestroyWindow(hwnd_); + hwnd_ = nullptr; + } + UnregisterClassW(kWindowClass, GetModuleHandleW(nullptr)); +} + +bool D3D11Window::create(const wchar_t* title) +{ + const HINSTANCE instance = GetModuleHandleW(nullptr); + + WNDCLASSEXW wc = {}; + wc.cbSize = sizeof(wc); + wc.style = CS_HREDRAW | CS_VREDRAW; + wc.lpfnWndProc = &D3D11Window::wnd_proc; + wc.hInstance = instance; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.lpszClassName = kWindowClass; + if (RegisterClassExW(&wc) == 0) + { + return false; + } + + // Borderless popup covering the primary monitor. Steam RPT only streams the + // focused window, and exclusive-fullscreen swap chains can't be captured, so + // a plain WS_POPUP is exactly what we want. + const int width = GetSystemMetrics(SM_CXSCREEN); + const int height = GetSystemMetrics(SM_CYSCREEN); + + hwnd_ = CreateWindowExW(0, kWindowClass, title, WS_POPUP, 0, 0, width, height, nullptr, nullptr, instance, this); + if (hwnd_ == nullptr) + { + return false; + } + + if (!create_device()) + { + return false; + } + + ShowWindow(hwnd_, SW_SHOW); + UpdateWindow(hwnd_); + SetForegroundWindow(hwnd_); + return true; +} + +bool D3D11Window::create_device() +{ + DXGI_SWAP_CHAIN_DESC1 desc = {}; + desc.Width = 0; // derive from the window client area + desc.Height = 0; + desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; + desc.BufferCount = 2; + desc.Scaling = DXGI_SCALING_STRETCH; + desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD; + desc.AlphaMode = DXGI_ALPHA_MODE_IGNORE; + + UINT flags = 0; +#ifdef _DEBUG + flags |= D3D11_CREATE_DEVICE_DEBUG; +#endif + const D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0}; + + if (FAILED(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, flags, levels, _countof(levels), + D3D11_SDK_VERSION, device_.GetAddressOf(), nullptr, context_.GetAddressOf()))) + { + return false; + } + + ComPtr dxgi_device; + if (FAILED(device_.As(&dxgi_device))) + { + return false; + } + ComPtr adapter; + if (FAILED(dxgi_device->GetAdapter(adapter.GetAddressOf()))) + { + return false; + } + ComPtr factory; + if (FAILED(adapter->GetParent(IID_PPV_ARGS(factory.GetAddressOf())))) + { + return false; + } + + if (FAILED(factory->CreateSwapChainForHwnd(device_.Get(), hwnd_, &desc, nullptr, nullptr, + swap_chain_.GetAddressOf()))) + { + return false; + } + // Don't let DXGI swallow Alt+Enter into an exclusive-fullscreen transition. + factory->MakeWindowAssociation(hwnd_, DXGI_MWA_NO_ALT_ENTER); + + create_render_target(); + return true; +} + +void D3D11Window::create_render_target() +{ + ComPtr back_buffer; + if (SUCCEEDED(swap_chain_->GetBuffer(0, IID_PPV_ARGS(back_buffer.GetAddressOf())))) + { + device_->CreateRenderTargetView(back_buffer.Get(), nullptr, rtv_.ReleaseAndGetAddressOf()); + } +} + +void D3D11Window::release_render_target() +{ + rtv_.Reset(); +} + +void D3D11Window::handle_resize(UINT width, UINT height) +{ + if (swap_chain_ == nullptr || width == 0 || height == 0) + { + return; + } + release_render_target(); + swap_chain_->ResizeBuffers(0, width, height, DXGI_FORMAT_UNKNOWN, 0); + create_render_target(); +} + +bool D3D11Window::pump_messages() +{ + MSG msg; + while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) + { + if (msg.message == WM_QUIT) + { + return false; + } + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + if (resize_pending_) + { + handle_resize(resize_width_, resize_height_); + resize_pending_ = false; + } + return true; +} + +void D3D11Window::render_frame(const RenderCallback& render) +{ + const float clear[4] = {0.06f, 0.06f, 0.08f, 1.0f}; + context_->OMSetRenderTargets(1, rtv_.GetAddressOf(), nullptr); + context_->ClearRenderTargetView(rtv_.Get(), clear); + + if (render) + { + render(); + } + + // vsync on: matches the captured stream cadence and avoids a busy spin. + swap_chain_->Present(1, 0); +} + +LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) +{ + if (msg == WM_NCCREATE) + { + auto* create = reinterpret_cast(lparam); + SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast(create->lpCreateParams)); + } + + if (ImGui_ImplWin32_WndProcHandler(hwnd, msg, wparam, lparam)) + { + return true; + } + + auto* self = reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_USERDATA)); + + switch (msg) + { + case WM_SIZE: + if (self != nullptr && wparam != SIZE_MINIMIZED) + { + self->resize_pending_ = true; + self->resize_width_ = LOWORD(lparam); + self->resize_height_ = HIWORD(lparam); + } + return 0; + case WM_KEYDOWN: + // Spike convenience: Esc quits so we aren't stuck in a borderless window. + if (wparam == VK_ESCAPE) + { + PostQuitMessage(0); + } + return 0; + case WM_DESTROY: + PostQuitMessage(0); + return 0; + default: + break; + } + return DefWindowProcW(hwnd, msg, wparam, lparam); +} + +} // namespace coop diff --git a/host/src/d3d11_window.hpp b/host/src/d3d11_window.hpp new file mode 100644 index 0000000..b9ec024 --- /dev/null +++ b/host/src/d3d11_window.hpp @@ -0,0 +1,68 @@ +// Borderless full-screen D3D11 window. This is the surface Steam Remote Play +// Together captures, so it must be a normal (non-exclusive) top-level window +// that owns a flip-model swap chain. +#pragma once + +#include +#include +#include + +#include + +namespace coop +{ + +class D3D11Window +{ +public: + using RenderCallback = std::function; + + D3D11Window() = default; + ~D3D11Window(); + + D3D11Window(const D3D11Window&) = delete; + D3D11Window& operator=(const D3D11Window&) = delete; + + // Creates the borderless window sized to the primary monitor and brings up + // the D3D11 device + swap chain. Returns false on any failure. + bool create(const wchar_t* title); + + // Pumps the message queue once. Returns false when the window is closing. + bool pump_messages(); + + // Clears the back buffer, invokes render (where ImGui draws), and presents. + void render_frame(const RenderCallback& render); + + [[nodiscard]] HWND hwnd() const + { + return hwnd_; + } + [[nodiscard]] ID3D11Device* device() const + { + return device_.Get(); + } + [[nodiscard]] ID3D11DeviceContext* context() const + { + return context_.Get(); + } + +private: + static LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam); + + bool create_device(); + void create_render_target(); + void release_render_target(); + void handle_resize(UINT width, UINT height); + + HWND hwnd_ = nullptr; + bool resize_pending_ = false; + UINT resize_width_ = 0; + UINT resize_height_ = 0; + + Microsoft::WRL::ComPtr device_; + Microsoft::WRL::ComPtr context_; + Microsoft::WRL::ComPtr swap_chain_; + Microsoft::WRL::ComPtr rtv_; +}; + +} // namespace coop diff --git a/host/src/debug_overlay.cpp b/host/src/debug_overlay.cpp new file mode 100644 index 0000000..0358995 --- /dev/null +++ b/host/src/debug_overlay.cpp @@ -0,0 +1,82 @@ +#include "debug_overlay.hpp" + +#include + +namespace coop +{ + +namespace +{ + +struct ButtonBit +{ + std::uint16_t mask; + const char* label; +}; + +// XINPUT_GAMEPAD_* bit values (kept local so this file needn't include Xinput.h). +constexpr ButtonBit kButtons[] = { + {0x0001, "Up"}, {0x0002, "Down"}, {0x0004, "Left"}, {0x0008, "Right"}, {0x0010, "Start"}, + {0x0020, "Back"}, {0x0040, "LS"}, {0x0080, "RS"}, {0x0100, "LB"}, {0x0200, "RB"}, + {0x1000, "A"}, {0x2000, "B"}, {0x4000, "X"}, {0x8000, "Y"}, +}; + +void draw_pad(int index, const PadInfo& pad) +{ + ImGui::PushID(index); + if (!pad.connected) + { + ImGui::TextDisabled("Slot %d: disconnected", index); + ImGui::PopID(); + return; + } + + ImGui::Text("Slot %d [%s]", index, pad.source.c_str()); + ImGui::Text("LT %3u RT %3u", pad.state.left_trigger, pad.state.right_trigger); + ImGui::Text("L (%6d, %6d) R (%6d, %6d)", pad.state.thumb_lx, pad.state.thumb_ly, pad.state.thumb_rx, + pad.state.thumb_ry); + + bool first = true; + ImGui::TextUnformatted("Buttons: "); + for (const ButtonBit& b : kButtons) + { + if ((pad.state.buttons & b.mask) != 0) + { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "%s%s", first ? "" : ", ", b.label); + first = false; + } + } + if (first) + { + ImGui::SameLine(); + ImGui::TextDisabled("(none)"); + } + ImGui::Separator(); + ImGui::PopID(); +} + +} // namespace + +void draw_debug_overlay(const InputSource& input) +{ + ImGui::SetNextWindowPos(ImVec2(24, 24), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(420, 0), ImGuiCond_FirstUseEver); + ImGui::Begin("CoopAllTheThings - Phase 0 spike"); + + ImGui::Text("Input backend: %s", input.name()); + ImGui::Text("%.1f FPS (%.2f ms)", ImGui::GetIO().Framerate, 1000.0f / ImGui::GetIO().Framerate); + ImGui::TextDisabled("This window is what Remote Play Together captures."); + ImGui::TextDisabled("Press Esc to quit."); + ImGui::Separator(); + + const auto& pads = input.pads(); + for (int i = 0; i < static_cast(pads.size()); ++i) + { + draw_pad(i, pads[i]); + } + + ImGui::End(); +} + +} // namespace coop diff --git a/host/src/debug_overlay.hpp b/host/src/debug_overlay.hpp new file mode 100644 index 0000000..35c6068 --- /dev/null +++ b/host/src/debug_overlay.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include "input/input_source.hpp" + +namespace coop +{ + +// Phase 0 overlay: confirms the host is alive and, crucially, shows which +// controllers are visible -- this is how we verify Remote Play Together is +// routing a guest's gamepad into our window. +void draw_debug_overlay(const InputSource& input); + +} // namespace coop diff --git a/host/src/imgui_layer.cpp b/host/src/imgui_layer.cpp new file mode 100644 index 0000000..4206962 --- /dev/null +++ b/host/src/imgui_layer.cpp @@ -0,0 +1,54 @@ +#include "imgui_layer.hpp" + +#include +#include +#include + +namespace coop +{ + +ImGuiLayer::~ImGuiLayer() +{ + if (initialized_) + { + ImGui_ImplDX11_Shutdown(); + ImGui_ImplWin32_Shutdown(); + ImGui::DestroyContext(); + initialized_ = false; + } +} + +bool ImGuiLayer::init(HWND hwnd, ID3D11Device* device, ID3D11DeviceContext* context) +{ + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + io.IniFilename = nullptr; // don't litter the cwd with imgui.ini during the spike + ImGui::StyleColorsDark(); + + if (!ImGui_ImplWin32_Init(hwnd)) + { + return false; + } + if (!ImGui_ImplDX11_Init(device, context)) + { + return false; + } + initialized_ = true; + return true; +} + +void ImGuiLayer::begin_frame() +{ + ImGui_ImplDX11_NewFrame(); + ImGui_ImplWin32_NewFrame(); + ImGui::NewFrame(); +} + +void ImGuiLayer::end_frame() +{ + ImGui::Render(); + ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData()); +} + +} // namespace coop diff --git a/host/src/imgui_layer.hpp b/host/src/imgui_layer.hpp new file mode 100644 index 0000000..7378fc6 --- /dev/null +++ b/host/src/imgui_layer.hpp @@ -0,0 +1,27 @@ +// Owns ImGui setup/teardown for the Win32 + DX11 backends. +#pragma once + +#include +#include + +namespace coop +{ + +class ImGuiLayer +{ +public: + ImGuiLayer() = default; + ~ImGuiLayer(); + + ImGuiLayer(const ImGuiLayer&) = delete; + ImGuiLayer& operator=(const ImGuiLayer&) = delete; + + bool init(HWND hwnd, ID3D11Device* device, ID3D11DeviceContext* context); + void begin_frame(); + void end_frame(); + +private: + bool initialized_ = false; +}; + +} // namespace coop diff --git a/host/src/input/input_source.hpp b/host/src/input/input_source.hpp new file mode 100644 index 0000000..46638c7 --- /dev/null +++ b/host/src/input/input_source.hpp @@ -0,0 +1,39 @@ +// Abstraction over "where controller input comes from" on the host side. +// +// Phase 0/1 use XInputSource: Remote Play Together delivers each guest's gamepad +// to the focused window (our host) as a virtual XInput controller, so reading +// XInput is enough to see guests. A future SteamInputSource can implement this +// same interface for cleaner per-guest handles once the Steamworks SDK is wired. +#pragma once + +#include +#include + +#include "coop/protocol.hpp" + +namespace coop +{ + +struct PadInfo +{ + bool connected = false; + CoopPadState state = {}; + std::string source; // human-readable label for the debug overlay +}; + +class InputSource +{ +public: + virtual ~InputSource() = default; + + // Name of the backend, shown in the overlay. + [[nodiscard]] virtual const char* name() const = 0; + + // Refresh all pad slots; call once per frame. + virtual void poll() = 0; + + // Latest snapshot of every slot (indexed 0..kMaxPads-1). + [[nodiscard]] virtual const std::array& pads() const = 0; +}; + +} // namespace coop diff --git a/host/src/input/xinput_source.cpp b/host/src/input/xinput_source.cpp new file mode 100644 index 0000000..03ea2ab --- /dev/null +++ b/host/src/input/xinput_source.cpp @@ -0,0 +1,42 @@ +#include "input/xinput_source.hpp" + +#include +#include + +namespace coop +{ + +void XInputSource::poll() +{ + for (DWORD i = 0; i < kMaxPads; ++i) + { + XINPUT_STATE state = {}; + const DWORD result = XInputGetState(i, &state); + + PadInfo& info = pads_[i]; + if (result == ERROR_SUCCESS) + { + info.connected = true; + info.source = "XInput #" + std::to_string(i); + + // XINPUT_GAMEPAD is laid out identically to the tail of CoopPadState, + // so copy field by field to keep the mapping explicit and safe. + const XINPUT_GAMEPAD& g = state.Gamepad; + info.state.connected = 1; + info.state.packet = state.dwPacketNumber; + info.state.buttons = g.wButtons; + info.state.left_trigger = g.bLeftTrigger; + info.state.right_trigger = g.bRightTrigger; + info.state.thumb_lx = g.sThumbLX; + info.state.thumb_ly = g.sThumbLY; + info.state.thumb_rx = g.sThumbRX; + info.state.thumb_ry = g.sThumbRY; + } + else + { + info = PadInfo{}; + } + } +} + +} // namespace coop diff --git a/host/src/input/xinput_source.hpp b/host/src/input/xinput_source.hpp new file mode 100644 index 0000000..4a670b7 --- /dev/null +++ b/host/src/input/xinput_source.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "input/input_source.hpp" + +namespace coop +{ + +// Reads the four XInput slots. Remote Play Together exposes guest controllers +// here, alongside any controllers physically attached to the host. +class XInputSource final : public InputSource +{ +public: + [[nodiscard]] const char* name() const override + { + return "XInput"; + } + + void poll() override; + + [[nodiscard]] const std::array& pads() const override + { + return pads_; + } + +private: + std::array pads_; +}; + +} // namespace coop diff --git a/host/src/main.cpp b/host/src/main.cpp new file mode 100644 index 0000000..78f0f3e --- /dev/null +++ b/host/src/main.cpp @@ -0,0 +1,58 @@ +// CoopAllTheThings host -- Phase 0 spike. +// +// Brings up the borderless window Steam Remote Play Together will capture and an +// ImGui overlay that lists every controller it can see. The goal of this build +// is to validate the riskiest assumption end-to-end: launch this exe under a +// donor appid (steam -applaunch ), invite a +// friend, and confirm (a) the window streams and (b) the guest's gamepad shows +// up in the overlay. Capture/injection are built on top of this once it holds. + +#include + +#include + +#include "d3d11_window.hpp" +#include "debug_overlay.hpp" +#include "imgui_layer.hpp" +#include "input/xinput_source.hpp" + +namespace +{ + +int run() +{ + coop::D3D11Window window; + if (!window.create(L"CoopAllTheThings")) + { + MessageBoxW(nullptr, L"Failed to create the D3D11 window.", L"CoopAllTheThings", MB_ICONERROR); + return 1; + } + + coop::ImGuiLayer imgui; + if (!imgui.init(window.hwnd(), window.device(), window.context())) + { + MessageBoxW(nullptr, L"Failed to initialize ImGui.", L"CoopAllTheThings", MB_ICONERROR); + return 1; + } + + auto input = std::make_unique(); + + while (window.pump_messages()) + { + input->poll(); + + imgui.begin_frame(); + coop::draw_debug_overlay(*input); + + window.render_frame([&imgui]() { imgui.end_frame(); }); + } + + return 0; +} + +} // namespace + +int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) +{ + return run(); +} diff --git a/third_party/CMakeLists.txt b/third_party/CMakeLists.txt new file mode 100644 index 0000000..0013a1a --- /dev/null +++ b/third_party/CMakeLists.txt @@ -0,0 +1,20 @@ +# Dear ImGui built as a static lib with the Win32 + DX11 backends the host uses. +add_library(imgui STATIC + imgui/imgui.cpp + imgui/imgui_draw.cpp + imgui/imgui_tables.cpp + imgui/imgui_widgets.cpp + imgui/imgui_demo.cpp + imgui/backends/imgui_impl_win32.cpp + imgui/backends/imgui_impl_dx11.cpp) + +target_include_directories(imgui PUBLIC + imgui + imgui/backends) + +# ImGui's own sources are third-party; don't subject them to /W4. +if(MSVC) + target_compile_options(imgui PRIVATE /W0) +endif() + +target_link_libraries(imgui PUBLIC d3d11 dxgi dwmapi) diff --git a/third_party/imgui b/third_party/imgui new file mode 160000 index 0000000..d15966f --- /dev/null +++ b/third_party/imgui @@ -0,0 +1 @@ +Subproject commit d15966ff6cb48adacaae2f6d40230b4194d8ea70 diff --git a/third_party/safetyhook b/third_party/safetyhook new file mode 160000 index 0000000..2f28386 --- /dev/null +++ b/third_party/safetyhook @@ -0,0 +1 @@ +Subproject commit 2f283866189c5c728384ae8b9e7f58c268ae036c