Audio: operator re-measure + format override (host<->hook op channel)

Add a per-stream op channel in AudioRingHeader (op_seq + op_* fields, version
2): the host posts re-measure / override commands, the hook applies them and
re-publishes (bumping format_generation), and the host rebuilds its render
client live on the change. The Audio panel (under Debug details) gains a
"Re-measure rate" button and a rate/channels/bit-depth/format override -- for
when detection is wrong or the channels/bit-depth were unrecoverable.

Also add a debug-only IPC test harness (-DCOOP_TEST_HARNESS, off by default,
absent from the shipped host): a file-based command channel that drives the
host's real UI code paths (inject / audio / re-measure / override / screenshot
/ status) for scripted validation, instead of unreliable synthetic mouse input.
Used to validate live: late-attach to coop_tone@44100 -> measured 44100,
promoted to hooked; override -> 2ch state, re-measure -> reconverge.

Trim the README roadmap to what's left; document the harness + rate_estimator_test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:46:59 +02:00
parent c24bfdd64f
commit 90f40ae479
13 changed files with 607 additions and 111 deletions

105
README.md
View File

@@ -77,8 +77,10 @@ and covers anything the hooked path doesn't (Vulkan, D3D9 — see Roadmap).
device would be mirrored with the wrong layout (garbled audio) on the hooked path —
but never an over-read/crash (a `VirtualQuery` clamp guards the copy), and the
loopback fallback is always format-correct. The Audio panel shows each stream's
format provenance (*known* / *measuring* / *measured rate (ch/bits assumed)*) so the
assumption is visible. Streams created *after* injection are captured exactly.
format provenance (*known* / *measuring* / *measured rate* / *low-confidence* /
*override*) so the assumption is visible, and (under Debug details) lets the operator
**re-measure** the rate or **override** the format when the guess is wrong. Streams
created *after* injection are captured exactly.
- **Debug-oriented UI:** the ImGui overlay is laid out for diagnosing the
pipeline, not for end use. F1 hides it entirely so the window is a clean mirror
for RPT; F2 frees the operator cursor; **F10 saves a PNG screenshot** (back buffer,
@@ -88,71 +90,30 @@ and covers anything the hooked path doesn't (Vulkan, D3D9 — see Roadmap).
### Planned (next up)
**Audio format reliability.** The hooked audio path can mis-detect a pre-existing
stream's sample rate, or fall back to loopback with no explanation. Two root causes:
the rate measurement is fragile, and late injection forces the guess path in the
first place.
- *Why the rate is sometimes wrong.* The measurement window is only ~200 ms. WASAPI
delivers audio in quantized ~10 ms buffers, so one extra buffer at a window edge is
a ~5% error (e.g. 44100 → ~46205 Hz). The snap-to-standard tolerance is ±2%, so a
~5%-off value snaps to *nothing* and is published verbatim instead of rejected, and
it's single-shot (no averaging). Fix: **measure over a longer window (~1–1.5 s),
sample the (frames, QPC) endpoints atomically, require consensus across a few
windows, and refuse to publish a rate that doesn't land near a standard rate** —
keep measuring (or flag low-confidence) rather than committing a bogus value.
- *Why it falls back to loopback silently.* The host waits a fixed 1 s for the hook
to publish a format; the guess+measure path needs ~400–700 ms of *continuous* audio
after the ring attaches, which a momentarily-quiet game can miss. Once on loopback
it never retries, and the UI shows no reason. Fix: **keep the rings live and
auto-promote to the hooked path whenever the hook later publishes a format, surface
the concrete fallback reason** (no format in time / not renderable / hook off) in the
Audio panel + log, and **flag a low-confidence rate (non-standard, didn't snap) in
red** so a bad measurement is obvious at a glance.
- **Re-measure button.** A debug action (Audio panel) to re-run a stream's rate
measurement on demand. Needs a small host→hook control surface: add operator-control
fields to the reserved area of `AudioRingHeader` (per-stream, already shared both
ways) — a bumped `op_seq` plus a "re-measure" request. The hook resets that stream's
measurement window, returns it to *Measuring*, clears `format_valid`, and republishes
once it reconverges; the host re-inits its render client when `format_generation`
bumps (so the corrected rate takes effect live).
- **Per-game format overrides (persisted), incl. the unmeasurable channels/bit-depth.**
A dropdown/inputs in the Audio panel — **shown only under the Debug details flag**,
per stream — to set sample rate, channels, bit-depth, and PCM/float when detection is
wrong or unrecoverable, **keyed by game image name and persisted** to a small store
next to the exe, so a known-bad game is auto-corrected on its next launch. A
**rate-only** override is host-side only (re-init
the render loop at the operator's rate — the bytes are already framed correctly); a
**channels/bits** override goes through the same `AudioRingHeader` op channel so the
*hook* re-frames its capture copy (`block_align`) and drops the over-read clamp, then
the host re-inits on the `format_generation` bump. **A format caught exactly at
`IAudioClient::Initialize` is itself saved as that game's override** (ground truth),
so a later late-attach to the same game is corrected automatically; if that overwrites
a previously-stored override that *differs*, log a warning.
- **Per-game persisted audio overrides + auto-learn.** The Audio panel already lets
the operator re-measure or override a stream's format (Debug details → *Fix the
primary stream*), but the override is session-only. Persist overrides **keyed by game
image name** to a small store next to the exe so a known-bad game is auto-corrected on
its next launch, and **auto-save a format caught exactly at `IAudioClient::Initialize`
as that game's override** (ground truth) so a later late-attach is corrected
automatically. If a caught format overwrites a stored override that *differs*, log a
warning.
- **Auto re-attach the same game on relaunch (session-only).** A checkbox on the
attached (or just-terminated) target, default off, *not* persisted, and **always
visible — not gated behind Debug details** (it's the recommended recovery path, not a
diagnostic). While it's on and the target has terminated, the host watches the process
list for a process of the same image name and injects automatically the moment it
reappears — built on the existing re-attach-by-image-name path, so there's no target
to type (it's the same game that was just attached). The workflow this enables: if
audio came out wrong (late attach forced the guess), tick the box, kill and relaunch
the game, and the host re-attaches *early* — early enough to catch
`IAudioClient::Initialize` and read the exact format, no guessing. This is a
**dependable fallback, not the intended primary workflow**: hardened first-launch
detection (above) should get it right without a relaunch; auto-re-attach is the
reliable recovery when it doesn't, and never asks the operator to guess a format.
- **Color-coded log levels.** The Log window should color warnings and errors (amber /
red) so they stand out from routine lines. `LogRecord` already carries a
(currently-unused) `level` field; add severity variants to the hook's `logf` and have
the host color by level. Used by the override-overwrite warning and the fallback
reasons above.
Ordering: measurement hardening + visible/red fallback reason + log levels first
(fixes the bug outright for most cases, no protocol change) → the `AudioRingHeader` op
channel (re-measure + channels/bits override; bumps `kAudioRingVersion`) → per-game
override persistence + auto-saving caught formats → session auto-re-attach (host-only,
extends the existing re-attach path).
attached (or just-terminated) target, default off, *not* persisted, **always visible**
(it's the recommended recovery path, not a diagnostic). While on and the target has
terminated, the host watches the process list for the same image name and injects the
moment it reappears — built on the existing re-attach-by-image-name path, so there's no
target to type. Workflow: if audio came out wrong (late attach forced the guess), tick
the box, kill and relaunch the game, and the host re-attaches *early* — early enough to
catch `IAudioClient::Initialize` and read the exact format. A dependable fallback, not
the intended primary path (hardened first-launch detection should get it right anyway).
- **Mock game + stress-test suite.** A bare-bones test game that renders an animated
(non-static) pattern — so dropped/duplicated/torn frames are obvious — with a
selectable graphics backend (DX11/DX12 now; structured to add OpenGL/Vulkan) and a
configurable audio output (rate/channels/bit-depth/format, like `coop_tone`). Then a
comprehensive suite that drives it through the real capture + audio + hook paths:
verify both capture backends, every audio variant, hook/unhook cycles, and that
nothing races or crashes under stress.
### Future work
@@ -225,6 +186,11 @@ ctest --test-dir build -C Debug --output-on-failure
push/pop, wrap-around, format handshake, overrun/drop). No device needed.
- **`audio_mix_test`** — unit test of the multi-stream mixer math (decode / sum /
soft-clip / encode for float32 + int16). No device needed.
- **`rate_estimator_test`** — unit test of the robust sample-rate estimator (the fix for
the wrong-rate bug). Feeds synthetic, adversarial render cadences and asserts it
converges to the right standard rate, rejects burst windows (never commits to a wrong
neighbour, incl. the real 46205 misread), flags a genuinely non-standard rate
low-confidence instead of spinning, and ignores idle windows. Pure logic, no device.
- **`audio_hook_test`** — in-process self-test of the WASAPI render-hook's **format
detection**, the part that gets pitch right. Using a shared configurable
`ToneSource` (the same render helper `coop_tone` uses), it renders tones at a matrix
@@ -283,6 +249,13 @@ artifacts; tests build into `bin/<config>/tests/`) and resolve `coop_hook.dll` f
the root one level up, so run them from there. **Kill the game between runs** — the
loaded DLL locks `coop_hook.dll` against the next rebuild.
A debug-only **test harness** drives the host overlay's own code paths (inject / enable
audio / re-measure / override / screenshot / read state) without simulating mouse input,
for scripted UI validation. Build it with `-DCOOP_TEST_HARNESS=ON` (off by default, so
the shipped host never contains it); the host then reads one command line from
`%TEMP%\coop_test_cmd.txt` and replies in `%TEMP%\coop_test_resp.txt`. See
[`host/src/test_harness.hpp`](host/src/test_harness.hpp).
## Running the tool (manual, end-to-end)
This needs Steam, a donor game that supports Remote Play Together, and a second

View File

@@ -24,7 +24,18 @@ namespace coop
inline constexpr std::uint32_t kAudioRingMagic = 0x47525541u;
// Bump whenever AudioRingHeader's layout changes.
inline constexpr std::uint32_t kAudioRingVersion = 1;
inline constexpr std::uint32_t kAudioRingVersion = 2;
// Operator commands the host issues per stream (host -> hook), applied via the op_*
// fields in the header. The host writes the fields then bumps op_seq; the hook applies
// the command once per new op_seq. Lets the Audio panel re-measure a stream's rate or
// override its format when detection is wrong/unrecoverable.
enum AudioRingOp : std::uint32_t
{
AudioRingOp_None = 0,
AudioRingOp_Remeasure = 1, // re-run the sample-rate measurement for this stream
AudioRingOp_Override = 2, // adopt the op_rate/channels/bits/format_tag verbatim
};
// Per-pid mapping name, mirroring kSharedMemoryPrefix: coop_audio_<pid>.
inline constexpr wchar_t kAudioRingPrefix[] = L"Local\\coop_audio_";
@@ -66,7 +77,17 @@ struct AudioRingHeader
std::atomic<std::uint64_t> frames_produced; // cumulative frames pushed
std::atomic<std::uint64_t> overruns; // packets dropped on a full ring
std::uint8_t reserved[64];
// --- Operator control (host -> hook) ---------------------------------------
// The host writes op_kind + the op_* fields, then bumps op_seq (release); the hook
// applies the command once per new op_seq (acquire). See AudioRingOp.
std::atomic<std::uint32_t> op_seq; // bumped by the host on each new command
std::uint32_t op_kind; // AudioRingOp
std::uint32_t op_rate; // override: sample rate
std::uint32_t op_channels; // override: channel count
std::uint32_t op_bits; // override: bits per sample
std::uint32_t op_format_tag; // override: WAVE_FORMAT_PCM / _IEEE_FLOAT
std::uint8_t reserved[40];
// std::uint8_t data[capacity] follows immediately in the mapping.
};
@@ -104,6 +125,12 @@ inline void audio_ring_init(AudioRingHeader& h, std::uint32_t capacity)
h.read_pos.store(0, std::memory_order_relaxed);
h.frames_produced.store(0, std::memory_order_relaxed);
h.overruns.store(0, std::memory_order_relaxed);
h.op_seq.store(0, std::memory_order_relaxed);
h.op_kind = 0;
h.op_rate = 0;
h.op_channels = 0;
h.op_bits = 0;
h.op_format_tag = 0;
std::memset(h.reserved, 0, sizeof(h.reserved));
}
@@ -185,6 +212,50 @@ inline std::uint32_t audio_ring_pop(AudioRingHeader& h, void* dst, std::uint32_t
return bytes;
}
// Host: post an operator command to the hook for this stream. Writes the fields, then
// bumps op_seq (release) so the hook applies it exactly once. For a re-measure the
// rate/channels/bits are ignored.
inline void audio_ring_post_op(AudioRingHeader& h, 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)
{
h.op_kind = kind;
h.op_rate = rate;
h.op_channels = channels;
h.op_bits = bits;
h.op_format_tag = format_tag;
h.op_seq.fetch_add(1, std::memory_order_release);
}
// One operator command read back by the hook.
struct AudioRingOpCmd
{
std::uint32_t kind = AudioRingOp_None;
std::uint32_t rate = 0;
std::uint32_t channels = 0;
std::uint32_t bits = 0;
std::uint32_t format_tag = 0;
};
// Hook: if a new op was posted since `last_seq`, read it into `out`, advance `last_seq`,
// and return its kind; otherwise returns AudioRingOp_None. Robust to ring re-creation
// (op_seq resets to 0 -> a stale higher last_seq just reads the zeroed None command).
inline std::uint32_t audio_ring_poll_op(AudioRingHeader& h, std::uint32_t& last_seq, AudioRingOpCmd& out)
{
const std::uint32_t seq = h.op_seq.load(std::memory_order_acquire);
if (seq == last_seq)
{
return AudioRingOp_None;
}
last_seq = seq;
out.kind = h.op_kind;
out.rate = h.op_rate;
out.channels = h.op_channels;
out.bits = h.op_bits;
out.format_tag = h.op_format_tag;
return out.kind;
}
// Build the per-pid audio ring name both sides agree on. Stream 0 keeps the bare
// coop_audio_<pid> name (backward compatible / the single-stream case); additional
// streams append _<index> (coop_audio_<pid>_1, _2, ...). The host captures every

