From 2dae86a6f21e8b2e01926d6db6614480e98897d8 Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:08:52 -0700 Subject: [PATCH] gitadora: (arena model) simulate Realtek device, fix asio redirect hooks (#732) ## Link to GitHub Issue or related Pull Request, if one exists #730, #718 ## Description of change Part 1) When ASIO is in use, the game also looks for a Realtek device so that it can open a WASAPI Exclusive mode stream for headphones. Add an option to fake that, in case the user doesn't have a Realtek device. Part 2) `-gdaasio` wasn't workign properly - fix the logic. ## Testing Seems to work with FlexASIO, and Xonar with no real Realtek device. --- src/spice2x/CMakeLists.txt | 2 + src/spice2x/games/gitadora/asio.cpp | 13 +- src/spice2x/games/gitadora/gitadora.cpp | 1 + src/spice2x/hooks/audio/audio.cpp | 1 + src/spice2x/hooks/audio/audio.h | 5 + .../backends/mmdevice/device_collection.cpp | 34 ++- .../backends/mmdevice/device_collection.h | 7 +- .../backends/mmdevice/device_enumerator.cpp | 2 +- .../audio/backends/mmdevice/null_device.cpp | 195 ++++++++++++++++++ .../audio/backends/mmdevice/null_device.h | 39 ++++ .../mmdevice/null_discard_backend.cpp | 141 +++++++++++++ .../backends/mmdevice/null_discard_backend.h | 54 +++++ src/spice2x/launcher/launcher.cpp | 3 + src/spice2x/launcher/options.cpp | 14 +- src/spice2x/launcher/options.h | 1 + 15 files changed, 504 insertions(+), 8 deletions(-) create mode 100644 src/spice2x/hooks/audio/backends/mmdevice/null_device.cpp create mode 100644 src/spice2x/hooks/audio/backends/mmdevice/null_device.h create mode 100644 src/spice2x/hooks/audio/backends/mmdevice/null_discard_backend.cpp create mode 100644 src/spice2x/hooks/audio/backends/mmdevice/null_discard_backend.h diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index d19aabb..5d39d7a 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -503,6 +503,8 @@ set(SOURCE_FILES ${SOURCE_FILES} hooks/audio/backends/mmdevice/device.cpp hooks/audio/backends/mmdevice/device_collection.cpp hooks/audio/backends/mmdevice/device_enumerator.cpp + hooks/audio/backends/mmdevice/null_device.cpp + hooks/audio/backends/mmdevice/null_discard_backend.cpp hooks/audio/backends/wasapi/audio_client.cpp hooks/audio/backends/wasapi/audio_render_client.cpp hooks/audio/backends/wasapi/downmix.cpp diff --git a/src/spice2x/games/gitadora/asio.cpp b/src/spice2x/games/gitadora/asio.cpp index e5045b0..ee06fb7 100644 --- a/src/spice2x/games/gitadora/asio.cpp +++ b/src/spice2x/games/gitadora/asio.cpp @@ -21,6 +21,7 @@ namespace games::gitadora { static decltype(RegCloseKey) *RegCloseKey_orig = nullptr; static decltype(RegEnumKeyA) *RegEnumKeyA_orig = nullptr; + static decltype(RegOpenKeyA) *RegOpenKeyA_orig = nullptr; static decltype(RegOpenKeyExA) *RegOpenKeyExA_orig = nullptr; static decltype(RegQueryValueExA) *RegQueryValueExA_orig = nullptr; @@ -30,7 +31,6 @@ namespace games::gitadora { static LONG WINAPI RegOpenKeyExA_hook(HKEY hKey, LPCSTR lpSubKey, DWORD ulOptions, REGSAM samDesired, PHKEY phkResult) { - // ASIO\XONAR redirect to ASIO\ if (ASIO_DRIVER.has_value() && lpSubKey != nullptr && phkResult != nullptr && @@ -60,7 +60,10 @@ namespace games::gitadora { return result; } - // open of the ASIO root: hand back a sentinel + return RegOpenKeyExA_orig(hKey, lpSubKey, ulOptions, samDesired, phkResult); + } + + static LONG WINAPI RegOpenKeyA_hook(HKEY hKey, LPCSTR lpSubKey, PHKEY phkResult) { if (ASIO_DRIVER.has_value() && lpSubKey != nullptr && phkResult != nullptr && @@ -68,10 +71,10 @@ namespace games::gitadora { _stricmp(lpSubKey, "software\\asio") == 0) { *phkResult = PARENT_ASIO_REG_HANDLE; - return RegOpenKeyExA_orig(hKey, lpSubKey, ulOptions, samDesired, &real_asio_reg_handle); + return RegOpenKeyA_orig(hKey, lpSubKey, &real_asio_reg_handle); } - return RegOpenKeyExA_orig(hKey, lpSubKey, ulOptions, samDesired, phkResult); + return RegOpenKeyA_orig(hKey, lpSubKey, phkResult); } static LONG WINAPI RegEnumKeyA_hook(HKEY hKey, DWORD dwIndex, LPSTR lpName, DWORD cchName) { @@ -158,6 +161,8 @@ namespace games::gitadora { "RegCloseKey", RegCloseKey_hook, avs::game::DLL_INSTANCE); RegEnumKeyA_orig = detour::iat_try( "RegEnumKeyA", RegEnumKeyA_hook, avs::game::DLL_INSTANCE); + RegOpenKeyA_orig = detour::iat_try( + "RegOpenKeyA", RegOpenKeyA_hook, avs::game::DLL_INSTANCE); RegOpenKeyExA_orig = detour::iat_try( "RegOpenKeyExA", RegOpenKeyExA_hook, avs::game::DLL_INSTANCE); RegQueryValueExA_orig = detour::iat_try( diff --git a/src/spice2x/games/gitadora/gitadora.cpp b/src/spice2x/games/gitadora/gitadora.cpp index 89e1d5a..ced4b1b 100644 --- a/src/spice2x/games/gitadora/gitadora.cpp +++ b/src/spice2x/games/gitadora/gitadora.cpp @@ -8,6 +8,7 @@ #include #include "cfg/configurator.h" +#include "hooks/audio/audio.h" #include "hooks/audio/mme.h" #include "hooks/graphics/graphics.h" #include "misc/wintouchemu.h" diff --git a/src/spice2x/hooks/audio/audio.cpp b/src/spice2x/hooks/audio/audio.cpp index 0404358..09cf9ba 100644 --- a/src/spice2x/hooks/audio/audio.cpp +++ b/src/spice2x/hooks/audio/audio.cpp @@ -47,6 +47,7 @@ namespace hooks::audio { bool USE_DUMMY = false; WAVEFORMATEXTENSIBLE FORMAT {}; std::optional BACKEND = std::nullopt; + bool FAKE_REALTEK_RENDER_DEVICE = false; std::optional ASIO_DRIVER_ID = std::nullopt; std::string ASIO_DRIVER_NAME = ""; bool ASIO_FORCE_UNLOAD_ON_STOP = false; diff --git a/src/spice2x/hooks/audio/audio.h b/src/spice2x/hooks/audio/audio.h index 3645f45..8b9a30c 100644 --- a/src/spice2x/hooks/audio/audio.h +++ b/src/spice2x/hooks/audio/audio.h @@ -40,6 +40,11 @@ namespace hooks::audio { extern bool USE_DUMMY; extern WAVEFORMATEXTENSIBLE FORMAT; extern std::optional BACKEND; + + // when true, a synthetic "Realtek" render endpoint is injected into device enumeration that + // discards all audio. used by gitadora arena, whose device search crashes when no render + // endpoint reports a "Realtek" friendly name. + extern bool FAKE_REALTEK_RENDER_DEVICE; extern std::optional ASIO_DRIVER_ID; extern std::string ASIO_DRIVER_NAME; extern bool ASIO_FORCE_UNLOAD_ON_STOP; diff --git a/src/spice2x/hooks/audio/backends/mmdevice/device_collection.cpp b/src/spice2x/hooks/audio/backends/mmdevice/device_collection.cpp index 0b86aed..97b8552 100644 --- a/src/spice2x/hooks/audio/backends/mmdevice/device_collection.cpp +++ b/src/spice2x/hooks/audio/backends/mmdevice/device_collection.cpp @@ -1,5 +1,6 @@ #include "device_collection.h" #include "device.h" +#include "null_device.h" #include "util/utils.h" #include "util/logging.h" @@ -28,17 +29,48 @@ ULONG STDMETHODCALLTYPE WrappedIMMDeviceCollection::Release() { return refs; } +bool WrappedIMMDeviceCollection::should_inject_fake_realtek() const { + return null_render_device_enabled() + && (data_flow == eRender || data_flow == eAll); +} + HRESULT STDMETHODCALLTYPE WrappedIMMDeviceCollection::GetCount(UINT *pcDevices) { + // when active, hide all real devices and present only the synthetic one + if (should_inject_fake_realtek()) { + if (pcDevices == nullptr) { + return E_POINTER; + } + *pcDevices = 1; + return S_OK; + } + return pReal->GetCount(pcDevices); } HRESULT STDMETHODCALLTYPE WrappedIMMDeviceCollection::Item(UINT nDevice, IMMDevice **ppDevice) { + if (ppDevice == nullptr) { + return E_POINTER; + } + + // when active, the only device in the collection is the synthetic fake Realtek + // render device; all real devices are hidden + if (should_inject_fake_realtek()) { + if (nDevice != 0) { + return E_INVALIDARG; + } + log_info("audio", "WrappedIMMDeviceCollection::Item[{}] -> synthetic fake Realtek render device", nDevice); + *ppDevice = new NullMMDevice(); + return S_OK; + } + log_info("audio", "WrappedIMMDeviceCollection::Item[{}]", nDevice); // call original const auto hr = pReal->Item(nDevice, ppDevice); // wrap interface - *ppDevice = new WrappedIMMDevice(*ppDevice); + if (SUCCEEDED(hr) && *ppDevice != nullptr) { + *ppDevice = new WrappedIMMDevice(*ppDevice); + } return hr; } \ No newline at end of file diff --git a/src/spice2x/hooks/audio/backends/mmdevice/device_collection.h b/src/spice2x/hooks/audio/backends/mmdevice/device_collection.h index ffdbe10..1fe9684 100644 --- a/src/spice2x/hooks/audio/backends/mmdevice/device_collection.h +++ b/src/spice2x/hooks/audio/backends/mmdevice/device_collection.h @@ -4,7 +4,8 @@ #include struct WrappedIMMDeviceCollection : IMMDeviceCollection { - explicit WrappedIMMDeviceCollection(IMMDeviceCollection *orig) : pReal(orig) { + WrappedIMMDeviceCollection(IMMDeviceCollection *orig, EDataFlow dataFlow) + : pReal(orig), data_flow(dataFlow) { } WrappedIMMDeviceCollection(const WrappedIMMDeviceCollection &) = delete; @@ -24,5 +25,9 @@ struct WrappedIMMDeviceCollection : IMMDeviceCollection { #pragma endregion private: + // whether the synthetic fake Realtek render device should be appended to this collection + bool should_inject_fake_realtek() const; + IMMDeviceCollection *const pReal; + const EDataFlow data_flow; }; diff --git a/src/spice2x/hooks/audio/backends/mmdevice/device_enumerator.cpp b/src/spice2x/hooks/audio/backends/mmdevice/device_enumerator.cpp index 1384a7c..fb6d4c1 100644 --- a/src/spice2x/hooks/audio/backends/mmdevice/device_enumerator.cpp +++ b/src/spice2x/hooks/audio/backends/mmdevice/device_enumerator.cpp @@ -45,7 +45,7 @@ HRESULT STDMETHODCALLTYPE WrappedIMMDeviceEnumerator::EnumAudioEndpoints( { const auto hr = pReal->EnumAudioEndpoints(dataFlow, dwStateMask, ppDevices); if (SUCCEEDED(hr) && (ppDevices != nullptr) && (*ppDevices != nullptr)) { - *ppDevices = new WrappedIMMDeviceCollection(*ppDevices); + *ppDevices = new WrappedIMMDeviceCollection(*ppDevices, dataFlow); } return hr; } diff --git a/src/spice2x/hooks/audio/backends/mmdevice/null_device.cpp b/src/spice2x/hooks/audio/backends/mmdevice/null_device.cpp new file mode 100644 index 0000000..6ab95f4 --- /dev/null +++ b/src/spice2x/hooks/audio/backends/mmdevice/null_device.cpp @@ -0,0 +1,195 @@ +#include "null_device.h" + +#include +#include + +#include + +#include "hooks/audio/audio.h" +#include "hooks/audio/audio_private.h" +#include "hooks/audio/backends/wasapi/dummy_audio_client.h" +#include "util/logging.h" +#include "util/utils.h" + +#include "null_discard_backend.h" + +// friendly name reported by the synthetic device. must contain "Realtek" so the +// gitadora arena device search matches it. +static const wchar_t NULL_DEVICE_FRIENDLY_NAME[] = L"Realtek High Definition Audio"; + +// arbitrary identifier reported by the synthetic device. +static const wchar_t NULL_DEVICE_ID[] = L"{spice2x-null-render-device}"; + +// PKEY_Device_FriendlyName, hardcoded to avoid pulling in functiondiscoverykeys_devpkey.h +static const PROPERTYKEY PKEY_DEVICE_FRIENDLY_NAME_LOCAL = { + { 0xa45c254e, 0xdf1c, 0x4efd, { 0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0 } }, + 14 +}; + +bool null_render_device_enabled() { + return hooks::audio::FAKE_REALTEK_RENDER_DEVICE; +} + +// duplicate a wide string into CoTaskMem so the caller can free it with +// CoTaskMemFree / PropVariantClear as the COM API contract requires. +static LPWSTR co_task_wcsdup(const wchar_t *src) { + const size_t bytes = (wcslen(src) + 1) * sizeof(wchar_t); + auto *dst = static_cast(CoTaskMemAlloc(bytes)); + if (dst != nullptr) { + memcpy(dst, src, bytes); + } + return dst; +} + +namespace { + + // minimal IPropertyStore that only answers PKEY_Device_FriendlyName. + struct NullPropertyStore : IPropertyStore { + std::atomic ref_cnt = 1; + + virtual ~NullPropertyStore() = default; + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObj) override { + if (ppvObj == nullptr) { + return E_POINTER; + } + if (riid == __uuidof(IUnknown) || riid == __uuidof(IPropertyStore)) { + this->AddRef(); + *ppvObj = this; + return S_OK; + } + *ppvObj = nullptr; + return E_NOINTERFACE; + } + ULONG STDMETHODCALLTYPE AddRef() override { + return ++this->ref_cnt; + } + ULONG STDMETHODCALLTYPE Release() override { + const ULONG refs = --this->ref_cnt; + if (refs == 0) { + delete this; + } + return refs; + } + HRESULT STDMETHODCALLTYPE GetCount(DWORD *cProps) override { + if (cProps == nullptr) { + return E_POINTER; + } + *cProps = 1; + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetAt(DWORD iProp, PROPERTYKEY *pkey) override { + if (pkey == nullptr) { + return E_POINTER; + } + if (iProp != 0) { + return E_INVALIDARG; + } + *pkey = PKEY_DEVICE_FRIENDLY_NAME_LOCAL; + return S_OK; + } + HRESULT STDMETHODCALLTYPE GetValue(REFPROPERTYKEY key, PROPVARIANT *pv) override { + if (pv == nullptr) { + return E_POINTER; + } + PropVariantInit(pv); + if (key.fmtid == PKEY_DEVICE_FRIENDLY_NAME_LOCAL.fmtid + && key.pid == PKEY_DEVICE_FRIENDLY_NAME_LOCAL.pid) { + pv->pwszVal = co_task_wcsdup(NULL_DEVICE_FRIENDLY_NAME); + if (pv->pwszVal == nullptr) { + return E_OUTOFMEMORY; + } + pv->vt = VT_LPWSTR; + } + // unknown keys are returned as VT_EMPTY / S_OK + return S_OK; + } + HRESULT STDMETHODCALLTYPE SetValue(REFPROPERTYKEY, REFPROPVARIANT) override { + return STG_E_ACCESSDENIED; + } + HRESULT STDMETHODCALLTYPE Commit() override { + return S_OK; + } + }; +} + +#pragma region IUnknown +HRESULT STDMETHODCALLTYPE NullMMDevice::QueryInterface(REFIID riid, void **ppvObj) { + if (ppvObj == nullptr) { + return E_POINTER; + } + if (riid == __uuidof(IUnknown) || riid == __uuidof(IMMDevice)) { + this->AddRef(); + *ppvObj = this; + return S_OK; + } + *ppvObj = nullptr; + return E_NOINTERFACE; +} +ULONG STDMETHODCALLTYPE NullMMDevice::AddRef() { + return ++this->ref_cnt; +} +ULONG STDMETHODCALLTYPE NullMMDevice::Release() { + const ULONG refs = --this->ref_cnt; + if (refs == 0) { + delete this; + } + return refs; +} +#pragma endregion + +#pragma region IMMDevice +HRESULT STDMETHODCALLTYPE NullMMDevice::Activate( + REFIID iid, + DWORD, + PROPVARIANT *, + void **ppInterface) +{ + if (ppInterface == nullptr) { + return E_POINTER; + } + *ppInterface = nullptr; + + log_info("audio::null", "NullMMDevice::Activate {}", guid2s(iid)); + + if (iid == IID_IAudioClient) { + + // release any previously persisted client + if (hooks::audio::CLIENT != nullptr) { + hooks::audio::CLIENT->Release(); + } + + auto *client = static_cast(new DummyIAudioClient(new NullDiscardBackend())); + *ppInterface = client; + + // persist the audio client + hooks::audio::CLIENT = client; + hooks::audio::CLIENT->AddRef(); + + return S_OK; + } + + return E_NOINTERFACE; +} +HRESULT STDMETHODCALLTYPE NullMMDevice::OpenPropertyStore(DWORD, IPropertyStore **ppProperties) { + if (ppProperties == nullptr) { + return E_POINTER; + } + *ppProperties = new NullPropertyStore(); + return S_OK; +} +HRESULT STDMETHODCALLTYPE NullMMDevice::GetId(LPWSTR *ppstrId) { + if (ppstrId == nullptr) { + return E_POINTER; + } + *ppstrId = co_task_wcsdup(NULL_DEVICE_ID); + return *ppstrId != nullptr ? S_OK : E_OUTOFMEMORY; +} +HRESULT STDMETHODCALLTYPE NullMMDevice::GetState(DWORD *pdwState) { + if (pdwState == nullptr) { + return E_POINTER; + } + *pdwState = DEVICE_STATE_ACTIVE; + return S_OK; +} +#pragma endregion diff --git a/src/spice2x/hooks/audio/backends/mmdevice/null_device.h b/src/spice2x/hooks/audio/backends/mmdevice/null_device.h new file mode 100644 index 0000000..ea64f6f --- /dev/null +++ b/src/spice2x/hooks/audio/backends/mmdevice/null_device.h @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include + +// returns true when a synthetic render endpoint should be injected into device +// enumeration. games like gitadora arena search the render endpoint list for a +// device whose friendly name contains "Realtek" and crash with a null pointer +// dereference when no match exists. presenting a fake match that routes to the +// null audio backend lets the search succeed while discarding the audio. +bool null_render_device_enabled(); + +// fake IMMDevice that reports a "Realtek" friendly name and activates straight +// into the null audio backend, never touching real hardware. +struct NullMMDevice : IMMDevice { + NullMMDevice() = default; + + NullMMDevice(const NullMMDevice &) = delete; + NullMMDevice &operator=(const NullMMDevice &) = delete; + + virtual ~NullMMDevice() = default; + +#pragma region IUnknown + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObj) override; + ULONG STDMETHODCALLTYPE AddRef() override; + ULONG STDMETHODCALLTYPE Release() override; +#pragma endregion + +#pragma region IMMDevice + HRESULT STDMETHODCALLTYPE Activate(REFIID iid, DWORD dwClsCtx, PROPVARIANT *pActivationParams, void **ppInterface) override; + HRESULT STDMETHODCALLTYPE OpenPropertyStore(DWORD stgmAccess, IPropertyStore **ppProperties) override; + HRESULT STDMETHODCALLTYPE GetId(LPWSTR *ppstrId) override; + HRESULT STDMETHODCALLTYPE GetState(DWORD *pdwState) override; +#pragma endregion + +private: + std::atomic ref_cnt = 1; +}; diff --git a/src/spice2x/hooks/audio/backends/mmdevice/null_discard_backend.cpp b/src/spice2x/hooks/audio/backends/mmdevice/null_discard_backend.cpp new file mode 100644 index 0000000..42900b3 --- /dev/null +++ b/src/spice2x/hooks/audio/backends/mmdevice/null_discard_backend.cpp @@ -0,0 +1,141 @@ +#include "null_discard_backend.h" + +#include +#include + +#include "hooks/audio/util.h" +#include "util/logging.h" +#include "util/precise_timer.h" + +NullDiscardBackend::~NullDiscardBackend() { + this->running = false; + if (this->pacing_thread.joinable()) { + this->pacing_thread.join(); + } +} + +const WAVEFORMATEXTENSIBLE &NullDiscardBackend::format() const noexcept { + return this->format_; +} + +HRESULT NullDiscardBackend::on_initialize( + AUDCLNT_SHAREMODE *, + DWORD *, + REFERENCE_TIME *hnsBufferDuration, + REFERENCE_TIME *, + const WAVEFORMATEX *pFormat, + LPCGUID) +{ + copy_wave_format(&this->format_, pFormat); + + // honor the game's requested buffer duration, falling back to 10 ms + constexpr REFERENCE_TIME DEFAULT_REFTIME = 100000; // 10 ms in 100-ns units + this->period_reftime = (hnsBufferDuration && *hnsBufferDuration > 0) + ? *hnsBufferDuration + : DEFAULT_REFTIME; + + this->buffer_frames = std::max(1, static_cast( + static_cast(this->format_.Format.nSamplesPerSec) + * this->period_reftime / 10000000.0 + 0.5)); + + log_info("audio::null", "initializing null render device with {} channels, {} Hz, {}-bit", + this->format_.Format.nChannels, + this->format_.Format.nSamplesPerSec, + this->format_.Format.wBitsPerSample); + + return S_OK; +} + +HRESULT NullDiscardBackend::on_get_buffer_size(uint32_t *buffer_frames) { + *buffer_frames = this->buffer_frames; + return S_OK; +} + +HRESULT NullDiscardBackend::on_get_stream_latency(REFERENCE_TIME *latency) { + *latency = this->period_reftime; + return S_OK; +} + +HRESULT NullDiscardBackend::on_get_current_padding(std::optional &padding_frames) { + // discarded immediately, so the buffer always reads as fully drained + padding_frames = 0; + return S_OK; +} + +HRESULT NullDiscardBackend::on_is_format_supported( + AUDCLNT_SHAREMODE *, + const WAVEFORMATEX *, + WAVEFORMATEX **ppClosestMatch) +{ + if (ppClosestMatch) { + *ppClosestMatch = nullptr; + } + return S_OK; +} + +HRESULT NullDiscardBackend::on_get_mix_format(WAVEFORMATEX **) { + return E_NOTIMPL; +} + +HRESULT NullDiscardBackend::on_get_device_period( + REFERENCE_TIME *default_device_period, + REFERENCE_TIME *minimum_device_period) +{ + if (default_device_period) { + *default_device_period = this->period_reftime; + } + if (minimum_device_period) { + *minimum_device_period = this->period_reftime; + } + return S_OK; +} + +HRESULT NullDiscardBackend::on_start() { + if (!this->running.exchange(true)) { + this->pacing_thread = std::thread(&NullDiscardBackend::pace_loop, this); + } + return S_OK; +} + +HRESULT NullDiscardBackend::on_stop() { + return S_OK; +} + +HRESULT NullDiscardBackend::on_set_event_handle(HANDLE *event_handle) { + // keep the game's event so pace_loop() can wake it; there is no real device behind it + this->relay_handle = *event_handle; + return S_OK; +} + +HRESULT NullDiscardBackend::on_get_buffer(uint32_t num_frames_requested, BYTE **ppData) { + const size_t buffer_size = + static_cast(this->format_.Format.nBlockAlign) * num_frames_requested; + if (this->scratch.size() < buffer_size) { + this->scratch.resize(buffer_size); + } + *ppData = this->scratch.data(); + return S_OK; +} + +HRESULT NullDiscardBackend::on_release_buffer(uint32_t, DWORD) { + // discard the audio entirely + return S_OK; +} + +void NullDiscardBackend::pace_loop() { + using namespace std::chrono; + + timeutils::PreciseSleepTimer timer; + + // audio is discarded, so timing precision and drift do not matter; just wake the + // game once per buffer period to keep its render thread from blocking on the event. + const auto period = duration_cast( + duration(this->period_reftime / 10000000.0)); + + while (this->running.load()) { + if (this->relay_handle) { + SetEvent(this->relay_handle); + } + timer.sleep(period); + } +} diff --git a/src/spice2x/hooks/audio/backends/mmdevice/null_discard_backend.h b/src/spice2x/hooks/audio/backends/mmdevice/null_discard_backend.h new file mode 100644 index 0000000..ceef773 --- /dev/null +++ b/src/spice2x/hooks/audio/backends/mmdevice/null_discard_backend.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "hooks/audio/implementations/backend.h" + +// discards all audio while pacing the game's event handle once per buffer period, so the game +// keeps running normally with nothing output to any real device. routed through the shared +// DummyIAudioClient, the same plumbing the asio backend uses. +struct NullDiscardBackend final : AudioBackend { + ~NullDiscardBackend() final; + + const WAVEFORMATEXTENSIBLE &format() const noexcept override; + + HRESULT on_initialize( + AUDCLNT_SHAREMODE *, + DWORD *, + REFERENCE_TIME *hnsBufferDuration, + REFERENCE_TIME *, + const WAVEFORMATEX *pFormat, + LPCGUID) override; + HRESULT on_get_buffer_size(uint32_t *buffer_frames) override; + HRESULT on_get_stream_latency(REFERENCE_TIME *latency) override; + HRESULT on_get_current_padding(std::optional &padding_frames) override; + HRESULT on_is_format_supported( + AUDCLNT_SHAREMODE *, + const WAVEFORMATEX *, + WAVEFORMATEX **ppClosestMatch) override; + HRESULT on_get_mix_format(WAVEFORMATEX **) override; + HRESULT on_get_device_period( + REFERENCE_TIME *default_device_period, + REFERENCE_TIME *minimum_device_period) override; + HRESULT on_start() override; + HRESULT on_stop() override; + HRESULT on_set_event_handle(HANDLE *event_handle) override; + HRESULT on_get_buffer(uint32_t num_frames_requested, BYTE **ppData) override; + HRESULT on_release_buffer(uint32_t, DWORD) override; + +private: + void pace_loop(); + + WAVEFORMATEXTENSIBLE format_ {}; + uint32_t buffer_frames = 0; + REFERENCE_TIME period_reftime = 0; + HANDLE relay_handle = nullptr; + std::vector scratch; + std::thread pacing_thread; + std::atomic running = false; +}; diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index 7715bdc..3e69258 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -687,6 +687,9 @@ int main_implementation(int argc, char *argv[]) { if (options[launcher::Options::GitaDoraArenaAsioDriver].is_active()) { games::gitadora::ASIO_DRIVER = options[launcher::Options::GitaDoraArenaAsioDriver].value_text(); } + if (options[launcher::Options::GitaDoraArenaFakeRealtekDevice].value_bool()) { + hooks::audio::FAKE_REALTEK_RENDER_DEVICE = true; + } if (options[launcher::Options::LoadNostalgiaModule].value_bool()) { attach_nostalgia = true; } diff --git a/src/spice2x/launcher/options.cpp b/src/spice2x/launcher/options.cpp index 6aa9790..4b7c943 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -1207,12 +1207,24 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "For Arena Model: ASIO driver name to use in place of XONAR. " "String should match a subkey under HKLM\\SOFTWARE\\ASIO\\\n\n" "Requires 7.1 @ 48kHz; if the game rejects your ASIO device, it will automatically " - "fall back to using WASAPI.", + "fall back to using WASAPI.\n\n" + "If the game crashes after successful ASIO init, try enabling -gdafakerealtek.", .type = OptionType::Text, .game_name = "GitaDora", .category = "Game Options", .picker = OptionPickerType::AsioDriver, }, + { + // GitaDoraArenaFakeRealtekDevice + .title = "GitaDora Arena Fake Realtek Device", + .name = "gdafakerealtek", + .desc = "For Arena Model: inject a fake \"Realtek\" audio render device that discards all " + "audio sent to it. This is needed as the game crashes when ASIO is in use but Realtek " + "audio is not available. On an arcade cabinet, Realtek is used for headphone output.", + .type = OptionType::Bool, + .game_name = "GitaDora", + .category = "Game Options", + }, { .title = "Force Load Jubeat Module", .name = "jb", diff --git a/src/spice2x/launcher/options.h b/src/spice2x/launcher/options.h index f4a2fe6..6bfe862 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -117,6 +117,7 @@ namespace launcher { GitaDoraArenaSingleWindow, GitaDoraArenaWindowLayout, GitaDoraArenaAsioDriver, + GitaDoraArenaFakeRealtekDevice, LoadJubeatModule, LoadReflecBeatModule, LoadShogikaiModule,