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:
105
README.md
105
README.md
@@ -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 —
|
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
|
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
|
loopback fallback is always format-correct. The Audio panel shows each stream's
|
||||||
format provenance (*known* / *measuring* / *measured rate (ch/bits assumed)*) so the
|
format provenance (*known* / *measuring* / *measured rate* / *low-confidence* /
|
||||||
assumption is visible. Streams created *after* injection are captured exactly.
|
*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
|
- **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
|
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,
|
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)
|
### Planned (next up)
|
||||||
|
|
||||||
**Audio format reliability.** The hooked audio path can mis-detect a pre-existing
|
- **Per-game persisted audio overrides + auto-learn.** The Audio panel already lets
|
||||||
stream's sample rate, or fall back to loopback with no explanation. Two root causes:
|
the operator re-measure or override a stream's format (Debug details → *Fix the
|
||||||
the rate measurement is fragile, and late injection forces the guess path in the
|
primary stream*), but the override is session-only. Persist overrides **keyed by game
|
||||||
first place.
|
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`
|
||||||
- *Why the rate is sometimes wrong.* The measurement window is only ~200 ms. WASAPI
|
as that game's override** (ground truth) so a later late-attach is corrected
|
||||||
delivers audio in quantized ~10 ms buffers, so one extra buffer at a window edge is
|
automatically. If a caught format overwrites a stored override that *differs*, log a
|
||||||
a ~5% error (e.g. 44100 → ~46205 Hz). The snap-to-standard tolerance is ±2%, so a
|
warning.
|
||||||
~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.
|
|
||||||
- **Auto re-attach the same game on relaunch (session-only).** A checkbox on the
|
- **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
|
attached (or just-terminated) target, default off, *not* persisted, **always visible**
|
||||||
visible — not gated behind Debug details** (it's the recommended recovery path, not a
|
(it's the recommended recovery path, not a diagnostic). While on and the target has
|
||||||
diagnostic). While it's on and the target has terminated, the host watches the process
|
terminated, the host watches the process list for the same image name and injects the
|
||||||
list for a process of the same image name and injects automatically the moment it
|
moment it reappears — built on the existing re-attach-by-image-name path, so there's no
|
||||||
reappears — built on the existing re-attach-by-image-name path, so there's no target
|
target to type. Workflow: if audio came out wrong (late attach forced the guess), tick
|
||||||
to type (it's the same game that was just attached). The workflow this enables: if
|
the box, kill and relaunch the game, and the host re-attaches *early* — early enough to
|
||||||
audio came out wrong (late attach forced the guess), tick the box, kill and relaunch
|
catch `IAudioClient::Initialize` and read the exact format. A dependable fallback, not
|
||||||
the game, and the host re-attaches *early* — early enough to catch
|
the intended primary path (hardened first-launch detection should get it right anyway).
|
||||||
`IAudioClient::Initialize` and read the exact format, no guessing. This is a
|
- **Mock game + stress-test suite.** A bare-bones test game that renders an animated
|
||||||
**dependable fallback, not the intended primary workflow**: hardened first-launch
|
(non-static) pattern — so dropped/duplicated/torn frames are obvious — with a
|
||||||
detection (above) should get it right without a relaunch; auto-re-attach is the
|
selectable graphics backend (DX11/DX12 now; structured to add OpenGL/Vulkan) and a
|
||||||
reliable recovery when it doesn't, and never asks the operator to guess a format.
|
configurable audio output (rate/channels/bit-depth/format, like `coop_tone`). Then a
|
||||||
- **Color-coded log levels.** The Log window should color warnings and errors (amber /
|
comprehensive suite that drives it through the real capture + audio + hook paths:
|
||||||
red) so they stand out from routine lines. `LogRecord` already carries a
|
verify both capture backends, every audio variant, hook/unhook cycles, and that
|
||||||
(currently-unused) `level` field; add severity variants to the hook's `logf` and have
|
nothing races or crashes under stress.
|
||||||
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).
|
|
||||||
|
|
||||||
### Future work
|
### 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.
|
push/pop, wrap-around, format handshake, overrun/drop). No device needed.
|
||||||
- **`audio_mix_test`** — unit test of the multi-stream mixer math (decode / sum /
|
- **`audio_mix_test`** — unit test of the multi-stream mixer math (decode / sum /
|
||||||
soft-clip / encode for float32 + int16). No device needed.
|
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
|
- **`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
|
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
|
`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
|
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.
|
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)
|
## Running the tool (manual, end-to-end)
|
||||||
|
|
||||||
This needs Steam, a donor game that supports Remote Play Together, and a second
|
This needs Steam, a donor game that supports Remote Play Together, and a second
|
||||||
|
|||||||
@@ -24,7 +24,18 @@ namespace coop
|
|||||||
inline constexpr std::uint32_t kAudioRingMagic = 0x47525541u;
|
inline constexpr std::uint32_t kAudioRingMagic = 0x47525541u;
|
||||||
|
|
||||||
// Bump whenever AudioRingHeader's layout changes.
|
// 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>.
|
// Per-pid mapping name, mirroring kSharedMemoryPrefix: coop_audio_<pid>.
|
||||||
inline constexpr wchar_t kAudioRingPrefix[] = L"Local\\coop_audio_";
|
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> frames_produced; // cumulative frames pushed
|
||||||
std::atomic<std::uint64_t> overruns; // packets dropped on a full ring
|
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.
|
// 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.read_pos.store(0, std::memory_order_relaxed);
|
||||||
h.frames_produced.store(0, std::memory_order_relaxed);
|
h.frames_produced.store(0, std::memory_order_relaxed);
|
||||||
h.overruns.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));
|
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;
|
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
|
// 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
|
// coop_audio_<pid> name (backward compatible / the single-stream case); additional
|
||||||
// streams append _<index> (coop_audio_<pid>_1, _2, ...). The host captures every
|
// streams append _<index> (coop_audio_<pid>_1, _2, ...). The host captures every
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ RateEstimator g_rate_estimator[kMaxAudioStreams] = {};
|
|||||||
// Per-stream AudioFormatState (how its format was determined), mirrored to the host UI.
|
// Per-stream AudioFormatState (how its format was determined), mirrored to the host UI.
|
||||||
std::uint32_t g_stream_format_state[kMaxAudioStreams] = {};
|
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
|
// GetBuffer/ReleaseBuffer are paired on one thread, never nested: stash the
|
||||||
// pointer the game just got so ReleaseBuffer can copy it before releasing.
|
// pointer the game just got so ReleaseBuffer can copy it before releasing.
|
||||||
thread_local IAudioRenderClient* t_gb_client = nullptr;
|
thread_local IAudioRenderClient* t_gb_client = nullptr;
|
||||||
@@ -404,6 +408,58 @@ bool publish_stream_format_locked(std::uint32_t slot)
|
|||||||
return true;
|
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
|
// 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
|
// 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
|
// 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()
|
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);
|
std::scoped_lock lock(g_setup_mutex);
|
||||||
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
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
|
// Publishes an exact format immediately; a guessed rate is measured first and
|
||||||
// published once a measurement window completes (retried on the next tick).
|
// published once a measurement window completes (retried on the next tick).
|
||||||
publish_stream_format_locked(i);
|
publish_stream_format_locked(i);
|
||||||
@@ -784,6 +836,7 @@ void remove_audio_hooks()
|
|||||||
g_stream_rate_guess[i] = false;
|
g_stream_rate_guess[i] = false;
|
||||||
g_stream_format_state[i] = AudioFormat_Unknown;
|
g_stream_format_state[i] = AudioFormat_Unknown;
|
||||||
g_rate_estimator[i] = RateEstimator{};
|
g_rate_estimator[i] = RateEstimator{};
|
||||||
|
g_last_op_seq[i] = 0;
|
||||||
g_rings[i].store(nullptr, std::memory_order_release);
|
g_rings[i].store(nullptr, std::memory_order_release);
|
||||||
}
|
}
|
||||||
g_client_formats.clear();
|
g_client_formats.clear();
|
||||||
|
|||||||
@@ -19,10 +19,21 @@ add_executable(coop_host WIN32
|
|||||||
src/capture/window_capture.cpp
|
src/capture/window_capture.cpp
|
||||||
src/capture/shared_texture.cpp
|
src/capture/shared_texture.cpp
|
||||||
src/audio/audio_loopback.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)
|
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
|
# Process-loopback capture (AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK) needs the
|
||||||
# Windows 10 20H1 (NTDDI_WIN10_CO) headers; raise the target SDK version for it.
|
# Windows 10 20H1 (NTDDI_WIN10_CO) headers; raise the target SDK version for it.
|
||||||
target_compile_definitions(coop_host PRIVATE NTDDI_VERSION=0x0A00000B)
|
target_compile_definitions(coop_host PRIVATE NTDDI_VERSION=0x0A00000B)
|
||||||
|
|||||||
@@ -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)
|
bool AudioMirror::start(DWORD pid)
|
||||||
{
|
{
|
||||||
stop();
|
stop();
|
||||||
@@ -228,6 +256,7 @@ void AudioMirror::thread_main(DWORD pid)
|
|||||||
audio_ring_init(*rings[i], kAudioRingCapacity);
|
audio_ring_init(*rings[i], kAudioRingCapacity);
|
||||||
created_primary = created_primary || (i == 0);
|
created_primary = created_primary || (i == 0);
|
||||||
}
|
}
|
||||||
|
session_rings_[i] = rings[i]; // visible to drain_ops on this (audio) thread
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!created_primary)
|
if (!created_primary)
|
||||||
@@ -259,10 +288,15 @@ void AudioMirror::thread_main(DWORD pid)
|
|||||||
if (wait_for_format(rings[0], kHookWaitMs))
|
if (wait_for_format(rings[0], kHookWaitMs))
|
||||||
{
|
{
|
||||||
set_fallback_reason({}); // hooked path is taking over
|
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
|
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())
|
if (stop_requested())
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
@@ -289,6 +323,10 @@ void AudioMirror::thread_main(DWORD pid)
|
|||||||
}
|
}
|
||||||
|
|
||||||
enable_capture(rings, false);
|
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_)
|
for (auto& shm : audio_ring_shm_)
|
||||||
{
|
{
|
||||||
shm.reset();
|
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
|
// 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
|
// 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).
|
// 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];
|
AudioRingHeader* primary = rings[0];
|
||||||
enable_capture(rings, true); // hook silences the game + pushes frames into the rings
|
enable_capture(rings, true); // hook silences the game + pushes frames into the rings
|
||||||
auto disable_all = [&] {
|
|
||||||
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
|
// 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
|
||||||
if (rings[i] != nullptr)
|
// re-reads the new format and rebuilds the render client.
|
||||||
{
|
const std::uint32_t start_gen = primary->format_generation.load(std::memory_order_acquire);
|
||||||
rings[i]->capture_enabled.store(0, std::memory_order_release);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
auto fail_to_loopback = [&] {
|
|
||||||
disable_all(); // let the game play locally again
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const unsigned rate = primary->sample_rate;
|
const unsigned rate = primary->sample_rate;
|
||||||
const unsigned channels = primary->channels;
|
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);
|
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)
|
||||||
{
|
{
|
||||||
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
|
// 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;
|
IAudioRenderClient* render = nullptr;
|
||||||
HANDLE render_event = nullptr;
|
HANDLE render_event = nullptr;
|
||||||
bool started = false;
|
bool started = false;
|
||||||
|
HookedResult result = HookedResult::Stopped;
|
||||||
|
|
||||||
auto fail = [&](const char* msg, HRESULT hr) {
|
auto fail = [&](const char* msg, HRESULT hr) {
|
||||||
char buf[160];
|
char buf[160];
|
||||||
@@ -477,6 +509,12 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
|
|||||||
{
|
{
|
||||||
break; // stop requested
|
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;
|
UINT32 padding = 0;
|
||||||
if (FAILED(render_client->GetCurrentPadding(&padding)))
|
if (FAILED(render_client->GetCurrentPadding(&padding)))
|
||||||
@@ -552,7 +590,12 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
|
|||||||
render_client->Stop();
|
render_client->Stop();
|
||||||
} while (false);
|
} 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)
|
if (render)
|
||||||
{
|
{
|
||||||
@@ -577,11 +620,11 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
|
|||||||
|
|
||||||
if (!started)
|
if (!started)
|
||||||
{
|
{
|
||||||
// Never got a working render client; let the caller try loopback. Leave
|
// Never got a working render client; let the caller try loopback. Capture is
|
||||||
// capture disabled (already cleared above) so loopback hears the game.
|
// already disabled above so loopback hears the game.
|
||||||
return false;
|
return HookedResult::Failed;
|
||||||
}
|
}
|
||||||
return true;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
|
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());
|
set_status(capture.status());
|
||||||
break;
|
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
|
// 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).
|
// 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))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
|
||||||
@@ -99,18 +100,32 @@ public:
|
|||||||
// panel. Empty when on the hooked path or before any fallback decision.
|
// panel. Empty when on the hooked path or before any fallback decision.
|
||||||
[[nodiscard]] std::string fallback_reason() const;
|
[[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:
|
private:
|
||||||
void thread_main(DWORD pid);
|
void thread_main(DWORD pid);
|
||||||
// Returns true if it owned the session to a clean stop; false if setup failed
|
// Outcome of a hooked render session.
|
||||||
// and the caller should fall back to the loopback path. `rings[0]` is the primary
|
enum class HookedResult
|
||||||
// stream; additional non-null rings are mixed in.
|
{
|
||||||
bool run_hooked(AudioRingHeader* const* rings);
|
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
|
// 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);
|
// 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).
|
// returns false when stopped. With a null ring it only returns false (on stop).
|
||||||
bool run_loopback(DWORD pid, AudioRingHeader* promote_ring);
|
bool run_loopback(DWORD pid, AudioRingHeader* promote_ring);
|
||||||
bool wait_for_format(AudioRingHeader* ring, DWORD timeout_ms);
|
bool wait_for_format(AudioRingHeader* ring, DWORD timeout_ms);
|
||||||
static void enable_capture(AudioRingHeader* const* rings, bool on);
|
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;
|
bool stop_requested() const;
|
||||||
void set_status(std::string s);
|
void set_status(std::string s);
|
||||||
void set_fallback_reason(std::string s);
|
void set_fallback_reason(std::string s);
|
||||||
@@ -120,6 +135,17 @@ private:
|
|||||||
DWORD pid_ = 0;
|
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
|
||||||
|
{
|
||||||
|
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<bool> running_{false};
|
||||||
std::atomic<Source> source_{Source::None};
|
std::atomic<Source> source_{Source::None};
|
||||||
|
|||||||
@@ -72,12 +72,16 @@ ImVec4 audio_format_state_color(std::uint32_t state)
|
|||||||
|
|
||||||
void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
|
void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
|
||||||
{
|
{
|
||||||
const bool have_target = target_ != nullptr && IsWindow(target_);
|
|
||||||
DWORD pid = 0;
|
DWORD pid = 0;
|
||||||
if (have_target)
|
if (target_ != nullptr && IsWindow(target_))
|
||||||
{
|
{
|
||||||
GetWindowThreadProcessId(target_, &pid);
|
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);
|
apply_panel_layout(Panel::Audio);
|
||||||
ImGui::Begin("Audio mirror");
|
ImGui::Begin("Audio mirror");
|
||||||
@@ -256,6 +260,44 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
|
|||||||
rate_base_time_ = now;
|
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();
|
ImGui::End();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,47 @@ public:
|
|||||||
// `debug_details` on, the per-stream table is shown.
|
// `debug_details` on, the per-stream table is shown.
|
||||||
void draw_ui(const HookStatusView& status, bool debug_details);
|
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:
|
private:
|
||||||
HWND target_ = nullptr;
|
HWND target_ = nullptr;
|
||||||
|
DWORD dev_pid_ = 0; // test harness only: force a (windowless) target pid; 0 in production
|
||||||
bool enabled_ = false;
|
bool enabled_ = false;
|
||||||
AudioMirror mirror_;
|
AudioMirror mirror_;
|
||||||
|
|
||||||
@@ -41,6 +80,12 @@ private:
|
|||||||
std::uint64_t rate_base_frames_[kMaxAudioStreams] = {};
|
std::uint64_t rate_base_frames_[kMaxAudioStreams] = {};
|
||||||
double frames_per_s_[kMaxAudioStreams] = {};
|
double frames_per_s_[kMaxAudioStreams] = {};
|
||||||
double rate_base_time_ = 0.0;
|
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
|
} // namespace coop
|
||||||
|
|||||||
@@ -254,6 +254,24 @@ void InjectionPanel::reattach()
|
|||||||
inject_selected();
|
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)
|
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))
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ public:
|
|||||||
// focus-API counts, input-path detection); off shows a general summary.
|
// focus-API counts, input-path detection); off shows a general summary.
|
||||||
void draw(bool debug_details);
|
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
|
// 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`.
|
// test-input mode is on, a synthetic pattern is sent instead of `pads`.
|
||||||
void publish(const std::array<PadInfo, kMaxPads>& pads);
|
void publish(const std::array<PadInfo, kMaxPads>& pads);
|
||||||
|
|||||||
@@ -11,8 +11,14 @@
|
|||||||
|
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <mmreg.h>
|
||||||
#include <timeapi.h>
|
#include <timeapi.h>
|
||||||
|
|
||||||
|
#ifdef COOP_TEST_HARNESS
|
||||||
|
#include <sstream>
|
||||||
|
#include <vector>
|
||||||
|
#endif
|
||||||
|
|
||||||
#include <winrt/Windows.Foundation.h>
|
#include <winrt/Windows.Foundation.h>
|
||||||
|
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
@@ -27,6 +33,7 @@
|
|||||||
#include "injection_panel.hpp"
|
#include "injection_panel.hpp"
|
||||||
#include "input/input_worker.hpp"
|
#include "input/input_worker.hpp"
|
||||||
#include "log_panel.hpp"
|
#include "log_panel.hpp"
|
||||||
|
#include "test_harness.hpp"
|
||||||
#include "ui/app_chrome.hpp"
|
#include "ui/app_chrome.hpp"
|
||||||
|
|
||||||
namespace
|
namespace
|
||||||
@@ -82,6 +89,102 @@ void draw_screenshot_toast(double seconds_since, const std::string& name)
|
|||||||
ImGui::End();
|
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
|
#ifdef COOP_WITH_STEAM
|
||||||
// Absolute path to the bundled Steam Input action manifest (next to the exe).
|
// Absolute path to the bundled Steam Input action manifest (next to the exe).
|
||||||
std::string steam_manifest_path()
|
std::string steam_manifest_path()
|
||||||
@@ -208,6 +311,9 @@ int run()
|
|||||||
coop::register_ui_settings(ui);
|
coop::register_ui_settings(ui);
|
||||||
coop::FrameStats stats;
|
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).
|
// Frame-sync: the hook generation we last presented (so we wait for the next one).
|
||||||
std::uint32_t last_synced_gen = 0;
|
std::uint32_t last_synced_gen = 0;
|
||||||
|
|
||||||
@@ -249,6 +355,13 @@ int run()
|
|||||||
stats.tick(ImGui::GetIO().DeltaTime * 1000.0f);
|
stats.tick(ImGui::GetIO().DeltaTime * 1000.0f);
|
||||||
log.pull(injection); // drain hook log lines even while the Log window is hidden
|
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))
|
if (ImGui::IsKeyPressed(ImGuiKey_F1, false))
|
||||||
{
|
{
|
||||||
show_overlay = !show_overlay;
|
show_overlay = !show_overlay;
|
||||||
|
|||||||
51
host/src/test_harness.cpp
Normal file
51
host/src/test_harness.cpp
Normal 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
43
host/src/test_harness.hpp
Normal 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
|
||||||
Reference in New Issue
Block a user