View File

@@ -199,6 +199,10 @@ RateEstimator g_rate_estimator[kMaxAudioStreams] = {};
// Per-stream AudioFormatState (how its format was determined), mirrored to the host UI.
std::uint32_t g_stream_format_state[kMaxAudioStreams] = {};
// Last operator-op sequence applied per stream (host posts re-measure / override via the
// ring's op channel; we apply each new op once). Guarded by g_setup_mutex.
std::uint32_t g_last_op_seq[kMaxAudioStreams] = {};
// GetBuffer/ReleaseBuffer are paired on one thread, never nested: stash the
// pointer the game just got so ReleaseBuffer can copy it before releasing.
thread_local IAudioRenderClient* t_gb_client = nullptr;
@@ -404,6 +408,58 @@ bool publish_stream_format_locked(std::uint32_t slot)
return true;
}
// Apply an operator command (host -> hook via the ring op channel) to stream `slot`:
// re-run the rate measurement, or override the format. Both clear the ring's published
// format so publish_stream_format_locked re-publishes (bumping format_generation, which
// makes the host rebuild its render client at the new format). Caller holds g_setup_mutex.
void apply_audio_op_locked(std::uint32_t slot, const AudioRingOpCmd& cmd)
{
AudioRingHeader* ring = g_rings[slot].load(std::memory_order_acquire);
if (ring == nullptr || g_stream_formats[slot].rate == 0)
{
return; // no ring / no stream in this slot
}
if (cmd.kind == AudioRingOp_Remeasure)
{
logw("audio stream %u: operator requested re-measure", slot);
g_stream_rate_guess[slot] = true;
g_rate_estimator[slot] = RateEstimator{};
g_stream_formats[slot].rate = g_mix_format.rate; // back to the device-mix guess while measuring
g_stream_format_state[slot] = AudioFormat_Measuring;
g_streams[slot].assumed_format.store(1, std::memory_order_relaxed);
ring->format_valid.store(0, std::memory_order_release); // force re-publish after measuring
publish_stream_info_locked(slot, g_stream_formats[slot], AudioFormat_Measuring,
g_streams[slot].frames.load(std::memory_order_relaxed));
}
else if (cmd.kind == AudioRingOp_Override)
{
CapturedFormat cf;
cf.rate = cmd.rate;
cf.channels = cmd.channels;
cf.bits = cmd.bits;
cf.tag = cmd.format_tag ? cmd.format_tag : WAVE_FORMAT_PCM;
cf.block_align = cmd.channels * (cmd.bits / 8);
if (cf.rate == 0 || cf.channels == 0 || cf.block_align == 0)
{
logw("audio stream %u: ignoring invalid override %uHz/%uch/%ubit", slot, cf.rate, cf.channels,
cf.bits);
return;
}
logw("audio stream %u: operator override -> %uHz/%uch/%ubit tag=%u", slot, cf.rate, cf.channels,
cf.bits, cf.tag);
g_stream_formats[slot] = cf;
g_stream_rate_guess[slot] = false;
g_stream_format_state[slot] = AudioFormat_Override;
// Keep the over-read clamp on: a too-large operator block is capped to the real
// buffer (garbled but safe); a correct override makes the clamp a no-op.
g_streams[slot].assumed_format.store(1, std::memory_order_relaxed);
g_streams[slot].block_align.store(cf.block_align, std::memory_order_relaxed);
ring->format_valid.store(0, std::memory_order_release); // re-publish at the new format
publish_stream_info_locked(slot, cf, AudioFormat_Override,
g_streams[slot].frames.load(std::memory_order_relaxed));
}
}
// Registers a newly created render client: assigns it a debug slot, marks the
// first as primary (the one we capture), publishes it to HookStatus, and hooks
// the render-client vtable on first sight. `rate_is_guess` is true when `cf` is the
@@ -700,24 +756,20 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
void republish_audio_format()
{
// Nothing to do if no ring needs a format yet (cheap pre-check, no lock).
bool any_pending = false;
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
{
AudioRingHeader* ring = g_rings[i].load(std::memory_order_acquire);
if (ring != nullptr && !audio_ring_format_ready(*ring))
{
any_pending = true;
break;
}
}
if (!any_pending)
{
return;
}
std::scoped_lock lock(g_setup_mutex);
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
{
AudioRingHeader* ring = g_rings[i].load(std::memory_order_acquire);
if (ring == nullptr)
{
continue;
}
// Apply any operator command (re-measure / override) the host posted on this ring.
AudioRingOpCmd cmd;
if (audio_ring_poll_op(*ring, g_last_op_seq[i], cmd) != AudioRingOp_None)
{
apply_audio_op_locked(i, cmd);
}
// Publishes an exact format immediately; a guessed rate is measured first and
// published once a measurement window completes (retried on the next tick).
publish_stream_format_locked(i);
@@ -784,6 +836,7 @@ void remove_audio_hooks()
g_stream_rate_guess[i] = false;
g_stream_format_state[i] = AudioFormat_Unknown;
g_rate_estimator[i] = RateEstimator{};
g_last_op_seq[i] = 0;
g_rings[i].store(nullptr, std::memory_order_release);
}
g_client_formats.clear();

View File

@@ -19,10 +19,21 @@ add_executable(coop_host WIN32
src/capture/window_capture.cpp
src/capture/shared_texture.cpp
src/audio/audio_loopback.cpp
src/audio/process_loopback_capture.cpp)
src/audio/process_loopback_capture.cpp
src/test_harness.cpp)
target_include_directories(coop_host PRIVATE src)
# Debug-only IPC test harness: lets a script drive the real UI code paths (inject /
# audio / override / ...) and read state back, instead of simulating mouse input. OFF
# by default, so the shipped host never contains it (the source compiles to nothing
# without the macro). Enable with -DCOOP_TEST_HARNESS=ON for a debuggable build.
option(COOP_TEST_HARNESS "Build the host with the debug IPC test harness (dev only)" OFF)
if(COOP_TEST_HARNESS)
target_compile_definitions(coop_host PRIVATE COOP_TEST_HARNESS)
message(STATUS "Host test harness ENABLED (debug IPC driver)")
endif()
# Process-loopback capture (AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK) needs the
# Windows 10 20H1 (NTDDI_WIN10_CO) headers; raise the target SDK version for it.
target_compile_definitions(coop_host PRIVATE NTDDI_VERSION=0x0A00000B)

