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>
This commit is contained in:
2026-06-18 22:48:31 +02:00
commit cf058aecfa
23 changed files with 1129 additions and 0 deletions

222
host/src/d3d11_window.cpp Normal file
View File

@@ -0,0 +1,222 @@
#include "d3d11_window.hpp"
#include <imgui_impl_win32.h>
// 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<IDXGIDevice> dxgi_device;
if (FAILED(device_.As(&dxgi_device)))
{
return false;
}
ComPtr<IDXGIAdapter> adapter;
if (FAILED(dxgi_device->GetAdapter(adapter.GetAddressOf())))
{
return false;
}
ComPtr<IDXGIFactory2> 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<ID3D11Texture2D> 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<CREATESTRUCTW*>(lparam);
SetWindowLongPtrW(hwnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(create->lpCreateParams));
}
if (ImGui_ImplWin32_WndProcHandler(hwnd, msg, wparam, lparam))
{
return true;
}
auto* self = reinterpret_cast<D3D11Window*>(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

68
host/src/d3d11_window.hpp Normal file
View File

@@ -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 <d3d11.h>
#include <dxgi1_2.h>
#include <wrl/client.h>
#include <functional>
namespace coop
{
class D3D11Window
{
public:
using RenderCallback = std::function<void()>;
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<ID3D11Device> device_;
Microsoft::WRL::ComPtr<ID3D11DeviceContext> context_;
Microsoft::WRL::ComPtr<IDXGISwapChain1> swap_chain_;
Microsoft::WRL::ComPtr<ID3D11RenderTargetView> rtv_;
};
} // namespace coop

View File

@@ -0,0 +1,82 @@
#include "debug_overlay.hpp"
#include <imgui.h>
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<int>(pads.size()); ++i)
{
draw_pad(i, pads[i]);
}
ImGui::End();
}
} // namespace coop

View File

@@ -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

54
host/src/imgui_layer.cpp Normal file
View File

@@ -0,0 +1,54 @@
#include "imgui_layer.hpp"
#include <imgui.h>
#include <imgui_impl_dx11.h>
#include <imgui_impl_win32.h>
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

27
host/src/imgui_layer.hpp Normal file
View File

@@ -0,0 +1,27 @@
// Owns ImGui setup/teardown for the Win32 + DX11 backends.
#pragma once
#include <d3d11.h>
#include <windows.h>
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

View File

@@ -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 <array>
#include <string>
#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<PadInfo, kMaxPads>& pads() const = 0;
};
} // namespace coop

View File

@@ -0,0 +1,42 @@
#include "input/xinput_source.hpp"
#include <windows.h>
#include <xinput.h>
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

View File

@@ -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<PadInfo, kMaxPads>& pads() const override
{
return pads_;
}
private:
std::array<PadInfo, kMaxPads> pads_;
};
} // namespace coop

58
host/src/main.cpp Normal file
View File

@@ -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 <donorAppId> <path-to-host.exe>), 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 <memory>
#include <windows.h>
#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<coop::XInputSource>();
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();
}