diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index dd4e230..d19aabb 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -494,6 +494,7 @@ set(SOURCE_FILES ${SOURCE_FILES} # hooks hooks/audio/acm.cpp hooks/audio/audio.cpp + hooks/audio/asio_proxy.cpp hooks/audio/buffer.cpp hooks/audio/mme.cpp hooks/audio/util.cpp diff --git a/src/spice2x/games/iidx/iidx.cpp b/src/spice2x/games/iidx/iidx.cpp index 4321de6..e6f74ec 100644 --- a/src/spice2x/games/iidx/iidx.cpp +++ b/src/spice2x/games/iidx/iidx.cpp @@ -893,38 +893,10 @@ namespace games::iidx { } } - // patch iidx32+ for asio compatibility - // only do this if NOT wasapi (as opposed to checking if it's asio) - // the patch is only really needed for (some) non-XONAR devices but since people sometimes disguise - // other devices as a XONAR, don't check for the exact string (common ASIO workaround for INF) - if (avs::game::is_ext(2024090100, INT_MAX) && - !(SOUND_OUTPUT_DEVICE_IN_EFFECT.has_value() && - SOUND_OUTPUT_DEVICE_IN_EFFECT.value() == "wasapi")) { - - // in iidx32 final: - // ff 50 08 call QWORD PTR [rax+0x8] ; ASIO instance AddRef - // 48 8b 4b 08 mov rcx,QWORD PTR [rbx+0x8] - // 48 8b 01 mov rax,QWORD PTR [rcx] - // ff 50 08 call QWORD PTR [rax+0x8] ; ASIO instance AddRef - - intptr_t result = replace_pattern( - avs::game::DLL_INSTANCE, - "FF50????????????????FF50??4533C94533C0418D51", - "????????????????????909090??????????????????", - 0, 0); - - if (result == 0) { - log_warning( - "iidx", - "Failed to apply ASIO compatibility fix for iidx32+. " - "Unless patches are applied, your ASIO device may hang or fail to work"); - } else { - log_info( - "iidx", - "Successfully applied ASIO compatibility fix for iidx32+ using signature matching @ 0x{:x}.", - result); - } - } + // note: the iidx32+ ASIO refcount bug (a duplicate AddRef on the ASIO instance with + // no matching Release, which leaks the driver and can hang non-XONAR devices) is now + // handled transparently by the WrappedAsio proxy (see hooks/audio/asio_proxy.cpp), + // so no game-DLL signature patch is needed here anymore #endif diff --git a/src/spice2x/hooks/audio/asio_proxy.cpp b/src/spice2x/hooks/audio/asio_proxy.cpp new file mode 100644 index 0000000..ac84d97 --- /dev/null +++ b/src/spice2x/hooks/audio/asio_proxy.cpp @@ -0,0 +1,813 @@ +#include "asio_proxy.h" + +#include +#include +#include +#include +#include +#include + +#include "external/asio/asiolist.h" +#include "hooks/audio/audio.h" +#include "util/logging.h" +#include "util/utils.h" + +namespace { + + // readable name for an ASIO sample type (e.g. "ASIOSTInt32LSB"), falling back to the + // numeric value for unknown types + const char *asio_sample_type_name(AsioSampleType type) { + switch (type) { + case ASIOSTInt16MSB: return "ASIOSTInt16MSB"; + case ASIOSTInt24MSB: return "ASIOSTInt24MSB"; + case ASIOSTInt32MSB: return "ASIOSTInt32MSB"; + case ASIOSTFloat32MSB: return "ASIOSTFloat32MSB"; + case ASIOSTFloat64MSB: return "ASIOSTFloat64MSB"; + case ASIOSTInt32MSB16: return "ASIOSTInt32MSB16"; + case ASIOSTInt32MSB18: return "ASIOSTInt32MSB18"; + case ASIOSTInt32MSB20: return "ASIOSTInt32MSB20"; + case ASIOSTInt32MSB24: return "ASIOSTInt32MSB24"; + case ASIOSTInt16LSB: return "ASIOSTInt16LSB"; + case ASIOSTInt24LSB: return "ASIOSTInt24LSB"; + case ASIOSTInt32LSB: return "ASIOSTInt32LSB"; + case ASIOSTFloat32LSB: return "ASIOSTFloat32LSB"; + case ASIOSTFloat64LSB: return "ASIOSTFloat64LSB"; + case ASIOSTInt32LSB16: return "ASIOSTInt32LSB16"; + case ASIOSTInt32LSB18: return "ASIOSTInt32LSB18"; + case ASIOSTInt32LSB20: return "ASIOSTInt32LSB20"; + case ASIOSTInt32LSB24: return "ASIOSTInt32LSB24"; + default: return "unknown"; + } + } + + // duration in milliseconds of a buffer of the given frame count at a sample rate, or a + // negative sentinel when the frame count or sample rate is unusable + double frames_to_ms(long frames, AsioSampleRate sample_rate) { + if (frames < 0 || sample_rate <= 0.0) { + return -1.0; + } + return (frames * 1000.0) / sample_rate; + } + + // scales one planar ASIO output buffer (frames samples of the given type) by gain in + // place, clamping integer formats so a boost saturates instead of wrapping. unsupported + // formats are left untouched. runs on the driver's realtime thread, so no allocation, + // locking or logging here + void apply_gain_planar(void *buffer, long frames, AsioSampleType type, float gain) { + if (buffer == nullptr || frames <= 0) { + return; + } + switch (type) { + case ASIOSTFloat32LSB: { + auto p = static_cast(buffer); + for (long i = 0; i < frames; i++) { + p[i] = std::clamp(p[i] * gain, -1.0f, 1.0f); + } + break; + } + case ASIOSTFloat64LSB: { + auto p = static_cast(buffer); + for (long i = 0; i < frames; i++) { + p[i] = std::clamp(p[i] * static_cast(gain), -1.0, 1.0); + } + break; + } + case ASIOSTInt16LSB: { + auto p = static_cast(buffer); + for (long i = 0; i < frames; i++) { + p[i] = static_cast( + std::clamp(std::lround(p[i] * gain), -32768L, 32767L)); + } + break; + } + case ASIOSTInt24LSB: { + // packed 24-bit little-endian, 3 bytes per sample + auto bytes = static_cast(buffer); + for (long i = 0; i < frames; i++) { + uint8_t *s = bytes + i * 3; + int32_t v = s[0] | (s[1] << 8) | (s[2] << 16); + if (v & 0x800000) { + v |= ~0xFFFFFF; // sign extend + } + int64_t scaled = std::clamp( + std::llround(static_cast(v) * gain), -8388608, 8388607); + s[0] = scaled & 0xFF; + s[1] = (scaled >> 8) & 0xFF; + s[2] = (scaled >> 16) & 0xFF; + } + break; + } + case ASIOSTInt32LSB: { + auto p = static_cast(buffer); + for (long i = 0; i < frames; i++) { + p[i] = static_cast(std::clamp( + std::llround(static_cast(p[i]) * gain), INT32_MIN, INT32_MAX)); + } + break; + } + default: + // unsupported format (MSB, aligned 32-bit, DSD): leave untouched + break; + } + } + + // live wrappers by CLSID. ASIO drivers are single-instance, so a host that creates a + // new instance without releasing the old one has leaked it; we track this to tear the + // stale one down + std::mutex g_wrappers_mutex; + std::vector> g_wrappers; + + // register a wrapper as the live instance for its CLSID, returning any stale wrapper it + // replaces so the caller can tear it down outside the lock + WrappedAsio *register_wrapper(REFCLSID clsid, WrappedAsio *wrapper) { + std::lock_guard lock(g_wrappers_mutex); + for (auto &entry : g_wrappers) { + if (IsEqualCLSID(entry.first, clsid)) { + WrappedAsio *stale = entry.second; + entry.second = wrapper; + return stale; + } + } + + g_wrappers.emplace_back(clsid, wrapper); + return nullptr; + } + + void unregister_wrapper(WrappedAsio *wrapper) { + std::lock_guard lock(g_wrappers_mutex); + for (auto it = g_wrappers.begin(); it != g_wrappers.end(); ++it) { + if (it->second == wrapper) { + g_wrappers.erase(it); + return; + } + } + } + + // ASIO drivers registered on this system (CLSID + registry name), scanned once + const std::vector> ®istered_asio_drivers() { + static const std::vector> drivers = [] { + std::vector> result; + AsioDriverList driver_list; + for (const auto &driver : driver_list.driver_list) { + result.emplace_back(driver.clsid, driver.name); + log_info( + "audio::wrappedasio", + "registered ASIO driver: name='{}', clsid={}", + driver.name, + guid2s(driver.clsid)); + } + + log_info("audio::wrappedasio", "discovered {} registered ASIO driver(s)", result.size()); + return result; + }(); + + return drivers; + } + + std::string registered_asio_name(REFCLSID clsid) { + for (const auto &driver : registered_asio_drivers()) { + if (IsEqualCLSID(driver.first, clsid)) { + return driver.second; + } + } + + return guid2s(clsid); + } +} + +namespace hooks::audio::asio { + + bool is_asio_creation(REFCLSID rclsid, REFIID riid) { + + // ASIO hosts request the driver using its own CLSID as the interface id + if (!IsEqualGUID(rclsid, riid)) { + return false; + } + + for (const auto &driver : registered_asio_drivers()) { + if (IsEqualCLSID(driver.first, rclsid)) { + return true; + } + } + + return false; + } +} + +#pragma region IUnknown +bool WrappedAsio::FORCE_TWO_CHANNELS = false; +std::atomic WrappedAsio::volume_active_instance {nullptr}; + +WrappedAsio::~WrappedAsio() { + unregister_wrapper(this); + + this->detach_volume(); + + // our refcount is decoupled from the host's (see AddRef/Release), so pReal's count is + // exactly one here and this releases/unloads it deterministically + this->pReal->Release(); + + log_info("audio::wrappedasio", "destroying wrapped ASIO driver, clsid={}", guid2s(this->clsid)); +} + +HRESULT STDMETHODCALLTYPE WrappedAsio::QueryInterface(REFIID riid, void **ppv) { + if (ppv == nullptr) { + return E_POINTER; + } + + // ASIO hosts query for the driver using its own CLSID as the IID + if (IsEqualIID(riid, IID_IUnknown) || IsEqualIID(riid, this->clsid)) { + this->AddRef(); + *ppv = static_cast(this); + + return S_OK; + } + + // the host is asking for some other interface; forward to the real driver. a failure + // here is a common reason a host discards a driver and retries + const HRESULT ret = this->pReal->QueryInterface(riid, ppv); + if (SUCCEEDED(ret)) { + log_info("audio::wrappedasio", "QueryInterface({}) -> forwarded to real driver", guid2s(riid)); + } else { + log_info( + "audio::wrappedasio", + "QueryInterface({}) -> not supported by driver, hr={:#x}", + guid2s(riid), + static_cast(ret)); + } + + return ret; +} + +ULONG STDMETHODCALLTYPE WrappedAsio::AddRef() { + // decoupled from the real driver: we count host references on the wrapper and hold a + // single reference on pReal for our lifetime. this neutralizes a host bug (iidx32+) + // that takes a duplicate AddRef with no matching Release, which would leak the driver + return ++this->ref_count; +} + +ULONG STDMETHODCALLTYPE WrappedAsio::Release() { + const ULONG refs = --this->ref_count; + if (refs == 0) { + delete this; + } + + return refs; +} +#pragma endregion + +#pragma region IAsio +AsioBool __thiscall WrappedAsio::init(void *sys_handle) { + const AsioBool result = this->pReal->init(sys_handle); + if (result == AsioTrue) { + log_info( + "audio::wrappedasio", + "init succeeded for '{}' (driver version {})", + this->driver_name, + this->pReal->get_driver_version()); + } else { + char message[128] = {}; + this->pReal->get_error_message(message); + log_warning("audio::wrappedasio", "init failed: {}", message); + } + + return result; +} + +void __thiscall WrappedAsio::get_driver_name(char *name) { + this->pReal->get_driver_name(name); +} + +long __thiscall WrappedAsio::get_driver_version() { + return this->pReal->get_driver_version(); +} + +void __thiscall WrappedAsio::get_error_message(char *string) { + this->pReal->get_error_message(string); +} + +AsioError __thiscall WrappedAsio::start() { + const AsioError result = this->pReal->start(); + if (result == ASE_OK) { + log_info( + "audio::wrappedasio", + "start succeeded, ASIO stream is now running on '{}'", + this->driver_name); + } else { + log_warning("audio::wrappedasio", "start failed, err={}", static_cast(result)); + } + + return result; +} + +AsioError __thiscall WrappedAsio::stop() { + const AsioError result = this->pReal->stop(); + if (result == ASE_OK) { + log_info("audio::wrappedasio", "stop succeeded, ASIO stream on '{}' halted", this->driver_name); + } else { + log_warning("audio::wrappedasio", "stop failed, err={}", static_cast(result)); + } + + return result; +} + +AsioError __thiscall WrappedAsio::get_channels(long *num_input_channels, long *num_output_channels) { + const AsioError result = this->pReal->get_channels(num_input_channels, num_output_channels); + if (result != ASE_OK) { + log_warning("audio::wrappedasio", "get_channels failed, err={}", static_cast(result)); + return result; + } + + if (FORCE_TWO_CHANNELS + && num_output_channels != nullptr + && *num_output_channels < FORCED_OUTPUT_CHANNELS) + { + // the device has fewer outputs than the game hardcodes; report the count it + // expects so it proceeds to create_buffers, where we forward only the real + // front pair and discard the rest + log_info( + "audio::wrappedasio", + "reporting output channel count as {} (device has {}) for forced two-channel", + FORCED_OUTPUT_CHANNELS, + *num_output_channels); + *num_output_channels = FORCED_OUTPUT_CHANNELS; + } + + log_info( + "audio::wrappedasio", + "get_channels -> in={}, out={}", + num_input_channels ? *num_input_channels : -1, + num_output_channels ? *num_output_channels : -1); + + return result; +} + +AsioError __thiscall WrappedAsio::get_latencies(long *input_latency, long *output_latency) { + const AsioError result = this->pReal->get_latencies(input_latency, output_latency); + if (result == ASE_OK) { + // include millisecond equivalents alongside the frame counts for readability + AsioSampleRate sample_rate = 0.0; + this->pReal->get_sample_rate(&sample_rate); + const long in_frames = input_latency ? *input_latency : -1; + const long out_frames = output_latency ? *output_latency : -1; + log_info( + "audio::wrappedasio", + "get_latencies -> in={} frames ({:.2f} ms), out={} frames ({:.2f} ms)", + in_frames, + frames_to_ms(in_frames, sample_rate), + out_frames, + frames_to_ms(out_frames, sample_rate)); + } else { + log_warning("audio::wrappedasio", "get_latencies failed, err={}", static_cast(result)); + } + + return result; +} + +AsioError __thiscall WrappedAsio::get_buffer_size( + long *min_size, + long *max_size, + long *preferred_size, + long *granularity) +{ + const AsioError result = this->pReal->get_buffer_size(min_size, max_size, preferred_size, granularity); + if (result != ASE_OK) { + log_warning("audio::wrappedasio", "get_buffer_size failed, err={}", static_cast(result)); + return result; + } + + // include millisecond equivalents alongside the frame counts for readability + AsioSampleRate sample_rate = 0.0; + this->pReal->get_sample_rate(&sample_rate); + const long min_frames = min_size ? *min_size : -1; + const long max_frames = max_size ? *max_size : -1; + const long preferred_frames = preferred_size ? *preferred_size : -1; + log_info( + "audio::wrappedasio", + "get_buffer_size -> min={} frames ({:.2f} ms), max={} frames ({:.2f} ms), " + "preferred={} frames ({:.2f} ms), granularity={}", + min_frames, + frames_to_ms(min_frames, sample_rate), + max_frames, + frames_to_ms(max_frames, sample_rate), + preferred_frames, + frames_to_ms(preferred_frames, sample_rate), + granularity ? *granularity : -1); + + return result; +} + +AsioError __thiscall WrappedAsio::can_sample_rate(AsioSampleRate sample_rate) { + const AsioError result = this->pReal->can_sample_rate(sample_rate); + if (result == ASE_OK) { + log_misc("audio::wrappedasio", "can_sample_rate({} Hz) -> supported", sample_rate); + } else { + log_misc( + "audio::wrappedasio", + "can_sample_rate({} Hz) -> not supported, err={}", + sample_rate, + static_cast(result)); + } + + return result; +} + +AsioError __thiscall WrappedAsio::get_sample_rate(AsioSampleRate *sample_rate) { + const AsioError result = this->pReal->get_sample_rate(sample_rate); + if (result == ASE_OK) { + log_misc("audio::wrappedasio", "get_sample_rate -> {} Hz", sample_rate ? *sample_rate : 0.0); + } else { + log_warning("audio::wrappedasio", "get_sample_rate failed, err={}", static_cast(result)); + } + + return result; +} + +AsioError __thiscall WrappedAsio::set_sample_rate(AsioSampleRate sample_rate) { + const AsioError result = this->pReal->set_sample_rate(sample_rate); + if (result == ASE_OK) { + log_info("audio::wrappedasio", "set_sample_rate({} Hz) succeeded", sample_rate); + } else { + log_warning( + "audio::wrappedasio", + "set_sample_rate({} Hz) failed, err={}", + sample_rate, + static_cast(result)); + } + + return result; +} + +AsioError __thiscall WrappedAsio::get_clock_sources(ASIOClockSource *clocks, long *num_sources) { + return this->pReal->get_clock_sources(clocks, num_sources); +} + +AsioError __thiscall WrappedAsio::set_clock_source(long reference) { + return this->pReal->set_clock_source(reference); +} + +AsioError __thiscall WrappedAsio::get_sample_position(ASIOSamples *s_pos, ASIOTimeStamp *t_stamp) { + return this->pReal->get_sample_position(s_pos, t_stamp); +} + +AsioError __thiscall WrappedAsio::get_channel_info(AsioChannelInfo *info) { + // forced two-channel: the game probes all output channels it thinks exist, but the + // device only has the real front pair. fabricate a plausible entry for the channels + // beyond the device without touching the real driver - they are discarded in + // create_buffers anyway + long real_in = 0, real_out = 0; + if (FORCE_TWO_CHANNELS + && info != nullptr + && info->is_input == AsioFalse + && this->pReal->get_channels(&real_in, &real_out) == ASE_OK + && info->channel >= real_out) + { + const long channel = info->channel; + info->is_active = AsioTrue; + info->channel_group = 0; + info->type = ASIOSTInt32LSB; + snprintf(info->name, sizeof(info->name), "Fake ASIO OUT %ld", channel); + log_info( + "audio::wrappedasio", + "get_channel_info(channel={}, dir=output) -> fake channel, type={} ({})", + channel, + asio_sample_type_name(info->type), + static_cast(info->type)); + + return ASE_OK; + } + + const AsioError result = this->pReal->get_channel_info(info); + if (result == ASE_OK && info != nullptr) { + log_info( + "audio::wrappedasio", + "get_channel_info(channel={}, dir={}) -> active={}, group={}, type={} ({}), name='{}'", + info->channel, + info->is_input == AsioTrue ? "input" : "output", + info->is_active == AsioTrue, + info->channel_group, + asio_sample_type_name(info->type), + static_cast(info->type), + info->name); + } else if (result != ASE_OK) { + log_warning("audio::wrappedasio", "get_channel_info failed, err={}", static_cast(result)); + } + + return result; +} + +AsioCallbacks *WrappedAsio::install_volume_callbacks(AsioCallbacks *game_callbacks) { + const float gain = hooks::audio::VOLUME_BOOST; + + // start from a clean slate; a previous buffer set may have left state behind + this->volume_channels.clear(); + this->volume_active = false; + + // no boost configured (or no callbacks to wrap): pass the game's callbacks straight + // through and do zero realtime work, exactly as before + if (gain == 1.0f || game_callbacks == nullptr) { + return game_callbacks; + } + + this->volume_active = true; + this->volume_gain = gain; + this->volume_game_callbacks = *game_callbacks; + + // wrap only the buffer-switch callbacks, where the audio data lives and we apply the + // gain. the other two carry no data we touch, so forward the game's own pointers + // unchanged - the driver expects them non-null and the game already owns their context + this->volume_proxy_callbacks = {}; + this->volume_proxy_callbacks.buffer_switch = &WrappedAsio::volume_buffer_switch; + this->volume_proxy_callbacks.sample_rate_did_change = game_callbacks->sample_rate_did_change; + this->volume_proxy_callbacks.asio_message = game_callbacks->asio_message; + this->volume_proxy_callbacks.buffer_switch_time_info = + game_callbacks->buffer_switch_time_info ? &WrappedAsio::volume_buffer_switch_time_info : nullptr; + + return &this->volume_proxy_callbacks; +} + +void WrappedAsio::record_volume_output_channel(const AsioBufferInfo &info) { + if (info.is_input != AsioFalse) { + return; + } + + // ask the real driver for this channel's sample format so the realtime path knows how + // to scale it; fall back to a sentinel that apply_gain_planar leaves untouched + AsioChannelInfo ci {}; + ci.channel = info.channel_num; + ci.is_input = AsioFalse; + AsioSampleType type = ASIOSTLastEntry; + if (this->pReal->get_channel_info(&ci) == ASE_OK) { + type = ci.type; + } + + VolumeOutputChannel ch; + ch.buffers[0] = info.buffers[0]; + ch.buffers[1] = info.buffers[1]; + ch.type = type; + this->volume_channels.push_back(ch); +} + +void WrappedAsio::publish_volume(long buffer_size) { + if (!this->volume_active) { + return; + } + + // everything the realtime thread reads is now in place; make ourselves reachable + this->volume_buffer_size = buffer_size; + WrappedAsio::volume_active_instance.store(this, std::memory_order_release); + log_info( + "audio::wrappedasio", + "volume boost active: gain={}, scaling {} output channel(s)", + this->volume_gain, + this->volume_channels.size()); +} + +void WrappedAsio::detach_volume() { + // stop our realtime trampolines from reaching this wrapper, but only if we are the + // currently published instance + WrappedAsio *expected = this; + WrappedAsio::volume_active_instance.compare_exchange_strong(expected, nullptr); +} + +void WrappedAsio::apply_output_volume(long double_buffer_index) { + if (double_buffer_index != 0 && double_buffer_index != 1) { + return; + } + const float gain = this->volume_gain; + const long frames = this->volume_buffer_size; + for (const VolumeOutputChannel &ch : this->volume_channels) { + apply_gain_planar(ch.buffers[double_buffer_index], frames, ch.type, gain); + } +} + +void __cdecl WrappedAsio::volume_buffer_switch(long double_buffer_index, AsioBool direct_process) { + WrappedAsio *self = WrappedAsio::volume_active_instance.load(std::memory_order_acquire); + if (self == nullptr) { + return; + } + + // let the game write its samples into the driver buffers first, then scale them before + // the driver plays this half on the next switch + if (self->volume_game_callbacks.buffer_switch != nullptr) { + self->volume_game_callbacks.buffer_switch(double_buffer_index, direct_process); + } + self->apply_output_volume(double_buffer_index); +} + +AsioTime * __cdecl WrappedAsio::volume_buffer_switch_time_info( + AsioTime *params, long double_buffer_index, AsioBool direct_process) +{ + WrappedAsio *self = WrappedAsio::volume_active_instance.load(std::memory_order_acquire); + if (self == nullptr) { + return params; + } + + AsioTime *ret = params; + if (self->volume_game_callbacks.buffer_switch_time_info != nullptr) { + ret = self->volume_game_callbacks.buffer_switch_time_info( + params, double_buffer_index, direct_process); + } else if (self->volume_game_callbacks.buffer_switch != nullptr) { + self->volume_game_callbacks.buffer_switch(double_buffer_index, direct_process); + } + self->apply_output_volume(double_buffer_index); + return ret; +} + +AsioError __thiscall WrappedAsio::create_buffers( + AsioBufferInfo *buffer_infos, + long num_channels, + long buffer_size, + AsioCallbacks *callbacks) +{ + // swap in our buffer-switch trampolines if a volume boost is configured, so the real + // driver calls us and we scale its output after the game fills it (no-op otherwise) + AsioCallbacks *effective = this->install_volume_callbacks(callbacks); + + if (FORCE_TWO_CHANNELS) { + return this->create_buffers_front_pair(buffer_infos, num_channels, buffer_size, effective); + } + + const AsioError result = this->pReal->create_buffers(buffer_infos, num_channels, buffer_size, effective); + if (result == ASE_OK) { + log_info( + "audio::wrappedasio", + "create_buffers(channels={}, size={} frames) succeeded", + num_channels, + buffer_size); + + // capture the device output channels we will scale, then publish ourselves to the + // realtime thread once everything is in place + if (this->volume_active) { + for (long i = 0; i < num_channels; i++) { + this->record_volume_output_channel(buffer_infos[i]); + } + this->publish_volume(buffer_size); + } + } else { + log_warning( + "audio::wrappedasio", + "create_buffers(channels={}, size={} frames) failed, err={}", + num_channels, + buffer_size, + static_cast(result)); + } + + return result; +} + +AsioError WrappedAsio::create_buffers_front_pair( + AsioBufferInfo *buffer_infos, + long num_channels, + long buffer_size, + AsioCallbacks *callbacks) +{ + // front-pair extraction (forced two-channel ASIO): the game asks for more output + // channels than the real device has (e.g. 8 vs 2). forward only the channels the + // device actually provides (channel 0/1 = front L/R) and hand the game throwaway + // buffers for the rest, so its front mix lands on the device and the surround + // channels are discarded. the game writes directly into the driver/dummy buffers + // from its own bufferSwitch; the only realtime work we do is the volume boost (if + // configured), which scales the forwarded device channels via our trampolines + long real_in = 0, real_out = 0; + const AsioError ch_result = this->pReal->get_channels(&real_in, &real_out); + if (ch_result != ASE_OK) { + log_warning( + "audio::wrappedasio", + "create_buffers: get_channels failed, err={}", + static_cast(ch_result)); + return ch_result; + } + + // partition the requested channels: those the device can serve are forwarded, the rest + // are discarded. record source indices for both so we can patch the game's array after + std::vector forwarded; + std::vector forwarded_src; + std::vector discarded_src; + forwarded.reserve(num_channels); + forwarded_src.reserve(num_channels); + discarded_src.reserve(num_channels); + for (long i = 0; i < num_channels; i++) { + const AsioBufferInfo &bi = buffer_infos[i]; + const long limit = (bi.is_input == AsioTrue) ? real_in : real_out; + if (bi.channel_num < limit) { + forwarded.push_back(bi); + forwarded_src.push_back(i); + } else { + discarded_src.push_back(i); + } + } + + const AsioError result = this->pReal->create_buffers( + forwarded.data(), static_cast(forwarded.size()), buffer_size, callbacks); + if (result != ASE_OK) { + log_warning( + "audio::wrappedasio", + "create_buffers(forwarded={} of {}, size={} frames) failed, err={}", + forwarded.size(), + num_channels, + buffer_size, + static_cast(result)); + return result; + } + + // copy the real driver buffer pointers back into the game's array + for (size_t k = 0; k < forwarded.size(); k++) { + AsioBufferInfo &dst = buffer_infos[forwarded_src[k]]; + dst.buffers[0] = forwarded[k].buffers[0]; + dst.buffers[1] = forwarded[k].buffers[1]; + + // only the forwarded channels reach the device, so those are the ones the volume + // boost scales (the discarded channels go to throwaway buffers below) + if (this->volume_active) { + this->record_volume_output_channel(dst); + } + } + + this->publish_volume(buffer_size); + + // hand throwaway double buffers to the discarded channels. sized generously at + // 8 bytes/sample (covers every ASIO sample type) so the game can never overrun them + // regardless of the negotiated format + this->dummy_buffers.clear(); + this->dummy_buffers.reserve(discarded_src.size() * 2); + const size_t dummy_bytes = static_cast(buffer_size) * 8; + for (const long i : discarded_src) { + for (void *&buffer : buffer_infos[i].buffers) { + auto buf = std::make_unique(dummy_bytes); + std::memset(buf.get(), 0, dummy_bytes); + buffer = buf.get(); + this->dummy_buffers.push_back(std::move(buf)); + } + } + + log_info( + "audio::wrappedasio", + "create_buffers: front-pair extraction - forwarded {} channel(s) to device, " + "discarded {} (requested {}, size={} frames)", + forwarded.size(), + discarded_src.size(), + num_channels, + buffer_size); + + return ASE_OK; +} + +AsioError __thiscall WrappedAsio::dispose_buffers() { + // stop our realtime trampolines from touching buffers the driver is about to free + this->detach_volume(); + + const AsioError result = this->pReal->dispose_buffers(); + this->dummy_buffers.clear(); + this->volume_channels.clear(); + this->volume_active = false; + return result; +} + +AsioError __thiscall WrappedAsio::control_panel() { + return this->pReal->control_panel(); +} + +AsioError __thiscall WrappedAsio::future(long selector, void *opt) { + return this->pReal->future(selector, opt); +} + +AsioError __thiscall WrappedAsio::output_ready() { + return this->pReal->output_ready(); +} +#pragma endregion + +namespace hooks::audio::asio { + + IUnknown *wrap(REFCLSID clsid, void *real) { + log_info("audio::wrappedasio", "wrapping ASIO driver interface, clsid={}", guid2s(clsid)); + + auto *wrapper = new WrappedAsio( + reinterpret_cast(real), clsid, registered_asio_name(clsid)); + + // if the host already had a live wrapper for this CLSID it leaked the previous + // instance (ASIO is single-instance); tear it down now so the real driver is + // released before the reinit. works around games (iidx32+) that ref twice but + // deref once before re-initializing. + // + // FlexASIO 1.9 and many DAC ASIO drivers can't handle this; FlexASIO 1.10 and + // Xonar AE can + if (WrappedAsio *stale = register_wrapper(clsid, wrapper)) { + log_info( + "audio::wrappedasio", + "host did not release previous instance for clsid={}, forcing teardown", + guid2s(clsid)); + + // the stale instance may still have a running stream on the driver's realtime + // thread. stop and dispose it before deleting so no in-flight buffer switch + // (e.g. our volume trampoline) touches buffers we are about to free. ASIO + // guarantees no further buffer_switch once stop() returns, and dispose_buffers + // detaches our trampolines, fully quiescing the realtime path before delete + stale->stop(); + stale->dispose_buffers(); + delete stale; + } + + return static_cast(wrapper); + } +} diff --git a/src/spice2x/hooks/audio/asio_proxy.h b/src/spice2x/hooks/audio/asio_proxy.h new file mode 100644 index 0000000..480b526 --- /dev/null +++ b/src/spice2x/hooks/audio/asio_proxy.h @@ -0,0 +1,160 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "external/asio/asio.h" +#include "external/asio/iasiodrv.h" + +namespace hooks::audio::asio { + + // returns true if a CoCreateInstance call is instantiating a registered ASIO driver. + // ASIO hosts pass the driver CLSID as both class id and interface id; we also validate + // it against the system's registered ASIO drivers to avoid false positives + bool is_asio_creation(REFCLSID rclsid, REFIID riid); + + // wrap a real ASIO driver instance, taking ownership of the supplied reference, and + // return a proxy that forwards every call to it + IUnknown *wrap(REFCLSID clsid, void *real); +} + +// transparent proxy around a real ASIO driver; a single place to intercept ASIO traffic +struct WrappedAsio final : IAsio { + WrappedAsio(IAsio *real, REFCLSID clsid, std::string name) + : pReal(real), clsid(clsid), driver_name(std::move(name)) { + } + + WrappedAsio(const WrappedAsio &) = delete; + WrappedAsio &operator=(const WrappedAsio &) = delete; + + virtual ~WrappedAsio(); + + // when set, the proxy presents the game's expected multichannel layout to the host so + // it proceeds to create_buffers, then forwards only the device's real front pair and + // discards the rest (see create_buffers). set once at boot, before any wrapper exists, + // so it needs no synchronization + static bool FORCE_TWO_CHANNELS; + + // some games hardcode a multichannel ASIO output and bail before create_buffers if + // get_channels reports fewer, so we report at least this many output channels when + // FORCE_TWO_CHANNELS is active + static constexpr long FORCED_OUTPUT_CHANNELS = 8; + +#pragma region IUnknown + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppv) override; + ULONG STDMETHODCALLTYPE AddRef() override; + ULONG STDMETHODCALLTYPE Release() override; +#pragma endregion + +#pragma region IAsio + AsioBool __thiscall init(void *sys_handle) override; + void __thiscall get_driver_name(char *name) override; + long __thiscall get_driver_version() override; + void __thiscall get_error_message(char *string) override; + AsioError __thiscall start() override; + AsioError __thiscall stop() override; + AsioError __thiscall get_channels(long *num_input_channels, long *num_output_channels) override; + AsioError __thiscall get_latencies(long *input_latency, long *output_latency) override; + AsioError __thiscall get_buffer_size( + long *min_size, + long *max_size, + long *preferred_size, + long *granularity) override; + AsioError __thiscall can_sample_rate(AsioSampleRate sample_rate) override; + AsioError __thiscall get_sample_rate(AsioSampleRate *sample_rate) override; + AsioError __thiscall set_sample_rate(AsioSampleRate sample_rate) override; + AsioError __thiscall get_clock_sources(ASIOClockSource *clocks, long *num_sources) override; + AsioError __thiscall set_clock_source(long reference) override; + AsioError __thiscall get_sample_position(ASIOSamples *s_pos, ASIOTimeStamp *t_stamp) override; + AsioError __thiscall get_channel_info(AsioChannelInfo *info) override; + AsioError __thiscall create_buffers( + AsioBufferInfo *buffer_infos, + long num_channels, + long buffer_size, + AsioCallbacks *callbacks) override; + AsioError __thiscall dispose_buffers() override; + AsioError __thiscall control_panel() override; + AsioError __thiscall future(long selector, void *opt) override; + AsioError __thiscall output_ready() override; +#pragma endregion + +private: + // create_buffers implementation used when FORCE_TWO_CHANNELS is active: forwards only + // the channels the real device has and hands the game throwaway buffers for the rest + AsioError create_buffers_front_pair( + AsioBufferInfo *buffer_infos, + long num_channels, + long buffer_size, + AsioCallbacks *callbacks); + + // if hooks::audio::VOLUME_BOOST is set, saves the game's callbacks and returns a proxy + // callback set (our buffer-switch trampolines) to hand the real driver instead, so we + // can scale its output buffers after the game fills them. otherwise returns the game's + // callbacks unchanged. called at create_buffers time, before the stream starts + AsioCallbacks *install_volume_callbacks(AsioCallbacks *game_callbacks); + + // records a device output channel whose buffers we scale by the volume boost. queries + // the real driver for the channel's sample format. called at create_buffers time + void record_volume_output_channel(const AsioBufferInfo &info); + + // publishes the captured volume state to the realtime thread once the buffers exist, + // making our trampolines start scaling. called at the end of either create_buffers path + void publish_volume(long buffer_size); + + // detaches this instance from the realtime trampolines so they stop touching its + // buffers. called from dispose_buffers and the destructor + void detach_volume(); + + // multiplies every recorded output channel's buffer for the given double-buffer index + // by the volume boost. runs on the driver's realtime thread from our buffer switch + void apply_output_volume(long double_buffer_index); + + // realtime-thread trampolines for the buffer-switch callbacks, handed to the real + // driver in place of the game's; ASIO callbacks carry no user data, so they reach the + // active wrapper through volume_active_instance, call the game's original, then scale. + // the other two callbacks (sample_rate_did_change, asio_message) are forwarded as the + // game's own pointers, so they need no trampoline + static void __cdecl volume_buffer_switch(long double_buffer_index, AsioBool direct_process); + static AsioTime * __cdecl volume_buffer_switch_time_info( + AsioTime *params, long double_buffer_index, AsioBool direct_process); + + // the single wrapper whose proxy callbacks are installed (ASIO is single-instance with + // one running stream); read by the static trampolines to reach the right wrapper + static std::atomic volume_active_instance; + + IAsio *const pReal; + const CLSID clsid; + + // registry name of the driver (not get_driver_name), used in our logs as a single + // unambiguous name; constant for our lifetime + std::string driver_name; + + // our own reference count; we hold one reference on pReal and release it when this + // drops to zero + std::atomic ref_count {1}; + + // throwaway double buffers handed to the channels we discard when FORCE_TWO_CHANNELS + // is active (see create_buffers). owned for the lifetime of the buffer set and freed + // in dispose_buffers; only read by the game from its own bufferSwitch, never by us + std::vector> dummy_buffers; + + // one device output channel scaled by the volume boost in our buffer switch + struct VolumeOutputChannel { + void *buffers[2]; + AsioSampleType type; + }; + + // volume boost state, captured at create_buffers time and published to the realtime + // thread via volume_active_instance once fully built; untouched while the stream runs. + // volume_active gates whether we install our proxy callbacks at all + bool volume_active = false; + float volume_gain = 1.0f; + long volume_buffer_size = 0; + AsioCallbacks volume_game_callbacks {}; + AsioCallbacks volume_proxy_callbacks {}; + std::vector volume_channels; +}; diff --git a/src/spice2x/hooks/audio/audio.cpp b/src/spice2x/hooks/audio/audio.cpp index a413a4e..0404358 100644 --- a/src/spice2x/hooks/audio/audio.cpp +++ b/src/spice2x/hooks/audio/audio.cpp @@ -14,6 +14,7 @@ #include "audio_private.h" #include "acm.h" +#include "asio_proxy.h" #ifdef _MSC_VER DEFINE_GUID(CLSID_MMDeviceEnumerator, @@ -95,6 +96,10 @@ static HRESULT STDAPICALLTYPE CoCreateInstance_hook( // wrap object auto mmde = reinterpret_cast(ppv); *mmde = new WrappedIMMDeviceEnumerator(*mmde); + } else if (ppv != nullptr && *ppv != nullptr && hooks::audio::asio::is_asio_creation(rclsid, riid)) { + + // wrap every ASIO driver so calls pass through to the real driver + *ppv = hooks::audio::asio::wrap(rclsid, *ppv); } // return original result diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index 58ed6be..7715bdc 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -72,6 +72,7 @@ #include "games/museca/museca.h" #include "hooks/avshook.h" #include "hooks/audio/audio.h" +#include "hooks/audio/asio_proxy.h" #include "hooks/audio/backends/wasapi/downmix.h" #include "hooks/debughook.h" #include "hooks/devicehook.h" @@ -496,6 +497,9 @@ int main_implementation(int argc, char *argv[]) { if (options[launcher::Options::spice2x_SDVXAsioDriver].is_active()) { games::sdvx::ASIO_DRIVER = options[launcher::Options::spice2x_SDVXAsioDriver].value_text(); } + if (options[launcher::Options::SDVXAsioTwoChannel].value_bool()) { + WrappedAsio::FORCE_TWO_CHANNELS = true; + } if (options[launcher::Options::spice2x_SDVXSubPos].is_active()) { auto txt = options[launcher::Options::spice2x_SDVXSubPos].value_text(); if (txt == "top") { diff --git a/src/spice2x/launcher/options.cpp b/src/spice2x/launcher/options.cpp index cd9a134..6aa9790 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -948,6 +948,15 @@ static const std::vector OPTION_DEFINITIONS = { .category = "Game Options", .picker = OptionPickerType::AsioDriver, }, + { + // SDVXAsioTwoChannel + .title = "SDVX ASIO Two Channel Audio", + .name = "sdvxasio2ch", + .desc = "Force the game to use two channels for ASIO output.", + .type = OptionType::Bool, + .game_name = "Sound Voltex", + .category = "Game Options", + }, { // spice2x_SDVXSubPos .title = "SDVX Subscreen Overlay Position", @@ -1965,7 +1974,7 @@ static const std::vector OPTION_DEFINITIONS = { }, { // VolumeBoost - .title = "WASAPI Boost Audio Volume", + .title = "WASAPI/ASIO Boost Audio Volume", .name = "volumeboost", .desc = "Artificially amplifies the hooked audio output by the selected amount, applied " "right before the audio reaches the device. Works regardless of channel layout or " diff --git a/src/spice2x/launcher/options.h b/src/spice2x/launcher/options.h index f7b2162..f4a2fe6 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -94,6 +94,7 @@ namespace launcher { spice2x_SDVXDigitalKnobSensitivity, SDVXDigitalKnobSocd, spice2x_SDVXAsioDriver, + SDVXAsioTwoChannel, spice2x_SDVXSubPos, SDVXSubMonitorOverride, LoadDDRModule,