View File

@@ -140,6 +140,34 @@ void AudioMirror::enable_capture(AudioRingHeader* const* rings, bool on)
}
}
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)
{
if (slot >= kMaxAudioStreams)
{
return;
}
std::lock_guard<std::mutex> lock(ops_mutex_);
pending_ops_.push_back({slot, kind, rate, channels, bits, format_tag});
}
void AudioMirror::drain_ops()
{
std::vector<PendingOp> ops;
{
std::lock_guard<std::mutex> lock(ops_mutex_);
ops.swap(pending_ops_);
}
for (const PendingOp& op : ops)
{
AudioRingHeader* ring = (op.slot < kMaxAudioStreams) ? session_rings_[op.slot] : nullptr;
if (ring != nullptr)
{
audio_ring_post_op(*ring, op.kind, op.rate, op.channels, op.bits, op.format_tag);
}
}
}
bool AudioMirror::start(DWORD pid)
{
stop();
@@ -228,6 +256,7 @@ void AudioMirror::thread_main(DWORD pid)
audio_ring_init(*rings[i], kAudioRingCapacity);
created_primary = created_primary || (i == 0);
}
session_rings_[i] = rings[i]; // visible to drain_ops on this (audio) thread
}
if (!created_primary)
@@ -259,10 +288,15 @@ void AudioMirror::thread_main(DWORD pid)
if (wait_for_format(rings[0], kHookWaitMs))
{
set_fallback_reason({}); // hooked path is taking over
if (run_hooked(rings))
const HookedResult r = run_hooked(rings);
if (r == HookedResult::Stopped)
{
break; // ran to a clean stop
}
if (r == HookedResult::Reinit)
{
continue; // hook re-published (re-measure / override) -> re-read the new format
}
if (stop_requested())
{
break;
@@ -289,6 +323,10 @@ void AudioMirror::thread_main(DWORD pid)
}
enable_capture(rings, false);
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
session_rings_[i] = nullptr; // audio thread owns this; cleared before unmapping
}
for (auto& shm : audio_ring_shm_)
{
shm.reset();
@@ -310,23 +348,15 @@ void AudioMirror::thread_main(DWORD pid)
// Consume the render-hook's shared ring and re-render the game's frames. The
// game is silenced locally by the hook, so the operator hears no echo. Returns
// true if it ran to a clean stop; false on setup failure (caller falls back).
bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
{
AudioRingHeader* primary = rings[0];
enable_capture(rings, true); // hook silences the game + pushes frames into the rings
auto disable_all = [&] {
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
if (rings[i] != nullptr)
{
rings[i]->capture_enabled.store(0, std::memory_order_release);
}
}
};
auto fail_to_loopback = [&] {
disable_all(); // let the game play locally again
return false;
};
// Snapshot the format generation up front; if the hook re-publishes (operator
// re-measure / override) it bumps, and we tear down + return Reinit so the caller
// re-reads the new format and rebuilds the render client.
const std::uint32_t start_gen = primary->format_generation.load(std::memory_order_acquire);
const unsigned rate = primary->sample_rate;
const unsigned channels = primary->channels;
@@ -335,7 +365,8 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
const unsigned block_align = primary->block_align ? primary->block_align : channels * (bits / 8);
if (rate == 0 || channels == 0 || block_align == 0)
{
return fail_to_loopback();
enable_capture(rings, false); // let the game play locally again
return HookedResult::Failed;
}
// Reconstruct the game's WAVEFORMATEX and let shared-mode WASAPI convert it
@@ -379,6 +410,7 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
IAudioRenderClient* render = nullptr;
HANDLE render_event = nullptr;
bool started = false;
HookedResult result = HookedResult::Stopped;
auto fail = [&](const char* msg, HRESULT hr) {
char buf[160];
@@ -477,6 +509,12 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
{
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)
{
result = HookedResult::Reinit; // hook re-published -> re-read the new format
break;
}
UINT32 padding = 0;
if (FAILED(render_client->GetCurrentPadding(&padding)))
@@ -552,7 +590,12 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
render_client->Stop();
} while (false);
disable_all(); // game audible again on stop
// 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)
{
enable_capture(rings, false);
}
if (render)
{
@@ -577,11 +620,11 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
if (!started)
{
// Never got a working render client; let the caller try loopback. Leave
// capture disabled (already cleared above) so loopback hears the game.
return false;
// Never got a working render client; let the caller try loopback. Capture is
// already disabled above so loopback hears the game.
return HookedResult::Failed;
}
return true;
return result;
}
bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
@@ -713,6 +756,7 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
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))

