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:
@@ -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))
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
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