Apply clang-format across the whole tree

Run clang-format (the repo's .clang-format: LLVM base, 120 cols, tabs,
Allman functions) over every source file so the tree is formatter-clean.
Whitespace only -- no behavior change; full x64 + x86 suites pass.

Also set SortIncludes: false in .clang-format. Windows include order is
load-bearing (windows.h must precede tlhelp32.h / mmreg.h / xinput.h /
dinput.h; winsock2.h must precede windows.h), and the default
alphabetical sort reorders tlhelp32.h ahead of windows.h -- a build
break. Leaving order alone keeps the manual, correct grouping.
This commit is contained in:
2026-07-12 11:52:53 +02:00
parent c684a15fb9
commit 30eccf749d
155 changed files with 3333 additions and 6171 deletions

View File

@@ -11,14 +11,11 @@
#include "audio/process_loopback_capture.hpp"
#include "coop/audio_correlate.hpp"
namespace coop
{
namespace
{
namespace coop {
namespace {
// Resolve a (possibly EXTENSIBLE) WAVEFORMATEX to scalar channels / bits / tag.
struct ScalarFormat
{
struct ScalarFormat {
unsigned rate = 0;
unsigned channels = 0;
unsigned bits = 0;
@@ -32,15 +29,11 @@ ScalarFormat resolve(const WAVEFORMATEX* wfx)
f.channels = wfx->nChannels;
f.bits = wfx->wBitsPerSample;
f.tag = wfx->wFormatTag;
if (wfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && wfx->cbSize >= 22)
{
if (wfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && wfx->cbSize >= 22) {
const auto* ext = reinterpret_cast<const WAVEFORMATEXTENSIBLE*>(wfx);
if (ext->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT)
{
if (ext->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) {
f.tag = WAVE_FORMAT_IEEE_FLOAT;
}
else if (ext->SubFormat == KSDATAFORMAT_SUBTYPE_PCM)
{
} else if (ext->SubFormat == KSDATAFORMAT_SUBTYPE_PCM) {
f.tag = WAVE_FORMAT_PCM;
}
}
@@ -58,8 +51,7 @@ std::vector<float> to_mono(const std::vector<BYTE>& bytes, const ScalarFormat& f
void drain_ring(AudioRingHeader& ring, std::vector<BYTE>& scratch)
{
while (audio_ring_pop(ring, scratch.data(), static_cast<std::uint32_t>(scratch.size())) > 0)
{
while (audio_ring_pop(ring, scratch.data(), static_cast<std::uint32_t>(scratch.size())) > 0) {
}
}
@@ -70,19 +62,16 @@ ChunkedCapture parse_chunks(const std::vector<BYTE>& raw, unsigned stride)
{
ChunkedCapture cap;
cap.stride = stride;
if (stride == 0)
{
if (stride == 0) {
return cap;
}
std::size_t off = 0;
while (off + sizeof(std::uint32_t) <= raw.size())
{
while (off + sizeof(std::uint32_t) <= raw.size()) {
std::uint32_t count = 0;
std::memcpy(&count, raw.data() + off, sizeof(count));
off += sizeof(count);
const std::size_t payload = static_cast<std::size_t>(count) * stride;
if (count == 0 || off + payload > raw.size())
{
if (count == 0 || off + payload > raw.size()) {
break; // truncated or garbled -> stop
}
cap.counts.push_back(count);
@@ -97,14 +86,12 @@ ChunkedCapture parse_chunks(const std::vector<BYTE>& raw, unsigned stride)
FormatVerification verify_stream_format(DWORD pid, AudioRingHeader* ring, unsigned window_ms, bool recover_layout)
{
FormatVerification result;
if (ring == nullptr)
{
if (ring == nullptr) {
return result;
}
WAVEFORMATEX* dev_wfx = default_render_format();
if (dev_wfx == nullptr)
{
if (dev_wfx == nullptr) {
return result;
}
const ScalarFormat dev = resolve(dev_wfx);
@@ -120,12 +107,10 @@ FormatVerification verify_stream_format(DWORD pid, AudioRingHeader* ring, unsign
ProcessLoopbackCapture loop;
const std::uint32_t loop_block = dev_wfx->nBlockAlign;
if (!loop.start(pid, dev_wfx, [&](const BYTE* data, std::uint32_t frames, bool silent) {
if (!silent && data != nullptr)
{
if (!silent && data != nullptr) {
loop_bytes.insert(loop_bytes.end(), data, data + static_cast<std::size_t>(frames) * loop_block);
}
}))
{
})) {
// Distinguish "couldn't activate process loopback" from "captured fine but didn't correlate":
// without the ground-truth post-mix path there's nothing to correlate against, so bail now
// (don't burn the window capturing only the hook side) and leave the diagnostic visible.
@@ -137,18 +122,15 @@ FormatVerification verify_stream_format(DWORD pid, AudioRingHeader* ring, unsign
// Pull the hook's pre-mix bytes out of the ring across the window.
std::vector<BYTE> hook_bytes;
const DWORD end = GetTickCount() + window_ms;
while (GetTickCount() < end)
{
while (GetTickCount() < end) {
std::uint32_t n = 0;
while ((n = audio_ring_pop(*ring, scratch.data(), static_cast<std::uint32_t>(scratch.size()))) > 0)
{
while ((n = audio_ring_pop(*ring, scratch.data(), static_cast<std::uint32_t>(scratch.size()))) > 0) {
hook_bytes.insert(hook_bytes.end(), scratch.data(), scratch.data() + n);
}
Sleep(10);
}
std::uint32_t n = 0;
while ((n = audio_ring_pop(*ring, scratch.data(), static_cast<std::uint32_t>(scratch.size()))) > 0)
{
while ((n = audio_ring_pop(*ring, scratch.data(), static_cast<std::uint32_t>(scratch.size()))) > 0) {
hook_bytes.insert(hook_bytes.end(), scratch.data(), scratch.data() + n);
}
@@ -166,19 +148,16 @@ FormatVerification verify_stream_format(DWORD pid, AudioRingHeader* ring, unsign
CoTaskMemFree(dev_wfx);
char dbg[2] = {};
if (GetEnvironmentVariableA("COOP_VERIFY_DEBUG", dbg, sizeof(dbg)) > 0 && dbg[0] == '1')
{
if (GetEnvironmentVariableA("COOP_VERIFY_DEBUG", dbg, sizeof(dbg)) > 0 && dbg[0] == '1') {
std::fprintf(stderr, "[verify] dev=%uHz/%uch/%ubit blk=%u chunks=%zu hook_frames=%zu loop=%zu layout=%d\n",
dev.rate, dev.channels, dev.bits, dev_block, cap.counts.size(), hook_frames, loop_mono.size(),
recover_layout ? 1 : 0);
}
if (loop_mono.size() < need || hook_frames < need)
{
if (loop_mono.size() < need || hook_frames < need) {
return result; // not enough non-silent audio captured (game quiet, or stream wasn't a guess)
}
if (recover_layout)
{
if (recover_layout) {
// Recover channels + bit depth too, by trying candidate de-interleavings of the
// (de-padded) hook bytes and keeping whichever (layout, rate) correlates with the loopback.
const FormatCorrelation fc =
@@ -190,9 +169,7 @@ FormatVerification verify_stream_format(DWORD pid, AudioRingHeader* ring, unsign
result.channels = fc.channels;
result.bits = fc.bits;
result.format_tag = fc.tag;
}
else
{
} else {
// Rate only, assuming the hook layout matches the device (common stereo case), so
// the de-padded payload is already clean device-layout audio.
const std::vector<float> hook_mono = to_mono(cap.bytes, dev);

View File

@@ -20,19 +20,17 @@
#include "coop/audio_ring.hpp"
namespace coop
{
namespace coop {
struct FormatVerification
{
bool ok = false; // a confident rate correlation was found
unsigned rate = 0; // recovered true sample rate (Hz)
double score = 0.0; // correlation score of the winning rate, [0,1]
struct FormatVerification {
bool ok = false; // a confident rate correlation was found
unsigned rate = 0; // recovered true sample rate (Hz)
double score = 0.0; // correlation score of the winning rate, [0,1]
bool layout_ok = false; // a confident channels/bit-depth correlation was found (step b)
unsigned channels = 0; // recovered channel count
unsigned bits = 0; // recovered bits per sample
unsigned format_tag = 0; // recovered WAVE_FORMAT_PCM / _IEEE_FLOAT
bool layout_ok = false; // a confident channels/bit-depth correlation was found (step b)
unsigned channels = 0; // recovered channel count
unsigned bits = 0; // recovered bits per sample
unsigned format_tag = 0; // recovered WAVE_FORMAT_PCM / _IEEE_FLOAT
};
// One-shot: co-capture the hook (pre-mix, via the ring's verify_capture tap) and a parallel

View File

@@ -14,15 +14,12 @@
#include "audio/process_loopback_capture.hpp"
#include "audio/render_pacer.hpp"
namespace coop
{
namespace
{
namespace coop {
namespace {
// Single-producer/single-consumer byte FIFO guarded by a mutex (the capture
// thread pushes, the render thread pops). Overflow drops the oldest samples.
struct ByteRing
{
struct ByteRing {
std::mutex mutex;
std::vector<BYTE> buf;
size_t head = 0;
@@ -37,8 +34,7 @@ struct ByteRing
void drop_for(size_t incoming)
{
if (count + incoming > buf.size())
{
if (count + incoming > buf.size()) {
const size_t drop = count + incoming - buf.size();
head = (head + drop) % buf.size();
count -= drop;
@@ -48,10 +44,8 @@ struct ByteRing
void push(const BYTE* data, size_t bytes, bool silent)
{
std::lock_guard<std::mutex> lock(mutex);
if (bytes > buf.size())
{
if (data)
{
if (bytes > buf.size()) {
if (data) {
data += bytes - buf.size();
}
bytes = buf.size();
@@ -59,29 +53,21 @@ struct ByteRing
drop_for(bytes);
const size_t tail = (head + count) % buf.size();
const size_t first = std::min(bytes, buf.size() - tail);
if (silent || !data)
{
if (silent || !data) {
std::memset(&buf[tail], 0, first);
if (bytes > first)
{
if (bytes > first) {
std::memset(&buf[0], 0, bytes - first);
}
}
else
{
} else {
std::memcpy(&buf[tail], data, first);
if (bytes > first)
{
if (bytes > first) {
std::memcpy(&buf[0], data + first, bytes - first);
}
}
count += bytes;
}
size_t available() const
{
return count;
}
size_t available() const { return count; }
// Copy up to `bytes` into `dst`; returns how many bytes were available.
size_t pop(BYTE* dst, size_t bytes)
@@ -90,8 +76,7 @@ struct ByteRing
bytes = std::min(bytes, count);
const size_t first = std::min(bytes, buf.size() - head);
std::memcpy(dst, &buf[head], first);
if (bytes > first)
{
if (bytes > first) {
std::memcpy(dst + first, &buf[0], bytes - first);
}
head = (head + bytes) % buf.size();
@@ -102,8 +87,7 @@ struct ByteRing
// The default render endpoint plus an event-driven render client, shared by the hooked and
// loopback mirror paths. RAII: everything acquired is released on destruction.
struct RenderEndpoint
{
struct RenderEndpoint {
IMMDeviceEnumerator* enumerator = nullptr;
IMMDevice* endpoint = nullptr;
IAudioClient* client = nullptr;
@@ -117,24 +101,19 @@ struct RenderEndpoint
~RenderEndpoint()
{
if (render)
{
if (render) {
render->Release();
}
if (client)
{
if (client) {
client->Release();
}
if (endpoint)
{
if (endpoint) {
endpoint->Release();
}
if (enumerator)
{
if (enumerator) {
enumerator->Release();
}
if (event)
{
if (event) {
CloseHandle(event);
}
}
@@ -144,28 +123,24 @@ struct RenderEndpoint
template <typename Fail>
bool activate(Fail&& fail)
{
HRESULT hr = CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator));
if (FAILED(hr))
{
HRESULT hr = CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator),
reinterpret_cast<void**>(&enumerator));
if (FAILED(hr)) {
fail("CoCreateInstance(MMDeviceEnumerator)", hr);
return false;
}
hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint);
if (FAILED(hr))
{
if (FAILED(hr)) {
fail("GetDefaultAudioEndpoint", hr);
return false;
}
hr = endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, reinterpret_cast<void**>(&client));
if (FAILED(hr))
{
if (FAILED(hr)) {
fail("Activate render client", hr);
return false;
}
event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (!event)
{
if (!event) {
fail("CreateEvent(render)", HRESULT_FROM_WIN32(GetLastError()));
return false;
}
@@ -185,20 +160,17 @@ struct RenderEndpoint
bool wire(Fail&& fail)
{
HRESULT hr = client->SetEventHandle(event);
if (FAILED(hr))
{
if (FAILED(hr)) {
fail("Render SetEventHandle", hr);
return false;
}
hr = client->GetService(__uuidof(IAudioRenderClient), reinterpret_cast<void**>(&render));
if (FAILED(hr))
{
if (FAILED(hr)) {
fail("GetService(RenderClient)", hr);
return false;
}
hr = client->GetBufferSize(&buffer_frames);
if (FAILED(hr))
{
if (FAILED(hr)) {
fail("GetBufferSize", hr);
return false;
}
@@ -246,20 +218,17 @@ void AudioMirror::set_fallback_reason(std::string s)
void AudioMirror::enable_capture(AudioRingHeader* const* rings, bool on)
{
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
if (rings[i] != nullptr)
{
for (unsigned i = 0; i < kMaxAudioStreams; ++i) {
if (rings[i] != nullptr) {
rings[i]->capture_enabled.store(on ? 1u : 0u, std::memory_order_release);
}
}
}
void AudioMirror::request_op(unsigned slot, std::uint32_t kind, std::uint32_t rate, std::uint32_t channels,
std::uint32_t bits, std::uint32_t format_tag)
std::uint32_t bits, std::uint32_t format_tag)
{
if (slot >= kMaxAudioStreams)
{
if (slot >= kMaxAudioStreams) {
return;
}
std::lock_guard<std::mutex> lock(ops_mutex_);
@@ -273,11 +242,9 @@ void AudioMirror::drain_ops()
std::lock_guard<std::mutex> lock(ops_mutex_);
ops.swap(pending_ops_);
}
for (const PendingOp& op : ops)
{
for (const PendingOp& op : ops) {
AudioRingHeader* ring = (op.slot < kMaxAudioStreams) ? session_rings_[op.slot] : nullptr;
if (ring != nullptr)
{
if (ring != nullptr) {
audio_ring_post_op(*ring, op.kind, op.rate, op.channels, op.bits, op.format_tag);
}
}
@@ -286,14 +253,12 @@ void AudioMirror::drain_ops()
bool AudioMirror::start(DWORD pid)
{
stop();
if (!pid)
{
if (!pid) {
set_status("No target process.");
return false;
}
stop_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (!stop_event_)
{
if (!stop_event_) {
set_status("CreateEvent failed.");
return false;
}
@@ -305,21 +270,17 @@ bool AudioMirror::start(DWORD pid)
void AudioMirror::stop()
{
if (stop_event_)
{
if (stop_event_) {
SetEvent(stop_event_);
}
if (thread_.joinable())
{
if (thread_.joinable()) {
thread_.join();
}
if (stop_event_)
{
if (stop_event_) {
CloseHandle(stop_event_);
stop_event_ = nullptr;
}
for (auto& shm : audio_ring_shm_)
{
for (auto& shm : audio_ring_shm_) {
shm.reset();
}
running_.store(false, std::memory_order_release);
@@ -337,18 +298,14 @@ bool AudioMirror::stop_requested() const
bool AudioMirror::wait_for_format(AudioRingHeader* ring, DWORD timeout_ms)
{
const DWORD end = GetTickCount() + timeout_ms;
for (;;)
{
if (audio_ring_format_ready(*ring))
{
for (;;) {
if (audio_ring_format_ready(*ring)) {
return true;
}
if (stop_event_ && WaitForSingleObject(stop_event_, 25) == WAIT_OBJECT_0)
{
if (stop_event_ && WaitForSingleObject(stop_event_, 25) == WAIT_OBJECT_0) {
return false; // stopping
}
if (GetTickCount() >= end)
{
if (GetTickCount() >= end) {
return false; // hook never published a format -> fall back to loopback
}
}
@@ -363,10 +320,8 @@ void AudioMirror::thread_main(DWORD pid)
// live the whole session and promote loopback -> hooked the moment a format appears.
AudioRingHeader* rings[kMaxAudioStreams] = {};
bool created_primary = false;
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
if (audio_ring_shm_[i].create(audio_ring_name(pid, i), audio_ring_total_size(kAudioRingCapacity)))
{
for (unsigned i = 0; i < kMaxAudioStreams; ++i) {
if (audio_ring_shm_[i].create(audio_ring_name(pid, i), audio_ring_total_size(kAudioRingCapacity))) {
rings[i] = audio_ring_shm_[i].as<AudioRingHeader>();
audio_ring_init(*rings[i], kAudioRingCapacity);
created_primary = created_primary || (i == 0);
@@ -374,17 +329,13 @@ void AudioMirror::thread_main(DWORD pid)
session_rings_[i] = rings[i]; // visible to drain_ops on this (audio) thread
}
if (!created_primary)
{
if (!created_primary) {
// Couldn't create the hook's ring -> loopback only (no promote target).
set_fallback_reason("Couldn't create the audio ring; using loopback (echo).");
if (!stop_requested())
{
if (!stop_requested()) {
run_loopback(pid, nullptr);
}
}
else
{
} else {
// Prefer the hooked (no-echo) path. While it isn't ready, run loopback (echo) so
// guests still hear audio, but watch the ring and promote to hooked the instant the
// hook publishes a format. A short wait first catches the fast cases (exact format /
@@ -393,37 +344,29 @@ void AudioMirror::thread_main(DWORD pid)
// that gap and the promote hands off seamlessly.
constexpr DWORD kHookWaitMs = 1200;
bool format_verified = false; // run the two-path correlation verify/correct at most once
for (;;)
{
if (stop_requested())
{
for (;;) {
if (stop_requested()) {
break;
}
set_status("Waiting for render-hook…");
bool watch_for_promote = true;
if (wait_for_format(rings[0], kHookWaitMs))
{
if (wait_for_format(rings[0], kHookWaitMs)) {
set_fallback_reason({}); // hooked path is taking over
const HookedResult r = run_hooked(rings);
if (r == HookedResult::Stopped)
{
if (r == HookedResult::Stopped) {
break; // ran to a clean stop
}
if (r == HookedResult::Reinit)
{
if (r == HookedResult::Reinit) {
continue; // hook re-published (re-measure / override) -> re-read the new format
}
if (stop_requested())
{
if (stop_requested()) {
break;
}
// run_hooked failed to initialize (the game's format isn't renderable here).
// That won't fix itself, so don't bounce back to it -- stay on loopback.
set_fallback_reason("Render-hook format isn't renderable on this endpoint; using loopback (echo).");
watch_for_promote = false;
}
else if (!stop_requested())
{
} else if (!stop_requested()) {
set_fallback_reason(
"Render-hook hasn't published a format yet; using loopback (echo) -- will switch to "
"hooked automatically once it does.");
@@ -433,8 +376,7 @@ void AudioMirror::thread_main(DWORD pid)
// and correct it through the existing override channel -- this hardens the cadence
// method's intermittent pitch-shift. It's hidden inside the measurement gap loopback
// already covers, so exact streams (format published immediately) never pay for it.
if (!format_verified)
{
if (!format_verified) {
format_verified = true;
// recover_layout: correlate the full format (rate AND channels/bit-depth), so a
// game rendering a different layout than the device is corrected too, not just
@@ -442,8 +384,7 @@ void AudioMirror::thread_main(DWORD pid)
// a silent game, or a genuinely ambiguous identical-channel layout).
const FormatVerification fv = verify_stream_format(pid, rings[0], /*window_ms=*/900,
/*recover_layout=*/true);
if (fv.ok)
{
if (fv.ok) {
set_status("Verified render-hook format by correlation.");
audio_ring_post_op(*rings[0], AudioRingOp_Override, fv.rate, fv.channels, fv.bits,
fv.format_tag);
@@ -452,8 +393,7 @@ void AudioMirror::thread_main(DWORD pid)
}
enable_capture(rings, false); // game audible locally so loopback can capture it
if (!run_loopback(pid, watch_for_promote ? rings[0] : nullptr))
{
if (!run_loopback(pid, watch_for_promote ? rings[0] : nullptr)) {
break; // stopped (not a promote)
}
// Promoted: a format appeared -> loop and try the hooked path again.
@@ -461,24 +401,20 @@ void AudioMirror::thread_main(DWORD pid)
}
enable_capture(rings, false);
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
for (unsigned i = 0; i < kMaxAudioStreams; ++i) {
session_rings_[i] = nullptr; // audio thread owns this; cleared before unmapping
}
for (auto& shm : audio_ring_shm_)
{
for (auto& shm : audio_ring_shm_) {
shm.reset();
}
if (running_.load(std::memory_order_acquire))
{
if (running_.load(std::memory_order_acquire)) {
running_.store(false, std::memory_order_release);
set_status("Stopped.");
}
source_.store(Source::None, std::memory_order_relaxed);
if (com_ok)
{
if (com_ok) {
CoUninitialize();
}
}
@@ -501,8 +437,7 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
const unsigned bits = primary->bits;
const unsigned tag = primary->format_tag;
const unsigned block_align = primary->block_align ? primary->block_align : channels * (bits / 8);
if (rate == 0 || channels == 0 || block_align == 0)
{
if (rate == 0 || channels == 0 || block_align == 0) {
enable_capture(rings, false); // let the game play locally again
return HookedResult::Failed;
}
@@ -515,8 +450,7 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
wfx.Format.wBitsPerSample = static_cast<WORD>(bits);
wfx.Format.nBlockAlign = static_cast<WORD>(block_align);
wfx.Format.nAvgBytesPerSec = block_align * rate;
if (channels > 2 || bits > 16)
{
if (channels > 2 || bits > 16) {
wfx.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE;
wfx.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX);
wfx.Samples.wValidBitsPerSample = static_cast<WORD>(bits);
@@ -532,11 +466,8 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
wfx.dwChannelMask = (channels >= 32) ? 0xFFFFFFFFu : ((1u << channels) - 1u);
break;
}
wfx.SubFormat =
(tag == WAVE_FORMAT_IEEE_FLOAT) ? KSDATAFORMAT_SUBTYPE_IEEE_FLOAT : KSDATAFORMAT_SUBTYPE_PCM;
}
else
{
wfx.SubFormat = (tag == WAVE_FORMAT_IEEE_FLOAT) ? KSDATAFORMAT_SUBTYPE_IEEE_FLOAT : KSDATAFORMAT_SUBTYPE_PCM;
} else {
wfx.Format.wFormatTag = static_cast<WORD>(tag ? tag : WAVE_FORMAT_PCM);
wfx.Format.cbSize = 0;
}
@@ -547,22 +478,18 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
HookedResult result = HookedResult::Stopped;
auto fail = [this](const char* step, HRESULT hr) { set_error(step, hr); };
do
{
if (!ep.activate(fail))
{
do {
if (!ep.activate(fail)) {
break;
}
const DWORD flags = AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM |
AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY;
if (FAILED(ep.initialize(fmt, flags)))
{
const DWORD flags = AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM
| AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY;
if (FAILED(ep.initialize(fmt, flags))) {
// The game's format isn't renderable here (rare). Bail to loopback.
break;
}
started = true; // past the point where falling back is clean
if (!ep.wire(fail))
{
if (!ep.wire(fail)) {
break;
}
IAudioClient* render_client = ep.client;
@@ -582,8 +509,7 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
std::vector<BYTE> temp(static_cast<size_t>(render_frames) * frame_bytes);
std::vector<float> acc(static_cast<size_t>(render_frames) * channels);
if (const HRESULT hr = render_client->Start(); FAILED(hr))
{
if (const HRESULT hr = render_client->Start(); FAILED(hr)) {
set_error("Render Start", hr);
break;
}
@@ -595,23 +521,19 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
running_.store(true, std::memory_order_release);
HANDLE waits[2] = {stop_event_, ep.event};
for (;;)
{
for (;;) {
const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 200);
if (w == WAIT_OBJECT_0)
{
if (w == WAIT_OBJECT_0) {
break; // stop requested
}
drain_ops(); // post any queued operator ops (re-measure / override) to the hook
if (primary->format_generation.load(std::memory_order_acquire) != start_gen)
{
if (primary->format_generation.load(std::memory_order_acquire) != start_gen) {
result = HookedResult::Reinit; // hook re-published -> re-read the new format
break;
}
UINT32 padding = 0;
if (FAILED(render_client->GetCurrentPadding(&padding)))
{
if (FAILED(render_client->GetCurrentPadding(&padding))) {
continue;
}
const UINT32 avail = render_frames - padding;
@@ -622,43 +544,35 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
const UINT32 have = static_cast<UINT32>(ring_bytes / frame_bytes);
const UINT32 to_write = pacer.pump(avail, have, padding);
{
if (to_write > 0)
{
if (to_write > 0) {
// Active streams = same format as primary (so they can be summed).
// Streams with a different format are still silenced by the hook (no
// echo) but can't be mixed here without resampling -> skipped.
unsigned active[kMaxAudioStreams];
unsigned n_active = 0;
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
for (unsigned i = 0; i < kMaxAudioStreams; ++i) {
AudioRingHeader* r = rings[i];
if (r == nullptr)
{
if (r == nullptr) {
continue;
}
if (i == 0 || (audio_ring_format_ready(*r) && r->sample_rate == rate &&
r->channels == channels && r->bits == bits && r->format_tag == tag))
{
if (i == 0
|| (audio_ring_format_ready(*r) && r->sample_rate == rate && r->channels == channels
&& r->bits == bits && r->format_tag == tag)) {
active[n_active++] = i;
}
}
BYTE* dst = nullptr;
if (SUCCEEDED(render->GetBuffer(to_write, &dst)))
{
if (SUCCEEDED(render->GetBuffer(to_write, &dst))) {
const std::uint32_t want_bytes = to_write * static_cast<std::uint32_t>(frame_bytes);
if (n_active <= 1 || !mixer_ok)
{
if (n_active <= 1 || !mixer_ok) {
// Single stream (the common case) or an unmixable format:
// passthrough the primary, byte-for-byte (no mixer overhead).
audio_ring_pop(*primary, dst, want_bytes);
}
else
{
} else {
const std::uint32_t samples = to_write * channels;
std::fill(acc.begin(), acc.begin() + samples, 0.0f);
for (unsigned k = 0; k < n_active; ++k)
{
for (unsigned k = 0; k < n_active; ++k) {
std::memset(temp.data(), 0, want_bytes); // zero-fill short reads
audio_ring_pop(*rings[active[k]], temp.data(), want_bytes);
mix_add(acc.data(), temp.data(), samples, tag, bits);
@@ -677,13 +591,11 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
// On a re-init (format changed) keep capturing so the rebuilt render client picks up
// seamlessly; otherwise free the game's local playback (stop / fall back to loopback).
if (result != HookedResult::Reinit)
{
if (result != HookedResult::Reinit) {
enable_capture(rings, false);
}
if (!started)
{
if (!started) {
// Never got a working render client; let the caller try loopback. Capture is
// already disabled above so loopback hears the game.
return HookedResult::Failed;
@@ -701,17 +613,14 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
ProcessLoopbackCapture capture;
auto fail = [this](const char* step, HRESULT hr) { set_error(step, hr); };
do
{
if (!ep.activate(fail))
{
do {
if (!ep.activate(fail)) {
break;
}
// Capture and render share one format (the output endpoint's mix format);
// WASAPI converts the captured process audio into it.
HRESULT hr = ep.client->GetMixFormat(&fmt);
if (FAILED(hr))
{
if (FAILED(hr)) {
fail("GetMixFormat", hr);
break;
}
@@ -719,13 +628,11 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
channels_.store(fmt->nChannels, std::memory_order_relaxed);
hr = ep.initialize(fmt, AUDCLNT_STREAMFLAGS_EVENTCALLBACK);
if (FAILED(hr))
{
if (FAILED(hr)) {
fail("Render Initialize", hr);
break;
}
if (!ep.wire(fail))
{
if (!ep.wire(fail)) {
break;
}
IAudioClient* render_client = ep.client;
@@ -743,14 +650,12 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
// Capture pushes packets straight into the render ring.
if (!capture.start(pid, fmt, [&ring, frame_bytes](const BYTE* data, UINT32 frames, bool silent) {
ring.push(data, static_cast<size_t>(frames) * frame_bytes, silent);
}))
{
})) {
fail("Capture start", E_FAIL);
break;
}
if (FAILED(hr = render_client->Start()))
{
if (FAILED(hr = render_client->Start())) {
fail("Render Start", hr);
break;
}
@@ -762,44 +667,36 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
running_.store(true, std::memory_order_release);
HANDLE waits[2] = {stop_event_, ep.event};
for (;;)
{
for (;;) {
const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 200);
if (w == WAIT_OBJECT_0)
{
if (w == WAIT_OBJECT_0) {
break;
}
if (!capture.running())
{
if (!capture.running()) {
set_status(capture.status());
break;
}
drain_ops(); // operator ops (re-measure / override) reach the hook even on loopback
// Auto-promote: the hook published a format -> hand back so the caller switches
// to the no-echo hooked path (the rings stayed live the whole time).
if (promote_ring != nullptr && audio_ring_format_ready(*promote_ring))
{
if (promote_ring != nullptr && audio_ring_format_ready(*promote_ring)) {
set_status("Render-hook ready -- switching to hooked (no echo)…");
promote = true;
break;
}
UINT32 padding = 0;
if (FAILED(render_client->GetCurrentPadding(&padding)))
{
if (FAILED(render_client->GetCurrentPadding(&padding))) {
continue;
}
const UINT32 avail = render_frames - padding;
buffered_ms_.store(
static_cast<unsigned>(ring.available() / frame_bytes * 1000 / fmt->nSamplesPerSec),
std::memory_order_relaxed);
buffered_ms_.store(static_cast<unsigned>(ring.available() / frame_bytes * 1000 / fmt->nSamplesPerSec),
std::memory_order_relaxed);
const UINT32 have = static_cast<UINT32>(ring.available() / frame_bytes);
const UINT32 to_write = pacer.pump(avail, have, padding);
if (to_write > 0)
{
if (to_write > 0) {
BYTE* dst = nullptr;
if (SUCCEEDED(render->GetBuffer(to_write, &dst)))
{
if (SUCCEEDED(render->GetBuffer(to_write, &dst))) {
ring.pop(dst, static_cast<size_t>(to_write) * frame_bytes);
render->ReleaseBuffer(to_write, 0);
}
@@ -810,8 +707,7 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
} while (false);
capture.stop();
if (fmt)
{
if (fmt) {
CoTaskMemFree(fmt);
}
return promote; // true = hook caught up, caller should switch to hooked

View File

@@ -22,12 +22,10 @@
#include "coop/protocol.hpp" // kMaxAudioStreams
#include "coop/shared_memory.hpp"
namespace coop
{
namespace coop {
class AudioMirror
{
public:
class AudioMirror {
public:
AudioMirror() = default;
~AudioMirror();
@@ -42,49 +40,29 @@ public:
// True once the audio thread is actively mirroring (false while starting or
// after a failure).
[[nodiscard]] bool running() const
{
return running_.load(std::memory_order_acquire);
}
[[nodiscard]] bool running() const { return running_.load(std::memory_order_acquire); }
// The process currently targeted (0 if stopped). Updated synchronously by
// start()/stop() so the UI can detect target changes without races.
[[nodiscard]] DWORD target_pid() const
{
return pid_;
}
[[nodiscard]] DWORD target_pid() const { return pid_; }
[[nodiscard]] unsigned sample_rate() const
{
return sample_rate_.load(std::memory_order_relaxed);
}
[[nodiscard]] unsigned channels() const
{
return channels_.load(std::memory_order_relaxed);
}
[[nodiscard]] unsigned sample_rate() const { return sample_rate_.load(std::memory_order_relaxed); }
[[nodiscard]] unsigned channels() const { return channels_.load(std::memory_order_relaxed); }
// Audio currently buffered between capture and the output device, in ms — a
// health/latency proxy (rises if the consumer can't keep up). 0 when stopped.
[[nodiscard]] unsigned buffered_ms() const
{
return buffered_ms_.load(std::memory_order_relaxed);
}
[[nodiscard]] unsigned buffered_ms() const { return buffered_ms_.load(std::memory_order_relaxed); }
// Which capture path is active, for the UI's source indicator.
enum class Source
{
enum class Source {
None,
Hooked, // shared audio ring from the render-hook (no echo)
Loopback, // WASAPI process loopback (echo)
};
[[nodiscard]] Source source() const
{
return source_.load(std::memory_order_relaxed);
}
[[nodiscard]] Source source() const { return source_.load(std::memory_order_relaxed); }
[[nodiscard]] const char* source_name() const
{
switch (source())
{
switch (source()) {
case Source::Hooked:
return "Hooked"; // echo depends on the format provenance; the panel shows it
case Source::Loopback:
@@ -106,11 +84,10 @@ public:
void request_op(unsigned slot, std::uint32_t kind, std::uint32_t rate = 0, std::uint32_t channels = 0,
std::uint32_t bits = 0, std::uint32_t format_tag = 0);
private:
private:
void thread_main(DWORD pid);
// Outcome of a hooked render session.
enum class HookedResult
{
enum class HookedResult {
Stopped, // clean stop (mirror stopping) -> done
Failed, // setup failed (format not renderable) -> caller falls back to loopback
Reinit, // the hook re-published the format (re-measure/override) -> re-read and retry
@@ -135,13 +112,12 @@ private:
HANDLE stop_event_ = nullptr;
DWORD pid_ = 0;
SharedMemory audio_ring_shm_[kMaxAudioStreams]; // per-stream rings (coop_audio_<pid>[_<i>])
SharedMemory audio_ring_shm_[kMaxAudioStreams]; // per-stream rings (coop_audio_<pid>[_<i>])
AudioRingHeader* session_rings_[kMaxAudioStreams] = {}; // set on the audio thread for the session
// Operator ops queued by request_op (any thread) and applied to the rings on the
// audio thread (which owns the mappings). Guarded by ops_mutex_.
struct PendingOp
{
struct PendingOp {
unsigned slot;
std::uint32_t kind, rate, channels, bits, format_tag;
};

View File

@@ -6,8 +6,7 @@
#include <cmath>
#include <cstdint>
namespace coop
{
namespace coop {
// WAVE_FORMAT_* values used here (kept local to avoid an mmreg.h dependency).
inline constexpr std::uint32_t kWaveFormatPcm = 1;
@@ -31,19 +30,14 @@ inline float soft_clip(float x)
inline void mix_add(float* acc, const std::uint8_t* src, std::uint32_t samples, std::uint32_t format_tag,
std::uint32_t bits)
{
if (format_tag == kWaveFormatFloat && bits == 32)
{
if (format_tag == kWaveFormatFloat && bits == 32) {
const auto* f = reinterpret_cast<const float*>(src);
for (std::uint32_t i = 0; i < samples; ++i)
{
for (std::uint32_t i = 0; i < samples; ++i) {
acc[i] += f[i];
}
}
else if (format_tag == kWaveFormatPcm && bits == 16)
{
} else if (format_tag == kWaveFormatPcm && bits == 16) {
const auto* s = reinterpret_cast<const std::int16_t*>(src);
for (std::uint32_t i = 0; i < samples; ++i)
{
for (std::uint32_t i = 0; i < samples; ++i) {
acc[i] += static_cast<float>(s[i]) / 32768.0f;
}
}
@@ -54,19 +48,14 @@ inline void mix_add(float* acc, const std::uint8_t* src, std::uint32_t samples,
inline void mix_store(std::uint8_t* dst, const float* acc, std::uint32_t samples, std::uint32_t format_tag,
std::uint32_t bits)
{
if (format_tag == kWaveFormatFloat && bits == 32)
{
if (format_tag == kWaveFormatFloat && bits == 32) {
auto* f = reinterpret_cast<float*>(dst);
for (std::uint32_t i = 0; i < samples; ++i)
{
for (std::uint32_t i = 0; i < samples; ++i) {
f[i] = soft_clip(acc[i]);
}
}
else if (format_tag == kWaveFormatPcm && bits == 16)
{
} else if (format_tag == kWaveFormatPcm && bits == 16) {
auto* s = reinterpret_cast<std::int16_t*>(dst);
for (std::uint32_t i = 0; i < samples; ++i)
{
for (std::uint32_t i = 0; i < samples; ++i) {
int v = static_cast<int>(soft_clip(acc[i]) * 32767.0f);
v = v > 32767 ? 32767 : (v < -32768 ? -32768 : v);
s[i] = static_cast<std::int16_t>(v);

View File

@@ -11,14 +11,11 @@
#include "coop/tool_paths.hpp"
#include "util/utf8.hpp"
namespace coop
{
namespace
{
namespace coop {
namespace {
std::wstring to_lower(std::wstring s)
{
for (wchar_t& c : s)
{
for (wchar_t& c : s) {
c = static_cast<wchar_t>(::towlower(c));
}
return s;
@@ -29,8 +26,7 @@ std::wstring to_lower(std::wstring s)
AudioOverrideStore::AudioOverrideStore(std::wstring path) : path_(std::move(path))
{
if (path_.empty())
{
if (path_.empty()) {
path_ = exe_directory() + L"coop_audio_overrides.ini";
}
}
@@ -45,46 +41,38 @@ void AudioOverrideStore::load()
{
map_.clear();
std::ifstream f(path_.c_str());
if (!f)
{
if (!f) {
return;
}
std::string line;
while (std::getline(f, line))
{
while (std::getline(f, line)) {
// "<image> = <rate> <ch> <bits> <pcm|float>"; skip blank lines and # comments.
const std::size_t hash = line.find('#');
if (hash != std::string::npos)
{
if (hash != std::string::npos) {
line.resize(hash);
}
const std::size_t eq = line.find('=');
if (eq == std::string::npos)
{
if (eq == std::string::npos) {
continue;
}
std::string name = line.substr(0, eq);
// trim trailing/leading whitespace from the name
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back())))
{
while (!name.empty() && std::isspace(static_cast<unsigned char>(name.back()))) {
name.pop_back();
}
std::size_t b = 0;
while (b < name.size() && std::isspace(static_cast<unsigned char>(name[b])))
{
while (b < name.size() && std::isspace(static_cast<unsigned char>(name[b]))) {
++b;
}
name = name.substr(b);
if (name.empty())
{
if (name.empty()) {
continue;
}
std::istringstream vs(line.substr(eq + 1));
AudioFormatOverride fmt;
std::string tag;
vs >> fmt.rate >> fmt.channels >> fmt.bits >> tag;
if (!fmt.valid())
{
if (!fmt.valid()) {
continue;
}
fmt.format_tag = (tag == "float") ? WAVE_FORMAT_IEEE_FLOAT : WAVE_FORMAT_PCM;
@@ -95,8 +83,7 @@ void AudioOverrideStore::load()
bool AudioOverrideStore::find(const std::wstring& image_name, AudioFormatOverride& out) const
{
const auto it = map_.find(key_of(image_name));
if (it == map_.end())
{
if (it == map_.end()) {
return false;
}
out = it->second;
@@ -106,8 +93,7 @@ bool AudioOverrideStore::find(const std::wstring& image_name, AudioFormatOverrid
void AudioOverrideStore::set(const std::wstring& image_name, const AudioFormatOverride& fmt, bool* differed)
{
const std::wstring key = key_of(image_name);
if (differed != nullptr)
{
if (differed != nullptr) {
const auto it = map_.find(key);
*differed = (it != map_.end() && it->second != fmt);
}
@@ -118,14 +104,12 @@ void AudioOverrideStore::set(const std::wstring& image_name, const AudioFormatOv
void AudioOverrideStore::save() const
{
std::ofstream f(path_.c_str(), std::ios::trunc);
if (!f)
{
if (!f) {
return;
}
f << "# CoopAllTheThings per-game audio format overrides (auto-managed)\n";
f << "# <image.exe> = <rate> <channels> <bits> <pcm|float>\n";
for (const auto& [name, fmt] : map_)
{
for (const auto& [name, fmt] : map_) {
f << narrow(name) << " = " << fmt.rate << ' ' << fmt.channels << ' ' << fmt.bits << ' '
<< (fmt.format_tag == WAVE_FORMAT_IEEE_FLOAT ? "float" : "pcm") << '\n';
}

View File

@@ -13,33 +13,24 @@
#include <map>
#include <string>
namespace coop
{
namespace coop {
struct AudioFormatOverride
{
struct AudioFormatOverride {
std::uint32_t rate = 0;
std::uint32_t channels = 0;
std::uint32_t bits = 0;
std::uint32_t format_tag = 0; // WAVE_FORMAT_PCM (1) / WAVE_FORMAT_IEEE_FLOAT (3)
[[nodiscard]] bool valid() const
{
return rate != 0 && channels != 0 && bits != 0;
}
[[nodiscard]] bool valid() const { return rate != 0 && channels != 0 && bits != 0; }
bool operator==(const AudioFormatOverride& o) const
{
return rate == o.rate && channels == o.channels && bits == o.bits && format_tag == o.format_tag;
}
bool operator!=(const AudioFormatOverride& o) const
{
return !(*this == o);
}
bool operator!=(const AudioFormatOverride& o) const { return !(*this == o); }
};
class AudioOverrideStore
{
public:
class AudioOverrideStore {
public:
// `path` empty -> default (exe_dir/coop_audio_overrides.ini). Does not load yet.
explicit AudioOverrideStore(std::wstring path = {});
@@ -52,12 +43,9 @@ public:
// existing entry for that game differed from `fmt` (caller warns the operator).
void set(const std::wstring& image_name, const AudioFormatOverride& fmt, bool* differed = nullptr);
[[nodiscard]] const std::wstring& path() const
{
return path_;
}
[[nodiscard]] const std::wstring& path() const { return path_; }
private:
private:
static std::wstring key_of(const std::wstring& image_name); // lowercased basename
void save() const;

View File

@@ -6,16 +6,13 @@
#include <audioclientactivationparams.h>
#include <mmdeviceapi.h>
namespace coop
{
namespace
{
namespace coop {
namespace {
// Completion handler for ActivateAudioInterfaceAsync. The call is async even when
// used synchronously: it signals `done`, and the caller waits on it.
class ActivateHandler : public IActivateAudioInterfaceCompletionHandler
{
public:
class ActivateHandler : public IActivateAudioInterfaceCompletionHandler {
public:
HANDLE done = CreateEventW(nullptr, FALSE, FALSE, nullptr);
HRESULT result = E_FAIL;
IAudioClient* client = nullptr;
@@ -25,16 +22,13 @@ public:
HRESULT activate_hr = E_FAIL;
IUnknown* punk = nullptr;
HRESULT hr = op->GetActivateResult(&activate_hr, &punk);
if (SUCCEEDED(hr))
{
if (SUCCEEDED(hr)) {
hr = activate_hr;
}
if (SUCCEEDED(hr) && punk)
{
if (SUCCEEDED(hr) && punk) {
hr = punk->QueryInterface(__uuidof(IAudioClient), reinterpret_cast<void**>(&client));
}
if (punk)
{
if (punk) {
punk->Release();
}
result = hr;
@@ -44,16 +38,14 @@ public:
STDMETHODIMP QueryInterface(REFIID riid, void** ppv) override
{
if (riid == __uuidof(IUnknown) || riid == __uuidof(IActivateAudioInterfaceCompletionHandler))
{
if (riid == __uuidof(IUnknown) || riid == __uuidof(IActivateAudioInterfaceCompletionHandler)) {
*ppv = static_cast<IActivateAudioInterfaceCompletionHandler*>(this);
AddRef();
return S_OK;
}
// Mark the handler agile; ActivateAudioInterfaceAsync requires an agile
// completion handler and otherwise rejects the call (E_ILLEGAL_METHOD_CALL).
if (riid == __uuidof(IAgileObject))
{
if (riid == __uuidof(IAgileObject)) {
*ppv = static_cast<IUnknown*>(this);
AddRef();
return S_OK;
@@ -61,25 +53,20 @@ public:
*ppv = nullptr;
return E_NOINTERFACE;
}
STDMETHODIMP_(ULONG) AddRef() override
{
return ++ref_;
}
STDMETHODIMP_(ULONG) AddRef() override { return ++ref_; }
STDMETHODIMP_(ULONG) Release() override
{
const ULONG r = --ref_;
if (r == 0)
{
if (r == 0) {
delete this;
}
return r;
}
private:
private:
~ActivateHandler()
{
if (done)
{
if (done) {
CloseHandle(done);
}
}
@@ -100,26 +87,20 @@ HRESULT activate_loopback_client(DWORD pid, IAudioClient** out)
auto* handler = new ActivateHandler();
HRESULT hr = E_FAIL;
if (handler->done)
{
if (handler->done) {
IActivateAudioInterfaceAsyncOperation* op = nullptr;
hr = ActivateAudioInterfaceAsync(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, __uuidof(IAudioClient),
&pv, handler, &op);
if (SUCCEEDED(hr))
{
hr = ActivateAudioInterfaceAsync(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, __uuidof(IAudioClient), &pv, handler,
&op);
if (SUCCEEDED(hr)) {
WaitForSingleObject(handler->done, INFINITE);
hr = handler->result;
if (SUCCEEDED(hr))
{
if (SUCCEEDED(hr)) {
*out = handler->client; // transfer the QueryInterface reference
}
else if (handler->client)
{
} else if (handler->client) {
handler->client->Release();
}
}
if (op)
{
if (op) {
op->Release();
}
}
@@ -132,19 +113,16 @@ HRESULT activate_loopback_client(DWORD pid, IAudioClient** out)
WAVEFORMATEX* default_render_format()
{
IMMDeviceEnumerator* enumerator = nullptr;
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator))))
{
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, __uuidof(IMMDeviceEnumerator),
reinterpret_cast<void**>(&enumerator)))) {
return nullptr;
}
IMMDevice* endpoint = nullptr;
WAVEFORMATEX* fmt = nullptr;
if (SUCCEEDED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint)))
{
if (SUCCEEDED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint))) {
IAudioClient* client = nullptr;
if (SUCCEEDED(endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&client))))
{
if (SUCCEEDED(
endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, reinterpret_cast<void**>(&client)))) {
client->GetMixFormat(&fmt);
client->Release();
}
@@ -174,14 +152,12 @@ void ProcessLoopbackCapture::set_status(std::string s)
bool ProcessLoopbackCapture::start(DWORD pid, const WAVEFORMATEX* format, FrameSink sink)
{
stop();
if (!pid || !format)
{
if (!pid || !format) {
set_status("No target/format.");
return false;
}
stop_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (!stop_event_)
{
if (!stop_event_) {
set_status("CreateEvent failed.");
return false;
}
@@ -192,23 +168,19 @@ bool ProcessLoopbackCapture::start(DWORD pid, const WAVEFORMATEX* format, FrameS
std::memcpy(fmt_copy.data(), format, fmt_copy.size());
set_status("Starting…");
thread_ = std::thread(&ProcessLoopbackCapture::thread_main, this, pid, std::move(fmt_copy),
std::move(sink));
thread_ = std::thread(&ProcessLoopbackCapture::thread_main, this, pid, std::move(fmt_copy), std::move(sink));
return true;
}
void ProcessLoopbackCapture::stop()
{
if (stop_event_)
{
if (stop_event_) {
SetEvent(stop_event_);
}
if (thread_.joinable())
{
if (thread_.joinable()) {
thread_.join();
}
if (stop_event_)
{
if (stop_event_) {
CloseHandle(stop_event_);
stop_event_ = nullptr;
}
@@ -231,18 +203,15 @@ void ProcessLoopbackCapture::thread_main(DWORD pid, std::vector<BYTE> format, Fr
set_status(buf);
};
do
{
do {
HRESULT hr = activate_loopback_client(pid, &client);
if (FAILED(hr))
{
if (FAILED(hr)) {
fail("Process loopback activate", hr);
break;
}
capture_event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (!capture_event)
{
if (!capture_event) {
fail("CreateEvent(capture)", HRESULT_FROM_WIN32(GetLastError()));
break;
}
@@ -250,27 +219,22 @@ void ProcessLoopbackCapture::thread_main(DWORD pid, std::vector<BYTE> format, Fr
// Process loopback requires shared mode, the LOOPBACK + EVENTCALLBACK flags,
// and zero buffer/periodicity (there is no device period to query).
hr = client->Initialize(AUDCLNT_SHAREMODE_SHARED,
AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK, 0, 0,
fmt, nullptr);
if (FAILED(hr))
{
AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK, 0, 0, fmt, nullptr);
if (FAILED(hr)) {
fail("Capture Initialize", hr);
break;
}
hr = client->SetEventHandle(capture_event);
if (FAILED(hr))
{
if (FAILED(hr)) {
fail("Capture SetEventHandle", hr);
break;
}
hr = client->GetService(__uuidof(IAudioCaptureClient), reinterpret_cast<void**>(&capture));
if (FAILED(hr))
{
if (FAILED(hr)) {
fail("GetService(CaptureClient)", hr);
break;
}
if (FAILED(hr = client->Start()))
{
if (FAILED(hr = client->Start())) {
fail("Capture Start", hr);
break;
}
@@ -279,47 +243,38 @@ void ProcessLoopbackCapture::thread_main(DWORD pid, std::vector<BYTE> format, Fr
running_.store(true, std::memory_order_release);
HANDLE waits[2] = {stop_event_, capture_event};
for (;;)
{
for (;;) {
const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 200);
if (w == WAIT_OBJECT_0)
{
if (w == WAIT_OBJECT_0) {
break;
}
UINT32 packet = 0;
while (SUCCEEDED(capture->GetNextPacketSize(&packet)) && packet > 0)
{
while (SUCCEEDED(capture->GetNextPacketSize(&packet)) && packet > 0) {
BYTE* data = nullptr;
UINT32 frames = 0;
DWORD flags = 0;
if (FAILED(capture->GetBuffer(&data, &frames, &flags, nullptr, nullptr)))
{
if (FAILED(capture->GetBuffer(&data, &frames, &flags, nullptr, nullptr))) {
break;
}
const bool silent = (flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0;
frames_captured_.fetch_add(frames, std::memory_order_relaxed);
if (!silent && frames > 0)
{
if (!silent && frames > 0) {
// Count frames that carry any non-zero sample.
const BYTE* p = data;
const BYTE* end = data + static_cast<size_t>(frames) * frame_bytes;
bool any = false;
for (; p < end; ++p)
{
if (*p != 0)
{
for (; p < end; ++p) {
if (*p != 0) {
any = true;
break;
}
}
if (any)
{
if (any) {
nonsilent_frames_.fetch_add(frames, std::memory_order_relaxed);
}
}
if (sink)
{
if (sink) {
sink(data, frames, silent);
}
capture->ReleaseBuffer(frames);
@@ -329,26 +284,21 @@ void ProcessLoopbackCapture::thread_main(DWORD pid, std::vector<BYTE> format, Fr
client->Stop();
} while (false);
if (running_.load(std::memory_order_acquire))
{
if (running_.load(std::memory_order_acquire)) {
running_.store(false, std::memory_order_release);
set_status("Stopped.");
}
if (capture)
{
if (capture) {
capture->Release();
}
if (client)
{
if (client) {
client->Release();
}
if (capture_event)
{
if (capture_event) {
CloseHandle(capture_event);
}
if (com_ok)
{
if (com_ok) {
CoUninitialize();
}
}

View File

@@ -15,16 +15,14 @@
#include <mmreg.h> // WAVEFORMATEX
namespace coop
{
namespace coop {
// Default render endpoint mix format (caller owns the returned pointer; free with
// CoTaskMemFree). Returns nullptr on failure. Requires a COM-initialized thread.
WAVEFORMATEX* default_render_format();
class ProcessLoopbackCapture
{
public:
class ProcessLoopbackCapture {
public:
// Called on the capture thread for each delivered packet. `silent` means the
// engine flagged the packet as silence (data may be undefined).
using FrameSink = std::function<void(const BYTE* data, std::uint32_t frames, bool silent)>;
@@ -41,22 +39,13 @@ public:
bool start(DWORD pid, const WAVEFORMATEX* format, FrameSink sink);
void stop();
[[nodiscard]] bool running() const
{
return running_.load(std::memory_order_acquire);
}
[[nodiscard]] bool running() const { return running_.load(std::memory_order_acquire); }
[[nodiscard]] std::string status() const;
[[nodiscard]] std::uint64_t frames_captured() const
{
return frames_captured_.load(std::memory_order_relaxed);
}
[[nodiscard]] std::uint64_t nonsilent_frames() const
{
return nonsilent_frames_.load(std::memory_order_relaxed);
}
[[nodiscard]] std::uint64_t frames_captured() const { return frames_captured_.load(std::memory_order_relaxed); }
[[nodiscard]] std::uint64_t nonsilent_frames() const { return nonsilent_frames_.load(std::memory_order_relaxed); }
private:
private:
void thread_main(DWORD pid, std::vector<BYTE> format, FrameSink sink);
void set_status(std::string s);

View File

@@ -22,11 +22,9 @@
#include <algorithm>
#include <cstdint>
namespace coop
{
namespace coop {
struct RenderPacer
{
struct RenderPacer {
std::uint32_t prime_frames = 0; // cushion to (re)build before playback resumes
bool primed = false;
@@ -37,28 +35,22 @@ struct RenderPacer
// Returns the frame count to write (0 while still priming or when the ring is empty).
std::uint32_t pump(std::uint32_t avail, std::uint32_t have, std::uint32_t padding)
{
if (!primed && have >= prime_frames)
{
if (!primed && have >= prime_frames) {
primed = true;
}
if (!primed)
{
if (!primed) {
return 0; // still building the initial / post-starvation cushion
}
const std::uint32_t to_write = std::min(avail, have);
// Genuine starvation only: the device emptied and the ring has nothing to give.
// A partial fill (have < avail) is normal jitter and must NOT trigger a re-prime.
if (padding == 0 && have == 0)
{
if (padding == 0 && have == 0) {
primed = false;
}
return to_write;
}
void reset()
{
primed = false;
}
void reset() { primed = false; }
};
} // namespace coop

View File

@@ -10,11 +10,9 @@
#include "ui/app_chrome.hpp"
#include "util/utf8.hpp"
namespace coop
{
namespace coop {
namespace
{
namespace {
const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f);
const ImVec4 kAmber(1.0f, 0.8f, 0.3f, 1.0f);
@@ -22,8 +20,7 @@ const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f);
const char* format_tag_name(std::uint32_t tag)
{
switch (tag)
{
switch (tag) {
case WAVE_FORMAT_PCM:
return "PCM";
case WAVE_FORMAT_IEEE_FLOAT:
@@ -38,8 +35,7 @@ const char* format_tag_name(std::uint32_t tag)
// How the hooked backend learned a stream's format (drives the pitch correctness).
const char* audio_format_state_name(std::uint32_t state)
{
switch (state)
{
switch (state) {
case AudioFormat_Exact:
return "known (from game)";
case AudioFormat_Measuring:
@@ -57,8 +53,7 @@ const char* audio_format_state_name(std::uint32_t state)
ImVec4 audio_format_state_color(std::uint32_t state)
{
switch (state)
{
switch (state) {
case AudioFormat_Exact:
case AudioFormat_Measured:
case AudioFormat_Override:
@@ -81,20 +76,17 @@ std::string image_basename(const std::wstring& image_path)
std::wstring AudioPanel::image_name_from_pid(DWORD pid)
{
if (pid == 0)
{
if (pid == 0) {
return {};
}
HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
if (h == nullptr)
{
if (h == nullptr) {
return {};
}
wchar_t buf[MAX_PATH] = {};
DWORD n = MAX_PATH;
std::wstring name;
if (QueryFullProcessImageNameW(h, 0, buf, &n))
{
if (QueryFullProcessImageNameW(h, 0, buf, &n)) {
name.assign(buf, n);
}
CloseHandle(h);
@@ -110,24 +102,20 @@ void AudioPanel::manage_overrides(const HookStatusView& status, DWORD pid)
override_applied_ = false;
exact_saved_ = false;
}
if (pid == 0 || target_image_.empty() || !mirror_.running() || status.audio_streams_seen == 0)
{
if (pid == 0 || target_image_.empty() || !mirror_.running() || status.audio_streams_seen == 0) {
return;
}
const AudioStreamInfo& s = status.audio_streams[0]; // the primary (mirrored) stream
const std::uint32_t st = s.format_state;
if (st == AudioFormat_Exact)
{
if (st == AudioFormat_Exact) {
// Ground truth: persist it as this game's override (so a later late-attach is fixed).
if (!exact_saved_)
{
if (!exact_saved_) {
exact_saved_ = true;
const AudioFormatOverride fmt{s.sample_rate, s.channels, s.bits, s.format_tag};
bool differed = false;
overrides_.set(target_image_, fmt, &differed);
if (differed && logger_)
{
if (differed && logger_) {
char msg[160];
std::snprintf(msg, sizeof(msg),
"%s: exact format %uHz/%uch/%ubit caught -> replaced a DIFFERING saved override",
@@ -135,19 +123,14 @@ void AudioPanel::manage_overrides(const HookStatusView& status, DWORD pid)
logger_(LogLevel_Warn, msg);
}
}
}
else if (st == AudioFormat_Measuring || st == AudioFormat_Measured || st == AudioFormat_LowConfidence)
{
} else if (st == AudioFormat_Measuring || st == AudioFormat_Measured || st == AudioFormat_LowConfidence) {
// A guessed stream: if we have a saved override for this game, apply it.
if (!override_applied_)
{
if (!override_applied_) {
override_applied_ = true;
AudioFormatOverride ov;
if (overrides_.find(target_image_, ov))
{
if (overrides_.find(target_image_, ov)) {
mirror_.request_op(0, AudioRingOp_Override, ov.rate, ov.channels, ov.bits, ov.format_tag);
if (logger_)
{
if (logger_) {
char msg[160];
std::snprintf(msg, sizeof(msg), "%s: applied saved audio override %uHz/%uch/%ubit",
image_basename(target_image_).c_str(), ov.rate, ov.channels, ov.bits);
@@ -161,12 +144,10 @@ void AudioPanel::manage_overrides(const HookStatusView& status, DWORD pid)
void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
{
DWORD pid = 0;
if (target_ != nullptr && IsWindow(target_))
{
if (target_ != nullptr && IsWindow(target_)) {
GetWindowThreadProcessId(target_, &pid);
}
if (dev_pid_ != 0)
{
if (dev_pid_ != 0) {
pid = dev_pid_; // test harness: a windowless target (e.g. coop_tone) has no HWND
}
const bool have_target = pid != 0;
@@ -175,26 +156,20 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
ImGui::Begin("Audio mirror");
ImGui::BeginDisabled(!have_target);
if (ImGui::Checkbox("Mirror game audio", &enabled_))
{
if (!enabled_)
{
if (ImGui::Checkbox("Mirror game audio", &enabled_)) {
if (!enabled_) {
mirror_.stop();
}
}
ImGui::EndDisabled();
if (!have_target)
{
if (!have_target) {
ImGui::TextDisabled("Inject into a game first (its audio is the source).");
}
// Start when enabled and the target process changes; stop if it disappears.
if (enabled_ && pid != 0 && mirror_.target_pid() != pid)
{
if (enabled_ && pid != 0 && mirror_.target_pid() != pid) {
mirror_.start(pid);
}
else if (enabled_ && pid == 0 && mirror_.target_pid() != 0)
{
} else if (enabled_ && pid == 0 && mirror_.target_pid() != 0) {
mirror_.stop();
}
@@ -214,14 +189,12 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
demo_ ? std::string("render-hook did not publish a format in time; using WASAPI process loopback.")
: mirror_.fallback_reason();
if (running)
{
if (running) {
const bool hooked = src == AudioMirror::Source::Hooked;
ImGui::TextColored(kGreen, "Mirroring %u Hz, %u ch", m_rate, m_ch);
ImGui::Text("Source:");
ImGui::SameLine();
if (hooked)
{
if (hooked) {
// The hooked path only silences (no echo) an EXACT / override format, whose frame
// size is known. A guessed stream is captured but not silenced (silencing a guessed
// buffer could over-write it), so the game stays audible -- an echo. Make that clear.
@@ -229,9 +202,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
const bool no_echo = (st == AudioFormat_Exact || st == AudioFormat_Override);
ImGui::TextColored(no_echo ? kGreen : kAmber, "Hooked (%s)",
no_echo ? "no echo" : "echo -- guessed format");
}
else
{
} else {
ImGui::TextColored(kAmber, "%s", demo_ ? "Loopback (echo)" : mirror_.source_name());
}
@@ -240,26 +211,21 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
// captured post-mix at the device endpoint format, so it's always known-correct.
ImGui::Text("Format:");
ImGui::SameLine();
if (hooked)
{
if (hooked) {
const std::uint32_t st = status.audio_streams[0].format_state; // [0] is the primary
ImGui::TextColored(audio_format_state_color(st), "%s", audio_format_state_name(st));
}
else
{
} else {
ImGui::TextColored(kGreen, "device endpoint (known, post-mix)");
}
ImGui::Text("Buffered: %4u ms", m_buffered);
}
if (!mirror_status.empty())
{
if (!mirror_status.empty()) {
ImGui::TextWrapped("%s", mirror_status.c_str());
}
// Why we're on loopback instead of the no-echo hooked path (empty when hooked). Amber
// because it's a degraded-but-working state that auto-resolves when the hook catches up.
if (!reason.empty())
{
if (!reason.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, kAmber);
ImGui::TextWrapped("Why loopback: %s", reason.c_str());
ImGui::PopStyleColor();
@@ -267,25 +233,18 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
// Only the loopback path leaves the game audible locally (the echo); the
// hooked path silences it, so don't warn there.
if (src == AudioMirror::Source::Loopback)
{
if (src == AudioMirror::Source::Loopback) {
bool audio_hook_on = false;
const std::uint32_t hn =
status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries;
for (std::uint32_t i = 0; i < hn; ++i)
{
if (status.hook_entries[i].subsystem == HookSubsys_Audio && status.hook_entries[i].installed)
{
const std::uint32_t hn = status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries;
for (std::uint32_t i = 0; i < hn; ++i) {
if (status.hook_entries[i].subsystem == HookSubsys_Audio && status.hook_entries[i].installed) {
audio_hook_on = true;
break;
}
}
if (audio_hook_on)
{
if (audio_hook_on) {
ImGui::TextDisabled("Game audio also plays locally (echo).");
}
else
{
} else {
ImGui::TextDisabled("Audio render-hook is off -> loopback (echo). Enable it in the Injection panel.");
}
}
@@ -296,14 +255,12 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
// (debug details) shows each stream's format/provenance/activity.
ImGui::Separator();
ImGui::Text("Render streams: %u", status.audio_streams_seen);
if (status.audio_streams_seen > kMaxAudioStreams)
{
if (status.audio_streams_seen > kMaxAudioStreams) {
ImGui::SameLine();
ImGui::TextDisabled("(showing first %u)", kMaxAudioStreams);
}
if (!debug_details)
{
if (!debug_details) {
record_panel_fit("Audio");
ImGui::End();
return; // the per-stream table below is diagnostic detail
@@ -312,9 +269,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
const std::uint32_t rows = std::min<std::uint32_t>(status.audio_streams_seen, kMaxAudioStreams);
const double now = ImGui::GetTime();
const bool resample = (now - rate_base_time_) >= 0.5; // recompute frames/s ~2x a second
if (rows > 0 &&
ImGui::BeginTable("audio_streams", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit))
{
if (rows > 0 && ImGui::BeginTable("audio_streams", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit)) {
ImGui::TableSetupColumn("#");
ImGui::TableSetupColumn("role");
ImGui::TableSetupColumn("format");
@@ -322,23 +277,19 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
ImGui::TableSetupColumn("frames");
ImGui::TableSetupColumn("live");
ImGui::TableHeadersRow();
for (std::uint32_t i = 0; i < rows; ++i)
{
for (std::uint32_t i = 0; i < rows; ++i) {
const AudioStreamInfo& s = status.audio_streams[i];
// Debounced activity: remember when this stream last advanced, and call it
// live for a short window afterwards so bursty releases don't flicker.
if (s.frames_rendered > prev_frames_[i])
{
if (s.frames_rendered > prev_frames_[i]) {
last_active_[i] = now;
}
prev_frames_[i] = s.frames_rendered;
const bool live = last_active_[i] > 0.0 && (now - last_active_[i]) < 0.4;
if (resample)
{
if (resample) {
const double dt = now - rate_base_time_;
frames_per_s_[i] =
dt > 0.0 ? static_cast<double>(s.frames_rendered - rate_base_frames_[i]) / dt : 0.0;
frames_per_s_[i] = dt > 0.0 ? static_cast<double>(s.frames_rendered - rate_base_frames_[i]) / dt : 0.0;
rate_base_frames_[i] = s.frames_rendered;
}
@@ -346,43 +297,35 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
ImGui::TableNextColumn();
ImGui::Text("%u", i);
ImGui::TableNextColumn();
ImGui::TextColored(s.is_primary ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) : ImVec4(0.7f, 0.7f, 0.7f, 1.0f),
"%s", s.is_primary ? "primary" : "extra");
ImGui::TextColored(s.is_primary ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) : ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s",
s.is_primary ? "primary" : "extra");
ImGui::TableNextColumn();
ImGui::Text("%u Hz %uch %u-bit %s", s.sample_rate, s.channels, s.bits,
format_tag_name(s.format_tag));
ImGui::Text("%u Hz %uch %u-bit %s", s.sample_rate, s.channels, s.bits, format_tag_name(s.format_tag));
ImGui::TableNextColumn();
ImGui::TextColored(audio_format_state_color(s.format_state), "%s",
audio_format_state_name(s.format_state));
ImGui::TextColored(audio_format_state_color(s.format_state), "%s", audio_format_state_name(s.format_state));
ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(s.frames_rendered));
ImGui::TableNextColumn();
if (live)
{
if (live) {
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "live");
ImGui::SameLine();
ImGui::TextDisabled("%6.0f/s", frames_per_s_[i]);
}
else
{
} else {
ImGui::TextDisabled("idle");
}
}
ImGui::EndTable();
}
if (resample)
{
if (resample) {
rate_base_time_ = now;
}
// --- Operator controls: re-measure / override the primary stream's format -----
// For when detection is wrong (re-measure) or unrecoverable (override the channels/
// bit-depth the hook had to assume). Only meaningful while mirroring is active.
if (running)
{
if (running) {
ImGui::SeparatorText("Fix the primary stream (debug)");
if (ImGui::Button("Re-measure rate"))
{
if (ImGui::Button("Re-measure rate")) {
mirror_.request_op(0, AudioRingOp_Remeasure);
}
ImGui::SameLine();
@@ -395,25 +338,26 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
ImGui::InputInt("ch", &ov_channels_, 0, 0);
ImGui::SameLine();
ImGui::SetNextItemWidth(90.0f);
ImGui::Combo("##ovbits", &ov_bits_idx_, "16-bit\0" "32-bit\0");
ImGui::Combo("##ovbits", &ov_bits_idx_,
"16-bit\0"
"32-bit\0");
ImGui::SameLine();
ImGui::SetNextItemWidth(80.0f);
ImGui::Combo("##ovfmt", &ov_fmt_idx_, "PCM\0" "float\0");
ImGui::Combo("##ovfmt", &ov_fmt_idx_,
"PCM\0"
"float\0");
ImGui::SameLine();
if (ImGui::Button("Override"))
{
if (ImGui::Button("Override")) {
ov_rate_ = std::clamp(ov_rate_, 8000, 384000);
ov_channels_ = std::clamp(ov_channels_, 1, 8);
const std::uint32_t bits = ov_bits_idx_ == 0 ? 16u : 32u;
const std::uint32_t tag =
ov_fmt_idx_ == 1 ? static_cast<std::uint32_t>(WAVE_FORMAT_IEEE_FLOAT)
: static_cast<std::uint32_t>(WAVE_FORMAT_PCM);
const std::uint32_t tag = ov_fmt_idx_ == 1 ? static_cast<std::uint32_t>(WAVE_FORMAT_IEEE_FLOAT)
: static_cast<std::uint32_t>(WAVE_FORMAT_PCM);
const AudioFormatOverride fmt{static_cast<std::uint32_t>(ov_rate_),
static_cast<std::uint32_t>(ov_channels_), bits, tag};
mirror_.request_op(0, AudioRingOp_Override, fmt.rate, fmt.channels, fmt.bits, fmt.format_tag);
// Persist it for this game so the correction sticks across launches.
if (!target_image_.empty())
{
if (!target_image_.empty()) {
overrides_.set(target_image_, fmt);
override_applied_ = true; // don't let manage_overrides re-apply an older saved value
}

View File

@@ -13,30 +13,19 @@
#include "audio/audio_overrides.hpp"
#include "ipc/ipc_server.hpp"
namespace coop
{
namespace coop {
class AudioPanel
{
public:
AudioPanel()
{
overrides_.load();
}
class AudioPanel {
public:
AudioPanel() { overrides_.load(); }
// The window whose process audio to mirror (0 if none); typically the
// injected game's HWND.
void set_target(HWND target)
{
target_ = target;
}
void set_target(HWND target) { target_ = target; }
// Wire a sink for host-side log lines (override-overwrite warnings etc.). main
// connects this to the injection panel's Log-window channel.
void set_logger(std::function<void(std::uint32_t, const char*)> logger)
{
logger_ = std::move(logger);
}
void set_logger(std::function<void(std::uint32_t, const char*)> logger) { logger_ = std::move(logger); }
// `status` is the hook's back-channel, for the render-stream view. With
// `debug_details` on, the per-stream table is shown.
@@ -46,50 +35,26 @@ public:
// loopback mirror were running with long status/reason strings -- without a live
// AudioMirror, so the fit test can measure the panel's worst-case size. Never set in
// the shipping host (the render path is identical, just fed synthetic values).
void dev_set_demo(bool on)
{
demo_ = on;
}
void dev_set_demo(bool on) { demo_ = on; }
#ifdef COOP_TEST_HARNESS
// Test-harness hooks (debug builds only): drive the real audio code paths and read
// state back, incl. targeting a windowless process by pid (coop_tone has no window).
void dev_set_enabled(bool on)
{
enabled_ = on;
}
void dev_set_pid(DWORD pid)
{
dev_pid_ = pid;
}
void dev_request_op(unsigned slot, std::uint32_t kind, std::uint32_t rate, std::uint32_t ch,
std::uint32_t bits, std::uint32_t tag)
void dev_set_enabled(bool on) { enabled_ = on; }
void dev_set_pid(DWORD pid) { dev_pid_ = pid; }
void dev_request_op(unsigned slot, std::uint32_t kind, std::uint32_t rate, std::uint32_t ch, std::uint32_t bits,
std::uint32_t tag)
{
mirror_.request_op(slot, kind, rate, ch, bits, tag);
}
[[nodiscard]] bool dev_running() const
{
return mirror_.running();
}
[[nodiscard]] unsigned dev_rate() const
{
return mirror_.sample_rate();
}
[[nodiscard]] unsigned dev_channels() const
{
return mirror_.channels();
}
[[nodiscard]] std::string dev_source() const
{
return mirror_.source_name();
}
[[nodiscard]] std::string dev_reason() const
{
return mirror_.fallback_reason();
}
[[nodiscard]] bool dev_running() const { return mirror_.running(); }
[[nodiscard]] unsigned dev_rate() const { return mirror_.sample_rate(); }
[[nodiscard]] unsigned dev_channels() const { return mirror_.channels(); }
[[nodiscard]] std::string dev_source() const { return mirror_.source_name(); }
[[nodiscard]] std::string dev_reason() const { return mirror_.fallback_reason(); }
#endif
private:
private:
// Auto-apply a stored override over a guessed stream, and auto-save a format the hook
// caught exactly at Initialize (warning if it overwrites a differing stored value).
void manage_overrides(const HookStatusView& status, DWORD pid);
@@ -114,7 +79,7 @@ private:
// flickers. Instead we remember when each stream last advanced and debounce the
// live/idle indicator over a short window, plus a ~2 Hz frames/s estimate.
std::uint64_t prev_frames_[kMaxAudioStreams] = {};
double last_active_[kMaxAudioStreams] = {}; // ImGui time a stream last advanced
double last_active_[kMaxAudioStreams] = {}; // ImGui time a stream last advanced
std::uint64_t rate_base_frames_[kMaxAudioStreams] = {};
double frames_per_s_[kMaxAudioStreams] = {};
double rate_base_time_ = 0.0;

View File

@@ -3,8 +3,7 @@
#include <dxgiformat.h>
namespace coop
{
namespace coop {
// Map an sRGB DXGI format to its plain UNORM sibling (same byte layout / type
// group), leaving non-sRGB formats unchanged.
@@ -19,8 +18,7 @@ namespace coop
// (the producer's sRGB texture -> the host's UNORM copy) is allowed.
inline DXGI_FORMAT srgb_to_unorm(DXGI_FORMAT format)
{
switch (format)
{
switch (format) {
case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB:
return DXGI_FORMAT_R8G8B8A8_UNORM;
case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB:

View File

@@ -6,11 +6,9 @@
using Microsoft::WRL::ComPtr;
namespace coop
{
namespace coop {
namespace
{
namespace {
// Fullscreen triangle generated from SV_VertexID -- no vertex/index buffers
// needed. Samples the source texture across the [0,1] UV range.
@@ -38,10 +36,10 @@ ComPtr<ID3DBlob> compile(const char* entry, const char* target)
{
ComPtr<ID3DBlob> blob;
ComPtr<ID3DBlob> errors;
const HRESULT hr = D3DCompile(kShaderSource, sizeof(kShaderSource) - 1, "frame_renderer", nullptr, nullptr, entry,
target, D3DCOMPILE_OPTIMIZATION_LEVEL3, 0, blob.GetAddressOf(), errors.GetAddressOf());
if (FAILED(hr))
{
const HRESULT hr =
D3DCompile(kShaderSource, sizeof(kShaderSource) - 1, "frame_renderer", nullptr, nullptr, entry, target,
D3DCOMPILE_OPTIMIZATION_LEVEL3, 0, blob.GetAddressOf(), errors.GetAddressOf());
if (FAILED(hr)) {
return nullptr;
}
return blob;
@@ -53,18 +51,15 @@ bool FrameRenderer::init(ID3D11Device* device)
{
ComPtr<ID3DBlob> vs_blob = compile("vs_main", "vs_5_0");
ComPtr<ID3DBlob> ps_blob = compile("ps_main", "ps_5_0");
if (vs_blob == nullptr || ps_blob == nullptr)
{
if (vs_blob == nullptr || ps_blob == nullptr) {
return false;
}
if (FAILED(device->CreateVertexShader(vs_blob->GetBufferPointer(), vs_blob->GetBufferSize(), nullptr,
vs_.GetAddressOf())))
{
vs_.GetAddressOf()))) {
return false;
}
if (FAILED(device->CreatePixelShader(ps_blob->GetBufferPointer(), ps_blob->GetBufferSize(), nullptr,
ps_.GetAddressOf())))
{
ps_.GetAddressOf()))) {
return false;
}
@@ -74,8 +69,7 @@ bool FrameRenderer::init(ID3D11Device* device)
sd.AddressV = D3D11_TEXTURE_ADDRESS_CLAMP;
sd.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP;
sd.ComparisonFunc = D3D11_COMPARISON_NEVER;
if (FAILED(device->CreateSamplerState(&sd, sampler_.GetAddressOf())))
{
if (FAILED(device->CreateSamplerState(&sd, sampler_.GetAddressOf()))) {
return false;
}
return true;
@@ -84,8 +78,7 @@ bool FrameRenderer::init(ID3D11Device* device)
void FrameRenderer::draw(ID3D11DeviceContext* ctx, ID3D11ShaderResourceView* srv, std::uint32_t src_w,
std::uint32_t src_h, std::uint32_t dst_w, std::uint32_t dst_h)
{
if (srv == nullptr || src_w == 0 || src_h == 0 || dst_w == 0 || dst_h == 0)
{
if (srv == nullptr || src_w == 0 || src_h == 0 || dst_w == 0 || dst_h == 0) {
return;
}

View File

@@ -7,12 +7,10 @@
#include <d3d11.h>
#include <wrl/client.h>
namespace coop
{
namespace coop {
class FrameRenderer
{
public:
class FrameRenderer {
public:
bool init(ID3D11Device* device);
// Draws `srv` (a srcW x srcH image) centered and scaled to fit within a
@@ -21,7 +19,7 @@ public:
void draw(ID3D11DeviceContext* ctx, ID3D11ShaderResourceView* srv, std::uint32_t src_w, std::uint32_t src_h,
std::uint32_t dst_w, std::uint32_t dst_h);
private:
private:
Microsoft::WRL::ComPtr<ID3D11VertexShader> vs_;
Microsoft::WRL::ComPtr<ID3D11PixelShader> ps_;
Microsoft::WRL::ComPtr<ID3D11SamplerState> sampler_;

View File

@@ -4,8 +4,7 @@
#include <windows.h> // HRESULT, S_OK, WAIT_ABANDONED, WAIT_TIMEOUT
namespace coop
{
namespace coop {
// True when an IDXGIKeyedMutex::AcquireSync result means we now HOLD the mutex and must copy + then
// release it. S_OK is the normal case. WAIT_ABANDONED is success-with-recovery: a previous owner

View File

@@ -5,13 +5,11 @@
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
namespace coop
{
namespace coop {
bool SharedTextureSource::init(ID3D11Device* device)
{
if (device == nullptr || FAILED(device->QueryInterface(IID_PPV_ARGS(&device_))))
{
if (device == nullptr || FAILED(device->QueryInterface(IID_PPV_ARGS(&device_)))) {
return false;
}
device_->GetImmediateContext(&ctx_);
@@ -40,21 +38,17 @@ bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share)
width_ = height_ = format_ = 0;
pid_ = pid;
if (pid == 0 || share.width == 0 || share.height == 0)
{
if (pid == 0 || share.width == 0 || share.height == 0) {
return false; // the hook hasn't shared a backbuffer yet
}
const std::wstring name = video_share_name(pid);
if (FAILED(device_->OpenSharedResourceByName(name.c_str(),
DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE,
IID_PPV_ARGS(&shared_))) ||
shared_ == nullptr)
{
if (FAILED(device_->OpenSharedResourceByName(name.c_str(), DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE,
IID_PPV_ARGS(&shared_)))
|| shared_ == nullptr) {
return false;
}
if (FAILED(shared_.As(&mutex_)) || mutex_ == nullptr)
{
if (FAILED(shared_.As(&mutex_)) || mutex_ == nullptr) {
shared_.Reset();
return false;
}
@@ -72,14 +66,12 @@ bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share)
desc.SampleDesc.Count = 1;
desc.Usage = D3D11_USAGE_DEFAULT;
desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
if (FAILED(device_->CreateTexture2D(&desc, nullptr, &private_)) || private_ == nullptr)
{
if (FAILED(device_->CreateTexture2D(&desc, nullptr, &private_)) || private_ == nullptr) {
mutex_.Reset();
shared_.Reset();
return false;
}
if (FAILED(device_->CreateShaderResourceView(private_.Get(), nullptr, &srv_)))
{
if (FAILED(device_->CreateShaderResourceView(private_.Get(), nullptr, &srv_))) {
srv_.Reset();
private_.Reset();
mutex_.Reset();
@@ -96,8 +88,7 @@ bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share)
bool SharedTextureSource::map_staging_copy(Microsoft::WRL::ComPtr<ID3D11Texture2D>& staging,
D3D11_MAPPED_SUBRESOURCE& map, D3D11_TEXTURE2D_DESC& desc)
{
if (private_ == nullptr || ctx_ == nullptr || device_ == nullptr)
{
if (private_ == nullptr || ctx_ == nullptr || device_ == nullptr) {
return false;
}
private_->GetDesc(&desc);
@@ -106,8 +97,7 @@ bool SharedTextureSource::map_staging_copy(Microsoft::WRL::ComPtr<ID3D11Texture2
staging_desc.BindFlags = 0;
staging_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
staging_desc.MiscFlags = 0;
if (FAILED(device_->CreateTexture2D(&staging_desc, nullptr, &staging)))
{
if (FAILED(device_->CreateTexture2D(&staging_desc, nullptr, &staging))) {
return false;
}
ctx_->CopyResource(staging.Get(), private_.Get());
@@ -119,15 +109,13 @@ bool SharedTextureSource::read_frame(std::vector<std::uint8_t>& out, std::uint32
Microsoft::WRL::ComPtr<ID3D11Texture2D> staging;
D3D11_MAPPED_SUBRESOURCE map{};
D3D11_TEXTURE2D_DESC desc{};
if (!map_staging_copy(staging, map, desc))
{
if (!map_staging_copy(staging, map, desc)) {
return false;
}
w = desc.Width;
h = desc.Height;
out.resize(static_cast<std::size_t>(w) * h * 4);
for (std::uint32_t y = 0; y < h; ++y)
{
for (std::uint32_t y = 0; y < h; ++y) {
memcpy(out.data() + static_cast<std::size_t>(y) * w * 4,
static_cast<const std::uint8_t*>(map.pData) + static_cast<std::size_t>(y) * map.RowPitch,
static_cast<std::size_t>(w) * 4);
@@ -141,17 +129,15 @@ bool SharedTextureSource::read_pixel(std::uint32_t x, std::uint32_t y, std::uint
Microsoft::WRL::ComPtr<ID3D11Texture2D> staging;
D3D11_MAPPED_SUBRESOURCE map{};
D3D11_TEXTURE2D_DESC desc{};
if (!map_staging_copy(staging, map, desc))
{
if (!map_staging_copy(staging, map, desc)) {
return false;
}
if (x >= desc.Width || y >= desc.Height)
{
if (x >= desc.Width || y >= desc.Height) {
ctx_->Unmap(staging.Get(), 0);
return false;
}
const auto* px = static_cast<const std::uint8_t*>(map.pData) + static_cast<std::size_t>(y) * map.RowPitch +
static_cast<std::size_t>(x) * 4; // R8G8B8A8_UNORM
const auto* px = static_cast<const std::uint8_t*>(map.pData) + static_cast<std::size_t>(y) * map.RowPitch
+ static_cast<std::size_t>(x) * 4; // R8G8B8A8_UNORM
out[0] = px[0];
out[1] = px[1];
out[2] = px[2];
@@ -162,43 +148,36 @@ bool SharedTextureSource::read_pixel(std::uint32_t x, std::uint32_t y, std::uint
bool SharedTextureSource::update(const VideoShareView& share, unsigned long pid)
{
if (device_ == nullptr || pid == 0)
{
if (device_ == nullptr || pid == 0) {
reset();
return false;
}
// (Re)open whenever the target or the published backbuffer geometry changes.
if (pid != pid_ || share.width != width_ || share.height != height_ || share.format != format_)
{
if (!reopen(pid, share))
{
if (pid != pid_ || share.width != width_ || share.height != height_ || share.format != format_) {
if (!reopen(pid, share)) {
return srv_ != nullptr; // couldn't open yet; keep any prior frame
}
last_generation_ = 0; // force a copy of the current frame
}
if (shared_ == nullptr || mutex_ == nullptr)
{
if (shared_ == nullptr || mutex_ == nullptr) {
return srv_ != nullptr;
}
if (share.generation == last_generation_)
{
if (share.generation == last_generation_) {
return srv_ != nullptr; // no new frame; keep showing the last copy
}
// Bounded wait so a stalled producer can't hang the host's render thread. WAIT_ABANDONED (a prior
// owner died holding the mutex -- e.g. a host that crashed and reconnected) counts as acquired:
// recover by copying + releasing rather than skipping, which would hold it forever and freeze.
if (keyed_mutex_acquired(mutex_->AcquireSync(kVideoMutexKey, 8)))
{
if (keyed_mutex_acquired(mutex_->AcquireSync(kVideoMutexKey, 8))) {
ctx_->CopyResource(private_.Get(), shared_.Get());
mutex_->ReleaseSync(kVideoMutexKey);
// Generations between the last copy and this one were published but never shown
// (we only ever copy the newest). last_generation_ == 0 is the first copy after a
// (re)open, where the gap to a large generation is meaningless, so skip it.
if (last_generation_ != 0 && share.generation > last_generation_ + 1)
{
if (last_generation_ != 0 && share.generation > last_generation_ + 1) {
frames_missed_ += share.generation - last_generation_ - 1;
}
last_generation_ = share.generation;

View File

@@ -14,12 +14,10 @@
#include "ipc/ipc_server.hpp"
namespace coop
{
namespace coop {
class SharedTextureSource
{
public:
class SharedTextureSource {
public:
// Binds to the host's device (must support ID3D11Device1). Returns false if not.
bool init(ID3D11Device* device);
@@ -43,30 +41,15 @@ public:
// Returns false if no frame has been copied yet or the readback failed.
bool read_pixel(std::uint32_t x, std::uint32_t y, std::uint8_t out[4]);
[[nodiscard]] ID3D11ShaderResourceView* srv() const
{
return srv_.Get();
}
[[nodiscard]] std::uint32_t width() const
{
return width_;
}
[[nodiscard]] std::uint32_t height() const
{
return height_;
}
[[nodiscard]] std::uint64_t frames_copied() const
{
return frames_copied_;
}
[[nodiscard]] ID3D11ShaderResourceView* srv() const { return srv_.Get(); }
[[nodiscard]] std::uint32_t width() const { return width_; }
[[nodiscard]] std::uint32_t height() const { return height_; }
[[nodiscard]] std::uint64_t frames_copied() const { return frames_copied_; }
// Cumulative published frames the host never displayed because the generation
// advanced by more than one between copies (host render rate < hook publish rate).
[[nodiscard]] std::uint64_t frames_missed() const
{
return frames_missed_;
}
[[nodiscard]] std::uint64_t frames_missed() const { return frames_missed_; }
private:
private:
bool reopen(unsigned long pid, const VideoShareView& share);
// Copy the private texture into a fresh CPU staging texture and map it (shared body of
// read_frame/read_pixel). On success the caller reads via `map` and must Unmap `staging`.

View File

@@ -12,19 +12,16 @@
using Microsoft::WRL::ComPtr;
namespace winrt
{
namespace winrt {
using namespace Windows::Graphics;
using namespace Windows::Graphics::Capture;
using namespace Windows::Graphics::DirectX;
using namespace Windows::Graphics::DirectX::Direct3D11;
} // namespace winrt
namespace coop
{
namespace coop {
namespace
{
namespace {
constexpr auto kPixelFormat = winrt::DirectXPixelFormat::B8G8R8A8UIntNormalized;
@@ -33,8 +30,7 @@ ComPtr<ID3D11Texture2D> texture_from_surface(winrt::IDirect3DSurface const& surf
{
auto access = surface.as<::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>();
ComPtr<ID3D11Texture2D> texture;
if (access)
{
if (access) {
access->GetInterface(__uuidof(ID3D11Texture2D), reinterpret_cast<void**>(texture.GetAddressOf()));
}
return texture;
@@ -50,24 +46,20 @@ WindowCapture::~WindowCapture()
bool WindowCapture::start(HWND target, ID3D11Device* device)
{
stop();
if (target == nullptr || device == nullptr || !IsWindow(target))
{
if (target == nullptr || device == nullptr || !IsWindow(target)) {
return false;
}
try
{
try {
device_ = device;
// Wrap our D3D11 device as the WinRT device the frame pool renders on.
ComPtr<IDXGIDevice> dxgi_device;
if (FAILED(device->QueryInterface(IID_PPV_ARGS(dxgi_device.GetAddressOf()))))
{
if (FAILED(device->QueryInterface(IID_PPV_ARGS(dxgi_device.GetAddressOf())))) {
return false;
}
winrt::com_ptr<::IInspectable> inspectable;
if (FAILED(CreateDirect3D11DeviceFromDXGIDevice(dxgi_device.Get(), inspectable.put())))
{
if (FAILED(CreateDirect3D11DeviceFromDXGIDevice(dxgi_device.Get(), inspectable.put()))) {
return false;
}
winrt_device_ = inspectable.as<winrt::IDirect3DDevice>();
@@ -75,40 +67,30 @@ bool WindowCapture::start(HWND target, ID3D11Device* device)
// Create a capture item for the target window via the interop factory.
auto interop = winrt::get_activation_factory<winrt::GraphicsCaptureItem, ::IGraphicsCaptureItemInterop>();
if (FAILED(interop->CreateForWindow(target, winrt::guid_of<winrt::GraphicsCaptureItem>(),
winrt::put_abi(item_))))
{
winrt::put_abi(item_)))) {
return false;
}
pool_size_ = item_.Size();
frame_pool_ =
winrt::Direct3D11CaptureFramePool::CreateFreeThreaded(winrt_device_, kPixelFormat, 2, pool_size_);
frame_pool_ = winrt::Direct3D11CaptureFramePool::CreateFreeThreaded(winrt_device_, kPixelFormat, 2, pool_size_);
session_ = frame_pool_.CreateCaptureSession(item_);
frame_token_ = frame_pool_.FrameArrived({this, &WindowCapture::on_frame_arrived});
// Best-effort: hide the cursor and the yellow capture border (the border
// API requires a recent Windows build, hence the guard).
try
{
try {
session_.IsCursorCaptureEnabled(false);
} catch (...) {
}
catch (...)
{
}
try
{
try {
session_.IsBorderRequired(false);
}
catch (...)
{
} catch (...) {
}
session_.StartCapture();
target_ = target;
return true;
}
catch (...)
{
} catch (...) {
stop();
return false;
}
@@ -116,18 +98,15 @@ bool WindowCapture::start(HWND target, ID3D11Device* device)
void WindowCapture::stop()
{
if (frame_pool_ != nullptr && frame_token_)
{
if (frame_pool_ != nullptr && frame_token_) {
frame_pool_.FrameArrived(frame_token_);
frame_token_ = {};
}
if (session_ != nullptr)
{
if (session_ != nullptr) {
session_.Close();
session_ = nullptr;
}
if (frame_pool_ != nullptr)
{
if (frame_pool_ != nullptr) {
frame_pool_.Close();
frame_pool_ = nullptr;
}
@@ -152,8 +131,7 @@ void WindowCapture::on_frame_arrived(winrt::Direct3D11CaptureFramePool const& po
auto frame = pool.TryGetNextFrame();
std::lock_guard<std::mutex> lock(mutex_);
++frames_arrived_; // capture-rate metric (this is the WGC delivery cadence)
if (pending_ != nullptr)
{
if (pending_ != nullptr) {
pending_.Close(); // drop the un-consumed previous frame back to the pool
}
pending_ = frame;
@@ -169,15 +147,12 @@ void WindowCapture::draw_latest(FrameRenderer& renderer, ID3D11DeviceContext* ct
pending_ = nullptr;
}
if (frame != nullptr)
{
if (ComPtr<ID3D11Texture2D> src = texture_from_surface(frame.Surface()))
{
if (frame != nullptr) {
if (ComPtr<ID3D11Texture2D> src = texture_from_surface(frame.Surface())) {
D3D11_TEXTURE2D_DESC desc = {};
src->GetDesc(&desc);
if (latest_ == nullptr || desc.Width != width_ || desc.Height != height_)
{
if (latest_ == nullptr || desc.Width != width_ || desc.Height != height_) {
latest_srv_.Reset();
latest_.Reset();
@@ -186,16 +161,12 @@ void WindowCapture::draw_latest(FrameRenderer& renderer, ID3D11DeviceContext* ct
dst.BindFlags = D3D11_BIND_SHADER_RESOURCE;
dst.CPUAccessFlags = 0;
dst.MiscFlags = 0;
if (SUCCEEDED(device_->CreateTexture2D(&dst, nullptr, latest_.GetAddressOf())))
{
if (SUCCEEDED(device_->CreateShaderResourceView(latest_.Get(), nullptr,
latest_srv_.GetAddressOf())))
{
if (SUCCEEDED(device_->CreateTexture2D(&dst, nullptr, latest_.GetAddressOf()))) {
if (SUCCEEDED(
device_->CreateShaderResourceView(latest_.Get(), nullptr, latest_srv_.GetAddressOf()))) {
width_ = desc.Width;
height_ = desc.Height;
}
else
{
} else {
// Drop the texture so the (latest_ == nullptr) guard retries next frame instead
// of leaving a null SRV (a silently black mirror) until the next resize.
latest_.Reset();
@@ -203,8 +174,7 @@ void WindowCapture::draw_latest(FrameRenderer& renderer, ID3D11DeviceContext* ct
}
}
if (latest_ != nullptr)
{
if (latest_ != nullptr) {
ctx->CopyResource(latest_.Get(), src.Get());
}
}
@@ -212,15 +182,13 @@ void WindowCapture::draw_latest(FrameRenderer& renderer, ID3D11DeviceContext* ct
// If the window resized, the capture item changes size; re-fit the pool.
const winrt::SizeInt32 size = item_.Size();
if (size.Width != pool_size_.Width || size.Height != pool_size_.Height)
{
if (size.Width != pool_size_.Width || size.Height != pool_size_.Height) {
pool_size_ = size;
frame_pool_.Recreate(winrt_device_, kPixelFormat, 2, size);
}
}
if (latest_srv_ != nullptr)
{
if (latest_srv_ != nullptr) {
renderer.draw(ctx, latest_srv_.Get(), width_, height_, dst_w, dst_h);
}
}

View File

@@ -14,14 +14,12 @@
#include <winrt/Windows.Graphics.Capture.h>
#include <winrt/Windows.Graphics.DirectX.Direct3D11.h>
namespace coop
{
namespace coop {
class FrameRenderer;
class WindowCapture
{
public:
class WindowCapture {
public:
~WindowCapture();
// Begins capturing `target`. Returns false if WGC is unavailable or the
@@ -29,34 +27,19 @@ public:
bool start(HWND target, ID3D11Device* device);
void stop();
[[nodiscard]] bool running() const
{
return session_ != nullptr;
}
[[nodiscard]] HWND target() const
{
return target_;
}
[[nodiscard]] std::uint32_t frame_width() const
{
return width_;
}
[[nodiscard]] std::uint32_t frame_height() const
{
return height_;
}
[[nodiscard]] bool running() const { return session_ != nullptr; }
[[nodiscard]] HWND target() const { return target_; }
[[nodiscard]] std::uint32_t frame_width() const { return width_; }
[[nodiscard]] std::uint32_t frame_height() const { return height_; }
// Cumulative frames WGC has delivered (for the capture-rate metric).
[[nodiscard]] std::uint64_t frames_arrived() const
{
return frames_arrived_;
}
[[nodiscard]] std::uint64_t frames_arrived() const { return frames_arrived_; }
// Render thread: consume the newest frame (if any) and draw it letterboxed
// into a dst_w x dst_h target via `renderer`.
void draw_latest(FrameRenderer& renderer, ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h);
private:
private:
void on_frame_arrived(winrt::Windows::Graphics::Capture::Direct3D11CaptureFramePool const& pool,
winrt::Windows::Foundation::IInspectable const&);

View File

@@ -4,17 +4,14 @@
#include "injection_panel.hpp"
#include "ui/app_chrome.hpp"
namespace coop
{
namespace coop {
namespace
{
namespace {
const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f);
const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f);
// One colored line for the multi-series perf graph.
struct GraphSeries
{
struct GraphSeries {
const char* name;
const float* values; // oldest -> newest
int count;
@@ -37,16 +34,13 @@ void plot_multiseries(const char* id, const GraphSeries* series, int n_series, f
const float range = (y_max > y_min) ? (y_max - y_min) : 1.0f;
ImVec2 pts[256];
for (int s = 0; s < n_series; ++s)
{
for (int s = 0; s < n_series; ++s) {
const GraphSeries& g = series[s];
if (g.count < 2)
{
if (g.count < 2) {
continue;
}
int cnt = g.count > 256 ? 256 : g.count;
for (int i = 0; i < cnt; ++i)
{
for (int i = 0; i < cnt; ++i) {
const float t = static_cast<float>(i) / static_cast<float>(cnt - 1);
float norm = (g.values[i] - y_min) / range;
norm = norm < 0.0f ? 0.0f : (norm > 1.0f ? 1.0f : norm);
@@ -87,12 +81,10 @@ void CapturePanel::draw_ui(const FrameStats& stats)
const bool have_source = source_ == Source_Hooked ? have_hook : have_wgc_target;
ImGui::BeginDisabled(!have_source);
if (ImGui::Checkbox("Mirror game window", &enabled_) && !enabled_)
{
if (ImGui::Checkbox("Mirror game window", &enabled_) && !enabled_) {
capture_.stop();
shared_.reset();
if (injection_ != nullptr)
{
if (injection_ != nullptr) {
injection_->request_video(false); // stop the in-game Present hook
}
}
@@ -106,12 +98,10 @@ void CapturePanel::draw_ui(const FrameStats& stats)
ImGui::RadioButton("WGC", &source_, Source_Wgc);
ImGui::SameLine();
ImGui::RadioButton("Hooked (Present)", &source_, Source_Hooked);
if (source_ != prev_source)
{
if (source_ != prev_source) {
capture_.stop();
shared_.reset();
if (injection_ != nullptr)
{
if (injection_ != nullptr) {
injection_->request_video(enabled_ && source_ == Source_Hooked);
}
}
@@ -123,61 +113,44 @@ void CapturePanel::draw_ui(const FrameStats& stats)
ImGui::BeginDisabled(source_ != Source_Hooked || !enabled_);
ImGui::Checkbox("Sync flip to game frames", &frame_sync_);
ImGui::EndDisabled();
if (source_ != Source_Hooked)
{
if (source_ != Source_Hooked) {
ImGui::SameLine();
ImGui::TextDisabled("(Hooked only)");
}
else if (ImGui::IsItemHovered())
{
} else if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Present in lockstep with the game instead of vsync.");
}
if (!have_source)
{
ImGui::TextDisabled(source_ == Source_Hooked
? "Inject into a game first (the Present hook is the source)."
: "Inject into a game first (its window is the source).");
if (!have_source) {
ImGui::TextDisabled(source_ == Source_Hooked ? "Inject into a game first (the Present hook is the source)."
: "Inject into a game first (its window is the source).");
}
if (source_ == Source_Wgc)
{
if (source_ == Source_Wgc) {
// Start/restart WGC capture when enabled and the target window changes.
if (enabled_ && have_wgc_target && capture_.target() != target_)
{
if (!capture_.start(target_, device_))
{
if (enabled_ && have_wgc_target && capture_.target() != target_) {
if (!capture_.start(target_, device_)) {
enabled_ = false;
ImGui::TextColored(kRed, "Failed to start capture.");
}
}
if (capture_.running())
{
if (capture_.running()) {
ImGui::TextColored(kGreen, "Capturing %ux%u (WGC)", capture_.frame_width(), capture_.frame_height());
}
}
else // Source_Hooked
} else // Source_Hooked
{
if (enabled_ && injection_ != nullptr)
{
if (enabled_ && injection_ != nullptr) {
// Keep the subsystem requested (a fresh inject may have reset control).
if (!injection_->video_requested())
{
if (!injection_->video_requested()) {
injection_->request_video(true);
}
const VideoShareView share = injection_->video_share();
if (shared_.frames_copied() > 0 && shared_.width() > 0)
{
ImGui::TextColored(kGreen, "Mirroring %ux%u (hooked, %llu frames)", shared_.width(),
shared_.height(), static_cast<unsigned long long>(shared_.frames_copied()));
}
else if (share.present_calls > 0)
{
if (shared_.frames_copied() > 0 && shared_.width() > 0) {
ImGui::TextColored(kGreen, "Mirroring %ux%u (hooked, %llu frames)", shared_.width(), shared_.height(),
static_cast<unsigned long long>(shared_.frames_copied()));
} else if (share.present_calls > 0) {
ImGui::TextColored(kGreen, "Present hooked (%llu calls); opening shared texture...",
static_cast<unsigned long long>(share.present_calls));
}
else
{
} else {
ImGui::TextDisabled("Waiting for hooked frames (the game may not render via DXGI).");
}
}
@@ -192,8 +165,7 @@ void CapturePanel::draw_ui(const FrameStats& stats)
void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
{
if (!enabled_)
{
if (!enabled_) {
return;
}
const double now = ImGui::GetTime();
@@ -202,8 +174,7 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
// thresholds (e.g. 99 -> 100) frame to frame.
ImGui::Text("Tool render: %4.0f FPS (%6.2f ms)", stats.fps(), stats.avg_ms());
if (source_ == Source_Hooked)
{
if (source_ == Source_Hooked) {
const VideoShareView v = injection_ != nullptr ? injection_->video_share() : VideoShareView{};
ImGui::Text("Game present: %5.0f /s", present_rate_.sample(v.present_calls, now));
ImGui::Text("Hook publish: %5.0f /s", capture_rate_.sample(v.generation, now));
@@ -218,8 +189,7 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
disp_skip);
// On each newly published frame, measure now - present_qpc (system-wide clock).
if (v.generation != last_video_gen_ && v.present_qpc != 0 && qpc_freq_ > 0)
{
if (v.generation != last_video_gen_ && v.present_qpc != 0 && qpc_freq_ > 0) {
last_video_gen_ = v.generation;
LARGE_INTEGER now_qpc{};
QueryPerformanceCounter(&now_qpc);
@@ -228,12 +198,10 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
if (ms >= 0.0 && ms < 1000.0) // ignore clock edge cases
{
lat_sum_ += ms;
if (lat_n_ == 0 || ms < lat_wmin_)
{
if (lat_n_ == 0 || ms < lat_wmin_) {
lat_wmin_ = ms;
}
if (ms > lat_wmax_)
{
if (ms > lat_wmax_) {
lat_wmax_ = ms;
}
++lat_n_;
@@ -241,8 +209,7 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
}
if (now - lat_window_start_ >= 1.0) // publish min/avg/max once a second
{
if (lat_n_ > 0)
{
if (lat_n_ > 0) {
lat_avg_ = static_cast<float>(lat_sum_ / lat_n_);
lat_min_ = static_cast<float>(lat_wmin_);
lat_max_ = static_cast<float>(lat_wmax_);
@@ -253,17 +220,12 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
lat_wmax_ = 0.0;
lat_window_start_ = now;
}
if (lat_avg_ > 0.0f)
{
if (lat_avg_ > 0.0f) {
ImGui::Text("Capture->display: avg %6.1f min %6.1f max %6.1f ms", lat_avg_, lat_min_, lat_max_);
}
else
{
} else {
ImGui::TextDisabled("Capture->display latency: measuring...");
}
}
else
{
} else {
ImGui::TextDisabled("Game present: n/a (WGC has no game frame timing)");
ImGui::Text("WGC capture: %5.0f /s", capture_rate_.sample(capture_.frames_arrived(), now));
ImGui::TextDisabled("Latency: n/a (WGC frames aren't game-timestamped)");
@@ -277,14 +239,11 @@ void CapturePanel::sample_graph_series(double now)
const float dt = ImGui::GetIO().DeltaTime;
tool_fps_.push(dt > 0.0f ? 1.0f / dt : 0.0f);
if (source_ == Source_Hooked)
{
if (source_ == Source_Hooked) {
const VideoShareView v = injection_ != nullptr ? injection_->video_share() : VideoShareView{};
game_fps_.push(game_edge_.sample(v.present_calls, now));
hook_fps_.push(hook_edge_.sample(v.generation, now));
}
else
{
} else {
wgc_fps_.push(wgc_edge_.sample(capture_.frames_arrived(), now));
}
last_graph_time_ = now;
@@ -310,8 +269,7 @@ void CapturePanel::draw_perf_graphs(const FrameStats& /*stats*/)
const auto add = [&](const Series& s, const char* name, const ImVec4& col) {
const int c = s.copy(fps[n]);
for (int i = 0; i < c; ++i)
{
for (int i = 0; i < c; ++i) {
ms[n][i] = fps[n][i] > 1.0f ? 1000.0f / fps[n][i] : 0.0f;
}
names[n] = name;
@@ -321,42 +279,34 @@ void CapturePanel::draw_perf_graphs(const FrameStats& /*stats*/)
};
add(tool_fps_, "Tool", col_tool);
if (source_ == Source_Hooked)
{
if (source_ == Source_Hooked) {
add(game_fps_, "Game", col_game);
add(hook_fps_, "Hook", col_hook);
}
else
{
} else {
add(wgc_fps_, "WGC", col_hook);
}
if (counts[0] < 2)
{
if (counts[0] < 2) {
ImGui::TextDisabled("Gathering samples...");
return;
}
// Legend: a colored label + the latest value of each line, so colors map to series.
for (int i = 0; i < n; ++i)
{
for (int i = 0; i < n; ++i) {
ImGui::TextColored(cols[i], "%-4s %4.0f", names[i], counts[i] > 0 ? fps[i][counts[i] - 1] : 0.0f);
if (i + 1 < n)
{
if (i + 1 < n) {
ImGui::SameLine();
}
}
GraphSeries gs[3];
for (int i = 0; i < n; ++i)
{
for (int i = 0; i < n; ++i) {
gs[i] = GraphSeries{names[i], fps[i], counts[i], cols[i]};
}
ImGui::TextDisabled("FPS (0-144)");
plot_multiseries("##fps_multi", gs, n, 0.0f, 144.0f, 56.0f);
for (int i = 0; i < n; ++i)
{
for (int i = 0; i < n; ++i) {
gs[i].values = ms[i];
}
ImGui::TextDisabled("Frametime (0-33 ms)");
@@ -365,23 +315,17 @@ void CapturePanel::draw_perf_graphs(const FrameStats& /*stats*/)
void CapturePanel::render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h)
{
if (!enabled_)
{
if (!enabled_) {
return;
}
if (source_ == Source_Hooked)
{
if (injection_ == nullptr)
{
if (source_ == Source_Hooked) {
if (injection_ == nullptr) {
return;
}
if (shared_.update(injection_->video_share(), injection_->target_pid()) && shared_.srv() != nullptr)
{
if (shared_.update(injection_->video_share(), injection_->target_pid()) && shared_.srv() != nullptr) {
renderer_.draw(ctx, shared_.srv(), shared_.width(), shared_.height(), dst_w, dst_h);
}
}
else if (capture_.running())
{
} else if (capture_.running()) {
capture_.draw_latest(renderer_, ctx, dst_w, dst_h);
}
}

View File

@@ -14,28 +14,20 @@
#include "capture/window_capture.hpp"
#include "ui/app_chrome.hpp"
namespace coop
{
namespace coop {
class InjectionPanel;
class CapturePanel
{
public:
class CapturePanel {
public:
bool init(ID3D11Device* device);
// The window to mirror via WGC (0 if none yet); typically the injected game's HWND.
void set_target(HWND target)
{
target_ = target;
}
void set_target(HWND target) { target_ = target; }
// The injection panel supplies the target pid + Present-hook video channel and
// lets this panel install/remove the video subsystem when the source is Hooked.
void set_injection(InjectionPanel* injection)
{
injection_ = injection;
}
void set_injection(InjectionPanel* injection) { injection_ = injection; }
// `stats` are the host's render frame-timing, drawn as the mirror's
// frametime / FPS graphs (this window is what the mirror renders into).
@@ -46,17 +38,11 @@ public:
// Whether the video mirror is on (mouse forwarding is gated on this, since the
// operator can't aim clicks without seeing the game).
[[nodiscard]] bool mirroring() const
{
return enabled_;
}
[[nodiscard]] bool mirroring() const { return enabled_; }
// True when the active source is the injected Present-hook (client/backbuffer);
// false for WGC (whole-window). Drives the mouse coordinate mapping.
[[nodiscard]] bool source_hooked() const
{
return source_ == Source_Hooked;
}
[[nodiscard]] bool source_hooked() const { return source_ == Source_Hooked; }
// True when the operator asked to pace the tool's flip to the game's published frames
// (only meaningful with the Hooked source while mirroring) AND the target is alive and
@@ -66,9 +52,8 @@ public:
// since it needs the InjectionPanel definition for target liveness.
[[nodiscard]] bool frame_sync_active() const;
private:
enum Source : int
{
private:
enum Source : int {
Source_Wgc = 0, // Windows Graphics Capture
Source_Hooked = 1, // injected Present-hook shared texture
};
@@ -77,15 +62,13 @@ private:
void draw_pipeline_metrics(const FrameStats& stats);
// Turns a monotonic counter into a rate (recomputed ~2x/second).
struct RateTracker
{
struct RateTracker {
std::uint64_t last_count = 0;
double last_time = 0.0;
double rate = 0.0;
double sample(std::uint64_t count, double now)
{
if (now - last_time >= 0.5)
{
if (now - last_time >= 0.5) {
const double dt = now - last_time;
rate = dt > 0.0 ? static_cast<double>(count - last_count) / dt : 0.0;
last_count = count;
@@ -98,25 +81,20 @@ private:
// Turns a monotonic counter into an instantaneous rate the moment it advances (so a
// per-frame graph has real resolution instead of 0.5 s stair-steps); the rate is
// held between advances. Used for the game-present / hook-publish graph series.
struct EdgeRate
{
struct EdgeRate {
std::uint64_t last_count = 0;
double last_time = 0.0;
float fps = 0.0f;
bool primed = false;
float sample(std::uint64_t count, double now)
{
if (!primed)
{
if (!primed) {
last_count = count;
last_time = now;
primed = true;
}
else if (count != last_count)
{
} else if (count != last_count) {
const double dt = now - last_time;
if (dt > 0.0)
{
if (dt > 0.0) {
fps = static_cast<float>(static_cast<double>(count - last_count) / dt);
}
last_count = count;
@@ -127,8 +105,7 @@ private:
};
// Fixed-length rolling history of one FPS series, plotted in the perf graph.
struct Series
{
struct Series {
static constexpr int kCap = 240; // ~2 s at 120 FPS, matches FrameStats
float v[kCap] = {};
int pos = 0;
@@ -137,8 +114,7 @@ private:
{
v[pos] = fps;
pos = (pos + 1) % kCap;
if (count < kCap)
{
if (count < kCap) {
++count;
}
}
@@ -146,16 +122,12 @@ private:
int copy(float* out) const
{
const int start = (pos - count + kCap * 2) % kCap;
for (int i = 0; i < count; ++i)
{
for (int i = 0; i < count; ++i) {
out[i] = v[(start + i) % kCap];
}
return count;
}
float latest() const
{
return count > 0 ? v[(pos - 1 + kCap) % kCap] : 0.0f;
}
float latest() const { return count > 0 ? v[(pos - 1 + kCap) % kCap] : 0.0f; }
};
// Push one sample into each graph series for the current frame/source.
@@ -178,13 +150,13 @@ private:
RateTracker display_skip_rate_; // host-side published frames never displayed
// Per-frame FPS history for the multi-series perf graph (colored per source).
Series tool_fps_; // host render rate
Series game_fps_; // game Present() rate (Hooked)
Series hook_fps_; // hook publish rate (Hooked)
Series wgc_fps_; // WGC frame-arrival rate (WGC)
EdgeRate game_edge_; // present_calls -> instantaneous fps
EdgeRate hook_edge_; // generation -> instantaneous fps
EdgeRate wgc_edge_; // frames_arrived -> instantaneous fps
Series tool_fps_; // host render rate
Series game_fps_; // game Present() rate (Hooked)
Series hook_fps_; // hook publish rate (Hooked)
Series wgc_fps_; // WGC frame-arrival rate (WGC)
EdgeRate game_edge_; // present_calls -> instantaneous fps
EdgeRate hook_edge_; // generation -> instantaneous fps
EdgeRate wgc_edge_; // frames_arrived -> instantaneous fps
double last_graph_time_ = 0.0;
std::uint32_t last_video_gen_ = 0;
long long qpc_freq_ = 0;

View File

@@ -4,41 +4,36 @@
#include "ui/app_chrome.hpp"
namespace coop
{
namespace coop {
namespace
{
namespace {
const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f);
const ImVec4 kGrey(0.7f, 0.7f, 0.7f, 1.0f);
struct ButtonBit
{
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"},
{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, bool debug_details)
{
ImGui::PushID(index);
if (!pad.connected)
{
if (!pad.connected) {
ImGui::TextDisabled("Slot %d: disconnected", index);
ImGui::PopID();
return;
}
ImGui::TextColored(kGreen, "Slot %d [%s]", index, pad.source.c_str());
if (debug_details)
{
if (debug_details) {
// Triggers on the slot line (saves a row); thumbsticks below.
ImGui::SameLine();
ImGui::TextDisabled("LT %3u RT %3u", pad.state.left_trigger, pad.state.right_trigger);
@@ -46,25 +41,21 @@ void draw_pad(int index, const PadInfo& pad, bool debug_details)
bool first = true;
ImGui::TextUnformatted("Buttons: ");
for (const ButtonBit& b : kButtons)
{
if ((pad.state.buttons & b.mask) != 0)
{
for (const ButtonBit& b : kButtons) {
if ((pad.state.buttons & b.mask) != 0) {
ImGui::SameLine();
ImGui::TextColored(kGreen, "%s%s", first ? "" : ", ", b.label);
first = false;
}
}
if (first)
{
if (first) {
ImGui::SameLine();
ImGui::TextDisabled("(none)");
}
if (debug_details)
{
ImGui::Text("L (%6d, %6d) R (%6d, %6d)", pad.state.thumb_lx, pad.state.thumb_ly,
pad.state.thumb_rx, pad.state.thumb_ry);
if (debug_details) {
ImGui::Text("L (%6d, %6d) R (%6d, %6d)", pad.state.thumb_lx, pad.state.thumb_ly, pad.state.thumb_rx,
pad.state.thumb_ry);
}
ImGui::Separator();
ImGui::PopID();
@@ -81,15 +72,13 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
#ifdef COOP_WITH_STEAM
ImGui::Checkbox("Use Steam Input (experimental)", &steam_requested_);
if (steam_requested_)
{
if (steam_requested_) {
ImGui::SameLine();
ImGui::TextColored(steam_active_ ? kGreen : kGrey, steam_active_ ? "(active)" : "(starting...)");
ImGui::TextDisabled("Needs a controller bound to Steam Input for this app; otherwise");
ImGui::TextDisabled("XInput is hidden and no input arrives. Leave off for plain XInput.");
}
if (steam_note_[0] != '\0')
{
if (steam_note_[0] != '\0') {
ImGui::TextColored(kGrey, "%s", steam_note_);
}
#endif
@@ -97,13 +86,11 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
// Synthetic test input (debug aid): drives the game with a non-human pattern so
// forwarding can be proven without a real controller. Only meaningful once the
// XInput hook is attached.
if (debug_details)
{
if (debug_details) {
ImGui::BeginDisabled(!status.attached);
ImGui::Checkbox("Forward synthetic test input", &test_input_);
ImGui::EndDisabled();
if (test_input_)
{
if (test_input_) {
ImGui::SameLine();
ImGui::TextDisabled("(ignores your controller)");
}
@@ -112,15 +99,13 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
// --- Guest pads the host receives from RPT -----------------------------
ImGui::SeparatorText("Incoming (host receives)");
const auto& pads = input.pads;
for (int i = 0; i < static_cast<int>(pads.size()); ++i)
{
for (int i = 0; i < static_cast<int>(pads.size()); ++i) {
draw_pad(i, pads[i], debug_details);
}
// --- What the injected game reads back via the XInput hook --------------
ImGui::SeparatorText("Game polling (hook reports)");
if (!status.attached)
{
if (!status.attached) {
ImGui::TextDisabled("Not injected (no XInput hook).");
record_panel_fit("Controllers");
ImGui::End();
@@ -129,11 +114,9 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
// Convert the cumulative per-slot counters into rates every half second.
const double now = ImGui::GetTime();
if (now - last_sample_time_ >= 0.5)
{
if (now - last_sample_time_ >= 0.5) {
const double dt = now - last_sample_time_;
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
{
for (int i = 0; i < static_cast<int>(kMaxPads); ++i) {
const unsigned long long delta =
status.get_state[i] >= last_state_count_[i] ? status.get_state[i] - last_state_count_[i] : 0;
state_rate_[i] = dt > 0.0 ? static_cast<double>(delta) / dt : 0.0;
@@ -143,16 +126,12 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
}
double total_rate = 0.0;
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
{
for (int i = 0; i < static_cast<int>(kMaxPads); ++i) {
total_rate += state_rate_[i];
}
if (total_rate > 0.0)
{
if (total_rate > 0.0) {
ImGui::TextColored(kGreen, "Game reading controller: %5.0f polls/s", total_rate);
}
else
{
} else {
ImGui::TextColored(kGrey, "Game reading controller: idle");
}
@@ -160,9 +139,7 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
// (what we forwarded vs what the game read back through the hook). A round-trip mismatch
// isolates a tool->game forwarding problem from an input->tool one. Merged into a single
// table so the (debug) controller view stays inside its panel even with every slot busy.
if (debug_details &&
ImGui::BeginTable("slots", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
{
if (debug_details && ImGui::BeginTable("slots", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("Slot");
ImGui::TableSetupColumn("Poll/s");
ImGui::TableSetupColumn("Polls");
@@ -170,20 +147,16 @@ void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& st
ImGui::TableSetupColumn("Game read btn/LX,LY");
ImGui::TableHeadersRow();
const auto& fwd = input.pads;
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
{
for (int i = 0; i < static_cast<int>(kMaxPads); ++i) {
const CoopPadState& f = fwd[i].state;
const CoopPadState& r = status.read_state[i];
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::Text("%d", i);
ImGui::TableNextColumn();
if (state_rate_[i] > 0.0)
{
if (state_rate_[i] > 0.0) {
ImGui::TextColored(kGreen, "%5.0f", state_rate_[i]);
}
else
{
} else {
ImGui::TextDisabled("0");
}
ImGui::TableNextColumn();

View File

@@ -11,12 +11,10 @@
#include "input/input_source.hpp"
#include "ipc/ipc_server.hpp"
namespace coop
{
namespace coop {
class ControllersPanel
{
public:
class ControllersPanel {
public:
// `input` is the input worker's latest snapshot (guest pads + active backend);
// `status` is the hook's back-channel (per-slot poll counters); `debug_details`
// reveals the raw axis values, the per-slot poll-rate table, and the synthetic
@@ -25,10 +23,7 @@ public:
// Whether the operator enabled "Forward synthetic test input" (a controller debug
// aid). The host feeds this to InjectionPanel, which substitutes a synthetic pad.
[[nodiscard]] bool test_input() const
{
return test_input_;
}
[[nodiscard]] bool test_input() const { return test_input_; }
#ifdef COOP_WITH_STEAM
// Whether the operator has opted into Steam Input. It's off by default: simply
@@ -36,14 +31,8 @@ public:
// which hides controllers from XInput unless they're bound to our action set for
// this app -- so it can silently break the (working) XInput path. main reconciles
// this against the actual backend each frame.
[[nodiscard]] bool steam_input_requested() const
{
return steam_requested_;
}
void set_steam_active(bool active)
{
steam_active_ = active;
}
[[nodiscard]] bool steam_input_requested() const { return steam_requested_; }
void set_steam_active(bool active) { steam_active_ = active; }
void on_steam_init_failed()
{
steam_requested_ = false;
@@ -52,7 +41,7 @@ public:
}
#endif
private:
private:
bool test_input_ = false; // "Forward synthetic test input" (debug aid, default off)
// Sampled to turn the hook's cumulative per-slot counters into poll rates.

View File

@@ -9,11 +9,9 @@ extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hwnd, UINT msg
using Microsoft::WRL::ComPtr;
namespace coop
{
namespace coop {
namespace
{
namespace {
constexpr wchar_t kWindowClass[] = L"CoopAllTheThingsWindow";
// Encode a tightly-packed/row-pitched RGBA8 image to a PNG file via WIC. `src` is the
@@ -24,42 +22,34 @@ bool write_rgba8_png(const std::wstring& path, UINT width, UINT height, const BY
{
ComPtr<IWICImagingFactory> factory;
if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(factory.GetAddressOf()))))
{
IID_PPV_ARGS(factory.GetAddressOf())))) {
return false;
}
ComPtr<IWICBitmap> bitmap; // wrap the back-buffer bytes (RGBA, matches the swap chain)
if (FAILED(factory->CreateBitmapFromMemory(width, height, GUID_WICPixelFormat32bppRGBA, row_pitch,
row_pitch * height, const_cast<BYTE*>(src),
bitmap.GetAddressOf())))
{
row_pitch * height, const_cast<BYTE*>(src), bitmap.GetAddressOf()))) {
return false;
}
ComPtr<IWICStream> stream;
if (FAILED(factory->CreateStream(stream.GetAddressOf())) ||
FAILED(stream->InitializeFromFilename(path.c_str(), GENERIC_WRITE)))
{
if (FAILED(factory->CreateStream(stream.GetAddressOf()))
|| FAILED(stream->InitializeFromFilename(path.c_str(), GENERIC_WRITE))) {
return false;
}
ComPtr<IWICBitmapEncoder> encoder;
if (FAILED(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.GetAddressOf())) ||
FAILED(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache)))
{
if (FAILED(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.GetAddressOf()))
|| FAILED(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache))) {
return false;
}
ComPtr<IWICBitmapFrameEncode> frame;
ComPtr<IPropertyBag2> props;
if (FAILED(encoder->CreateNewFrame(frame.GetAddressOf(), props.GetAddressOf())) ||
FAILED(frame->Initialize(props.Get())) || FAILED(frame->SetSize(width, height)))
{
if (FAILED(encoder->CreateNewFrame(frame.GetAddressOf(), props.GetAddressOf()))
|| FAILED(frame->Initialize(props.Get())) || FAILED(frame->SetSize(width, height))) {
return false;
}
// Let the encoder pick its native pixel format; WriteSource converts our RGBA to it.
WICPixelFormatGUID fmt = GUID_WICPixelFormat32bppBGRA;
frame->SetPixelFormat(&fmt);
if (FAILED(frame->WriteSource(bitmap.Get(), nullptr)) || FAILED(frame->Commit()) ||
FAILED(encoder->Commit()))
{
if (FAILED(frame->WriteSource(bitmap.Get(), nullptr)) || FAILED(frame->Commit()) || FAILED(encoder->Commit())) {
return false;
}
return true;
@@ -69,8 +59,7 @@ bool write_rgba8_png(const std::wstring& path, UINT width, UINT height, const BY
D3D11Window::~D3D11Window()
{
release_render_target();
if (hwnd_ != nullptr)
{
if (hwnd_ != nullptr) {
DestroyWindow(hwnd_);
hwnd_ = nullptr;
}
@@ -88,8 +77,7 @@ bool D3D11Window::create(const wchar_t* title)
wc.hInstance = instance;
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
wc.lpszClassName = kWindowClass;
if (RegisterClassExW(&wc) == 0)
{
if (RegisterClassExW(&wc) == 0) {
return false;
}
@@ -100,13 +88,11 @@ bool D3D11Window::create(const wchar_t* title)
const int height = GetSystemMetrics(SM_CYSCREEN);
hwnd_ = CreateWindowExW(0, kWindowClass, title, WS_POPUP, 0, 0, width, height, nullptr, nullptr, instance, this);
if (hwnd_ == nullptr)
{
if (hwnd_ == nullptr) {
return false;
}
if (!create_device())
{
if (!create_device()) {
return false;
}
@@ -136,30 +122,25 @@ bool D3D11Window::create_device()
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())))
{
D3D11_SDK_VERSION, device_.GetAddressOf(), nullptr, context_.GetAddressOf()))) {
return false;
}
ComPtr<IDXGIDevice> dxgi_device;
if (FAILED(device_.As(&dxgi_device)))
{
if (FAILED(device_.As(&dxgi_device))) {
return false;
}
ComPtr<IDXGIAdapter> adapter;
if (FAILED(dxgi_device->GetAdapter(adapter.GetAddressOf())))
{
if (FAILED(dxgi_device->GetAdapter(adapter.GetAddressOf()))) {
return false;
}
ComPtr<IDXGIFactory2> factory;
if (FAILED(adapter->GetParent(IID_PPV_ARGS(factory.GetAddressOf()))))
{
if (FAILED(adapter->GetParent(IID_PPV_ARGS(factory.GetAddressOf())))) {
return false;
}
if (FAILED(factory->CreateSwapChainForHwnd(device_.Get(), hwnd_, &desc, nullptr, nullptr,
swap_chain_.GetAddressOf())))
{
swap_chain_.GetAddressOf()))) {
return false;
}
// Don't let DXGI swallow Alt+Enter into an exclusive-fullscreen transition.
@@ -171,8 +152,7 @@ bool D3D11Window::create_device()
bool D3D11Window::note_device_loss(HRESULT hr)
{
if (hr != DXGI_ERROR_DEVICE_REMOVED && hr != DXGI_ERROR_DEVICE_RESET)
{
if (hr != DXGI_ERROR_DEVICE_REMOVED && hr != DXGI_ERROR_DEVICE_RESET) {
return false;
}
// GetDeviceRemovedReason gives the specific cause (HUNG / driver internal / removed); a plain
@@ -186,10 +166,8 @@ bool D3D11Window::note_device_loss(HRESULT hr)
void D3D11Window::create_render_target()
{
ComPtr<ID3D11Texture2D> back_buffer;
if (SUCCEEDED(swap_chain_->GetBuffer(0, IID_PPV_ARGS(back_buffer.GetAddressOf()))))
{
const HRESULT hr =
device_->CreateRenderTargetView(back_buffer.Get(), nullptr, rtv_.ReleaseAndGetAddressOf());
if (SUCCEEDED(swap_chain_->GetBuffer(0, IID_PPV_ARGS(back_buffer.GetAddressOf())))) {
const HRESULT hr = device_->CreateRenderTargetView(back_buffer.Get(), nullptr, rtv_.ReleaseAndGetAddressOf());
note_device_loss(hr); // a removed device surfaces here too; the render loop checks device_lost()
}
}
@@ -201,14 +179,12 @@ void D3D11Window::release_render_target()
void D3D11Window::handle_resize(UINT width, UINT height)
{
if (swap_chain_ == nullptr || width == 0 || height == 0)
{
if (swap_chain_ == nullptr || width == 0 || height == 0) {
return;
}
release_render_target();
const HRESULT hr = swap_chain_->ResizeBuffers(0, width, height, DXGI_FORMAT_UNKNOWN, 0);
if (note_device_loss(hr))
{
if (note_device_loss(hr)) {
return; // device gone; the render loop will see device_lost() and stop
}
create_render_target();
@@ -217,17 +193,14 @@ void D3D11Window::handle_resize(UINT width, UINT height)
bool D3D11Window::pump_messages()
{
MSG msg;
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
{
if (msg.message == WM_QUIT)
{
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
if (msg.message == WM_QUIT) {
return false;
}
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
if (resize_pending_)
{
if (resize_pending_) {
handle_resize(resize_width_, resize_height_);
resize_pending_ = false;
}
@@ -240,17 +213,14 @@ void D3D11Window::render_frame(const RenderCallback& render, UINT sync_interval)
context_->OMSetRenderTargets(1, rtv_.GetAddressOf(), nullptr);
context_->ClearRenderTargetView(rtv_.Get(), clear);
if (render)
{
if (render) {
render();
}
// Screenshot (F10): capture after the overlay is drawn but before Present -- the
// flip-model back buffer is undefined once presented.
if (!pending_screenshot_.empty())
{
if (save_backbuffer_png(pending_screenshot_))
{
if (!pending_screenshot_.empty()) {
if (save_backbuffer_png(pending_screenshot_)) {
saved_screenshot_ = pending_screenshot_;
}
pending_screenshot_.clear();
@@ -277,8 +247,7 @@ std::wstring D3D11Window::take_screenshot_result()
bool D3D11Window::save_backbuffer_png(const std::wstring& path)
{
ComPtr<ID3D11Texture2D> back;
if (FAILED(swap_chain_->GetBuffer(0, IID_PPV_ARGS(back.GetAddressOf()))))
{
if (FAILED(swap_chain_->GetBuffer(0, IID_PPV_ARGS(back.GetAddressOf())))) {
return false;
}
D3D11_TEXTURE2D_DESC desc{};
@@ -291,44 +260,37 @@ bool D3D11Window::save_backbuffer_png(const std::wstring& path)
staging.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
staging.MiscFlags = 0;
ComPtr<ID3D11Texture2D> cpu;
if (FAILED(device_->CreateTexture2D(&staging, nullptr, cpu.GetAddressOf())))
{
if (FAILED(device_->CreateTexture2D(&staging, nullptr, cpu.GetAddressOf()))) {
return false;
}
context_->CopyResource(cpu.Get(), back.Get());
D3D11_MAPPED_SUBRESOURCE map{};
if (FAILED(context_->Map(cpu.Get(), 0, D3D11_MAP_READ, 0, &map)))
{
if (FAILED(context_->Map(cpu.Get(), 0, D3D11_MAP_READ, 0, &map))) {
return false;
}
// The swap chain is DXGI_FORMAT_R8G8B8A8_UNORM (see create_device), i.e. RGBA bytes.
const bool ok =
write_rgba8_png(path, desc.Width, desc.Height, static_cast<const BYTE*>(map.pData), map.RowPitch);
const bool ok = write_rgba8_png(path, desc.Width, desc.Height, static_cast<const BYTE*>(map.pData), map.RowPitch);
context_->Unmap(cpu.Get(), 0);
return ok;
}
LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
{
if (msg == WM_NCCREATE)
{
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))
{
if (ImGui_ImplWin32_WndProcHandler(hwnd, msg, wparam, lparam)) {
return true;
}
auto* self = reinterpret_cast<D3D11Window*>(GetWindowLongPtrW(hwnd, GWLP_USERDATA));
switch (msg)
{
switch (msg) {
case WM_SIZE:
if (self != nullptr && wparam != SIZE_MINIMIZED)
{
if (self != nullptr && wparam != SIZE_MINIMIZED) {
self->resize_pending_ = true;
self->resize_width_ = LOWORD(lparam);
self->resize_height_ = HIWORD(lparam);
@@ -338,13 +300,10 @@ LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARA
// Per-monitor-v2: the DPI of the display we're on changed. Resize to the rect Windows suggests
// in lparam (its recommended handling; the resulting WM_SIZE repaints the swap chain via the
// deferred-resize path above), then latch the new DPI for the overlay to rescale its font/style.
if (self != nullptr)
{
if (const auto* suggested = reinterpret_cast<const RECT*>(lparam); suggested != nullptr)
{
SetWindowPos(hwnd, nullptr, suggested->left, suggested->top,
suggested->right - suggested->left, suggested->bottom - suggested->top,
SWP_NOZORDER | SWP_NOACTIVATE);
if (self != nullptr) {
if (const auto* suggested = reinterpret_cast<const RECT*>(lparam); suggested != nullptr) {
SetWindowPos(hwnd, nullptr, suggested->left, suggested->top, suggested->right - suggested->left,
suggested->bottom - suggested->top, SWP_NOZORDER | SWP_NOACTIVATE);
}
self->dpi_pending_ = true;
self->pending_dpi_ = HIWORD(wparam); // X and Y DPI are equal; HIWORD is the Y value
@@ -354,8 +313,7 @@ LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARA
// F10 is our screenshot key; ImGui already saw this message (handler runs above),
// so swallow it here to stop DefWindowProc from flicking into Win32 menu mode.
// Alt+F4 (VK_F4) falls through to DefWindowProc so it still closes the window.
if (wparam == VK_F10)
{
if (wparam == VK_F10) {
return 0;
}
break;

View File

@@ -10,12 +10,10 @@
#include <functional>
#include <string>
namespace coop
{
namespace coop {
class D3D11Window
{
public:
class D3D11Window {
public:
using RenderCallback = std::function<void()>;
D3D11Window() = default;
@@ -50,15 +48,9 @@ public:
// True once Present/ResizeBuffers reported DXGI_ERROR_DEVICE_REMOVED/RESET (a host-side TDR,
// driver reset, or GPU hang). The render loop is expected to stop and surface the error rather
// than spin forever on a dead device; full device re-creation is intentionally not attempted.
[[nodiscard]] bool device_lost() const
{
return device_lost_;
}
[[nodiscard]] bool device_lost() const { return device_lost_; }
// The GetDeviceRemovedReason() HRESULT (or the originating error) when device_lost() is true.
[[nodiscard]] HRESULT device_lost_reason() const
{
return device_lost_reason_;
}
[[nodiscard]] HRESULT device_lost_reason() const { return device_lost_reason_; }
// If a WM_DPICHANGED arrived since the last call (the window moved to a different-DPI monitor, or
// the display scale changed at runtime), returns true and writes that monitor's DPI to `dpi`,
@@ -66,8 +58,7 @@ public:
// fonts/style. One-shot, mirroring the deferred-resize handling in pump_messages().
[[nodiscard]] bool take_dpi_change(unsigned& dpi)
{
if (!dpi_pending_)
{
if (!dpi_pending_) {
return false;
}
dpi = pending_dpi_;
@@ -75,20 +66,11 @@ public:
return true;
}
[[nodiscard]] HWND hwnd() const
{
return hwnd_;
}
[[nodiscard]] ID3D11Device* device() const
{
return device_.Get();
}
[[nodiscard]] ID3D11DeviceContext* context() const
{
return context_.Get();
}
[[nodiscard]] HWND hwnd() const { return hwnd_; }
[[nodiscard]] ID3D11Device* device() const { return device_.Get(); }
[[nodiscard]] ID3D11DeviceContext* context() const { return context_.Get(); }
private:
private:
static LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam);
bool create_device();
@@ -106,8 +88,8 @@ private:
bool resize_pending_ = false;
UINT resize_width_ = 0;
UINT resize_height_ = 0;
bool dpi_pending_ = false; // set by WM_DPICHANGED, consumed by take_dpi_change()
unsigned pending_dpi_ = 0; // the monitor DPI reported alongside that WM_DPICHANGED
bool dpi_pending_ = false; // set by WM_DPICHANGED, consumed by take_dpi_change()
unsigned pending_dpi_ = 0; // the monitor DPI reported alongside that WM_DPICHANGED
bool device_lost_ = false;
HRESULT device_lost_reason_ = S_OK;

View File

@@ -11,13 +11,11 @@
#include "ui/app_chrome.hpp"
#include "util/utf8.hpp"
namespace coop
{
namespace coop {
ImGuiLayer::~ImGuiLayer()
{
if (initialized_)
{
if (initialized_) {
ImGui_ImplDX11_Shutdown();
ImGui_ImplWin32_Shutdown();
ImGui::DestroyContext();
@@ -42,12 +40,10 @@ bool ImGuiLayer::init(HWND hwnd, ID3D11Device* device, ID3D11DeviceContext* cont
io.IniFilename = ini_path_.c_str();
set_layout_persisted(had_layout);
if (!ImGui_ImplWin32_Init(hwnd))
{
if (!ImGui_ImplWin32_Init(hwnd)) {
return false;
}
if (!ImGui_ImplDX11_Init(device, context))
{
if (!ImGui_ImplDX11_Init(device, context)) {
return false;
}
initialized_ = true;
@@ -78,8 +74,7 @@ void ImGuiLayer::apply_dpi(unsigned dpi)
// Drop the DX11 backend's cached font texture so it rebuilds from the new atlas next frame. Before
// the first frame nothing is built yet, so this is a harmless no-op during init().
if (initialized_)
{
if (initialized_) {
ImGui_ImplDX11_InvalidateDeviceObjects();
}
dpi_scale_ = scale;
@@ -87,8 +82,7 @@ void ImGuiLayer::apply_dpi(unsigned dpi)
void ImGuiLayer::set_dpi(unsigned dpi)
{
if (!initialized_ || dpi_scale_from(dpi) == dpi_scale_)
{
if (!initialized_ || dpi_scale_from(dpi) == dpi_scale_) {
return; // not up yet, or the scale didn't actually change -- skip a needless atlas rebuild
}
apply_dpi(dpi);

View File

@@ -6,12 +6,10 @@
#include <d3d11.h>
#include <windows.h>
namespace coop
{
namespace coop {
class ImGuiLayer
{
public:
class ImGuiLayer {
public:
ImGuiLayer() = default;
~ImGuiLayer();
@@ -27,7 +25,7 @@ public:
// scale changes at runtime. No-op before init() or when the resulting scale is unchanged.
void set_dpi(unsigned dpi);
private:
private:
// Rebuild the font atlas at the DPI-scaled size and re-apply the scaled dark style. Used by both
// init() (first apply) and set_dpi() (runtime change).
void apply_dpi(unsigned dpi);

View File

@@ -7,8 +7,7 @@
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
namespace coop
{
namespace coop {
bool hook_dll_alive(unsigned long pid, int timeout_ms)
{
@@ -18,17 +17,14 @@ bool hook_dll_alive(unsigned long pid, int timeout_ms)
// Poll rather than sample once: the worker only beats ~4x/s, so a single short read can straddle
// a gap and miss it; return the instant a beat lands, and give up after the timeout.
SharedMemory shm;
if (!shm.open(shared_memory_name(pid), sizeof(SharedBlock)))
{
if (!shm.open(shared_memory_name(pid), sizeof(SharedBlock))) {
return false;
}
auto* block = shm.as<SharedBlock>();
const std::uint32_t h0 = block->status.heartbeat.load(std::memory_order_acquire);
for (int waited = 0; waited < timeout_ms; waited += 25)
{
for (int waited = 0; waited < timeout_ms; waited += 25) {
Sleep(25);
if (block->status.heartbeat.load(std::memory_order_acquire) != h0)
{
if (block->status.heartbeat.load(std::memory_order_acquire) != h0) {
return true;
}
}

View File

@@ -3,8 +3,7 @@
// crash, since a connected DLL keeps the per-pid IPC section alive.
#pragma once
namespace coop
{
namespace coop {
// True if `pid` already hosts a live coop_hook DLL: the per-pid IPC section exists and its heartbeat
// advances within `timeout_ms` (the DLL's worker is still beating). Returns as soon as a beat lands,

View File

@@ -2,13 +2,11 @@
#include <windows.h>
namespace coop
{
namespace coop {
const char* to_string(InjectStatus status)
{
switch (status)
{
switch (status) {
case InjectStatus::Ok:
return "OK";
case InjectStatus::OpenProcessFailed:
@@ -33,8 +31,7 @@ const char* to_string(InjectStatus status)
return "unknown";
}
namespace
{
namespace {
InjectResult fail(InjectStatus status)
{
@@ -46,16 +43,14 @@ bool is_wow64_process(HANDLE process)
{
USHORT process_machine = IMAGE_FILE_MACHINE_UNKNOWN;
USHORT native_machine = IMAGE_FILE_MACHINE_UNKNOWN;
if (IsWow64Process2(process, &process_machine, &native_machine))
{
if (IsWow64Process2(process, &process_machine, &native_machine)) {
return process_machine != IMAGE_FILE_MACHINE_UNKNOWN;
}
// IsWow64Process2 failed -- fall back to the legacy query rather than guessing "native", since
// guessing wrong sends the x64 DLL into a 32-bit target (which can't load it). Only if BOTH
// queries fail do we fall back to permissive.
BOOL wow64 = FALSE;
if (IsWow64Process(process, &wow64))
{
if (IsWow64Process(process, &wow64)) {
return wow64 != FALSE;
}
return false; // both queries failed; best-effort assume native
@@ -75,9 +70,8 @@ InjectResult inject_via_helper(unsigned long pid, const std::wstring& dll_path)
{
const std::wstring helper = sibling(dll_path, L"coop_inject_x86.exe");
const std::wstring x86_dll = sibling(dll_path, L"coop_hook_x86.dll");
if (GetFileAttributesW(helper.c_str()) == INVALID_FILE_ATTRIBUTES ||
GetFileAttributesW(x86_dll.c_str()) == INVALID_FILE_ATTRIBUTES)
{
if (GetFileAttributesW(helper.c_str()) == INVALID_FILE_ATTRIBUTES
|| GetFileAttributesW(x86_dll.c_str()) == INVALID_FILE_ATTRIBUTES) {
return InjectResult{InjectStatus::HelperNotFound, 0};
}
@@ -87,9 +81,8 @@ InjectResult inject_via_helper(unsigned long pid, const std::wstring& dll_path)
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(helper.c_str(), cmd.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr,
&si, &pi))
{
if (!CreateProcessW(helper.c_str(), cmd.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &si,
&pi)) {
return fail(InjectStatus::HelperFailed);
}
WaitForSingleObject(pi.hProcess, INFINITE);
@@ -98,8 +91,7 @@ InjectResult inject_via_helper(unsigned long pid, const std::wstring& dll_path)
const DWORD err = got ? exit_code : GetLastError(); // on a failed query, surface the OS error
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
if (!got || exit_code != 0)
{
if (!got || exit_code != 0) {
return InjectResult{InjectStatus::HelperFailed, err};
}
return InjectResult{InjectStatus::Ok, 0};
@@ -109,63 +101,51 @@ InjectResult inject_via_helper(unsigned long pid, const std::wstring& dll_path)
InjectResult inject_dll(unsigned long pid, const std::wstring& dll_path)
{
if (GetFileAttributesW(dll_path.c_str()) == INVALID_FILE_ATTRIBUTES)
{
if (GetFileAttributesW(dll_path.c_str()) == INVALID_FILE_ATTRIBUTES) {
return fail(InjectStatus::DllNotFound);
}
const DWORD access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION |
PROCESS_VM_WRITE | PROCESS_VM_READ;
const DWORD access =
PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ;
HANDLE process = OpenProcess(access, FALSE, pid);
if (process == nullptr)
{
if (process == nullptr) {
return fail(InjectStatus::OpenProcessFailed);
}
struct HandleGuard
{
struct HandleGuard {
HANDLE h;
~HandleGuard()
{
if (h != nullptr)
{
if (h != nullptr) {
CloseHandle(h);
}
}
} process_guard{process};
if (is_wow64_process(process))
{
if (is_wow64_process(process)) {
// The x64 host can't inject a 32-bit target directly; delegate to the helper.
return inject_via_helper(pid, dll_path);
}
const SIZE_T bytes = (dll_path.size() + 1) * sizeof(wchar_t);
void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (remote == nullptr)
{
if (remote == nullptr) {
return fail(InjectStatus::AllocFailed);
}
InjectResult result{InjectStatus::Ok, 0};
if (!WriteProcessMemory(process, remote, dll_path.c_str(), bytes, nullptr))
{
if (!WriteProcessMemory(process, remote, dll_path.c_str(), bytes, nullptr)) {
result = fail(InjectStatus::WriteFailed);
}
else
{
} else {
// kernel32 is mapped at the same address in every process, so LoadLibraryW's
// address in this process is valid as the remote thread's start routine.
auto load_library =
reinterpret_cast<LPTHREAD_START_ROUTINE>(GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"));
HANDLE thread = CreateRemoteThread(process, nullptr, 0, load_library, remote, 0, nullptr);
if (thread == nullptr)
{
if (thread == nullptr) {
result = fail(InjectStatus::RemoteThreadFailed);
}
else
{
} else {
WaitForSingleObject(thread, INFINITE);
DWORD exit_code = 0;
GetExitCodeThread(thread, &exit_code);
@@ -173,8 +153,7 @@ InjectResult inject_dll(unsigned long pid, const std::wstring& dll_path)
// LoadLibraryW returns the module handle; 0 means it failed to load.
// (The handle is truncated to 32 bits here, but zero vs non-zero is
// all we need to distinguish success from failure.)
if (exit_code == 0)
{
if (exit_code == 0) {
result = InjectResult{InjectStatus::RemoteLoadFailed, 0};
}
}

View File

@@ -4,11 +4,9 @@
#include <string>
namespace coop
{
namespace coop {
enum class InjectStatus
{
enum class InjectStatus {
Ok,
OpenProcessFailed, // insufficient rights (try running the host as admin)
BitnessMismatch, // 32-bit target; the x86 hook/helper aren't available
@@ -21,8 +19,7 @@ enum class InjectStatus
HelperFailed, // the x86 injector helper ran but reported failure
};
struct InjectResult
{
struct InjectResult {
InjectStatus status = InjectStatus::OpenProcessFailed;
unsigned long os_error = 0; // GetLastError at the point of failure, if any
};

View File

@@ -6,80 +6,120 @@
#include "injection_panel.hpp"
#include "inject/mkb_map.hpp"
namespace coop
{
namespace coop {
namespace
{
namespace {
// Map an ImGui key to a Win32 virtual-key. Returns 0 for keys we don't forward.
int imgui_key_to_vk(ImGuiKey k)
{
if (k >= ImGuiKey_A && k <= ImGuiKey_Z)
{
if (k >= ImGuiKey_A && k <= ImGuiKey_Z) {
return 'A' + (k - ImGuiKey_A);
}
if (k >= ImGuiKey_0 && k <= ImGuiKey_9)
{
if (k >= ImGuiKey_0 && k <= ImGuiKey_9) {
return '0' + (k - ImGuiKey_0);
}
if (k >= ImGuiKey_Keypad0 && k <= ImGuiKey_Keypad9)
{
if (k >= ImGuiKey_Keypad0 && k <= ImGuiKey_Keypad9) {
return VK_NUMPAD0 + (k - ImGuiKey_Keypad0);
}
if (k >= ImGuiKey_F1 && k <= ImGuiKey_F12)
{
if (k >= ImGuiKey_F1 && k <= ImGuiKey_F12) {
return VK_F1 + (k - ImGuiKey_F1);
}
switch (k)
{
case ImGuiKey_Tab: return VK_TAB;
case ImGuiKey_LeftArrow: return VK_LEFT;
case ImGuiKey_RightArrow: return VK_RIGHT;
case ImGuiKey_UpArrow: return VK_UP;
case ImGuiKey_DownArrow: return VK_DOWN;
case ImGuiKey_PageUp: return VK_PRIOR;
case ImGuiKey_PageDown: return VK_NEXT;
case ImGuiKey_Home: return VK_HOME;
case ImGuiKey_End: return VK_END;
case ImGuiKey_Insert: return VK_INSERT;
case ImGuiKey_Delete: return VK_DELETE;
case ImGuiKey_Backspace: return VK_BACK;
case ImGuiKey_Space: return VK_SPACE;
case ImGuiKey_Enter: return VK_RETURN;
case ImGuiKey_Escape: return VK_ESCAPE;
case ImGuiKey_LeftCtrl: return VK_LCONTROL;
case ImGuiKey_LeftShift: return VK_LSHIFT;
case ImGuiKey_LeftAlt: return VK_LMENU;
case ImGuiKey_LeftSuper: return VK_LWIN;
case ImGuiKey_RightCtrl: return VK_RCONTROL;
case ImGuiKey_RightShift: return VK_RSHIFT;
case ImGuiKey_RightAlt: return VK_RMENU;
case ImGuiKey_RightSuper: return VK_RWIN;
case ImGuiKey_Menu: return VK_APPS;
case ImGuiKey_Apostrophe: return VK_OEM_7;
case ImGuiKey_Comma: return VK_OEM_COMMA;
case ImGuiKey_Minus: return VK_OEM_MINUS;
case ImGuiKey_Period: return VK_OEM_PERIOD;
case ImGuiKey_Slash: return VK_OEM_2;
case ImGuiKey_Semicolon: return VK_OEM_1;
case ImGuiKey_Equal: return VK_OEM_PLUS;
case ImGuiKey_LeftBracket: return VK_OEM_4;
case ImGuiKey_Backslash: return VK_OEM_5;
case ImGuiKey_RightBracket: return VK_OEM_6;
case ImGuiKey_GraveAccent: return VK_OEM_3;
case ImGuiKey_CapsLock: return VK_CAPITAL;
case ImGuiKey_ScrollLock: return VK_SCROLL;
case ImGuiKey_NumLock: return VK_NUMLOCK;
case ImGuiKey_PrintScreen: return VK_SNAPSHOT;
case ImGuiKey_Pause: return VK_PAUSE;
case ImGuiKey_KeypadDecimal: return VK_DECIMAL;
case ImGuiKey_KeypadDivide: return VK_DIVIDE;
case ImGuiKey_KeypadMultiply: return VK_MULTIPLY;
case ImGuiKey_KeypadSubtract: return VK_SUBTRACT;
case ImGuiKey_KeypadAdd: return VK_ADD;
case ImGuiKey_KeypadEnter: return VK_RETURN;
default: return 0;
switch (k) {
case ImGuiKey_Tab:
return VK_TAB;
case ImGuiKey_LeftArrow:
return VK_LEFT;
case ImGuiKey_RightArrow:
return VK_RIGHT;
case ImGuiKey_UpArrow:
return VK_UP;
case ImGuiKey_DownArrow:
return VK_DOWN;
case ImGuiKey_PageUp:
return VK_PRIOR;
case ImGuiKey_PageDown:
return VK_NEXT;
case ImGuiKey_Home:
return VK_HOME;
case ImGuiKey_End:
return VK_END;
case ImGuiKey_Insert:
return VK_INSERT;
case ImGuiKey_Delete:
return VK_DELETE;
case ImGuiKey_Backspace:
return VK_BACK;
case ImGuiKey_Space:
return VK_SPACE;
case ImGuiKey_Enter:
return VK_RETURN;
case ImGuiKey_Escape:
return VK_ESCAPE;
case ImGuiKey_LeftCtrl:
return VK_LCONTROL;
case ImGuiKey_LeftShift:
return VK_LSHIFT;
case ImGuiKey_LeftAlt:
return VK_LMENU;
case ImGuiKey_LeftSuper:
return VK_LWIN;
case ImGuiKey_RightCtrl:
return VK_RCONTROL;
case ImGuiKey_RightShift:
return VK_RSHIFT;
case ImGuiKey_RightAlt:
return VK_RMENU;
case ImGuiKey_RightSuper:
return VK_RWIN;
case ImGuiKey_Menu:
return VK_APPS;
case ImGuiKey_Apostrophe:
return VK_OEM_7;
case ImGuiKey_Comma:
return VK_OEM_COMMA;
case ImGuiKey_Minus:
return VK_OEM_MINUS;
case ImGuiKey_Period:
return VK_OEM_PERIOD;
case ImGuiKey_Slash:
return VK_OEM_2;
case ImGuiKey_Semicolon:
return VK_OEM_1;
case ImGuiKey_Equal:
return VK_OEM_PLUS;
case ImGuiKey_LeftBracket:
return VK_OEM_4;
case ImGuiKey_Backslash:
return VK_OEM_5;
case ImGuiKey_RightBracket:
return VK_OEM_6;
case ImGuiKey_GraveAccent:
return VK_OEM_3;
case ImGuiKey_CapsLock:
return VK_CAPITAL;
case ImGuiKey_ScrollLock:
return VK_SCROLL;
case ImGuiKey_NumLock:
return VK_NUMLOCK;
case ImGuiKey_PrintScreen:
return VK_SNAPSHOT;
case ImGuiKey_Pause:
return VK_PAUSE;
case ImGuiKey_KeypadDecimal:
return VK_DECIMAL;
case ImGuiKey_KeypadDivide:
return VK_DIVIDE;
case ImGuiKey_KeypadMultiply:
return VK_MULTIPLY;
case ImGuiKey_KeypadSubtract:
return VK_SUBTRACT;
case ImGuiKey_KeypadAdd:
return VK_ADD;
case ImGuiKey_KeypadEnter:
return VK_RETURN;
default:
return 0;
}
}
@@ -95,10 +135,8 @@ bool g_key_down[256] = {}; // indexed by VK
void release_held_keys(InjectionPanel& injection)
{
for (int vk = 0; vk < 256; ++vk)
{
if (g_key_down[vk])
{
for (int vk = 0; vk < 256; ++vk) {
if (g_key_down[vk]) {
injection.push_mkb(MkbEvent{Mkb_KeyUp, static_cast<std::uint32_t>(vk), 0, 0});
g_key_down[vk] = false;
}
@@ -107,10 +145,8 @@ void release_held_keys(InjectionPanel& injection)
void release_held_mouse(InjectionPanel& injection)
{
for (int b = 0; b < 3; ++b)
{
if (g_mouse_down[b])
{
for (int b = 0; b < 3; ++b) {
if (g_mouse_down[b]) {
injection.push_mkb(MkbEvent{Mkb_MouseUp, static_cast<std::uint32_t>(b), g_last_gx, g_last_gy});
g_mouse_down[b] = false;
}
@@ -126,8 +162,7 @@ void forward_mkb_frame(InjectionPanel& injection, HWND host_hwnd, bool mirroring
// own desktop use isn't injected. If we can't forward for ANY reason -- subsystem off, we lost
// focus, or the game is gone -- release everything we're still holding first, so a key/button
// held at that moment doesn't stick down in the guest.
if (!injection.mkb_enabled() || GetForegroundWindow() != host_hwnd || game == nullptr || !IsWindow(game))
{
if (!injection.mkb_enabled() || GetForegroundWindow() != host_hwnd || game == nullptr || !IsWindow(game)) {
release_held_keys(injection);
release_held_mouse(injection);
return;
@@ -136,35 +171,26 @@ void forward_mkb_frame(InjectionPanel& injection, HWND host_hwnd, bool mirroring
ImGuiIO& io = ImGui::GetIO();
// --- Keyboard (unless ImGui is using it for e.g. a text field -- then release what we hold) ---
if (io.WantCaptureKeyboard)
{
if (io.WantCaptureKeyboard) {
release_held_keys(injection);
}
else
{
for (ImGuiKey k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; k = static_cast<ImGuiKey>(k + 1))
{
} else {
for (ImGuiKey k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; k = static_cast<ImGuiKey>(k + 1)) {
const int vk = imgui_key_to_vk(k);
if (vk == 0)
{
if (vk == 0) {
continue;
}
if (ImGui::IsKeyPressed(k, false))
{
if (ImGui::IsKeyPressed(k, false)) {
injection.push_mkb(MkbEvent{Mkb_KeyDown, static_cast<std::uint32_t>(vk), 0, 0});
g_key_down[vk & 0xFF] = true;
}
if (ImGui::IsKeyReleased(k))
{
if (ImGui::IsKeyReleased(k)) {
injection.push_mkb(MkbEvent{Mkb_KeyUp, static_cast<std::uint32_t>(vk), 0, 0});
g_key_down[vk & 0xFF] = false;
}
}
for (int i = 0; i < io.InputQueueCharacters.Size; ++i)
{
for (int i = 0; i < io.InputQueueCharacters.Size; ++i) {
const ImWchar c = io.InputQueueCharacters[i];
if (c != 0)
{
if (c != 0) {
injection.push_mkb(MkbEvent{Mkb_Char, static_cast<std::uint32_t>(c), 0, 0});
}
}
@@ -173,8 +199,7 @@ void forward_mkb_frame(InjectionPanel& injection, HWND host_hwnd, bool mirroring
// --- Mouse (clicks + wheel only, and only while mirroring and ImGui isn't using the mouse) ---
// When we're not forwarding the mouse, still release any button we hold (below), so it can't stick.
const bool forwarding_mouse = mirroring && !io.WantCaptureMouse;
if (!forwarding_mouse)
{
if (!forwarding_mouse) {
release_held_mouse(injection);
return;
}
@@ -187,15 +212,12 @@ void forward_mkb_frame(InjectionPanel& injection, HWND host_hwnd, bool mirroring
m.host_y = static_cast<int>(io.MousePos.y);
m.dst_w = host_client.right;
m.dst_h = host_client.bottom;
if (source_hooked)
{
if (source_hooked) {
// Hooked capture mirrors the backbuffer (client area), no decorations.
const VideoShareView v = injection.video_share();
m.src_w = m.client_w = static_cast<int>(v.width);
m.src_h = m.client_h = static_cast<int>(v.height);
}
else
{
} else {
// WGC captures the whole window; the client area sits at a decoration offset.
RECT wr{}, cr{};
POINT client_origin{0, 0};
@@ -212,8 +234,7 @@ void forward_mkb_frame(InjectionPanel& injection, HWND host_hwnd, bool mirroring
int gx = 0, gy = 0;
const bool on_game = map_host_to_game_client(m, gx, gy);
if (on_game)
{
if (on_game) {
g_last_gx = gx;
g_last_gy = gy;
}
@@ -222,8 +243,7 @@ void forward_mkb_frame(InjectionPanel& injection, HWND host_hwnd, bool mirroring
for (int button = 0; button < 3; ++button) // 0=left, 1=right, 2=middle
{
if (on_game && ImGui::IsMouseClicked(button))
{
if (on_game && ImGui::IsMouseClicked(button)) {
injection.push_mkb(MkbEvent{Mkb_MouseDown, static_cast<std::uint32_t>(button), mx, my});
g_mouse_down[button] = true;
}
@@ -233,8 +253,7 @@ void forward_mkb_frame(InjectionPanel& injection, HWND host_hwnd, bool mirroring
g_mouse_down[button] = false;
}
}
if (on_game && io.MouseWheel != 0.0f)
{
if (on_game && io.MouseWheel != 0.0f) {
const int delta = static_cast<int>(io.MouseWheel * WHEEL_DELTA);
injection.push_mkb(MkbEvent{Mkb_Wheel, static_cast<std::uint32_t>(delta), mx, my});
}

View File

@@ -9,8 +9,7 @@
#include <windows.h>
namespace coop
{
namespace coop {
class InjectionPanel;

View File

@@ -12,44 +12,38 @@
#include <algorithm>
namespace coop
{
namespace coop {
struct MkbMapInput
{
int host_x = 0, host_y = 0; // mouse in host-window client pixels
int dst_w = 0, dst_h = 0; // host window client size
int src_w = 0, src_h = 0; // captured frame size (WGC=window, Hooked=backbuffer)
struct MkbMapInput {
int host_x = 0, host_y = 0; // mouse in host-window client pixels
int dst_w = 0, dst_h = 0; // host window client size
int src_w = 0, src_h = 0; // captured frame size (WGC=window, Hooked=backbuffer)
int client_off_x = 0, client_off_y = 0; // client-area top-left within the frame
int client_w = 0, client_h = 0; // game client size within the frame
int client_w = 0, client_h = 0; // game client size within the frame
};
// Returns true and writes gx,gy (game client px) if the point lands on the game's
// client area; false if it falls on a letterbox bar or the window decorations.
inline bool map_host_to_game_client(const MkbMapInput& in, int& gx, int& gy)
{
if (in.src_w <= 0 || in.src_h <= 0 || in.dst_w <= 0 || in.dst_h <= 0 || in.client_w <= 0 || in.client_h <= 0)
{
if (in.src_w <= 0 || in.src_h <= 0 || in.dst_w <= 0 || in.dst_h <= 0 || in.client_w <= 0 || in.client_h <= 0) {
return false;
}
// Invert the letterbox: the frame is fit (aspect-preserved) and centered in dst.
const double scale =
std::min(static_cast<double>(in.dst_w) / in.src_w, static_cast<double>(in.dst_h) / in.src_h);
const double scale = std::min(static_cast<double>(in.dst_w) / in.src_w, static_cast<double>(in.dst_h) / in.src_h);
const double ox = (in.dst_w - in.src_w * scale) * 0.5;
const double oy = (in.dst_h - in.src_h * scale) * 0.5;
const double fx = (in.host_x - ox) / scale; // position in captured-frame pixels
const double fy = (in.host_y - oy) / scale;
if (fx < 0.0 || fy < 0.0 || fx >= in.src_w || fy >= in.src_h)
{
if (fx < 0.0 || fy < 0.0 || fx >= in.src_w || fy >= in.src_h) {
return false; // on a letterbox bar
}
const double cx = fx - in.client_off_x; // into client space
const double cy = fy - in.client_off_y;
if (cx < 0.0 || cy < 0.0 || cx >= in.client_w || cy >= in.client_h)
{
if (cx < 0.0 || cy < 0.0 || cx >= in.client_w || cy >= in.client_h) {
return false; // on the window decorations
}

View File

@@ -5,27 +5,22 @@
#include <windows.h>
#include <tlhelp32.h>
namespace coop
{
namespace coop {
std::vector<ProcessEntry> list_processes()
{
std::vector<ProcessEntry> result;
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE)
{
if (snapshot == INVALID_HANDLE_VALUE) {
return result;
}
PROCESSENTRY32W entry = {};
entry.dwSize = sizeof(entry);
if (Process32FirstW(snapshot, &entry))
{
do
{
if (entry.th32ProcessID == 0)
{
if (Process32FirstW(snapshot, &entry)) {
do {
if (entry.th32ProcessID == 0) {
continue;
}
result.push_back(ProcessEntry{entry.th32ProcessID, entry.szExeFile});

View File

@@ -4,11 +4,9 @@
#include <string>
#include <vector>
namespace coop
{
namespace coop {
struct ProcessEntry
{
struct ProcessEntry {
unsigned long pid = 0;
std::wstring exe_name; // image base name, e.g. "game.exe"
};

View File

@@ -7,14 +7,11 @@
#include "inject/process_list.hpp"
namespace coop
{
namespace coop {
namespace
{
namespace {
struct EnumCtx
{
struct EnumCtx {
std::vector<WindowEntry>* out;
const std::unordered_map<unsigned long, std::wstring>* names;
DWORD self_pid;
@@ -25,23 +22,19 @@ BOOL CALLBACK enum_proc(HWND hwnd, LPARAM lparam)
auto* ctx = reinterpret_cast<EnumCtx*>(lparam);
// Keep only "alt-tab" windows: visible, titled, root-owner, non-tool, not ours.
if (!IsWindowVisible(hwnd) || GetAncestor(hwnd, GA_ROOTOWNER) != hwnd)
{
if (!IsWindowVisible(hwnd) || GetAncestor(hwnd, GA_ROOTOWNER) != hwnd) {
return TRUE;
}
const int len = GetWindowTextLengthW(hwnd);
if (len <= 0)
{
if (len <= 0) {
return TRUE;
}
if ((GetWindowLongW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) != 0)
{
if ((GetWindowLongW(hwnd, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) != 0) {
return TRUE;
}
DWORD pid = 0;
GetWindowThreadProcessId(hwnd, &pid);
if (pid == 0 || pid == ctx->self_pid)
{
if (pid == 0 || pid == ctx->self_pid) {
return TRUE;
}
@@ -49,8 +42,7 @@ BOOL CALLBACK enum_proc(HWND hwnd, LPARAM lparam)
GetWindowTextW(hwnd, title.data(), len + 1);
std::wstring exe;
if (const auto it = ctx->names->find(pid); it != ctx->names->end())
{
if (const auto it = ctx->names->find(pid); it != ctx->names->end()) {
exe = it->second;
}
ctx->out->push_back(WindowEntry{pid, hwnd, std::move(title), std::move(exe)});
@@ -64,8 +56,7 @@ std::vector<WindowEntry> list_windows()
// pid -> image name, so each window can show its owning process without a separate
// OpenProcess per window.
std::unordered_map<unsigned long, std::wstring> names;
for (const ProcessEntry& p : list_processes())
{
for (const ProcessEntry& p : list_processes()) {
names.emplace(p.pid, p.exe_name);
}

View File

@@ -6,15 +6,13 @@
#include <string>
#include <vector>
namespace coop
{
namespace coop {
struct WindowEntry
{
unsigned long pid = 0; // owning process id
void* hwnd = nullptr; // HWND (opaque here to keep windows.h out of the header)
std::wstring title; // window caption
std::wstring exe_name; // owning process image base name, e.g. "game.exe"
struct WindowEntry {
unsigned long pid = 0; // owning process id
void* hwnd = nullptr; // HWND (opaque here to keep windows.h out of the header)
std::wstring title; // window caption
std::wstring exe_name; // owning process image base name, e.g. "game.exe"
};
// Snapshot of the visible, titled, non-tool top-level (alt-tab-style) windows, with

View File

@@ -12,11 +12,9 @@
#include "ui/text_match.hpp"
#include "util/utf8.hpp"
namespace coop
{
namespace coop {
namespace
{
namespace {
const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f);
const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f);
@@ -40,8 +38,7 @@ std::wstring hook_dll_path()
const DWORD len = GetModuleFileNameW(nullptr, buffer, MAX_PATH);
std::wstring path(buffer, len);
const std::size_t slash = path.find_last_of(L"\\/");
if (slash != std::wstring::npos)
{
if (slash != std::wstring::npos) {
path.resize(slash + 1);
}
path += L"coop_hook.dll";
@@ -67,8 +64,7 @@ InjectionPanel::~InjectionPanel()
// stays injected, dormant). Short timeout -- the flags persist in the section the DLL keeps
// alive, so the unhook completes even if the process exits before it confirms.
disconnect_graceful(/*timeout_ms=*/300);
if (vk_layer_enabled_)
{
if (vk_layer_enabled_) {
unregister_vk_layer(); // don't leave the implicit layer registered after the tool closes
}
close_target_handle();
@@ -80,11 +76,9 @@ void InjectionPanel::disconnect_graceful(int timeout_ms)
// (bounded) for it to confirm before we drop the channel. The flags persist in the section the
// DLL keeps alive, so it unhooks even if we time out or exit first -- the wait just lets us
// observe a clean game. The DLL is left injected (dormant) for a later reconnect; we never eject.
if (server_.running())
{
if (server_.running()) {
server_.request_unhook_all();
for (int waited = 0; waited < timeout_ms && !server_.all_hooks_removed(); waited += 10)
{
for (int waited = 0; waited < timeout_ms && !server_.all_hooks_removed(); waited += 10) {
Sleep(10);
}
}
@@ -95,8 +89,7 @@ void InjectionPanel::disconnect_graceful(int timeout_ms)
void InjectionPanel::close_target_handle()
{
if (target_process_ != nullptr)
{
if (target_process_ != nullptr) {
CloseHandle(target_process_);
target_process_ = nullptr;
}
@@ -114,23 +107,19 @@ void InjectionPanel::tick()
void InjectionPanel::auto_reattach_tick()
{
if (!auto_reattach_ || target_state_ != TargetState::Terminated || selected_name_.empty())
{
if (!auto_reattach_ || target_state_ != TargetState::Terminated || selected_name_.empty()) {
return;
}
// Poll the process list a couple of times a second (cheap, and we want to catch the
// relaunch early to read the exact audio format before the game creates its client).
const double now = ImGui::GetTime();
if (now - last_auto_poll_ < 0.5)
{
if (now - last_auto_poll_ < 0.5) {
return;
}
last_auto_poll_ = now;
refresh_processes();
for (const ProcessEntry& e : processes_)
{
if (iequals_name(e.exe_name, selected_name_))
{
for (const ProcessEntry& e : processes_) {
if (iequals_name(e.exe_name, selected_name_)) {
// The same game relaunched -> tear down the stale channel and re-attach to it.
server_.stop();
close_target_handle();
@@ -144,8 +133,7 @@ void InjectionPanel::auto_reattach_tick()
void InjectionPanel::update_liveness()
{
if (!injected_)
{
if (!injected_) {
target_state_ = TargetState::NotInjected;
return;
}
@@ -153,8 +141,7 @@ void InjectionPanel::update_liveness()
// Process gone? The handle was opened with SYNCHRONIZE at inject time, so a
// signaled wait means it exited. This is authoritative even if the heartbeat
// happened to look alive a moment ago.
if (target_process_ != nullptr && WaitForSingleObject(target_process_, 0) == WAIT_OBJECT_0)
{
if (target_process_ != nullptr && WaitForSingleObject(target_process_, 0) == WAIT_OBJECT_0) {
target_state_ = TargetState::Terminated;
dll_alive_ = false;
return;
@@ -164,14 +151,11 @@ void InjectionPanel::update_liveness()
// process whose heartbeat stalled for ~2 s is frozen, not gone -- a distinct state.
const std::uint32_t hb = server_.hook_status().heartbeat;
const double now = ImGui::GetTime();
if (hb != last_heartbeat_)
{
if (hb != last_heartbeat_) {
last_heartbeat_ = hb;
last_heartbeat_time_ = now;
dll_alive_ = true;
}
else if (now - last_heartbeat_time_ > 2.0)
{
} else if (now - last_heartbeat_time_ > 2.0) {
dll_alive_ = false;
}
target_state_ = dll_alive_ ? TargetState::Alive : TargetState::Hung;
@@ -211,23 +195,21 @@ void InjectionPanel::reconnect_selected()
// Re-attach to a DLL that's already injected and alive (a prior session left it dormant after a
// graceful disconnect, or the tool restarted): bring the channel back up on the SAME per-pid
// section the DLL still holds and re-publish the desired subsystem state -- no re-injection.
if (!server_.start(selected_pid_))
{
if (!server_.start(selected_pid_)) {
status_ = "Failed to re-attach shared memory.";
status_color_ = kRed;
return;
}
publish_subsystem_state();
begin_liveness_tracking();
status_ = "Reconnected to " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) +
") -- reused the injected DLL.";
status_ = "Reconnected to " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_)
+ ") -- reused the injected DLL.";
status_color_ = kGreen;
}
void InjectionPanel::inject_selected()
{
if (selected_pid_ == 0)
{
if (selected_pid_ == 0) {
status_ = "Select a target process first.";
status_color_ = kRed;
return;
@@ -236,16 +218,14 @@ void InjectionPanel::inject_selected()
// If our DLL is already injected and alive in this target (left dormant by a graceful disconnect,
// or surviving a tool restart -- it keeps the section alive), reconnect to it instead of
// injecting a second time.
if (hook_dll_alive(selected_pid_))
{
if (hook_dll_alive(selected_pid_)) {
reconnect_selected();
return;
}
// Bring up the shared-memory channel before injecting so the hook finds it
// immediately on load.
if (!server_.start(selected_pid_))
{
if (!server_.start(selected_pid_)) {
status_ = "Failed to create shared memory.";
status_color_ = kRed;
return;
@@ -254,18 +234,14 @@ void InjectionPanel::inject_selected()
publish_subsystem_state();
const InjectResult result = inject_dll(selected_pid_, hook_dll_path());
if (result.status == InjectStatus::Ok)
{
if (result.status == InjectStatus::Ok) {
begin_liveness_tracking();
status_ = "Injected into " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) + ").";
status_color_ = kGreen;
}
else
{
} else {
server_.stop();
status_ = std::string("Injection failed: ") + to_string(result.status);
if (result.os_error != 0)
{
if (result.os_error != 0) {
status_ += " [err " + std::to_string(result.os_error) + "]";
}
status_color_ = kRed;
@@ -274,31 +250,26 @@ void InjectionPanel::inject_selected()
void InjectionPanel::reattach()
{
if (selected_name_.empty())
{
if (selected_name_.empty()) {
return;
}
// Find live processes that share the original target's image name.
refresh_processes();
std::vector<unsigned long> matches;
for (const ProcessEntry& e : processes_)
{
if (iequals_name(e.exe_name, selected_name_))
{
for (const ProcessEntry& e : processes_) {
if (iequals_name(e.exe_name, selected_name_)) {
matches.push_back(e.pid);
}
}
const std::string name = narrow(selected_name_);
if (matches.empty())
{
if (matches.empty()) {
status_ = "No running \"" + name + "\" to re-attach to.";
status_color_ = kRed;
return;
}
if (matches.size() > 1)
{
if (matches.size() > 1) {
// Don't guess which instance: filter the picker to the matches so the operator
// chooses, then injects via the normal button.
snprintf(filter_, sizeof(filter_), "%s", name.c_str());
@@ -320,10 +291,8 @@ void InjectionPanel::reattach()
unsigned long InjectionPanel::dev_inject_by_name(const std::wstring& image_name)
{
refresh_processes();
for (const ProcessEntry& e : processes_)
{
if (iequals_name(e.exe_name, image_name))
{
for (const ProcessEntry& e : processes_) {
if (iequals_name(e.exe_name, image_name)) {
selected_pid_ = e.pid;
selected_name_ = e.exe_name;
inject_selected();
@@ -336,8 +305,7 @@ unsigned long InjectionPanel::dev_inject_by_name(const std::wstring& image_name)
void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
{
if (!test_input_.load(std::memory_order_relaxed))
{
if (!test_input_.load(std::memory_order_relaxed)) {
server_.publish(pads);
return;
}
@@ -356,8 +324,7 @@ void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
pad.state.packet = static_cast<std::uint32_t>(ms);
pad.state.thumb_lx = static_cast<std::int16_t>(std::cos(t) * 30000.0);
pad.state.thumb_ly = static_cast<std::int16_t>(std::sin(t) * 30000.0);
if ((ms / 1000) % 2 == 0)
{
if ((ms / 1000) % 2 == 0) {
pad.state.buttons |= 0x1000; // XINPUT_GAMEPAD_A
}
server_.publish(synthetic);
@@ -368,35 +335,28 @@ void InjectionPanel::draw_hook_list(const HookStatusView& status)
static const char* kSubsysName[] = {"Input", "Focus", "Audio", "Video", "MKB"};
const std::uint32_t n = status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries;
if (n == 0)
{
if (n == 0) {
return;
}
if (!ImGui::CollapsingHeader("Installed hooks", ImGuiTreeNodeFlags_DefaultOpen))
{
if (!ImGui::CollapsingHeader("Installed hooks", ImGuiTreeNodeFlags_DefaultOpen)) {
return;
}
if (ImGui::BeginTable("hooks", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
{
if (ImGui::BeginTable("hooks", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("Hook");
ImGui::TableSetupColumn("On", ImGuiTableColumnFlags_WidthFixed);
ImGui::TableSetupColumn("Calls", ImGuiTableColumnFlags_WidthFixed);
ImGui::TableHeadersRow();
// Group rows by subsystem so related hooks sit together.
for (std::uint32_t sub = 0; sub < HookSubsys_Count; ++sub)
{
for (std::uint32_t sub = 0; sub < HookSubsys_Count; ++sub) {
bool header_done = false;
for (std::uint32_t i = 0; i < n; ++i)
{
for (std::uint32_t i = 0; i < n; ++i) {
const HookEntry& e = status.hook_entries[i];
if (e.subsystem != sub)
{
if (e.subsystem != sub) {
continue;
}
if (!header_done)
{
if (!header_done) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", kSubsysName[sub < HookSubsys_Count ? sub : 0]);
@@ -408,12 +368,9 @@ void InjectionPanel::draw_hook_list(const HookStatusView& status)
ImGui::TableNextColumn();
ImGui::TextUnformatted(e.name);
ImGui::TableNextColumn();
if (e.installed)
{
if (e.installed) {
ImGui::TextColored(kGreen, "yes");
}
else
{
} else {
ImGui::TextDisabled("no");
}
ImGui::TableNextColumn();
@@ -428,10 +385,8 @@ void InjectionPanel::draw_hook_list(const HookStatusView& status)
static bool subsystem_installed(const HookStatusView& status, std::uint32_t subsystem)
{
const std::uint32_t n = status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries;
for (std::uint32_t i = 0; i < n; ++i)
{
if (status.hook_entries[i].subsystem == subsystem && status.hook_entries[i].installed)
{
for (std::uint32_t i = 0; i < n; ++i) {
if (status.hook_entries[i].subsystem == subsystem && status.hook_entries[i].installed) {
return true;
}
}
@@ -442,8 +397,7 @@ void InjectionPanel::draw_subsystem_controls(const HookStatusView& status)
{
ImGui::SeparatorText("Subsystems (hook / unhook)");
struct Row
{
struct Row {
const char* label;
std::uint32_t subsystem;
bool* want;
@@ -457,25 +411,19 @@ void InjectionPanel::draw_subsystem_controls(const HookStatusView& status)
{"Mouse + keyboard forwarding", HookSubsys_Mkb, &want_mkb_, "clicks/keys reach the game"},
};
for (const Row& r : rows)
{
for (const Row& r : rows) {
ImGui::PushID(r.label);
if (ImGui::Checkbox(r.label, r.want))
{
if (ImGui::Checkbox(r.label, r.want)) {
server_.set_subsystem_enabled(r.subsystem, *r.want);
}
ImGui::SameLine();
const bool on = subsystem_installed(status, r.subsystem);
if (*r.want != on)
{
if (*r.want != on) {
ImGui::TextColored(kGrey, "(%s...)", *r.want ? "installing" : "removing");
}
else
{
} else {
ImGui::TextColored(on ? kGreen : kGrey, on ? "installed" : "off");
}
if (!*r.want)
{
if (!*r.want) {
ImGui::TextDisabled(" off: %s won't work", r.depends);
}
ImGui::PopID();
@@ -483,33 +431,28 @@ void InjectionPanel::draw_subsystem_controls(const HookStatusView& status)
// Cursor release is a Focus sub-option for games that clip/recenter the mouse,
// which would otherwise trap the operator.
if (ImGui::Checkbox("Release operator cursor (free the game's clip) [F2]", &release_cursor_))
{
if (ImGui::Checkbox("Release operator cursor (free the game's clip) [F2]", &release_cursor_)) {
server_.set_cursor_clip_allowed(!release_cursor_);
}
}
void InjectionPanel::draw_hook_status(bool debug_details)
{
if (!server_.running())
{
if (!server_.running()) {
return;
}
const HookStatusView status = server_.hook_status();
ImGui::SeparatorText("Hook status");
if (!injected_)
{
if (!injected_) {
ImGui::TextColored(kGrey, "Not injected.");
return;
}
switch (target_state_)
{
switch (target_state_) {
case TargetState::Alive:
ImGui::TextColored(kGreen, "Hook DLL loaded in pid %lu (heartbeat %u)", server_.target_pid(),
status.heartbeat);
ImGui::TextColored(kGreen, "Hook DLL loaded in pid %lu (heartbeat %u)", server_.target_pid(), status.heartbeat);
break;
case TargetState::Hung:
ImGui::TextColored(kRed, "Target not responding -- heartbeat stalled (frozen?).");
@@ -525,16 +468,14 @@ void InjectionPanel::draw_hook_status(bool debug_details)
ImGui::BeginDisabled(target_state_ != TargetState::Alive);
draw_subsystem_controls(status);
ImGui::EndDisabled();
if (target_state_ != TargetState::Alive)
{
if (target_state_ != TargetState::Alive) {
ImGui::TextDisabled("(connect to a live game to change these)"); // why the toggles are locked
}
ImGui::TextDisabled("Controller poll rates are in the Controllers panel.");
draw_hook_list(status);
if (!debug_details)
{
if (!debug_details) {
return; // everything below is diagnostic detail
}
@@ -548,17 +489,12 @@ void InjectionPanel::draw_hook_status(bool debug_details)
// Input-path diagnostics: a focus-gated detection path would explain a game
// that only accepts the controller when it has true focus.
ImGui::SeparatorText("Input path");
if (status.raw_input_gamepad)
{
if (status.raw_input_gamepad) {
ImGui::TextColored(status.raw_input_gamepad_sink ? kGreen : kRed, "Raw Input gamepad: yes (INPUTSINK %s)",
status.raw_input_gamepad_sink ? "set -> bg ok" : "MISSING -> focus-gated!");
}
else if (status.raw_input_registered)
{
} else if (status.raw_input_registered) {
ImGui::TextColored(kGrey, "Raw Input: registered, but not for a gamepad usage");
}
else
{
} else {
ImGui::TextColored(kGrey, "Raw Input: not registered");
}
ImGui::TextColored(status.dinput_loaded ? kRed : kGrey, "DirectInput dll loaded: %s",
@@ -570,22 +506,15 @@ void InjectionPanel::draw(bool debug_details)
apply_panel_layout(Panel::Injection);
ImGui::Begin("Injection");
if (server_.running())
{
if (target_state_ == TargetState::Terminated)
{
if (server_.running()) {
if (target_state_ == TargetState::Terminated) {
ImGui::TextColored(kRed, "Target (pid %lu) has terminated.", server_.target_pid());
}
else if (target_state_ == TargetState::Hung)
{
} else if (target_state_ == TargetState::Hung) {
ImGui::TextColored(kRed, "Target (pid %lu) is not responding.", server_.target_pid());
}
else
{
} else {
ImGui::TextColored(kGreen, "Connected to pid %lu", server_.target_pid());
}
if (ImGui::Button("Disconnect"))
{
if (ImGui::Button("Disconnect")) {
// Leave the game vanilla: unhook everything before dropping the channel. The DLL stays
// injected (dormant), so it can be reconnected later without re-injecting.
disconnect_graceful(/*timeout_ms=*/700);
@@ -594,11 +523,9 @@ void InjectionPanel::draw(bool debug_details)
}
// A relaunched game has a new pid; re-attach by image name without hunting for
// it in the list. Only offered once the old target is gone.
if (target_state_ == TargetState::Terminated && !selected_name_.empty())
{
if (target_state_ == TargetState::Terminated && !selected_name_.empty()) {
ImGui::SameLine();
if (ImGui::Button("Re-attach"))
{
if (ImGui::Button("Re-attach")) {
reattach();
}
ImGui::SameLine();
@@ -609,32 +536,25 @@ void InjectionPanel::draw(bool debug_details)
// Session options for the selected game, shown whether or not we're connected -- so they can be set
// up before launching the game, and an enabled auto re-attach is never hidden after a disconnect.
if (!selected_name_.empty())
{
if (!selected_name_.empty()) {
// Session-only auto re-attach: tick it, then kill + relaunch the game and it re-injects itself
// early -- the kill+relaunch fix for a wrong audio format, without picking a target again.
ImGui::Checkbox("Auto re-attach this game on relaunch", &auto_reattach_);
if (auto_reattach_ && target_state_ == TargetState::Terminated)
{
if (auto_reattach_ && target_state_ == TargetState::Terminated) {
ImGui::SameLine();
ImGui::TextColored(kGrey, "(watching for %s...)", narrow(selected_name_).c_str());
}
// Opt-in Vulkan capture layer: for Vulkan games that initialize Vulkan immediately (where even
// auto-attach injects too late -- see the red banner), register a per-user implicit layer scoped
// to this game so the next launch is captured from the first frame. Removed when unticked / exit.
if (ImGui::Checkbox("Set up Vulkan layer (for immediate-init Vulkan games)", &vk_layer_enabled_))
{
if (vk_layer_enabled_)
{
if (ImGui::Checkbox("Set up Vulkan layer (for immediate-init Vulkan games)", &vk_layer_enabled_)) {
if (vk_layer_enabled_) {
vk_layer_enabled_ = register_vk_layer(selected_name_);
}
else
{
} else {
unregister_vk_layer();
}
}
if (ImGui::IsItemHovered())
{
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Registers a per-user (HKCU, no admin) implicit Vulkan layer scoped to\n"
"this game, so a relaunch is captured before Vulkan init. Pair with\n"
"Auto re-attach. Removed when you untick it or close the tool.");
@@ -642,28 +562,23 @@ void InjectionPanel::draw(bool debug_details)
}
ImGui::TextUnformatted("Target window");
if (ImGui::Button("Refresh"))
{
if (ImGui::Button("Refresh")) {
refresh_targets();
}
ImGui::SameLine();
ImGui::SetNextItemWidth(-1.0f);
ImGui::InputTextWithHint("##wfilter", "filter by title or process...", window_filter_, sizeof(window_filter_));
if (ImGui::BeginListBox("##windows", ImVec2(-1.0f, 180.0f)))
{
for (const WindowEntry& w : windows_)
{
if (!contains_ci_w(w.title, window_filter_) && !contains_ci_w(w.exe_name, window_filter_))
{
if (ImGui::BeginListBox("##windows", ImVec2(-1.0f, 180.0f))) {
for (const WindowEntry& w : windows_) {
if (!contains_ci_w(w.title, window_filter_) && !contains_ci_w(w.exe_name, window_filter_)) {
continue;
}
const bool selected = w.pid == selected_pid_;
char label[400];
snprintf(label, sizeof(label), "%-32s [%s %lu]", narrow(w.title).c_str(),
narrow(w.exe_name).c_str(), w.pid);
if (ImGui::Selectable(label, selected))
{
snprintf(label, sizeof(label), "%-32s [%s %lu]", narrow(w.title).c_str(), narrow(w.exe_name).c_str(),
w.pid);
if (ImGui::Selectable(label, selected)) {
selected_pid_ = w.pid;
selected_name_ = w.exe_name;
}
@@ -673,24 +588,19 @@ void InjectionPanel::draw(bool debug_details)
// The full process list is the advanced fallback (e.g. a windowless game host),
// kept out of the way unless the operator wants it.
if (debug_details)
{
if (debug_details) {
ImGui::SeparatorText("All processes (advanced)");
ImGui::SetNextItemWidth(-1.0f);
ImGui::InputTextWithHint("##filter", "filter by name...", filter_, sizeof(filter_));
if (ImGui::BeginListBox("##processes", ImVec2(-1.0f, 160.0f)))
{
for (const ProcessEntry& entry : processes_)
{
if (!contains_ci_w(entry.exe_name, filter_))
{
if (ImGui::BeginListBox("##processes", ImVec2(-1.0f, 160.0f))) {
for (const ProcessEntry& entry : processes_) {
if (!contains_ci_w(entry.exe_name, filter_)) {
continue;
}
const bool selected = entry.pid == selected_pid_;
char label[300];
snprintf(label, sizeof(label), "%-40s %lu", narrow(entry.exe_name).c_str(), entry.pid);
if (ImGui::Selectable(label, selected))
{
if (ImGui::Selectable(label, selected)) {
selected_pid_ = entry.pid;
selected_name_ = entry.exe_name;
}
@@ -701,20 +611,17 @@ void InjectionPanel::draw(bool debug_details)
const bool can_inject = selected_pid_ != 0;
ImGui::BeginDisabled(!can_inject);
if (ImGui::Button("Inject & Connect", ImVec2(-1.0f, 0.0f)))
{
if (ImGui::Button("Inject & Connect", ImVec2(-1.0f, 0.0f))) {
inject_selected();
}
ImGui::EndDisabled();
// Explain the disabled state on hover (AllowWhenDisabled, since the button is greyed out).
if (!can_inject && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
{
if (!can_inject && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
ImGui::SetTooltip("Pick a target window or process above first.\n"
"If the game already has the DLL (e.g. after a reconnect), this reuses it.");
}
if (!status_.empty())
{
if (!status_.empty()) {
ImGui::TextColored(status_color_, "%s", status_.c_str());
}

View File

@@ -15,21 +15,18 @@
#include "inject/window_list.hpp"
#include "ipc/ipc_server.hpp"
namespace coop
{
namespace coop {
// Liveness of the injected target, surfaced in the UI so a dead/hung game is obvious.
enum class TargetState
{
enum class TargetState {
NotInjected, // no hook loaded
Alive, // process running and the hook heartbeat is advancing
Hung, // process still exists but the heartbeat stalled (not responding)
Terminated, // process has exited
};
class InjectionPanel
{
public:
class InjectionPanel {
public:
InjectionPanel();
~InjectionPanel();
@@ -49,10 +46,7 @@ public:
// Test harness (debug builds only): inject into the first running process whose image
// name matches. Returns the pid on success, 0 otherwise. Same path as the UI button.
unsigned long dev_inject_by_name(const std::wstring& image_name);
void dev_set_auto_reattach(bool on)
{
auto_reattach_ = on;
}
void dev_set_auto_reattach(bool on) { auto_reattach_ = on; }
#endif
// Forward the latest pad snapshot to the injected hook (if connected). When
@@ -61,35 +55,25 @@ public:
// Enable/disable synthetic test input. The toggle itself lives in the Controllers
// panel (a controller-debug aid); the host feeds its state here each frame.
void set_test_input(bool on)
{
test_input_.store(on, std::memory_order_relaxed);
}
void set_test_input(bool on) { test_input_.store(on, std::memory_order_relaxed); }
// The injected game's main window, as reported by the hook (null if none). A
// terminated target's HWND is stale/invalid, so report none -- the capture and
// audio panels then drop to idle instead of chasing a dead window.
[[nodiscard]] HWND game_hwnd() const
{
if (target_state_ == TargetState::Terminated)
{
if (target_state_ == TargetState::Terminated) {
return nullptr;
}
return reinterpret_cast<HWND>(server_.hook_status().game_hwnd);
}
// Current liveness of the injected target (for other panels / status).
[[nodiscard]] TargetState target_state() const
{
return target_state_;
}
[[nodiscard]] TargetState target_state() const { return target_state_; }
// The hook's full diagnostics back-channel (other panels read the audio
// render-stream counts from here).
[[nodiscard]] HookStatusView hook_status() const
{
return server_.hook_status();
}
[[nodiscard]] HookStatusView hook_status() const { return server_.hook_status(); }
// Drain log lines the hook streamed (for the Log window). No-op if not active.
template <typename F>
@@ -100,23 +84,14 @@ public:
// Emit a host-side line into the Log window (color-coded by level), e.g. an
// override-overwrite warning. No-op if not connected.
void host_log(std::uint32_t level, const char* text)
{
server_.host_log(level, text);
}
void host_log(std::uint32_t level, const char* text) { server_.host_log(level, text); }
// --- Present-hook video path (consumed by the Video mirror panel) ----------
[[nodiscard]] unsigned long target_pid() const
{
return server_.target_pid();
}
[[nodiscard]] unsigned long target_pid() const { return server_.target_pid(); }
// The hook's Present-hook video channel snapshot (shared-texture descriptor).
[[nodiscard]] VideoShareView video_share() const
{
return server_.video_share();
}
[[nodiscard]] VideoShareView video_share() const { return server_.video_share(); }
// Request the Present-hook video subsystem be installed/removed. Keeps the
// Injection panel's own checkbox in sync, so the Video panel can drive it.
@@ -126,25 +101,16 @@ public:
server_.set_subsystem_enabled(HookSubsys_Video, on);
}
[[nodiscard]] bool video_requested() const
{
return want_video_;
}
[[nodiscard]] bool video_requested() const { return want_video_; }
// --- Mouse + keyboard forwarding -------------------------------------------
// Whether the operator enabled the MKB-forwarding subsystem (the toggle is the
// hook). The host's MKB forwarder only runs while this is on and a hook is alive.
[[nodiscard]] bool mkb_enabled() const
{
return want_mkb_ && injected_;
}
[[nodiscard]] bool mkb_enabled() const { return want_mkb_ && injected_; }
// Enqueue an MKB event for the hook to forward into the game.
void push_mkb(const MkbEvent& ev)
{
server_.push_mkb(ev);
}
void push_mkb(const MkbEvent& ev) { server_.push_mkb(ev); }
// --- Cursor release (for cursor-clipping games) ----------------------------
@@ -156,31 +122,28 @@ public:
server_.set_cursor_clip_allowed(!release_cursor_);
}
[[nodiscard]] bool cursor_released() const
{
return release_cursor_;
}
[[nodiscard]] bool cursor_released() const { return release_cursor_; }
private:
void refresh_targets(); // refresh both the window list and the process list
private:
void refresh_targets(); // refresh both the window list and the process list
void refresh_processes();
void inject_selected(); // inject fresh, OR reconnect if a live DLL is already in the target
void reconnect_selected(); // re-attach to an already-injected, live DLL (no re-inject)
void inject_selected(); // inject fresh, OR reconnect if a live DLL is already in the target
void reconnect_selected(); // re-attach to an already-injected, live DLL (no re-inject)
void publish_subsystem_state(); // push the desired per-subsystem install state + cursor policy
void begin_liveness_tracking(); // mark connected: open the process handle, seed the heartbeat clock
// Graceful disconnect: ask the DLL to remove every hook (game returns to vanilla), wait
// (bounded) for it to take effect, then drop the channel. The DLL stays injected/dormant for a
// later reconnect; we never eject it. Used by the Disconnect button and the destructor.
void disconnect_graceful(int timeout_ms);
void reattach(); // re-inject a relaunched same-name target (Terminated state)
void auto_reattach_tick(); // poll for the same game relaunching while auto-reattach is on
void update_liveness(); // recompute target_state_ from process + heartbeat
void close_target_handle(); // close target_process_ and reset liveness state
void reattach(); // re-inject a relaunched same-name target (Terminated state)
void auto_reattach_tick(); // poll for the same game relaunching while auto-reattach is on
void update_liveness(); // recompute target_state_ from process + heartbeat
void close_target_handle(); // close target_process_ and reset liveness state
void draw_subsystem_controls(const HookStatusView& status);
void draw_hook_list(const HookStatusView& status);
void draw_hook_status(bool debug_details);
std::vector<WindowEntry> windows_; // default picker (visible top-level windows)
std::vector<WindowEntry> windows_; // default picker (visible top-level windows)
std::vector<ProcessEntry> processes_; // advanced picker (all processes)
char window_filter_[128] = {};
char filter_[128] = {};
@@ -200,17 +163,17 @@ private:
bool want_input_ = true;
bool want_focus_ = true;
bool want_audio_ = true;
bool want_video_ = false; // Present-hook video path: opt-in (WGC is the default)
bool want_mkb_ = false; // mouse+keyboard forwarding: opt-in
bool want_video_ = false; // Present-hook video path: opt-in (WGC is the default)
bool want_mkb_ = false; // mouse+keyboard forwarding: opt-in
bool release_cursor_ = true; // free the operator's mouse from the game's clip (default)
bool injected_ = false; // a hook DLL is loaded in the target
bool injected_ = false; // a hook DLL is loaded in the target
// Session-only (never persisted): while on and the target has terminated, auto-inject
// the same image name the moment it relaunches, so a quick kill+relaunch re-attaches
// early (catching IAudioClient::Initialize) without the operator picking a target.
bool auto_reattach_ = false;
bool vk_layer_enabled_ = false; // opt-in: registered the implicit Vulkan capture layer for this game
double last_auto_poll_ = 0.0; // throttle the process-list poll
double last_auto_poll_ = 0.0; // throttle the process-list poll
// Heartbeat liveness tracking (is the injected DLL responding?).
std::uint32_t last_heartbeat_ = 0;

View File

@@ -12,11 +12,9 @@
#include "coop/protocol.hpp"
namespace coop
{
namespace coop {
struct PadInfo
{
struct PadInfo {
bool connected = false;
CoopPadState state = {};
std::string source; // human-readable label for the debug overlay
@@ -25,16 +23,14 @@ struct PadInfo
// A copy-safe snapshot of the input backend's state, published by the input worker
// thread for the UI to display. This decouples the Controllers panel (UI thread) from
// the worker's live polling, so the worker can own its InputSource exclusively.
struct InputSnapshot
{
struct InputSnapshot {
std::array<PadInfo, kMaxPads> pads{};
const char* backend = "XInput"; // backend name (static string literal; thread-safe to share)
bool steam_active = false;
};
class InputSource
{
public:
class InputSource {
public:
virtual ~InputSource() = default;
// Name of the backend, shown in the overlay.

View File

@@ -11,8 +11,7 @@
#include "input/steam_input_source.hpp"
#endif
namespace coop
{
namespace coop {
InputWorker::~InputWorker()
{
@@ -21,8 +20,7 @@ InputWorker::~InputWorker()
void InputWorker::start(InjectionPanel* injection, std::string steam_manifest)
{
if (running_.load(std::memory_order_acquire))
{
if (running_.load(std::memory_order_acquire)) {
return;
}
injection_ = injection;
@@ -34,8 +32,7 @@ void InputWorker::start(InjectionPanel* injection, std::string steam_manifest)
void InputWorker::stop()
{
running_.store(false, std::memory_order_release);
if (thread_.joinable())
{
if (thread_.joinable()) {
thread_.join();
}
}
@@ -68,36 +65,28 @@ void InputWorker::run()
std::uint16_t last_rumble_l[kMaxPads] = {};
std::uint16_t last_rumble_r[kMaxPads] = {};
while (running_.load(std::memory_order_relaxed))
{
while (running_.load(std::memory_order_relaxed)) {
const bool want_steam = want_steam_.load(std::memory_order_relaxed);
#ifdef COOP_WITH_STEAM
// Reconcile the backend with the UI's request. Initializing Steam Input hijacks
// XInput, so it's strictly opt-in; a failed init falls back to plain XInput and
// flags steam_failed_ so the UI can reset its toggle (and a later retry is
// possible once the request is cleared).
if (want_steam && steam == nullptr && !steam_failed_.load(std::memory_order_relaxed))
{
if (want_steam && steam == nullptr && !steam_failed_.load(std::memory_order_relaxed)) {
steam = std::make_unique<SteamInputSource>();
if (steam->init(steam_manifest_))
{
if (steam->init(steam_manifest_)) {
active = steam.get();
}
else
{
} else {
steam.reset();
active = &xinput;
steam_failed_.store(true, std::memory_order_relaxed);
}
}
else if (!want_steam && steam != nullptr)
{
} else if (!want_steam && steam != nullptr) {
steam->shutdown();
steam.reset();
active = &xinput;
}
if (!want_steam)
{
if (!want_steam) {
steam_failed_.store(false, std::memory_order_relaxed); // allow a future retry
}
const bool steam_active = steam != nullptr;
@@ -108,17 +97,14 @@ void InputWorker::run()
active->poll();
if (injection_ != nullptr)
{
if (injection_ != nullptr) {
// Push the latest pads to the game (publish() substitutes synthetic test input
// itself when that mode is on), then forward any newly requested rumble.
injection_->publish(active->pads());
const HookStatusView hs = injection_->hook_status();
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
{
if (hs.rumble_left[i] != last_rumble_l[i] || hs.rumble_right[i] != last_rumble_r[i])
{
for (int i = 0; i < static_cast<int>(kMaxPads); ++i) {
if (hs.rumble_left[i] != last_rumble_l[i] || hs.rumble_right[i] != last_rumble_r[i]) {
active->set_rumble(i, hs.rumble_left[i], hs.rumble_right[i]);
last_rumble_l[i] = hs.rumble_left[i];
last_rumble_r[i] = hs.rumble_right[i];
@@ -134,8 +120,7 @@ void InputWorker::run()
}
#ifdef COOP_WITH_STEAM
if (steam != nullptr)
{
if (steam != nullptr) {
steam->shutdown();
}
#endif

View File

@@ -13,14 +13,12 @@
#include "input/input_source.hpp"
namespace coop
{
namespace coop {
class InjectionPanel;
class InputWorker
{
public:
class InputWorker {
public:
InputWorker() = default;
~InputWorker();
@@ -35,22 +33,16 @@ public:
void stop();
// UI -> worker: request the Steam Input backend (true) or plain XInput (false).
void set_want_steam(bool on)
{
want_steam_.store(on, std::memory_order_relaxed);
}
void set_want_steam(bool on) { want_steam_.store(on, std::memory_order_relaxed); }
// worker -> UI: latest snapshot for the Controllers panel (thread-safe copy).
[[nodiscard]] InputSnapshot snapshot() const;
// worker -> UI: Steam Input was requested but failed to start (so the panel can
// reset its toggle and fall back to XInput). Cleared once Steam is not requested.
[[nodiscard]] bool steam_failed() const
{
return steam_failed_.load(std::memory_order_relaxed);
}
[[nodiscard]] bool steam_failed() const { return steam_failed_.load(std::memory_order_relaxed); }
private:
private:
void run();
void publish_snapshot(const InputSource& src, bool steam_active);

View File

@@ -7,16 +7,13 @@
#include <steam/steam_api.h>
namespace coop
{
namespace coop {
namespace
{
namespace {
// Digital actions in the manifest, paired with the XInput button bit they map to.
// Names must match host/assets/steam_input_actions.vdf.
struct ButtonAction
{
struct ButtonAction {
const char* action;
std::uint16_t xinput_bit;
};
@@ -40,12 +37,10 @@ const ButtonAction kButtons[kSteamButtonActions] = {
std::int16_t to_axis(float v)
{
if (v > 1.0f)
{
if (v > 1.0f) {
v = 1.0f;
}
if (v < -1.0f)
{
if (v < -1.0f) {
v = -1.0f;
}
return static_cast<std::int16_t>(v * 32767.0f);
@@ -53,12 +48,10 @@ std::int16_t to_axis(float v)
std::uint8_t to_trigger(float v)
{
if (v > 1.0f)
{
if (v > 1.0f) {
v = 1.0f;
}
if (v < 0.0f)
{
if (v < 0.0f) {
v = 0.0f;
}
return static_cast<std::uint8_t>(v * 255.0f);
@@ -75,25 +68,21 @@ bool SteamInputSource::init(const std::string& manifest_absolute_path)
{
// Running standalone (not launched by Steam) without a steam_appid.txt makes
// SteamAPI_Init fail; that's fine -- we degrade to XInput.
if (!SteamAPI_Init())
{
if (!SteamAPI_Init()) {
std::printf("SteamInput: SteamAPI_Init failed (not under Steam?); using XInput.\n");
return false;
}
if (SteamInput() == nullptr)
{
if (SteamInput() == nullptr) {
std::printf("SteamInput: ISteamInput unavailable; using XInput.\n");
SteamAPI_Shutdown();
return false;
}
// Point Steam Input at our bundled action manifest so we don't depend on a
// partner-backend-registered config. Must be called before Init().
if (!manifest_absolute_path.empty())
{
if (!manifest_absolute_path.empty()) {
SteamInput()->SetInputActionManifestFilePath(manifest_absolute_path.c_str());
}
if (!SteamInput()->Init(/*bExplicitlyCallRunFrame=*/false))
{
if (!SteamInput()->Init(/*bExplicitlyCallRunFrame=*/false)) {
std::printf("SteamInput: ISteamInput::Init failed; using XInput.\n");
SteamAPI_Shutdown();
return false;
@@ -108,8 +97,7 @@ bool SteamInputSource::init(const std::string& manifest_absolute_path)
void SteamInputSource::shutdown()
{
if (steam_ready_)
{
if (steam_ready_) {
SteamInput()->Shutdown();
SteamAPI_Shutdown();
steam_ready_ = false;
@@ -120,8 +108,7 @@ void SteamInputSource::shutdown()
void SteamInputSource::resolve_handles()
{
action_set_ = SteamInput()->GetActionSetHandle("GameControls");
for (int i = 0; i < kSteamButtonActions; ++i)
{
for (int i = 0; i < kSteamButtonActions; ++i) {
button_handles_[i] = SteamInput()->GetDigitalActionHandle(kButtons[i].action);
}
left_stick_ = SteamInput()->GetAnalogActionHandle("LeftStick");
@@ -138,12 +125,10 @@ bool SteamInputSource::read_steam_pad(std::uint64_t controller, PadInfo& out) co
st.connected = 1;
bool any_active = false;
for (int i = 0; i < kSteamButtonActions; ++i)
{
for (int i = 0; i < kSteamButtonActions; ++i) {
const InputDigitalActionData_t d = SteamInput()->GetDigitalActionData(controller, button_handles_[i]);
any_active = any_active || d.bActive;
if (d.bState)
{
if (d.bState) {
st.buttons |= kButtons[i].xinput_bit;
}
}
@@ -163,8 +148,7 @@ bool SteamInputSource::read_steam_pad(std::uint64_t controller, PadInfo& out) co
// No action is bound/active (e.g. the controller isn't using our manifest) ->
// let the XInput fallback handle this slot instead of reporting an empty pad.
if (!any_active)
{
if (!any_active) {
return false;
}
@@ -182,8 +166,7 @@ void SteamInputSource::poll()
xinput_.poll();
pads_ = xinput_.pads();
if (!steam_ready_)
{
if (!steam_ready_) {
steam_count_ = 0;
return;
}
@@ -192,17 +175,14 @@ void SteamInputSource::poll()
InputHandle_t handles[STEAM_INPUT_MAX_COUNT] = {};
steam_count_ = SteamInput()->GetConnectedControllers(handles);
for (std::uint32_t i = 0; i < kMaxPads; ++i)
{
for (std::uint32_t i = 0; i < kMaxPads; ++i) {
controllers_[i] = 0;
steam_slot_[i] = false;
}
for (int i = 0; i < steam_count_ && i < static_cast<int>(kMaxPads); ++i)
{
for (int i = 0; i < steam_count_ && i < static_cast<int>(kMaxPads); ++i) {
controllers_[i] = handles[i];
PadInfo steam_pad;
if (read_steam_pad(handles[i], steam_pad))
{
if (read_steam_pad(handles[i], steam_pad)) {
pads_[i] = steam_pad; // Steam controller active on this slot -> use it
steam_slot_[i] = true;
}
@@ -211,12 +191,10 @@ void SteamInputSource::poll()
void SteamInputSource::set_rumble(int slot, std::uint16_t left, std::uint16_t right)
{
if (slot < 0 || slot >= static_cast<int>(kMaxPads))
{
if (slot < 0 || slot >= static_cast<int>(kMaxPads)) {
return;
}
if (steam_ready_ && steam_slot_[slot] && controllers_[slot] != 0)
{
if (steam_ready_ && steam_slot_[slot] && controllers_[slot] != 0) {
SteamInput()->TriggerVibration(controllers_[slot], left, right);
return;
}

View File

@@ -14,15 +14,13 @@
#include "input/input_source.hpp"
#include "input/xinput_source.hpp"
namespace coop
{
namespace coop {
// Number of digital (button) actions in the bundled action manifest.
inline constexpr int kSteamButtonActions = 15;
class SteamInputSource final : public InputSource
{
public:
class SteamInputSource final : public InputSource {
public:
~SteamInputSource() override;
// Initializes SteamAPI + Steam Input and loads the action manifest at the given
@@ -31,30 +29,18 @@ public:
bool init(const std::string& manifest_absolute_path);
void shutdown();
[[nodiscard]] const char* name() const override
{
return name_;
}
[[nodiscard]] const char* name() const override { return name_; }
void poll() override;
[[nodiscard]] const std::array<PadInfo, kMaxPads>& pads() const override
{
return pads_;
}
[[nodiscard]] const std::array<PadInfo, kMaxPads>& pads() const override { return pads_; }
// Forward rumble to the guest: SteamInput TriggerVibration on the slot's
// controller when it's Steam-active, else the XInput fallback.
void set_rumble(int slot, std::uint16_t left, std::uint16_t right) override;
[[nodiscard]] bool steam_active() const
{
return steam_ready_;
}
[[nodiscard]] int steam_controllers() const
{
return steam_count_;
}
[[nodiscard]] bool steam_active() const { return steam_ready_; }
[[nodiscard]] int steam_controllers() const { return steam_count_; }
private:
private:
void resolve_handles();
bool read_steam_pad(std::uint64_t controller, PadInfo& out) const;

View File

@@ -3,19 +3,16 @@
#include <windows.h>
#include <xinput.h>
namespace coop
{
namespace coop {
void XInputSource::poll()
{
for (DWORD i = 0; i < kMaxPads; ++i)
{
for (DWORD i = 0; i < kMaxPads; ++i) {
XINPUT_STATE state = {};
const DWORD result = XInputGetState(i, &state);
PadInfo& info = pads_[i];
if (result == ERROR_SUCCESS)
{
if (result == ERROR_SUCCESS) {
info.connected = true;
info.source = "XInput #" + std::to_string(i);
@@ -31,9 +28,7 @@ void XInputSource::poll()
info.state.thumb_ly = g.sThumbLY;
info.state.thumb_rx = g.sThumbRX;
info.state.thumb_ry = g.sThumbRY;
}
else
{
} else {
info = PadInfo{};
}
}
@@ -41,8 +36,7 @@ void XInputSource::poll()
void XInputSource::set_rumble(int slot, std::uint16_t left, std::uint16_t right)
{
if (slot < 0 || slot >= static_cast<int>(kMaxPads))
{
if (slot < 0 || slot >= static_cast<int>(kMaxPads)) {
return;
}
XINPUT_VIBRATION v{left, right};

View File

@@ -2,30 +2,22 @@
#include "input/input_source.hpp"
namespace coop
{
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";
}
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_;
}
[[nodiscard]] const std::array<PadInfo, kMaxPads>& pads() const override { return pads_; }
// Forward rumble to the XInput device at `slot` (the guest's RPT virtual pad).
void set_rumble(int slot, std::uint16_t left, std::uint16_t right) override;
private:
private:
std::array<PadInfo, kMaxPads> pads_;
};

View File

@@ -3,11 +3,9 @@
#include <atomic>
#include <cstdint>
namespace coop
{
namespace coop {
namespace
{
namespace {
// The hook writes these cumulative diagnostic counters cross-process (an x86 DLL can do a 64-bit
// store in two halves), so read them atomically to avoid a torn value. The shared mapping is
// genuinely mutable -- the const here is just our read-only view -- so const_cast for atomic_ref.
@@ -22,8 +20,7 @@ bool IpcServer::start(unsigned long target_pid)
std::scoped_lock lock(mutex_);
stop_locked();
if (!shm_.create(shared_memory_name(target_pid), sizeof(SharedBlock)))
{
if (!shm_.create(shared_memory_name(target_pid), sizeof(SharedBlock))) {
return false;
}
@@ -40,8 +37,7 @@ bool IpcServer::start(unsigned long target_pid)
// Log ring: the injected hook opens this and streams its log lines back for the
// Log window. Best-effort -- the rest of the tool works without it.
if (log_shm_.create(log_ring_name(target_pid), log_ring_total_size(kLogCapacity)))
{
if (log_shm_.create(log_ring_name(target_pid), log_ring_total_size(kLogCapacity))) {
log_ring_ = log_shm_.as<LogRing>();
log_ring_init(*log_ring_, kLogCapacity);
log_cursor_ = 0;
@@ -52,13 +48,11 @@ bool IpcServer::start(unsigned long target_pid)
void IpcServer::publish(const std::array<PadInfo, kMaxPads>& pads)
{
std::scoped_lock lock(mutex_);
if (block_ == nullptr)
{
if (block_ == nullptr) {
return;
}
CoopPadState states[kMaxPads];
for (std::size_t i = 0; i < pads.size(); ++i)
{
for (std::size_t i = 0; i < pads.size(); ++i) {
states[i] = pads[i].state;
states[i].connected = pads[i].connected ? 1 : 0;
}
@@ -69,21 +63,18 @@ HookStatusView IpcServer::hook_status() const
{
std::scoped_lock lock(mutex_);
HookStatusView view;
if (block_ == nullptr)
{
if (block_ == nullptr) {
return view;
}
const HookStatus& s = block_->status;
view.attached = s.attached != 0;
view.focus_spoof = s.focus_spoof != 0;
view.heartbeat = s.heartbeat.load(std::memory_order_relaxed);
for (std::uint32_t i = 0; i < kMaxPads; ++i)
{
for (std::uint32_t i = 0; i < kMaxPads; ++i) {
view.get_state[i] = s.get_state_calls[i].load(std::memory_order_relaxed);
view.get_caps[i] = s.get_caps_calls[i].load(std::memory_order_relaxed);
}
for (std::uint32_t i = 0; i < FocusApi_Count; ++i)
{
for (std::uint32_t i = 0; i < FocusApi_Count; ++i) {
view.focus_calls[i] = s.focus_query_calls[i].load(std::memory_order_relaxed);
}
view.game_pid = s.game_pid;
@@ -94,18 +85,15 @@ HookStatusView IpcServer::hook_status() const
view.dinput_loaded = s.dinput_loaded != 0;
view.vk_too_late = s.vk_too_late != 0;
view.audio_streams_seen = s.audio_streams_seen;
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
{
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i) {
view.audio_streams[i] = s.audio_streams[i];
view.audio_streams[i].frames_rendered = atomic_load_u64(s.audio_streams[i].frames_rendered);
}
view.hook_entry_count = s.hook_entry_count;
for (std::uint32_t i = 0; i < kMaxHookEntries; ++i)
{
for (std::uint32_t i = 0; i < kMaxHookEntries; ++i) {
view.hook_entries[i] = s.hook_entries[i];
}
for (std::uint32_t i = 0; i < kMaxPads; ++i)
{
for (std::uint32_t i = 0; i < kMaxPads; ++i) {
view.rumble_left[i] = s.rumble_left[i];
view.rumble_right[i] = s.rumble_right[i];
view.read_state[i] = s.read_state[i];
@@ -117,8 +105,7 @@ VideoShareView IpcServer::video_share() const
{
std::scoped_lock lock(mutex_);
VideoShareView v;
if (block_ == nullptr)
{
if (block_ == nullptr) {
return v;
}
const VideoShare& s = block_->video;
@@ -135,8 +122,7 @@ VideoShareView IpcServer::video_share() const
void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled)
{
std::scoped_lock lock(mutex_);
if (block_ != nullptr && subsystem < HookSubsys_Count)
{
if (block_ != nullptr && subsystem < HookSubsys_Count) {
// 0 = install, 1 = remove.
block_->control.subsystem_disabled[subsystem].store(enabled ? 0u : 1u, std::memory_order_release);
}
@@ -145,12 +131,10 @@ void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled)
void IpcServer::request_unhook_all()
{
std::scoped_lock lock(mutex_);
if (block_ == nullptr)
{
if (block_ == nullptr) {
return;
}
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
{
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s) {
block_->control.subsystem_disabled[s].store(1u, std::memory_order_release); // 1 = remove
}
}
@@ -158,20 +142,16 @@ void IpcServer::request_unhook_all()
bool IpcServer::all_hooks_removed() const
{
std::scoped_lock lock(mutex_);
if (block_ == nullptr)
{
if (block_ == nullptr) {
return true; // not connected -> nothing of ours is hooked
}
const HookStatus& s = block_->status;
std::uint32_t count = s.hook_entry_count;
if (count > kMaxHookEntries)
{
if (count > kMaxHookEntries) {
count = kMaxHookEntries;
}
for (std::uint32_t i = 0; i < count; ++i)
{
if (s.hook_entries[i].installed != 0)
{
for (std::uint32_t i = 0; i < count; ++i) {
if (s.hook_entries[i].installed != 0) {
return false;
}
}
@@ -181,8 +161,7 @@ bool IpcServer::all_hooks_removed() const
void IpcServer::host_log(std::uint32_t level, const char* text)
{
std::scoped_lock lock(mutex_);
if (log_ring_ != nullptr)
{
if (log_ring_ != nullptr) {
log_ring_push(*log_ring_, GetCurrentProcessId(), level, GetTickCount64(), text);
}
}
@@ -195,8 +174,7 @@ void IpcServer::stop()
void IpcServer::stop_locked()
{
if (block_ != nullptr)
{
if (block_ != nullptr) {
block_->magic = 0; // invalidate so a late hook read won't trust stale data
block_ = nullptr;
}

View File

@@ -11,12 +11,10 @@
#include "coop/shared_memory.hpp"
#include "input/input_source.hpp"
namespace coop
{
namespace coop {
// Plain (non-atomic) snapshot of the hook's back-channel for the overlay.
struct HookStatusView
{
struct HookStatusView {
bool attached = false; // XInput hooks installed in the game
bool focus_spoof = false; // focus spoofing active
std::uint32_t heartbeat = 0; // DLL liveness counter
@@ -48,20 +46,18 @@ struct HookStatusView
};
// Plain snapshot of the Present-hook video channel for the Video mirror panel.
struct VideoShareView
{
std::uint32_t generation = 0; // bumps per shared frame; 0 = nothing shared yet
std::uint32_t width = 0; // shared texture dimensions / DXGI format
struct VideoShareView {
std::uint32_t generation = 0; // bumps per shared frame; 0 = nothing shared yet
std::uint32_t width = 0; // shared texture dimensions / DXGI format
std::uint32_t height = 0;
std::uint32_t format = 0;
std::uint64_t present_calls = 0; // cumulative Present() detours (diagnostic)
std::int64_t present_qpc = 0; // QPC stamp of the last published frame
std::uint64_t frames_dropped = 0; // cumulative captures skipped (mutex busy at present)
std::uint64_t present_calls = 0; // cumulative Present() detours (diagnostic)
std::int64_t present_qpc = 0; // QPC stamp of the last published frame
std::uint64_t frames_dropped = 0; // cumulative captures skipped (mutex busy at present)
};
class IpcServer
{
public:
class IpcServer {
public:
// Creates and initializes the section for `target_pid`. The injected hook
// derives the same name from its own pid and opens it.
bool start(unsigned long target_pid);
@@ -98,8 +94,7 @@ public:
void push_mkb(const MkbEvent& ev)
{
std::scoped_lock lock(mutex_);
if (block_ != nullptr)
{
if (block_ != nullptr) {
push_mkb_event(block_->mkb, ev);
}
}
@@ -109,8 +104,7 @@ public:
void set_cursor_clip_allowed(bool allowed)
{
std::scoped_lock lock(mutex_);
if (block_ != nullptr)
{
if (block_ != nullptr) {
block_->control.allow_cursor_clip.store(allowed ? 1u : 0u, std::memory_order_release);
}
}
@@ -120,8 +114,7 @@ public:
template <typename F>
void drain_logs(F&& emit)
{
if (log_ring_ != nullptr)
{
if (log_ring_ != nullptr) {
log_ring_drain(*log_ring_, log_cursor_, emit);
}
}
@@ -130,16 +123,10 @@ public:
// shows color-coded in the Log window next to the hook's lines. No-op if not started.
void host_log(std::uint32_t level, const char* text);
[[nodiscard]] bool running() const
{
return block_ != nullptr;
}
[[nodiscard]] unsigned long target_pid() const
{
return target_pid_;
}
[[nodiscard]] bool running() const { return block_ != nullptr; }
[[nodiscard]] unsigned long target_pid() const { return target_pid_; }
private:
private:
void stop_locked(); // tear-down body shared by start()/stop(); caller holds mutex_
// Guards the mapping pointer (block_) and its accesses. The input worker thread
@@ -152,9 +139,9 @@ private:
SharedBlock* block_ = nullptr;
unsigned long target_pid_ = 0;
SharedMemory log_shm_; // shared log ring (named coop_log_<pid>)
SharedMemory log_shm_; // shared log ring (named coop_log_<pid>)
LogRing* log_ring_ = nullptr;
std::uint64_t log_cursor_ = 0; // consumer position into the log ring
std::uint64_t log_cursor_ = 0; // consumer position into the log ring
};
} // namespace coop

View File

@@ -8,13 +8,11 @@
#include "ui/app_chrome.hpp"
#include "ui/text_match.hpp"
namespace coop
{
namespace coop {
void LogPanel::add_line(const LogRecord& rec)
{
if (first_millis_ == 0)
{
if (first_millis_ == 0) {
first_millis_ = rec.millis;
}
const double secs = static_cast<double>(rec.millis - first_millis_) / 1000.0;
@@ -22,8 +20,7 @@ void LogPanel::add_line(const LogRecord& rec)
char buf[256];
std::snprintf(buf, sizeof(buf), "[%8.3f] %s", secs, rec.text);
lines_.push_back({buf, rec.level});
while (lines_.size() > kMaxLines)
{
while (lines_.size() > kMaxLines) {
lines_.pop_front();
}
}
@@ -38,8 +35,7 @@ void LogPanel::draw()
apply_panel_layout(Panel::Log);
ImGui::Begin("Log");
if (ImGui::Button("Clear"))
{
if (ImGui::Button("Clear")) {
lines_.clear();
first_millis_ = 0;
}
@@ -50,17 +46,13 @@ void LogPanel::draw()
ImGui::InputTextWithHint("##logfilter", "filter...", filter_, sizeof(filter_));
ImGui::Separator();
if (ImGui::BeginChild("loglines", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar))
{
if (ImGui::BeginChild("loglines", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar)) {
const bool has_filter = filter_[0] != '\0';
for (const Line& line : lines_)
{
if (has_filter && !contains_ci(line.text, filter_))
{
for (const Line& line : lines_) {
if (has_filter && !contains_ci(line.text, filter_)) {
continue;
}
switch (line.level)
{
switch (line.level) {
case LogLevel_Warn:
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", line.text.c_str()); // amber
break;
@@ -73,8 +65,7 @@ void LogPanel::draw()
}
}
// Stick to the bottom while new lines arrive (unless the user scrolled up).
if (autoscroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 1.0f)
{
if (autoscroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 1.0f) {
ImGui::SetScrollHereY(1.0f);
}
}

View File

@@ -9,24 +9,21 @@
#include "coop/log_ring.hpp"
namespace coop
{
namespace coop {
class InjectionPanel;
class LogPanel
{
public:
class LogPanel {
public:
// Pull any new lines the hook emitted (call once per frame before draw()).
void pull(InjectionPanel& injection);
void draw();
private:
private:
void add_line(const LogRecord& rec);
struct Line
{
struct Line {
std::string text;
std::uint32_t level; // LogLevel, for colouring
};

View File

@@ -38,8 +38,7 @@
#include "util/utf8.hpp"
#include "vk_layer_setup.hpp"
namespace
{
namespace {
// Timestamped screenshot path next to the exe (e.g. coop_shot_20260622_143501.png).
std::wstring screenshot_path()
@@ -47,8 +46,8 @@ std::wstring screenshot_path()
SYSTEMTIME st{};
GetLocalTime(&st);
wchar_t name[64];
swprintf(name, static_cast<int>(std::size(name)), L"coop_shot_%04u%02u%02u_%02u%02u%02u.png", st.wYear,
st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
swprintf(name, static_cast<int>(std::size(name)), L"coop_shot_%04u%02u%02u_%02u%02u%02u.png", st.wYear, st.wMonth,
st.wDay, st.wHour, st.wMinute, st.wSecond);
return coop::exe_directory() + name;
}
@@ -57,12 +56,11 @@ std::string screenshot_basename(const std::wstring& path)
{
const std::size_t slash = path.find_last_of(L"\\/");
const std::wstring file = slash == std::wstring::npos ? path : path.substr(slash + 1);
if (file.empty())
{
if (file.empty()) {
return {};
}
const int n = WideCharToMultiByte(CP_UTF8, 0, file.c_str(), static_cast<int>(file.size()), nullptr, 0,
nullptr, nullptr);
const int n =
WideCharToMultiByte(CP_UTF8, 0, file.c_str(), static_cast<int>(file.size()), nullptr, 0, nullptr, nullptr);
std::string out(static_cast<std::size_t>(n), '\0');
WideCharToMultiByte(CP_UTF8, 0, file.c_str(), static_cast<int>(file.size()), out.data(), n, nullptr, nullptr);
return out;
@@ -74,16 +72,15 @@ std::string screenshot_basename(const std::wstring& path)
void draw_screenshot_toast(double seconds_since, const std::string& name)
{
const float fade = 1.0f - static_cast<float>(seconds_since) / 2.5f;
if (fade <= 0.0f || name.empty())
{
if (fade <= 0.0f || name.empty()) {
return;
}
const ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + 12.0f, vp->WorkPos.y + vp->WorkSize.y - 44.0f));
ImGui::SetNextWindowBgAlpha(0.45f * fade);
const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav;
const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs
| ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings
| ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav;
ImGui::Begin("##shot_toast", nullptr, flags);
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.6f, 1.0f, 0.6f, fade));
ImGui::Text("Saved screenshot: %s", name.c_str());
@@ -103,98 +100,84 @@ std::string apply_test_command(const std::string& cmd, coop::UiState& ui, coop::
{
std::istringstream is(cmd);
std::string t;
while (is >> t)
{
while (is >> t) {
tok.push_back(t);
}
}
if (tok.empty())
{
if (tok.empty()) {
return "empty";
}
const std::string& v = tok[0];
auto arg = [&](std::size_t i) -> std::string { return i < tok.size() ? tok[i] : std::string(); };
auto num = [&](std::size_t i) -> unsigned { return static_cast<unsigned>(std::strtoul(arg(i).c_str(), nullptr, 10)); };
auto num = [&](std::size_t i) -> unsigned {
return static_cast<unsigned>(std::strtoul(arg(i).c_str(), nullptr, 10));
};
if (v == "inject")
{
if (v == "inject") {
const unsigned long pid = injection.dev_inject_by_name(widen(arg(1)));
return pid != 0 ? ("ok pid " + std::to_string(pid)) : "fail no-process-or-inject-failed";
}
if (v == "audio")
{
if (v == "audio") {
const bool on = arg(1) == "on";
if (on)
{
if (on) {
audio.dev_set_pid(injection.target_pid());
}
audio.dev_set_enabled(on);
return "ok";
}
if (v == "video")
{
if (v == "video") {
// Install/remove the hooked video subsystem (Present/GL/D3D9/Vulkan capture hooks).
injection.request_video(arg(1) == "on");
return "ok";
}
if (v == "debug")
{
if (v == "debug") {
ui.debug_details = (arg(1) == "on");
return "ok";
}
if (v == "autoattach")
{
if (v == "autoattach") {
injection.dev_set_auto_reattach(arg(1) == "on");
return "ok";
}
if (v == "remeasure")
{
if (v == "remeasure") {
audio.dev_request_op(num(1), coop::AudioRingOp_Remeasure, 0, 0, 0, 0);
return "ok";
}
if (v == "override")
{
if (v == "override") {
const std::uint32_t tag = arg(5) == "float" ? static_cast<std::uint32_t>(WAVE_FORMAT_IEEE_FLOAT)
: static_cast<std::uint32_t>(WAVE_FORMAT_PCM);
audio.dev_request_op(num(1), coop::AudioRingOp_Override, num(2), num(3), num(4), tag);
return "ok";
}
if (v == "screenshot")
{
if (v == "screenshot") {
const std::wstring p = screenshot_path();
window.request_screenshot(p);
return "ok";
}
if (v == "uisize")
{
if (v == "uisize") {
// Force a reference layout size so the UI-fit check is monitor-independent.
coop::set_layout_reference(static_cast<float>(num(1)), static_cast<float>(num(2)));
return "ok";
}
if (v == "uifit")
{
if (v == "uifit") {
// Report any panel whose content overflowed its assigned size last frame.
char buf[256];
coop::panel_fit_report(buf, sizeof(buf));
return buf;
}
if (v == "quit")
{
if (v == "quit") {
ui.request_quit = true;
return "ok";
}
if (v == "status")
{
if (v == "status") {
const coop::HookStatusView st = injection.hook_status();
const std::string reason = audio.dev_reason();
char buf[512];
std::snprintf(buf, sizeof(buf),
"audio_running=%d source=%s rate=%u ch=%u state=%u streams=%u inj_pid=%lu inj_state=%d "
"reason=%s",
audio.dev_running() ? 1 : 0, audio.dev_source().c_str(), audio.dev_rate(),
audio.dev_channels(), st.audio_streams[0].format_state, st.audio_streams_seen,
injection.target_pid(), static_cast<int>(injection.target_state()),
reason.empty() ? "-" : reason.c_str());
audio.dev_running() ? 1 : 0, audio.dev_source().c_str(), audio.dev_rate(), audio.dev_channels(),
st.audio_streams[0].format_state, st.audio_streams_seen, injection.target_pid(),
static_cast<int>(injection.target_state()), reason.empty() ? "-" : reason.c_str());
return buf;
}
return "unknown-command";
@@ -209,8 +192,7 @@ std::string steam_manifest_path()
const DWORD len = GetModuleFileNameA(nullptr, buffer, MAX_PATH);
std::string path(buffer, len);
const std::size_t slash = path.find_last_of("\\/");
if (slash != std::string::npos)
{
if (slash != std::string::npos) {
path.resize(slash + 1);
}
return path + "steam_input_actions.vdf";
@@ -226,16 +208,15 @@ void draw_vk_too_late_banner()
{
const ImGuiViewport* vp = ImGui::GetMainViewport();
float w = vp->WorkSize.x - 40.0f;
if (w > 760.0f)
{
if (w > 760.0f) {
w = 760.0f;
}
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + vp->WorkSize.x * 0.5f, vp->WorkPos.y + 16.0f),
ImGuiCond_Always, ImVec2(0.5f, 0.0f));
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + vp->WorkSize.x * 0.5f, vp->WorkPos.y + 16.0f), ImGuiCond_Always,
ImVec2(0.5f, 0.0f));
ImGui::SetNextWindowSize(ImVec2(w, 0.0f));
const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_AlwaysAutoResize;
const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs
| ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing
| ImGuiWindowFlags_NoNav | ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.28f, 0.03f, 0.03f, 0.92f));
ImGui::Begin("##vk_too_late", nullptr, flags);
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.5f, 0.45f, 1.0f));
@@ -255,15 +236,14 @@ void draw_vk_too_late_banner()
void draw_overlay_hidden_hint(double seconds_hidden)
{
const float fade = 1.0f - static_cast<float>(seconds_hidden) / 4.0f;
if (fade <= 0.0f)
{
if (fade <= 0.0f) {
return; // fully faded -> truly clean window for RPT capture
}
ImGui::SetNextWindowPos(ImVec2(12.0f, 12.0f));
ImGui::SetNextWindowBgAlpha(0.35f * fade);
const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav;
const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs
| ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings
| ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav;
ImGui::Begin("##overlay_hint", nullptr, flags);
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 1.0f, fade));
ImGui::TextUnformatted("F1: show overlay");
@@ -281,11 +261,9 @@ bool wait_for_hooked_frame(coop::D3D11Window& window, coop::InjectionPanel& inje
QueryPerformanceFrequency(&freq);
QueryPerformanceCounter(&start);
constexpr double kTimeoutMs = 200.0; // present anyway if the game stalls / is paused
for (;;)
{
for (;;) {
const std::uint32_t gen = injection.video_share().generation;
if (gen != last_gen)
{
if (gen != last_gen) {
last_gen = gen;
return true;
}
@@ -293,13 +271,11 @@ bool wait_for_hooked_frame(coop::D3D11Window& window, coop::InjectionPanel& inje
QueryPerformanceCounter(&now);
const double elapsed =
static_cast<double>(now.QuadPart - start.QuadPart) * 1000.0 / static_cast<double>(freq.QuadPart);
if (elapsed >= kTimeoutMs)
{
if (elapsed >= kTimeoutMs) {
last_gen = gen;
return true;
}
if (!window.pump_messages())
{
if (!window.pump_messages()) {
return false; // WM_QUIT
}
Sleep(1); // yield ~1 ms (timeBeginPeriod(1) keeps this granular) instead of busy-spinning
@@ -313,8 +289,7 @@ int run()
coop::cleanup_stale_vk_layer();
coop::D3D11Window window;
if (!window.create(L"CoopAllTheThings"))
{
if (!window.create(L"CoopAllTheThings")) {
MessageBoxW(nullptr, L"Failed to create the D3D11 window.", L"CoopAllTheThings", MB_ICONERROR);
return 1;
}
@@ -324,8 +299,7 @@ int run()
// Declaring ui first means it's destroyed AFTER imgui, so that final save never reads freed state.
coop::UiState ui;
coop::ImGuiLayer imgui;
if (!imgui.init(window.hwnd(), window.device(), window.context()))
{
if (!imgui.init(window.hwnd(), window.device(), window.context())) {
MessageBoxW(nullptr, L"Failed to initialize ImGui.", L"CoopAllTheThings", MB_ICONERROR);
return 1;
}
@@ -335,8 +309,7 @@ int run()
coop::AudioPanel audio;
coop::CapturePanel capture;
coop::LogPanel log;
if (!capture.init(window.device()))
{
if (!capture.init(window.device())) {
MessageBoxW(nullptr, L"Failed to initialize the video mirror.", L"CoopAllTheThings", MB_ICONERROR);
return 1;
}
@@ -376,22 +349,18 @@ int run()
// Frame-sync: the hook generation we last presented (so we wait for the next one).
std::uint32_t last_synced_gen = 0;
while (window.pump_messages())
{
while (window.pump_messages()) {
// Rescale the overlay if the window changed DPI (moved monitors, or the display scale changed).
// pump_messages() latches the new DPI; apply it here, outside any in-progress ImGui frame.
if (unsigned new_dpi = 0; window.take_dpi_change(new_dpi))
{
if (unsigned new_dpi = 0; window.take_dpi_change(new_dpi)) {
imgui.set_dpi(new_dpi);
}
// When the operator enabled "Sync flip to game frames" (Hooked source), pace the
// whole iteration to the game: wait for the next published frame before rendering,
// then present without vsync so the flip lands in lockstep with the game.
if (capture.frame_sync_active())
{
if (!wait_for_hooked_frame(window, injection, last_synced_gen))
{
if (capture.frame_sync_active()) {
if (!wait_for_hooked_frame(window, injection, last_synced_gen)) {
break;
}
}
@@ -401,12 +370,9 @@ int run()
const coop::InputSnapshot input_snapshot = input_worker.snapshot();
#ifdef COOP_WITH_STEAM
input_worker.set_want_steam(controllers.steam_input_requested());
if (input_worker.steam_failed())
{
if (input_worker.steam_failed()) {
controllers.on_steam_init_failed(); // resets the toggle; worker falls back to XInput
}
else
{
} else {
controllers.set_steam_active(input_snapshot.steam_active);
}
#endif
@@ -422,58 +388,45 @@ int run()
log.pull(injection); // drain hook log lines even while the Log window is hidden
#ifdef COOP_TEST_HARNESS
if (std::string tcmd = harness.poll_command(); !tcmd.empty())
{
if (std::string tcmd = harness.poll_command(); !tcmd.empty()) {
harness.write_response(apply_test_command(tcmd, ui, injection, audio, window));
}
#endif
if (ImGui::IsKeyPressed(ImGuiKey_F1, false))
{
if (ImGui::IsKeyPressed(ImGuiKey_F1, false)) {
show_overlay = !show_overlay;
if (!show_overlay)
{
if (!show_overlay) {
overlay_hidden_at = ImGui::GetTime();
}
}
if (ImGui::IsKeyPressed(ImGuiKey_F2, false))
{
if (ImGui::IsKeyPressed(ImGuiKey_F2, false)) {
injection.toggle_cursor_release(); // free/clip the operator's mouse for clipping games
}
if (ImGui::IsKeyPressed(ImGuiKey_F10, false))
{
if (ImGui::IsKeyPressed(ImGuiKey_F10, false)) {
window.request_screenshot(screenshot_path()); // captured at Present, overlay included
}
coop::reset_panel_fit(); // panels record their overflow as they draw (UI-fit check)
coop::set_layout_debug(ui.debug_details); // center split adapts to the debug verbosity
if (show_overlay)
{
if (show_overlay) {
coop::draw_main_menu_bar(ui, stats);
if (ui.show_controllers)
{
if (ui.show_controllers) {
controllers.draw(input_snapshot, injection.hook_status(), ui.debug_details);
}
if (ui.show_injection)
{
if (ui.show_injection) {
injection.draw(ui.debug_details);
}
if (ui.show_audio)
{
if (ui.show_audio) {
audio.draw_ui(injection.hook_status(), ui.debug_details);
}
if (ui.show_video)
{
if (ui.show_video) {
capture.draw_ui(stats);
}
if (ui.show_log)
{
if (ui.show_log) {
log.draw();
}
draw_screenshot_toast(ImGui::GetTime() - last_shot_at, last_shot_name);
}
else
{
} else {
draw_overlay_hidden_hint(ImGui::GetTime() - overlay_hidden_at);
}
if (injection.hook_status().vk_too_late) // Vulkan game injected too late -> relaunch prompt
@@ -510,8 +463,7 @@ int run()
// A host-side TDR / driver reset / GPU hang surfaces as a lost device on Present. We don't
// attempt to recreate the device (it would have to re-init ImGui + the capture pipeline);
// surface it and stop cleanly rather than spin forever rendering nothing.
if (window.device_lost())
{
if (window.device_lost()) {
wchar_t msg[320];
swprintf_s(msg,
L"The graphics device was lost (0x%08lX) -- a driver reset, GPU hang, or TDR on "
@@ -523,8 +475,7 @@ int run()
// render_frame saves a pending F10 screenshot just before Present; pick up the
// result here so next frame shows the confirmation toast (kept out of the shot).
if (std::wstring shot = window.take_screenshot_result(); !shot.empty())
{
if (std::wstring shot = window.take_screenshot_result(); !shot.empty()) {
last_shot_at = ImGui::GetTime();
last_shot_name = screenshot_basename(shot);
}

View File

@@ -6,10 +6,8 @@
#include <windows.h>
namespace coop
{
namespace
{
namespace coop {
namespace {
std::wstring temp_file(const wchar_t* name)
{
wchar_t dir[MAX_PATH] = {};
@@ -29,8 +27,7 @@ void TestHarness::init()
std::string TestHarness::poll_command()
{
std::ifstream f(cmd_path_.c_str()); // MSVC accepts a wide path
if (!f)
{
if (!f) {
return {};
}
std::string line;

View File

@@ -12,12 +12,10 @@
#include <string>
namespace coop
{
namespace coop {
class TestHarness
{
public:
class TestHarness {
public:
#ifdef COOP_TEST_HARNESS
void init(); // resolve the %TEMP% file paths and clear any stale command
// Main thread: returns the next pending command line (acking by deleting the cmd
@@ -26,16 +24,13 @@ public:
// Main thread: write the response for the command just handled.
void write_response(const std::string& resp);
private:
private:
std::wstring cmd_path_;
std::wstring resp_path_;
#else
// No-op shims so call sites don't need their own #ifdef.
void init() {}
std::string poll_command()
{
return {};
}
std::string poll_command() { return {}; }
void write_response(const std::string&) {}
#endif
};

View File

@@ -8,11 +8,9 @@
#include "imgui.h"
#include "imgui_internal.h" // ImGuiSettingsHandler / AddSettingsHandler (custom .ini section)
namespace coop
{
namespace coop {
namespace
{
namespace {
// --- Custom .ini persistence for the UI switches ---------------------------
// We piggy-back on ImGui's .ini so the "Debug details" verbosity survives restarts
// without inventing a separate settings file. The section looks like:
@@ -33,8 +31,7 @@ void ui_settings_read_line(ImGuiContext*, ImGuiSettingsHandler*, void* entry, co
auto* ui = static_cast<UiState*>(entry);
// Manual parse (avoids the sscanf CRT-secure deprecation for a single int key).
constexpr char kKey[] = "DebugDetails=";
if (std::strncmp(line, kKey, sizeof(kKey) - 1) == 0)
{
if (std::strncmp(line, kKey, sizeof(kKey) - 1) == 0) {
ui->debug_details = std::atoi(line + sizeof(kKey) - 1) != 0;
}
}
@@ -66,8 +63,7 @@ float g_ref_h = 0.0f;
bool g_layout_debug = false;
// Per-frame panel-overflow registry (UI-fit instrumentation).
struct PanelFit
{
struct PanelFit {
char name[24];
float over_x;
float over_y;
@@ -79,8 +75,7 @@ int g_fit_count = 0;
void register_ui_settings(UiState& ui)
{
// Idempotent: don't stack a second handler if this is somehow called twice.
if (ImGui::FindSettingsHandler(kUiSettingsType) != nullptr)
{
if (ImGui::FindSettingsHandler(kUiSettingsType) != nullptr) {
return;
}
ImGuiSettingsHandler handler;
@@ -106,8 +101,7 @@ void set_layout_persisted(bool had_persisted_layout)
void apply_layout_end_frame()
{
g_layout_reset = false;
if (g_startup_force > 0)
{
if (g_startup_force > 0) {
--g_startup_force;
}
}
@@ -135,8 +129,7 @@ void record_panel_fit(const char* name)
// so > 0 on either axis means content is cut off at the assigned size.
const float ox = ImGui::GetScrollMaxX();
const float oy = ImGui::GetScrollMaxY();
if (g_fit_count >= static_cast<int>(sizeof(g_fits) / sizeof(g_fits[0])))
{
if (g_fit_count >= static_cast<int>(sizeof(g_fits) / sizeof(g_fits[0]))) {
return;
}
PanelFit& f = g_fits[g_fit_count++];
@@ -148,17 +141,14 @@ void record_panel_fit(const char* name)
bool panel_fit_overflow(float* worst_x, float* worst_y)
{
float mx = 0.0f, my = 0.0f;
for (int i = 0; i < g_fit_count; ++i)
{
for (int i = 0; i < g_fit_count; ++i) {
mx = std::max(mx, g_fits[i].over_x);
my = std::max(my, g_fits[i].over_y);
}
if (worst_x != nullptr)
{
if (worst_x != nullptr) {
*worst_x = mx;
}
if (worst_y != nullptr)
{
if (worst_y != nullptr) {
*worst_y = my;
}
return mx > 0.5f || my > 0.5f;
@@ -166,24 +156,20 @@ bool panel_fit_overflow(float* worst_x, float* worst_y)
void panel_fit_report(char* buf, int cap)
{
if (buf == nullptr || cap <= 0)
{
if (buf == nullptr || cap <= 0) {
return;
}
int n = 0;
bool any = false;
for (int i = 0; i < g_fit_count && n < cap - 1; ++i)
{
if (g_fits[i].over_x <= 0.5f && g_fits[i].over_y <= 0.5f)
{
for (int i = 0; i < g_fit_count && n < cap - 1; ++i) {
if (g_fits[i].over_x <= 0.5f && g_fits[i].over_y <= 0.5f) {
continue;
}
any = true;
n += std::snprintf(buf + n, static_cast<size_t>(cap - n), "%s%s:%.0f,%.0f", n > 0 ? " " : "",
g_fits[i].name, g_fits[i].over_x, g_fits[i].over_y);
n += std::snprintf(buf + n, static_cast<size_t>(cap - n), "%s%s:%.0f,%.0f", n > 0 ? " " : "", g_fits[i].name,
g_fits[i].over_x, g_fits[i].over_y);
}
if (!any)
{
if (!any) {
std::snprintf(buf, static_cast<size_t>(cap), "fit");
}
}
@@ -227,8 +213,7 @@ void apply_panel_layout(Panel panel)
const float audio_h = stack_avail * audio_frac;
ImVec2 pos, size;
switch (panel)
{
switch (panel) {
case Panel::Injection:
pos = ImVec2(left_x, top);
size = ImVec2(left_w, full_h);
@@ -255,8 +240,7 @@ void apply_panel_layout(Panel panel)
// install with no saved layout to restore. Otherwise FirstUseEver lets ImGui's
// restored .ini positions stand (and still seeds any brand-new panel). A forced
// reference size (UI-fit check) also forces, so the assigned sizes are exact.
const bool force =
g_layout_reset || g_ref_w > 0.0f || (!g_had_persisted_layout && g_startup_force > 0);
const bool force = g_layout_reset || g_ref_w > 0.0f || (!g_had_persisted_layout && g_startup_force > 0);
const ImGuiCond cond = force ? ImGuiCond_Always : ImGuiCond_FirstUseEver;
ImGui::SetNextWindowPos(pos, cond);
ImGui::SetNextWindowSize(size, cond);
@@ -265,44 +249,37 @@ void apply_panel_layout(Panel panel)
float draw_main_menu_bar(UiState& ui, const FrameStats& stats)
{
float height = 0.0f;
if (!ImGui::BeginMainMenuBar())
{
if (!ImGui::BeginMainMenuBar()) {
return height;
}
ImGui::TextUnformatted("CoopAllTheThings");
ImGui::Separator();
if (ImGui::BeginMenu("File"))
{
if (ImGui::MenuItem("Exit", "Alt+F4"))
{
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("Exit", "Alt+F4")) {
ui.request_quit = true; // the main loop sees this and stops
}
ImGui::EndMenu();
}
if (ImGui::BeginMenu("View"))
{
if (ImGui::BeginMenu("View")) {
ImGui::MenuItem("Controllers", nullptr, &ui.show_controllers);
ImGui::MenuItem("Injection", nullptr, &ui.show_injection);
ImGui::MenuItem("Video mirror", nullptr, &ui.show_video);
ImGui::MenuItem("Audio mirror", nullptr, &ui.show_audio);
ImGui::MenuItem("Log", nullptr, &ui.show_log);
ImGui::Separator();
if (ImGui::MenuItem("Debug details", nullptr, &ui.debug_details))
{
if (ImGui::MenuItem("Debug details", nullptr, &ui.debug_details)) {
ImGui::MarkIniSettingsDirty(); // persist the new verbosity to coop_layout.ini
}
if (ImGui::MenuItem("Reset layout"))
{
if (ImGui::MenuItem("Reset layout")) {
request_layout_reset();
}
ImGui::EndMenu();
}
if (ImGui::BeginMenu("Help"))
{
if (ImGui::BeginMenu("Help")) {
ImGui::TextDisabled("F1 hide/show this overlay");
ImGui::TextDisabled("F2 release/clip the operator cursor");
ImGui::TextDisabled("F10 save a screenshot (PNG, next to the exe)");
@@ -318,16 +295,14 @@ float draw_main_menu_bar(UiState& ui, const FrameStats& stats)
char perf[96];
// Fixed field widths so the readout doesn't jitter/blur as values cross digit
// thresholds (e.g. 99 -> 100) each frame.
std::snprintf(perf, sizeof(perf), "%4.0f FPS %6.2f ms (%6.2f-%6.2f)", stats.fps(), stats.avg_ms(),
stats.min_ms(), stats.max_ms());
std::snprintf(perf, sizeof(perf), "%4.0f FPS %6.2f ms (%6.2f-%6.2f)", stats.fps(), stats.avg_ms(), stats.min_ms(),
stats.max_ms());
const float text_w = ImGui::CalcTextSize(perf).x;
ImGui::SameLine(ImGui::GetWindowWidth() - text_w - ImGui::GetStyle().FramePadding.x * 2.0f);
if (stats.max_ms() > 25.0f) // ~sub-40 FPS spike in the window
{
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", perf);
}
else
{
} else {
ImGui::TextUnformatted(perf);
}

View File

@@ -6,14 +6,12 @@
#include <algorithm>
namespace coop
{
namespace coop {
// Visibility + verbosity shared by all panels. Panels read `debug_details` to
// gate verbose diagnostics; the main loop reads the per-panel flags to decide
// what to draw.
struct UiState
{
struct UiState {
bool show_controllers = true;
bool show_injection = true;
bool show_video = true;
@@ -31,8 +29,7 @@ struct UiState
void register_ui_settings(UiState& ui);
// The overlay panels, for the shared default layout below.
enum class Panel
{
enum class Panel {
Injection, // left column, full height (room for hook diagnostics)
Controllers, // center column, top
Video, // center column, below Controllers
@@ -89,26 +86,22 @@ void panel_fit_report(char* buf, int cap);
// Rolling frame-timing over a ~1 s window, recomputed each window so the status
// bar can show a stable FPS plus the min/max frame time (jitter) underneath it.
class FrameStats
{
public:
class FrameStats {
public:
// Number of frame samples kept for the graphs (~2 s at 120 FPS).
static constexpr int kHistory = 240;
void tick(float dt_ms)
{
if (dt_ms < cur_min_)
{
if (dt_ms < cur_min_) {
cur_min_ = dt_ms;
}
if (dt_ms > cur_max_)
{
if (dt_ms > cur_max_) {
cur_max_ = dt_ms;
}
accum_ms_ += dt_ms;
++frames_;
if (accum_ms_ >= 1000.0f && frames_ > 0)
{
if (accum_ms_ >= 1000.0f && frames_ > 0) {
avg_ms_ = accum_ms_ / static_cast<float>(frames_);
min_ms_ = cur_min_;
max_ms_ = cur_max_;
@@ -120,43 +113,26 @@ public:
history_[hist_pos_] = dt_ms;
hist_pos_ = (hist_pos_ + 1) % kHistory;
if (hist_count_ < kHistory)
{
if (hist_count_ < kHistory) {
++hist_count_;
}
}
// --- 1 s windowed aggregates (stable readout for the menu bar) ---------
[[nodiscard]] float avg_ms() const
{
return avg_ms_;
}
[[nodiscard]] float min_ms() const
{
return min_ms_;
}
[[nodiscard]] float max_ms() const
{
return max_ms_;
}
[[nodiscard]] float fps() const
{
return avg_ms_ > 0.0f ? 1000.0f / avg_ms_ : 0.0f;
}
[[nodiscard]] float avg_ms() const { return avg_ms_; }
[[nodiscard]] float min_ms() const { return min_ms_; }
[[nodiscard]] float max_ms() const { return max_ms_; }
[[nodiscard]] float fps() const { return avg_ms_ > 0.0f ? 1000.0f / avg_ms_ : 0.0f; }
// --- Sample history (for graphs) ---------------------------------------
[[nodiscard]] int history_size() const
{
return hist_count_;
}
[[nodiscard]] int history_size() const { return hist_count_; }
// Copy the frame-time samples (ms) into `out` oldest-to-newest; `out` must
// hold at least kHistory floats. Returns the number written.
int copy_frame_ms(float* out) const
{
const int start = (hist_pos_ - hist_count_ + kHistory * 2) % kHistory;
for (int i = 0; i < hist_count_; ++i)
{
for (int i = 0; i < hist_count_; ++i) {
out[i] = history_[(start + i) % kHistory];
}
return hist_count_;
@@ -165,14 +141,12 @@ public:
// min / max / mean over the whole retained history (order-independent).
void history_stats(float& min_ms, float& max_ms, float& avg_ms) const
{
if (hist_count_ == 0)
{
if (hist_count_ == 0) {
min_ms = max_ms = avg_ms = 0.0f;
return;
}
float mn = 1.0e9f, mx = 0.0f, sum = 0.0f;
for (int i = 0; i < hist_count_; ++i)
{
for (int i = 0; i < hist_count_; ++i) {
const float v = history_[i];
mn = std::min(mn, v);
mx = std::max(mx, v);
@@ -183,7 +157,7 @@ public:
avg_ms = sum / static_cast<float>(hist_count_);
}
private:
private:
float accum_ms_ = 0.0f;
int frames_ = 0;
float cur_min_ = 1.0e9f;

View File

@@ -6,13 +6,11 @@
#include <cctype>
#include <string>
namespace coop
{
namespace coop {
inline std::string ascii_lower(std::string s)
{
for (char& c : s)
{
for (char& c : s) {
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
}
return s;
@@ -21,8 +19,7 @@ inline std::string ascii_lower(std::string s)
// True when `needle` is empty or a case-insensitive substring of `haystack`.
inline bool contains_ci(const std::string& haystack, const char* needle)
{
if (needle == nullptr || needle[0] == '\0')
{
if (needle == nullptr || needle[0] == '\0') {
return true;
}
return ascii_lower(haystack).find(ascii_lower(needle)) != std::string::npos;

View File

@@ -6,14 +6,12 @@
#include <windows.h>
namespace coop
{
namespace coop {
// UTF-16 -> UTF-8.
inline std::string narrow(const std::wstring& w)
{
if (w.empty())
{
if (w.empty()) {
return {};
}
const int n = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast<int>(w.size()), nullptr, 0, nullptr, nullptr);
@@ -25,8 +23,7 @@ inline std::string narrow(const std::wstring& w)
// UTF-8 -> UTF-16.
inline std::wstring widen(const std::string& s)
{
if (s.empty())
{
if (s.empty()) {
return {};
}
const int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), nullptr, 0);

View File

@@ -5,10 +5,8 @@
#include "coop/tool_paths.hpp"
#include "util/utf8.hpp"
namespace coop
{
namespace
{
namespace coop {
namespace {
// The Vulkan loader's per-user implicit-layer registry list. Each value is a manifest path; its
// DWORD data 0 = enabled.
constexpr const wchar_t* kImplicitLayersKey = L"SOFTWARE\\Khronos\\Vulkan\\ImplicitLayers";
@@ -30,14 +28,12 @@ bool register_vk_layer(const std::wstring& target_image)
{
// Write the scoping file (target image basename, UTF-8) the layer checks against its own image.
const std::wstring sf = scoping_file();
if (!sf.empty())
{
if (!sf.empty()) {
const std::size_t slash = target_image.find_last_of(L"\\/");
const std::wstring base = slash == std::wstring::npos ? target_image : target_image.substr(slash + 1);
const std::string utf8 = narrow(base);
HANDLE f = CreateFileW(sf.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (f != INVALID_HANDLE_VALUE)
{
if (f != INVALID_HANDLE_VALUE) {
DWORD written = 0;
WriteFile(f, utf8.data(), static_cast<DWORD>(utf8.size()), &written, nullptr);
CloseHandle(f);
@@ -45,15 +41,14 @@ bool register_vk_layer(const std::wstring& target_image)
}
HKEY key = nullptr;
if (RegCreateKeyExW(HKEY_CURRENT_USER, kImplicitLayersKey, 0, nullptr, 0, KEY_SET_VALUE, nullptr, &key,
nullptr) != ERROR_SUCCESS)
{
if (RegCreateKeyExW(HKEY_CURRENT_USER, kImplicitLayersKey, 0, nullptr, 0, KEY_SET_VALUE, nullptr, &key, nullptr)
!= ERROR_SUCCESS) {
return false;
}
const std::wstring mp = manifest_path();
DWORD enabled = 0; // 0 = enabled, per the loader's convention
const LONG r = RegSetValueExW(key, mp.c_str(), 0, REG_DWORD, reinterpret_cast<const BYTE*>(&enabled),
sizeof(enabled));
const LONG r =
RegSetValueExW(key, mp.c_str(), 0, REG_DWORD, reinterpret_cast<const BYTE*>(&enabled), sizeof(enabled));
RegCloseKey(key);
return r == ERROR_SUCCESS;
}
@@ -61,14 +56,12 @@ bool register_vk_layer(const std::wstring& target_image)
void unregister_vk_layer()
{
HKEY key = nullptr;
if (RegOpenKeyExW(HKEY_CURRENT_USER, kImplicitLayersKey, 0, KEY_SET_VALUE, &key) == ERROR_SUCCESS)
{
if (RegOpenKeyExW(HKEY_CURRENT_USER, kImplicitLayersKey, 0, KEY_SET_VALUE, &key) == ERROR_SUCCESS) {
RegDeleteValueW(key, manifest_path().c_str());
RegCloseKey(key);
}
const std::wstring sf = scoping_file();
if (!sf.empty())
{
if (!sf.empty()) {
DeleteFileW(sf.c_str());
}
}

View File

@@ -10,8 +10,7 @@
#include <string>
namespace coop
{
namespace coop {
// Register the implicit layer (HKCU) and scope it to `target_image` (the game's exe basename,
// e.g. "game.exe"). Returns true on success. Idempotent.