View File

@@ -14,6 +14,7 @@
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <windows.h>
@@ -99,18 +100,32 @@ public:
// panel. Empty when on the hooked path or before any fallback decision.
[[nodiscard]] std::string fallback_reason() const;
// Post an operator command (AudioRingOp) to the hook for stream `slot` -- re-measure
// the rate or override the format. Thread-safe; queued and applied to the ring on the
// audio thread (which owns the ring mappings). For a re-measure the format args are 0.
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:
void thread_main(DWORD pid);
// Returns true if it owned the session to a clean stop; false if setup failed
// and the caller should fall back to the loopback path. `rings[0]` is the primary
// stream; additional non-null rings are mixed in.
bool run_hooked(AudioRingHeader* const* rings);
// Outcome of a hooked render session.
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
};
// Runs the hooked (no-echo) render path until stop, a setup failure, or a format change
// (re-measure/override). `rings[0]` is the primary stream; additional non-null rings are
// mixed in.
HookedResult run_hooked(AudioRingHeader* const* rings);
// Loopback (echo) capture. If `promote_ring` is non-null, returns true the moment
// that ring's format becomes ready (the hook caught up -> caller promotes to hooked);
// returns false when stopped. With a null ring it only returns false (on stop).
bool run_loopback(DWORD pid, AudioRingHeader* promote_ring);
bool wait_for_format(AudioRingHeader* ring, DWORD timeout_ms);
static void enable_capture(AudioRingHeader* const* rings, bool on);
void drain_ops(); // audio thread: post queued operator ops to the session rings
bool stop_requested() const;
void set_status(std::string s);
void set_fallback_reason(std::string s);
@@ -120,6 +135,17 @@ private:
DWORD pid_ = 0;
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
{
unsigned slot;
std::uint32_t kind, rate, channels, bits, format_tag;
};
std::mutex ops_mutex_;
std::vector<PendingOp> pending_ops_;
std::atomic<bool> running_{false};
std::atomic<Source> source_{Source::None};

