- hook_guard.hpp top block: described removal as `hook = {}` (destroy/reset); the
model is now persistent disable_for_removal (never destroyed mid-session, the
trampoline stays alive). Updated to match.
- input_source.hpp: SteamInputSource is no longer "future" -- it exists and is
opt-in; reworded.
- audio_ring.hpp: format_generation actually bumps on every set_format (not
"reserved, v1 sets once"); verify_capture is a 4-byte atomic guarded by the
version gate (not "repurposed from a reserved byte old builds saw"); and the
SharedBlock is no longer "20-byte pads".
- audio_format_verifier.cpp: dropped a dead `(void)recover_layout;` with a stale
"step (a) only" comment -- the parameter is actually used.
Comment-only except the dead (void) cast.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
293 lines
12 KiB
C++
293 lines
12 KiB
C++
// Shared-memory audio ring for the injection render-hook audio path.
|
|
//
|
|
// The injected hook (coop_hook.dll) captures the game's WASAPI render frames and
|
|
// is the sole *producer*; the host (coop_host.exe) is the sole *consumer* and
|
|
// re-renders the frames for Steam Remote Play Together. This is a separate, larger mapping from the
|
|
// input/status SharedBlock (which holds fixed-size POD state, not bulk PCM): a header followed by a
|
|
// byte ring of `capacity` bytes.
|
|
//
|
|
// Lock-free SPSC with free-running 64-bit positions (release on publish, acquire
|
|
// on read) — the same cross-process atomic model as the input seqlock. POD and
|
|
// version-locked: both modules compile this identical header.
|
|
#pragma once
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <string>
|
|
|
|
namespace coop
|
|
{
|
|
|
|
// 'AURG' little-endian; sanity-checks the mapping before either side trusts it.
|
|
inline constexpr std::uint32_t kAudioRingMagic = 0x47525541u;
|
|
|
|
// Bump whenever AudioRingHeader's layout changes.
|
|
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_";
|
|
|
|
// Byte capacity of the PCM ring. 1 MiB is >1 s even at 48 kHz / 2 ch / 32-bit
|
|
// float (384 kB/s); the host should always keep up, so this is pure slack.
|
|
inline constexpr std::uint32_t kAudioRingCapacity = 1u << 20;
|
|
|
|
// Header preceding the PCM data. All multi-process-shared counters are atomic;
|
|
// the format fields are written once by the producer *before* it publishes
|
|
// format_valid (release), and read by the consumer *after* it observes
|
|
// format_valid (acquire), so they need no atomicity of their own.
|
|
struct AudioRingHeader
|
|
{
|
|
std::uint32_t magic;
|
|
std::uint32_t version;
|
|
|
|
// Host-owned gate. The hook copies+silences frames only while this is 1;
|
|
// when 0 the game audio passes through locally and nothing is mirrored
|
|
// (stream counting in HookStatus still runs regardless of this flag).
|
|
std::atomic<std::uint32_t> capture_enabled;
|
|
|
|
// Producer publishes the captured stream's format, then sets format_valid=1 (release).
|
|
// format_generation bumps on every (re)publish (audio_ring_set_format), so the host can notice a
|
|
// mid-session format change (a device re-init, or a measured-rate / override update).
|
|
std::atomic<std::uint32_t> format_valid;
|
|
std::atomic<std::uint32_t> format_generation;
|
|
|
|
std::uint32_t sample_rate;
|
|
std::uint32_t channels;
|
|
std::uint32_t bits;
|
|
std::uint32_t format_tag; // WAVE_FORMAT_* (PCM=1, IEEE_FLOAT=3, EXTENSIBLE=0xFFFE)
|
|
std::uint32_t block_align; // bytes per frame (all channels)
|
|
|
|
std::uint32_t capacity; // bytes in the trailing data region
|
|
|
|
std::atomic<std::uint64_t> write_pos; // producer cursor, free-running
|
|
std::atomic<std::uint64_t> read_pos; // consumer cursor, free-running
|
|
std::atomic<std::uint64_t> frames_produced; // cumulative frames pushed
|
|
std::atomic<std::uint64_t> overruns; // packets dropped on a full ring
|
|
|
|
// --- 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
|
|
|
|
// Host -> hook: format-verification co-capture. While 1, the hook pushes a still-being-measured
|
|
// (guessed) stream's raw pre-mix bytes into the ring WITHOUT silencing the game, so the host can
|
|
// capture both the hook (pre-mix) and a parallel process-loopback (post-mix) of the same audio
|
|
// and cross-correlate them to recover the true sample rate (and, in step b, channels/bit-depth)
|
|
// from ground truth instead of guessing. Inert (0) by default -- normal capture is unaffected,
|
|
// so it never changes the shipping no-echo path. It's a 4-byte atomic carved out of the header's
|
|
// reserved space; the version gate (kAudioRingVersion) rejects any layout that doesn't match.
|
|
std::atomic<std::uint32_t> verify_capture;
|
|
|
|
std::uint8_t reserved[36];
|
|
|
|
// std::uint8_t data[capacity] follows immediately in the mapping.
|
|
};
|
|
|
|
static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
|
|
"audio ring needs a lock-free 64-bit atomic for cross-process use");
|
|
|
|
// Total mapping size for a ring of `capacity` bytes.
|
|
inline constexpr std::size_t audio_ring_total_size(std::uint32_t capacity)
|
|
{
|
|
return sizeof(AudioRingHeader) + capacity;
|
|
}
|
|
|
|
// Pointer to the PCM data region following the header.
|
|
inline std::uint8_t* audio_ring_data(AudioRingHeader* h)
|
|
{
|
|
return reinterpret_cast<std::uint8_t*>(h) + sizeof(AudioRingHeader);
|
|
}
|
|
|
|
// Host side: stamp a freshly created mapping into a valid empty ring.
|
|
inline void audio_ring_init(AudioRingHeader& h, std::uint32_t capacity)
|
|
{
|
|
h.magic = kAudioRingMagic;
|
|
h.version = kAudioRingVersion;
|
|
h.capture_enabled.store(0, std::memory_order_relaxed);
|
|
h.format_valid.store(0, std::memory_order_relaxed);
|
|
h.format_generation.store(0, std::memory_order_relaxed);
|
|
h.sample_rate = 0;
|
|
h.channels = 0;
|
|
h.bits = 0;
|
|
h.format_tag = 0;
|
|
h.block_align = 0;
|
|
h.capacity = capacity;
|
|
h.write_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.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;
|
|
h.verify_capture.store(0, std::memory_order_relaxed);
|
|
std::memset(h.reserved, 0, sizeof(h.reserved));
|
|
}
|
|
|
|
// Validate a mapping the other side created/opened.
|
|
inline bool audio_ring_valid(const AudioRingHeader& h)
|
|
{
|
|
return h.magic == kAudioRingMagic && h.version == kAudioRingVersion && h.capacity != 0;
|
|
}
|
|
|
|
// Producer (hook): publish the captured stream format, then mark it valid.
|
|
inline void audio_ring_set_format(AudioRingHeader& h, std::uint32_t sample_rate, std::uint32_t channels,
|
|
std::uint32_t bits, std::uint32_t format_tag, std::uint32_t block_align)
|
|
{
|
|
h.sample_rate = sample_rate;
|
|
h.channels = channels;
|
|
h.bits = bits;
|
|
h.format_tag = format_tag;
|
|
h.block_align = block_align;
|
|
h.format_generation.fetch_add(1, std::memory_order_relaxed);
|
|
h.format_valid.store(1, std::memory_order_release);
|
|
}
|
|
|
|
// Consumer (host): true once the producer has published a format.
|
|
inline bool audio_ring_format_ready(const AudioRingHeader& h)
|
|
{
|
|
return h.format_valid.load(std::memory_order_acquire) != 0;
|
|
}
|
|
|
|
// Producer: push `bytes` of PCM. Returns false (and bumps overruns) if the ring
|
|
// can't hold the whole packet, in which case nothing is written — drop the
|
|
// packet rather than tear a frame. `frames` is recorded for the diagnostics.
|
|
inline bool audio_ring_push(AudioRingHeader& h, const void* src, std::uint32_t bytes, std::uint32_t frames)
|
|
{
|
|
const std::uint64_t w = h.write_pos.load(std::memory_order_relaxed);
|
|
const std::uint64_t r = h.read_pos.load(std::memory_order_acquire);
|
|
const std::uint32_t used = static_cast<std::uint32_t>(w - r);
|
|
if (bytes > h.capacity - used)
|
|
{
|
|
h.overruns.fetch_add(1, std::memory_order_relaxed);
|
|
return false;
|
|
}
|
|
std::uint8_t* data = audio_ring_data(&h);
|
|
const std::uint32_t off = static_cast<std::uint32_t>(w % h.capacity);
|
|
const std::uint32_t first = std::min(bytes, h.capacity - off);
|
|
std::memcpy(data + off, src, first);
|
|
if (bytes > first)
|
|
{
|
|
std::memcpy(data, static_cast<const std::uint8_t*>(src) + first, bytes - first);
|
|
}
|
|
h.write_pos.store(w + bytes, std::memory_order_release);
|
|
h.frames_produced.fetch_add(frames, std::memory_order_relaxed);
|
|
return true;
|
|
}
|
|
|
|
// Consumer: bytes currently available to read.
|
|
inline std::uint32_t audio_ring_available(const AudioRingHeader& h)
|
|
{
|
|
const std::uint64_t w = h.write_pos.load(std::memory_order_acquire);
|
|
const std::uint64_t r = h.read_pos.load(std::memory_order_relaxed);
|
|
return static_cast<std::uint32_t>(w - r);
|
|
}
|
|
|
|
// Producer: bytes currently free (so a multi-part packet can be checked to fit before any of it is
|
|
// written -- keeps a self-describing [header][payload] framing from tearing on a full ring).
|
|
inline std::uint32_t audio_ring_free_space(const AudioRingHeader& h)
|
|
{
|
|
const std::uint64_t w = h.write_pos.load(std::memory_order_relaxed);
|
|
const std::uint64_t r = h.read_pos.load(std::memory_order_acquire);
|
|
return h.capacity - static_cast<std::uint32_t>(w - r);
|
|
}
|
|
|
|
// Consumer: copy up to `bytes` into `dst`; returns the number actually popped.
|
|
inline std::uint32_t audio_ring_pop(AudioRingHeader& h, void* dst, std::uint32_t bytes)
|
|
{
|
|
const std::uint64_t r = h.read_pos.load(std::memory_order_relaxed);
|
|
const std::uint64_t w = h.write_pos.load(std::memory_order_acquire);
|
|
const std::uint32_t avail = static_cast<std::uint32_t>(w - r);
|
|
bytes = std::min(bytes, avail);
|
|
const std::uint8_t* data = audio_ring_data(&h);
|
|
const std::uint32_t off = static_cast<std::uint32_t>(r % h.capacity);
|
|
const std::uint32_t first = std::min(bytes, h.capacity - off);
|
|
std::memcpy(dst, data + off, first);
|
|
if (bytes > first)
|
|
{
|
|
std::memcpy(static_cast<std::uint8_t*>(dst) + first, data, bytes - first);
|
|
}
|
|
h.read_pos.store(r + bytes, std::memory_order_release);
|
|
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
|
|
// render stream into its own ring and mixes them.
|
|
inline std::wstring audio_ring_name(unsigned long target_pid, unsigned index = 0)
|
|
{
|
|
std::wstring name = std::wstring(kAudioRingPrefix) + std::to_wstring(target_pid);
|
|
if (index != 0)
|
|
{
|
|
name += L"_" + std::to_wstring(index);
|
|
}
|
|
return name;
|
|
}
|
|
|
|
} // namespace coop
|