View File

@@ -72,12 +72,16 @@ ImVec4 audio_format_state_color(std::uint32_t state)
void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
{
const bool have_target = target_ != nullptr && IsWindow(target_);
DWORD pid = 0;
if (have_target)
if (target_ != nullptr && IsWindow(target_))
{
GetWindowThreadProcessId(target_, &pid);
}
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;
apply_panel_layout(Panel::Audio);
ImGui::Begin("Audio mirror");
@@ -256,6 +260,44 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
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 (mirror_.running())
{
ImGui::SeparatorText("Fix the primary stream (debug)");
if (ImGui::Button("Re-measure rate"))
{
mirror_.request_op(0, AudioRingOp_Remeasure);
}
ImGui::SameLine();
ImGui::TextDisabled("re-run the rate measurement");
ImGui::SetNextItemWidth(110.0f);
ImGui::InputInt("Hz", &ov_rate_, 0, 0);
ImGui::SameLine();
ImGui::SetNextItemWidth(70.0f);
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::SameLine();
ImGui::SetNextItemWidth(80.0f);
ImGui::Combo("##ovfmt", &ov_fmt_idx_, "PCM\0" "float\0");
ImGui::SameLine();
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);
mirror_.request_op(0, AudioRingOp_Override, static_cast<std::uint32_t>(ov_rate_),
static_cast<std::uint32_t>(ov_channels_), bits, tag);
}
}
ImGui::End();
}

View File

@@ -27,8 +27,47 @@ public:
// `debug_details` on, the per-stream table is shown.
void draw_ui(const HookStatusView& status, bool debug_details);
#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)
{
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();
}
#endif
private:
HWND target_ = nullptr;
DWORD dev_pid_ = 0; // test harness only: force a (windowless) target pid; 0 in production
bool enabled_ = false;
AudioMirror mirror_;
@@ -41,6 +80,12 @@ private:
std::uint64_t rate_base_frames_[kMaxAudioStreams] = {};
double frames_per_s_[kMaxAudioStreams] = {};
double rate_base_time_ = 0.0;
// Operator format-override editor (debug details). Applies to the primary stream.
int ov_rate_ = 48000;
int ov_channels_ = 2;
int ov_bits_idx_ = 1; // 0 = 16-bit, 1 = 32-bit
int ov_fmt_idx_ = 1; // 0 = PCM, 1 = float
};
} // namespace coop

View File

@@ -254,6 +254,24 @@ void InjectionPanel::reattach()
inject_selected();
}
#ifdef COOP_TEST_HARNESS
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))
{
selected_pid_ = e.pid;
selected_name_ = e.exe_name;
inject_selected();
return injected_ ? selected_pid_ : 0;
}
}
return 0;
}
#endif
void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
{
if (!test_input_.load(std::memory_order_relaxed))

View File

@@ -45,6 +45,12 @@ public:
// focus-API counts, input-path detection); off shows a general summary.
void draw(bool debug_details);
#ifdef COOP_TEST_HARNESS
// 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);
#endif
// Forward the latest pad snapshot to the injected hook (if connected). When
// test-input mode is on, a synthetic pattern is sent instead of `pads`.
void publish(const std::array<PadInfo, kMaxPads>& pads);

View File

@@ -11,8 +11,14 @@
#include <windows.h>
#include <mmreg.h>
#include <timeapi.h>
#ifdef COOP_TEST_HARNESS
#include <sstream>
#include <vector>
#endif
#include <winrt/Windows.Foundation.h>
#include "imgui.h"
@@ -27,6 +33,7 @@
#include "injection_panel.hpp"
#include "input/input_worker.hpp"
#include "log_panel.hpp"
#include "test_harness.hpp"
#include "ui/app_chrome.hpp"
namespace
@@ -82,6 +89,102 @@ void draw_screenshot_toast(double seconds_since, const std::string& name)
ImGui::End();
}
#ifdef COOP_TEST_HARNESS
std::wstring widen(const std::string& s)
{
if (s.empty())
{
return {};
}
const int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), nullptr, 0);
std::wstring w(static_cast<std::size_t>(n), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.c_str(), static_cast<int>(s.size()), w.data(), n);
return w;
}
// Run a test-harness command on the main thread, hitting the same code the UI buttons do.
// Returns a one-line response the driver reads back.
std::string apply_test_command(const std::string& cmd, coop::UiState& ui, coop::InjectionPanel& injection,
coop::AudioPanel& audio, coop::D3D11Window& window)
{
std::vector<std::string> tok;
{
std::istringstream is(cmd);
std::string t;
while (is >> t)
{
tok.push_back(t);
}
}
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)); };
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")
{
const bool on = arg(1) == "on";
if (on)
{
audio.dev_set_pid(injection.target_pid());
}
audio.dev_set_enabled(on);
return "ok";
}
if (v == "debug")
{
ui.debug_details = (arg(1) == "on");
return "ok";
}
if (v == "remeasure")
{
audio.dev_request_op(num(1), coop::AudioRingOp_Remeasure, 0, 0, 0, 0);
return "ok";
}
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")
{
const std::wstring p = screenshot_path();
window.request_screenshot(p);
return "ok";
}
if (v == "quit")
{
ui.request_quit = true;
return "ok";
}
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());
return buf;
}
return "unknown-command";
}
#endif // COOP_TEST_HARNESS
#ifdef COOP_WITH_STEAM
// Absolute path to the bundled Steam Input action manifest (next to the exe).
std::string steam_manifest_path()
@@ -208,6 +311,9 @@ int run()
coop::register_ui_settings(ui);
coop::FrameStats stats;
coop::TestHarness harness; // debug builds only; a no-op shim otherwise
harness.init();
// Frame-sync: the hook generation we last presented (so we wait for the next one).
std::uint32_t last_synced_gen = 0;
@@ -249,6 +355,13 @@ int run()
stats.tick(ImGui::GetIO().DeltaTime * 1000.0f);
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())
{
harness.write_response(apply_test_command(tcmd, ui, injection, audio, window));
}
#endif
if (ImGui::IsKeyPressed(ImGuiKey_F1, false))
{
show_overlay = !show_overlay;

51
host/src/test_harness.cpp Normal file
View File

@@ -0,0 +1,51 @@
#include "test_harness.hpp"
#ifdef COOP_TEST_HARNESS
#include <fstream>
#include <windows.h>
namespace coop
{
namespace
{
std::wstring temp_file(const wchar_t* name)
{
wchar_t dir[MAX_PATH] = {};
const DWORD n = GetTempPathW(MAX_PATH, dir);
return (n != 0 && n < MAX_PATH) ? std::wstring(dir) + name : std::wstring(name);
}
} // namespace
void TestHarness::init()
{
cmd_path_ = temp_file(L"coop_test_cmd.txt");
resp_path_ = temp_file(L"coop_test_resp.txt");
DeleteFileW(cmd_path_.c_str()); // drop any stale command from a previous run
DeleteFileW(resp_path_.c_str());
}
std::string TestHarness::poll_command()
{
std::ifstream f(cmd_path_.c_str()); // MSVC accepts a wide path
if (!f)
{
return {};
}
std::string line;
std::getline(f, line);
f.close();
DeleteFileW(cmd_path_.c_str()); // ack: the command has been taken
return line;
}
void TestHarness::write_response(const std::string& resp)
{
std::ofstream f(resp_path_.c_str(), std::ios::trunc);
f << resp;
}
} // namespace coop
#endif // COOP_TEST_HARNESS

43
host/src/test_harness.hpp Normal file
View File

@@ -0,0 +1,43 @@
// Debug-only IPC test harness (compiled only when COOP_TEST_HARNESS is defined; the
// shipped product never contains it). Lets a test script drive the real overlay code
// paths -- inject, enable audio, re-measure, override, etc. -- by writing a command to
// %TEMP%\coop_test_cmd.txt and reading the reply from %TEMP%\coop_test_resp.txt, instead
// of simulating mouse/keyboard input (which ImGui doesn't accept reliably via PostMessage).
//
// Protocol: the driver writes one command line to the cmd file; the host consumes it
// (deleting the cmd file to ack), runs it on the main thread (so it hits the same code
// the UI buttons do), and writes a single response line to the resp file. One command at
// a time. See tools/test_drive notes / the per-phase validation scripts.
#pragma once
#include <string>
namespace coop
{
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
// file), or an empty string if none is waiting.
std::string poll_command();
// Main thread: write the response for the command just handled.
void write_response(const std::string& resp);
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 {};
}
void write_response(const std::string&) {}
#endif
};
} // namespace coop