mirror of
https://github.com/spice2x/spice2x.github.io.git
synced 2026-08-02 23:00:42 -07:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4879a35eb8 | |||
| fd7e850789 | |||
| 0cf07c38da | |||
| 1f073b2d75 | |||
| 3037214932 | |||
| 18d6d9783a | |||
| 267add3227 | |||
| a170627f52 | |||
| 5c244eaca7 | |||
| 6bcadccf09 | |||
| ccb3bac48c | |||
| 0a7ecf82a9 | |||
| 1c6929dd84 | |||
| 1d3d9040f2 | |||
| 8f1069628e | |||
| f8c585a9d1 | |||
| 9651051990 | |||
| a54a62d0d7 | |||
| 100caa0f5c | |||
| bb87aa2944 | |||
| 2e2968d1bc | |||
| 0457262472 | |||
| f23c359114 | |||
| 81e74dcb97 | |||
| 8ead941c6c | |||
| ece03cabba | |||
| 4b7f68f920 | |||
| 2dae86a6f2 | |||
| 85058c2156 | |||
| ea2f4c5572 | |||
| 48033816a8 |
@@ -11,9 +11,7 @@ assignees: ''
|
||||
*name of game, version of game*
|
||||
|
||||
## Version of spice2x
|
||||
*version of spice, this can be seen in the About tab, and in log.txt*
|
||||
|
||||
*please remember to test the LATEST beta version before posting a bug about it**
|
||||
*please remember to test the LATEST beta version before posting a bug about it*
|
||||
|
||||
## Describe the issue
|
||||
*what's the issue?*
|
||||
@@ -22,3 +20,7 @@ assignees: ''
|
||||
> [!WARNING]
|
||||
> Please make sure you remove any personally identifiable information from the log file.
|
||||
> Please set `-loglevel` to `all` before launching the game for more verbose logs.
|
||||
|
||||
## A note on issue management
|
||||
|
||||
`Closed as not planned` means exactly that - it's closed, because it's not on our plans to address them any time soon. It doesn't mean your issue is invalid, it just means it's not a priority, and we can always revisit them later.
|
||||
|
||||
+32
-16
@@ -236,7 +236,7 @@ add_compile_definitions(
|
||||
)
|
||||
|
||||
# SPICE_XP: set by build_all.sh for the WinXP-compat toolchains. Gates out
|
||||
# the DX11 overlay backend (WinXP lacks D3DCOMPILER_47.dll).
|
||||
# the DX11 overlay backend
|
||||
option(SPICE_XP "Build for the WinXP-compat toolchain" OFF)
|
||||
if(SPICE_XP)
|
||||
add_compile_definitions(SPICE_XP=1)
|
||||
@@ -494,6 +494,8 @@ set(SOURCE_FILES ${SOURCE_FILES}
|
||||
# hooks
|
||||
hooks/audio/acm.cpp
|
||||
hooks/audio/audio.cpp
|
||||
hooks/audio/asio_driver_scan.cpp
|
||||
hooks/audio/asio_proxy.cpp
|
||||
hooks/audio/buffer.cpp
|
||||
hooks/audio/mme.cpp
|
||||
hooks/audio/util.cpp
|
||||
@@ -502,9 +504,13 @@ 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
|
||||
hooks/audio/backends/wasapi/resample.cpp
|
||||
hooks/audio/backends/wasapi/shared.cpp
|
||||
hooks/audio/backends/wasapi/dummy_audio_client.cpp
|
||||
hooks/audio/backends/wasapi/dummy_audio_clock.cpp
|
||||
hooks/audio/backends/wasapi/dummy_audio_render_client.cpp
|
||||
@@ -665,37 +671,47 @@ set(SOURCE_FILES ${SOURCE_FILES}
|
||||
|
||||
source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} PREFIX "Source Files" FILES ${SOURCE_FILES})
|
||||
|
||||
# spice.exe / spice_laa.exe shared objects
|
||||
###########################################
|
||||
# spice.exe and spice_laa.exe are compiled identically; the only difference is
|
||||
# the large-address-aware bit, which is set at link time. Compile the sources
|
||||
# once into a shared OBJECT library so spice_laa.exe only costs an extra link
|
||||
# instead of a full recompile.
|
||||
|
||||
add_library(spicetools_spice_objs OBJECT ${SOURCE_FILES})
|
||||
target_link_libraries(spicetools_spice_objs
|
||||
PUBLIC d3d9 ws2_32 version comctl32 shlwapi iphlpapi hid secur32 setupapi psapi winmm winhttp
|
||||
PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features)
|
||||
target_link_libraries(spicetools_spice_objs PUBLIC winscard)
|
||||
|
||||
if(NOT MSVC)
|
||||
set_target_properties(spicetools_spice_objs PROPERTIES COMPILE_FLAGS "-m32")
|
||||
endif()
|
||||
|
||||
# spice.exe
|
||||
###########
|
||||
|
||||
set(RESOURCE_FILES build/manifest.manifest build/manifest.rc build/icon.rc cfg/Win32D.rc)
|
||||
add_executable(spicetools_spice ${SOURCE_FILES} ${RESOURCE_FILES})
|
||||
target_link_libraries(spicetools_spice
|
||||
PUBLIC d3d9 ws2_32 version comctl32 shlwapi iphlpapi hid secur32 setupapi psapi winmm winhttp
|
||||
PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features)
|
||||
target_link_libraries(spicetools_spice PUBLIC winscard)
|
||||
add_executable(spicetools_spice ${RESOURCE_FILES})
|
||||
target_link_libraries(spicetools_spice PRIVATE spicetools_spice_objs)
|
||||
set_target_properties(spicetools_spice PROPERTIES PREFIX "")
|
||||
set_target_properties(spicetools_spice PROPERTIES OUTPUT_NAME "spice")
|
||||
|
||||
IF(NOT MSVC)
|
||||
set_target_properties(spicetools_spice PROPERTIES COMPILE_FLAGS "-m32" LINK_FLAGS "-m32")
|
||||
set_target_properties(spicetools_spice PROPERTIES LINK_FLAGS "-m32")
|
||||
endif()
|
||||
|
||||
# spice_laa.exe
|
||||
###########
|
||||
|
||||
set(RESOURCE_FILES build/manifest.manifest build/manifest.rc build/icon.rc cfg/Win32D.rc)
|
||||
add_executable(spicetools_spice_laa ${SOURCE_FILES} ${RESOURCE_FILES})
|
||||
target_link_libraries(spicetools_spice_laa
|
||||
PUBLIC d3d9 ws2_32 version comctl32 shlwapi iphlpapi hid secur32 setupapi psapi winmm winhttp
|
||||
PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features)
|
||||
target_link_libraries(spicetools_spice_laa PUBLIC winscard)
|
||||
add_executable(spicetools_spice_laa ${RESOURCE_FILES})
|
||||
target_link_libraries(spicetools_spice_laa PRIVATE spicetools_spice_objs)
|
||||
set_target_properties(spicetools_spice_laa PROPERTIES PREFIX "")
|
||||
set_target_properties(spicetools_spice_laa PROPERTIES OUTPUT_NAME "spice_laa")
|
||||
target_compile_definitions(spicetools_spice_laa PRIVATE SPICE32_LARGE_ADDRESS_AWARE=1)
|
||||
|
||||
IF(NOT MSVC)
|
||||
set_target_properties(spicetools_spice_laa PROPERTIES COMPILE_FLAGS "-m32" LINK_FLAGS "-m32 -Wl,--large-address-aware")
|
||||
set_target_properties(spicetools_spice_laa PROPERTIES LINK_FLAGS "-m32 -Wl,--large-address-aware")
|
||||
endif()
|
||||
|
||||
# spice_linux.exe
|
||||
@@ -729,7 +745,7 @@ set_target_properties(spicetools_spice64 PROPERTIES PREFIX "")
|
||||
set_target_properties(spicetools_spice64 PROPERTIES OUTPUT_NAME "spice64")
|
||||
target_compile_definitions(spicetools_spice64 PRIVATE SPICE64=1)
|
||||
if(NOT SPICE_XP)
|
||||
target_link_libraries(spicetools_spice64 PUBLIC d3d11 dxgi d3dcompiler)
|
||||
target_link_libraries(spicetools_spice64 PUBLIC d3d11 dxgi)
|
||||
target_compile_definitions(spicetools_spice64 PRIVATE SPICE_D3D11=1)
|
||||
endif()
|
||||
|
||||
@@ -752,7 +768,7 @@ set_target_properties(spicetools_spice64_linux PROPERTIES OUTPUT_NAME "spice64_l
|
||||
target_compile_definitions(spicetools_spice64_linux PRIVATE SPICE64=1)
|
||||
target_compile_definitions(spicetools_spice64_linux PRIVATE NO_SCARD=1 PRIVATE SPICE_LINUX=1)
|
||||
# spice64_linux is never built under the WinXP toolchain, so dx11 is always on.
|
||||
target_link_libraries(spicetools_spice64_linux PUBLIC d3d11 dxgi d3dcompiler)
|
||||
target_link_libraries(spicetools_spice64_linux PUBLIC d3d11 dxgi)
|
||||
target_compile_definitions(spicetools_spice64_linux PRIVATE SPICE_D3D11=1)
|
||||
|
||||
IF(NOT MSVC)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "capture.h"
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
#include "external/rapidjson/document.h"
|
||||
#include "hooks/graphics/graphics.h"
|
||||
#include "util/crypt.h"
|
||||
@@ -14,6 +16,56 @@ namespace api::modules {
|
||||
|
||||
static thread_local std::vector<uint8_t> CAPTURE_BUFFER;
|
||||
|
||||
struct CachedFrame {
|
||||
std::vector<uint8_t> jpeg;
|
||||
uint64_t timestamp = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
static std::mutex FRAME_CACHE_M;
|
||||
static std::unordered_map<int, CachedFrame> FRAME_CACHE;
|
||||
|
||||
static void add_jpeg_response(
|
||||
int screen,
|
||||
uint64_t timestamp,
|
||||
int width,
|
||||
int height,
|
||||
const std::vector<uint8_t> &jpeg,
|
||||
Response &res) {
|
||||
|
||||
auto encoded = crypt::base64_encode(jpeg.data(), jpeg.size());
|
||||
|
||||
Value data;
|
||||
data.SetString(encoded.c_str(), encoded.length(), res.doc()->GetAllocator());
|
||||
res.add_data(timestamp);
|
||||
res.add_data(width);
|
||||
res.add_data(height);
|
||||
res.add_data(data);
|
||||
|
||||
std::lock_guard<std::mutex> lock(FRAME_CACHE_M);
|
||||
FRAME_CACHE[screen] = {jpeg, timestamp, width, height};
|
||||
}
|
||||
|
||||
static bool try_cached_response(int screen, Response &res) {
|
||||
std::lock_guard<std::mutex> lock(FRAME_CACHE_M);
|
||||
const auto pos = FRAME_CACHE.find(screen);
|
||||
if (pos == FRAME_CACHE.end() || pos->second.jpeg.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto &cached = pos->second;
|
||||
auto encoded = crypt::base64_encode(cached.jpeg.data(), cached.jpeg.size());
|
||||
|
||||
Value data;
|
||||
data.SetString(encoded.c_str(), encoded.length(), res.doc()->GetAllocator());
|
||||
res.add_data(cached.timestamp);
|
||||
res.add_data(cached.width);
|
||||
res.add_data(cached.height);
|
||||
res.add_data(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
Capture::Capture() : Module("capture") {
|
||||
functions["get_screens"] = std::bind(&Capture::get_screens, this, _1, _2);
|
||||
functions["get_jpg"] = std::bind(&Capture::get_jpg, this, _1, _2);
|
||||
@@ -41,6 +93,7 @@ namespace api::modules {
|
||||
* reduce: uint for dividing image size
|
||||
*/
|
||||
void Capture::get_jpg(Request &req, Response &res) {
|
||||
CAPTURE_BUFFER.clear();
|
||||
CAPTURE_BUFFER.reserve(1024 * 128);
|
||||
|
||||
// settings
|
||||
@@ -71,24 +124,15 @@ namespace api::modules {
|
||||
bool success = graphics_capture_receive_jpeg(screen, [] (uint8_t byte) {
|
||||
CAPTURE_BUFFER.push_back(byte);
|
||||
}, true, quality, true, divide, ×tamp, &width, &height);
|
||||
if (!success) {
|
||||
|
||||
if (success) {
|
||||
add_jpeg_response(screen, timestamp, width, height, CAPTURE_BUFFER, res);
|
||||
CAPTURE_BUFFER.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// encode to base64
|
||||
auto encoded = crypt::base64_encode(
|
||||
CAPTURE_BUFFER.data(),
|
||||
CAPTURE_BUFFER.size());
|
||||
|
||||
// clear buffer
|
||||
// fall back to the last successful frame while the game is busy loading
|
||||
CAPTURE_BUFFER.clear();
|
||||
|
||||
// add data to response
|
||||
Value data;
|
||||
data.SetString(encoded.c_str(), encoded.length(), res.doc()->GetAllocator());
|
||||
res.add_data(timestamp);
|
||||
res.add_data(width);
|
||||
res.add_data(height);
|
||||
res.add_data(data);
|
||||
try_cached_response(screen, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,8 +390,8 @@ namespace avs {
|
||||
|
||||
// fall back to default PCBID if node is not found
|
||||
if (!EA3_PCBID[0] && PCBID_CUSTOM.empty()) {
|
||||
log_warning("avs-ea3", "no PCBID set, falling back to default PCBID value (04040000000000000000)");
|
||||
PCBID_CUSTOM = "04040000000000000000";
|
||||
log_warning("avs-ea3", "no PCBID set, falling back to default PCBID value (01201000000000010101)");
|
||||
PCBID_CUSTOM = "01201000000000010101";
|
||||
}
|
||||
|
||||
// custom PCBID
|
||||
|
||||
File diff suppressed because one or more lines are too long
+42
-16
@@ -91,14 +91,20 @@ fi
|
||||
|
||||
# is the XP-compatible toolchain installed?
|
||||
XP_MUST_BUILD=0
|
||||
BUILD_XP=0
|
||||
if [ -f "$TOOLCHAIN_WINXP_32" ] && [ -f "$TOOLCHAIN_WINXP_64" ]; then
|
||||
BUILD_XP=1;
|
||||
# 64-bit WinXP builds are disabled by default; set to 1 to opt in
|
||||
BUILD_XP_64_ENABLE=0
|
||||
BUILD_XP_32=0
|
||||
BUILD_XP_64=0
|
||||
if [ -f "$TOOLCHAIN_WINXP_32" ]; then
|
||||
BUILD_XP_32=1;
|
||||
elif ((XP_MUST_BUILD > 0))
|
||||
then
|
||||
echo "WinXP toolchain not available, aborting"
|
||||
echo "WinXP 32bit toolchain not available, aborting"
|
||||
exit 1
|
||||
fi
|
||||
if ((BUILD_XP_64_ENABLE > 0)) && [ -f "$TOOLCHAIN_WINXP_64" ]; then
|
||||
BUILD_XP_64=1;
|
||||
fi
|
||||
|
||||
# determine number of cores
|
||||
CORES=$(nproc)
|
||||
@@ -113,12 +119,17 @@ echo "Git Branch: $GIT_BRANCH"
|
||||
echo "Git Head: $GIT_HEAD"
|
||||
echo "Toolchain for 32bit targets: $TOOLCHAIN_32"
|
||||
echo "Toolchain for 64bit targets: $TOOLCHAIN_64"
|
||||
if ((BUILD_XP > 0))
|
||||
if ((BUILD_XP_32 > 0))
|
||||
then
|
||||
echo "Toolchain for WinXP 32bit targets: $TOOLCHAIN_WINXP_32"
|
||||
else
|
||||
echo "WinXP 32bit toolchain not available, skipping WinXP 32bit builds"
|
||||
fi
|
||||
if ((BUILD_XP_64 > 0))
|
||||
then
|
||||
echo "Toolchain for WinXP 64bit targets: $TOOLCHAIN_WINXP_64"
|
||||
else
|
||||
echo "WinXP toolchain not available, skipping WinXP builds"
|
||||
echo "WinXP 64bit builds disabled, skipping WinXP 64bit builds"
|
||||
fi
|
||||
echo "Distribution Name: $DIST_NAME"
|
||||
echo "Build Type: $BUILD_TYPE"
|
||||
@@ -160,7 +171,7 @@ time (
|
||||
cmake -G "Ninja" -DCMAKE_TOOLCHAIN_FILE=${TOOLCHAIN_64} -DCMAKE_BUILD_TYPE=${BUILD_TYPE} "$OLDPWD" && ninja ${TARGETS_64}
|
||||
popd > /dev/null
|
||||
|
||||
if ((BUILD_XP > 0))
|
||||
if ((BUILD_XP_32 > 0))
|
||||
then
|
||||
# 32 bit Windows XP
|
||||
echo ""
|
||||
@@ -174,7 +185,13 @@ time (
|
||||
pushd ${BUILDDIR_WINXP_32} > /dev/null
|
||||
cmake -G "Ninja" -DCMAKE_TOOLCHAIN_FILE=${TOOLCHAIN_WINXP_32} -DCMAKE_BUILD_TYPE=${BUILD_TYPE} -DSPICE_XP=ON "$OLDPWD" && ninja ${TARGETS_XP32}
|
||||
popd > /dev/null
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping WinXP 32bit builds, toolchain not specified"
|
||||
fi
|
||||
|
||||
if ((BUILD_XP_64 > 0))
|
||||
then
|
||||
# 64 bit Windows XP
|
||||
echo ""
|
||||
echo "Building 64bit targets (WinXP toolchain)..."
|
||||
@@ -189,7 +206,7 @@ time (
|
||||
popd > /dev/null
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping WinXP builds, toolchain not specified"
|
||||
echo "Skipping WinXP 64bit builds"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
@@ -197,7 +214,7 @@ time (
|
||||
echo "==========================="
|
||||
)
|
||||
|
||||
if ((BUILD_XP > 0))
|
||||
if ((BUILD_XP_32 > 0)) || ((BUILD_XP_64 > 0))
|
||||
then
|
||||
echo ""
|
||||
echo "Checking XP compatibility..."
|
||||
@@ -205,11 +222,17 @@ then
|
||||
if ! command -v windows_dll_compat_checker &> /dev/null; then
|
||||
echo "WARNING: windows_dll_compat_checker not found, skipping XP compatibility check"
|
||||
else
|
||||
windows_dll_compat_checker -s PREMADE/winxp_x86_64.ini \
|
||||
${BUILDDIR_WINXP_64}/spicetools/64/spice64.exe
|
||||
windows_dll_compat_checker -s PREMADE/winxp_x86_64_32bit_dlls.ini \
|
||||
${BUILDDIR_WINXP_32}/spicetools/spicecfg.exe \
|
||||
${BUILDDIR_WINXP_32}/spicetools/32/spice.exe
|
||||
if ((BUILD_XP_64 > 0))
|
||||
then
|
||||
windows_dll_compat_checker -s PREMADE/winxp_x86_64.ini \
|
||||
${BUILDDIR_WINXP_64}/spicetools/64/spice64.exe
|
||||
fi
|
||||
if ((BUILD_XP_32 > 0))
|
||||
then
|
||||
windows_dll_compat_checker -s PREMADE/winxp_x86_64_32bit_dlls.ini \
|
||||
${BUILDDIR_WINXP_32}/spicetools/spicecfg.exe \
|
||||
${BUILDDIR_WINXP_32}/spicetools/32/spice.exe
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -271,7 +294,7 @@ mkdir -p ${OUTDIR_EXTRAS}/largeaddressaware
|
||||
mkdir -p ${OUTDIR_EXTRAS}/linux
|
||||
mkdir -p ${OUTDIR_EXTRAS}/sdk/samples/32
|
||||
mkdir -p ${OUTDIR_EXTRAS}/sdk/samples/64
|
||||
if ((BUILD_XP > 0))
|
||||
if ((BUILD_XP_32 > 0)) || ((BUILD_XP_64 > 0))
|
||||
then
|
||||
mkdir -p ${OUTDIR_EXTRAS}/winxp
|
||||
fi
|
||||
@@ -301,10 +324,13 @@ else
|
||||
cp ${BUILDDIR_32}/spicetools/32/sdk_sample_v0_flat_c.dll ${OUTDIR_EXTRAS}/sdk/samples/32/v0_flat_c.dll 2>/dev/null
|
||||
cp ${BUILDDIR_64}/spicetools/64/sdk_sample_v0_flat_c.dll ${OUTDIR_EXTRAS}/sdk/samples/64/v0_flat_c.dll 2>/dev/null
|
||||
cp ${BUILDDIR_64}/spicetools/64/sdk_sample_v0_cpp.dll ${OUTDIR_EXTRAS}/sdk/samples/64/v0_cpp.dll 2>/dev/null
|
||||
if ((BUILD_XP > 0))
|
||||
if ((BUILD_XP_32 > 0))
|
||||
then
|
||||
cp ${BUILDDIR_WINXP_32}/spicetools/spicecfg.exe ${OUTDIR_EXTRAS}/winxp 2>/dev/null
|
||||
cp ${BUILDDIR_WINXP_32}/spicetools/32/spice.exe ${OUTDIR_EXTRAS}/winxp 2>/dev/null
|
||||
fi
|
||||
if ((BUILD_XP_64 > 0))
|
||||
then
|
||||
cp ${BUILDDIR_WINXP_64}/spicetools/64/spice64.exe ${OUTDIR_EXTRAS}/winxp 2>/dev/null
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -93,7 +93,6 @@ namespace cfg {
|
||||
overlay::create_software(this->wnd.hWnd);
|
||||
}
|
||||
overlay::OVERLAY->set_active(true);
|
||||
overlay::OVERLAY->hotkeys_enable = false;
|
||||
ImGui::GetIO().MouseDrawCursor = false;
|
||||
|
||||
// run window
|
||||
|
||||
@@ -46,6 +46,8 @@ struct OptionDefinition {
|
||||
|
||||
// for OptionPickerType::FilePath
|
||||
std::string file_extension = "";
|
||||
|
||||
std::string quick_setting_category = "";
|
||||
};
|
||||
|
||||
class Option {
|
||||
|
||||
+1
-2
@@ -21,8 +21,7 @@ set(IMGUI_SOURCES
|
||||
misc/cpp/imgui_stdlib.cpp
|
||||
)
|
||||
|
||||
# spice2x: DX11 backend is only built for non-XP toolchains. WinXP lacks
|
||||
# D3DCOMPILER_47.dll, and including this TU would pull in D3DCompile imports.
|
||||
# spice2x: DX11 backend is only built for non-XP toolchains
|
||||
if(NOT SPICE_XP)
|
||||
list(APPEND IMGUI_HEADERS backends/imgui_impl_dx11.h)
|
||||
list(APPEND IMGUI_SOURCES backends/imgui_impl_dx11.cpp)
|
||||
|
||||
+37
-5
@@ -50,9 +50,35 @@
|
||||
#include <stdio.h>
|
||||
#include <d3d11.h>
|
||||
#include <d3dcompiler.h>
|
||||
#ifdef _MSC_VER
|
||||
#pragma comment(lib, "d3dcompiler") // Automatically link with d3dcompiler.lib as we are using D3DCompile() below.
|
||||
#endif
|
||||
// #ifdef _MSC_VER
|
||||
// #pragma comment(lib, "d3dcompiler") // Automatically link with d3dcompiler.lib as we are using D3DCompile() below.
|
||||
// #endif
|
||||
|
||||
// spice2x: resolve D3DCompile at runtime rather than static-linking d3dcompiler,
|
||||
// which would hardwire an import on d3dcompiler_47.dll (absent on stock Win7).
|
||||
static pD3DCompile ImGui_ImplDX11_GetD3DCompile()
|
||||
{
|
||||
static pD3DCompile fn = []() -> pD3DCompile {
|
||||
static const char *dlls[] = {
|
||||
"d3dcompiler_47.dll",
|
||||
"d3dcompiler_46.dll",
|
||||
"d3dcompiler_43.dll" };
|
||||
|
||||
for (const char *dll : dlls) {
|
||||
HMODULE mod = GetModuleHandleA(dll);
|
||||
if (!mod) {
|
||||
mod = LoadLibraryA(dll);
|
||||
}
|
||||
if (mod) {
|
||||
if (auto p = (pD3DCompile) GetProcAddress(mod, "D3DCompile")) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}();
|
||||
return fn;
|
||||
}
|
||||
|
||||
// Clang/GCC warnings with -Weverything
|
||||
#if defined(__clang__)
|
||||
@@ -442,6 +468,12 @@ bool ImGui_ImplDX11_CreateDeviceObjects()
|
||||
// 2) use code to detect any version of the DLL and grab a pointer to D3DCompile from the DLL.
|
||||
// See https://github.com/ocornut/imgui/pull/638 for sources and details.
|
||||
|
||||
// spice2x: dynamically resolved; see ImGui_ImplDX11_GetD3DCompile.
|
||||
pD3DCompile D3DCompile_fn = ImGui_ImplDX11_GetD3DCompile();
|
||||
if (!D3DCompile_fn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create the vertex shader
|
||||
{
|
||||
static const char* vertexShader =
|
||||
@@ -473,7 +505,7 @@ bool ImGui_ImplDX11_CreateDeviceObjects()
|
||||
}";
|
||||
|
||||
ID3DBlob* vertexShaderBlob;
|
||||
if (FAILED(D3DCompile(vertexShader, strlen(vertexShader), nullptr, nullptr, nullptr, "main", "vs_4_0", 0, 0, &vertexShaderBlob, nullptr)))
|
||||
if (FAILED(D3DCompile_fn(vertexShader, strlen(vertexShader), nullptr, nullptr, nullptr, "main", "vs_4_0", 0, 0, &vertexShaderBlob, nullptr)))
|
||||
return false; // NB: Pass ID3DBlob* pErrorBlob to D3DCompile() to get error showing in (const char*)pErrorBlob->GetBufferPointer(). Make sure to Release() the blob!
|
||||
if (bd->pd3dDevice->CreateVertexShader(vertexShaderBlob->GetBufferPointer(), vertexShaderBlob->GetBufferSize(), nullptr, &bd->pVertexShader) != S_OK)
|
||||
{
|
||||
@@ -530,7 +562,7 @@ bool ImGui_ImplDX11_CreateDeviceObjects()
|
||||
}";
|
||||
|
||||
ID3DBlob* pixelShaderBlob;
|
||||
if (FAILED(D3DCompile(pixelShader, strlen(pixelShader), nullptr, nullptr, nullptr, "main", "ps_4_0", 0, 0, &pixelShaderBlob, nullptr)))
|
||||
if (FAILED(D3DCompile_fn(pixelShader, strlen(pixelShader), nullptr, nullptr, nullptr, "main", "ps_4_0", 0, 0, &pixelShaderBlob, nullptr)))
|
||||
return false; // NB: Pass ID3DBlob* pErrorBlob to D3DCompile() to get error showing in (const char*)pErrorBlob->GetBufferPointer(). Make sure to Release() the blob!
|
||||
if (bd->pd3dDevice->CreatePixelShader(pixelShaderBlob->GetBufferPointer(), pixelShaderBlob->GetBufferSize(), nullptr, &bd->pPixelShader) != S_OK)
|
||||
{
|
||||
|
||||
@@ -203,10 +203,19 @@ namespace games::ccj {
|
||||
}
|
||||
|
||||
static BOOL WINAPI RegisterRawInputDevices_hook(PCRAWINPUTDEVICE pRawInputDevices, UINT uiNumDevices, UINT cbSize) {
|
||||
if (uiNumDevices == 2 && pRawInputDevices[1].usUsage == HID_USAGE_GENERIC_GAMEPAD)
|
||||
uiNumDevices = 1;
|
||||
|
||||
return RegisterRawInputDevices_orig(pRawInputDevices, uiNumDevices, cbSize);
|
||||
// if the caller is spice itself, then pass through.
|
||||
if (pRawInputDevices &&
|
||||
(uiNumDevices > 0) &&
|
||||
(pRawInputDevices[0].hwndTarget == RI_MGR->input_hwnd)) {
|
||||
|
||||
return RegisterRawInputDevices_orig(pRawInputDevices, uiNumDevices, cbSize);
|
||||
}
|
||||
|
||||
// otherwise, it must be the game; prevent the game from registering for raw input
|
||||
// and hijacking WM_INPUT messages; we need that for rawinput to work.
|
||||
// even if we drop this, trackball emulation and mouse-as-touch input still work
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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\<configured>
|
||||
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(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#include <ksmedia.h>
|
||||
|
||||
#include "cfg/configurator.h"
|
||||
#include "hooks/audio/audio.h"
|
||||
#include "hooks/audio/mme.h"
|
||||
#include "hooks/graphics/graphics.h"
|
||||
#include "misc/wintouchemu.h"
|
||||
@@ -34,6 +35,7 @@ namespace games::gitadora {
|
||||
std::optional<socd::SocdAlgorithm> PICK_ALGO = socd::SocdAlgorithm::PreferRecent;
|
||||
std::optional<uint8_t> ARENA_WINDOW_COUNT = std::nullopt;
|
||||
std::optional<std::string> ASIO_DRIVER = std::nullopt;
|
||||
bool ALLOW_REALTEK_AUDIO = false;
|
||||
|
||||
/*
|
||||
* Prevent GitaDora from creating folders on F drive
|
||||
@@ -593,6 +595,12 @@ namespace games::gitadora {
|
||||
#ifdef SPICE64
|
||||
// gitadora arena model
|
||||
auto aio = libutils::try_library("libaio.dll");
|
||||
|
||||
// before we start patching and hooking things, detect invalid configuration
|
||||
if (aio != nullptr && !is_arena_model()) {
|
||||
log_fatal("gitadora", "arena model i/o (libaio.dll) detected, but <spec> is not an arena model - bad prop XML files?");
|
||||
}
|
||||
|
||||
if (aio != nullptr) {
|
||||
SETUPAPI_SETTINGS settings{};
|
||||
settings.class_guid[0] = 0x86E0D1E0;
|
||||
@@ -624,6 +632,24 @@ namespace games::gitadora {
|
||||
// volume change prevention
|
||||
hooks::audio::mme::init(avs::game::DLL_INSTANCE);
|
||||
|
||||
// fake Realtek audio injection
|
||||
// if ASIO init succeeds, game tries to look for audio device with `Realtek` in friendly name
|
||||
// if ASIO init fails, game opens default audio device
|
||||
// therefore, it's safe to enable this hook by default regardless of ASIO preference
|
||||
// (unless the user explicitly disables it, of course)
|
||||
if (ALLOW_REALTEK_AUDIO) {
|
||||
log_info(
|
||||
"gitadora",
|
||||
"fake Realtek audio injection disabled "
|
||||
"(user's real Realtek audio may be used after successful ASIO init)");
|
||||
} else {
|
||||
log_info(
|
||||
"gitadora",
|
||||
"fake Realtek audio injection enabled "
|
||||
"(create a fake Realtek audio device to prevent crashes after successful ASIO init)");
|
||||
hooks::audio::INJECT_FAKE_REALTEK_AUDIO = true;
|
||||
}
|
||||
|
||||
// monitor/touch hooks (windowed or full screen)
|
||||
if (GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
|
||||
// enable touch hook for subscreen overlay
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace games::gitadora {
|
||||
extern std::optional<socd::SocdAlgorithm> PICK_ALGO;
|
||||
extern std::optional<uint8_t> ARENA_WINDOW_COUNT;
|
||||
extern std::optional<std::string> ASIO_DRIVER;
|
||||
extern bool ALLOW_REALTEK_AUDIO;
|
||||
|
||||
class GitaDoraGame : public games::Game {
|
||||
public:
|
||||
|
||||
@@ -543,11 +543,7 @@ namespace games::iidx {
|
||||
// if the user specified a value (other than auto), use it as the environment var
|
||||
// probably "wasapi" or "asio", but it's not explicitly checked here for forward compat
|
||||
if (SOUND_OUTPUT_DEVICE.has_value() && SOUND_OUTPUT_DEVICE.value() != "auto") {
|
||||
log_info(
|
||||
"iidx",
|
||||
"using user-supplied \"{}\" for SOUND_OUTPUT_DEVICE",
|
||||
SOUND_OUTPUT_DEVICE.value());
|
||||
SetEnvironmentVariable("SOUND_OUTPUT_DEVICE", SOUND_OUTPUT_DEVICE.value().c_str());
|
||||
// the environemnt variable was already set in launcher.cpp
|
||||
SOUND_OUTPUT_DEVICE_IN_EFFECT = SOUND_OUTPUT_DEVICE;
|
||||
return;
|
||||
}
|
||||
@@ -893,38 +889,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
|
||||
|
||||
|
||||
@@ -444,6 +444,8 @@ namespace games {
|
||||
// overlay button definitions
|
||||
names.emplace_back("Screenshot");
|
||||
vkey_defaults.push_back(VK_SNAPSHOT);
|
||||
names.emplace_back("Toggle All Windows");
|
||||
vkey_defaults.push_back(VK_OEM_3); // backtick `
|
||||
names.emplace_back("Toggle Main Menu");
|
||||
vkey_defaults.push_back(VK_ESCAPE);
|
||||
names.emplace_back("Toggle Sub Screen");
|
||||
@@ -468,7 +470,7 @@ namespace games {
|
||||
vkey_defaults.push_back(VK_F10);
|
||||
names.emplace_back("Toggle Screen Resize");
|
||||
vkey_defaults.push_back(VK_F11);
|
||||
names.emplace_back("Toggle Overlay");
|
||||
names.emplace_back("Toggle FPS");
|
||||
vkey_defaults.push_back(VK_F12);
|
||||
names.emplace_back("Toggle Camera Control");
|
||||
vkey_defaults.push_back(0xFF);
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace games {
|
||||
namespace OverlayButtons {
|
||||
enum {
|
||||
Screenshot,
|
||||
ToggleAllWindows,
|
||||
ToggleMainMenu,
|
||||
ToggleSubScreen,
|
||||
InsertCoin,
|
||||
@@ -20,7 +21,7 @@ namespace games {
|
||||
ToggleControl,
|
||||
TogglePatchManager,
|
||||
ToggleScreenResize,
|
||||
ToggleOverlay,
|
||||
ToggleFps,
|
||||
ToggleCameraControl,
|
||||
TriggerPinMacroP1,
|
||||
TriggerPinMacroP2,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#include "asio_driver_scan.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "util/utils.h"
|
||||
|
||||
namespace hooks::audio {
|
||||
|
||||
static constexpr char ASIO_REG_PATH[] = "software\\asio";
|
||||
static constexpr char ASIO_REG_DESC[] = "description";
|
||||
|
||||
// enumerate a single registry view, appending to entries while merging
|
||||
// duplicates discovered in another view. Drivers are matched by name (not
|
||||
// CLSID): the game's ASIO loader selects drivers by name, and some vendors
|
||||
// register the same CLSID under different 32-bit/64-bit names (e.g. "XONAR
|
||||
// SOUND CARD" vs "XONAR SOUND CARD(64)"), which are distinct user choices.
|
||||
static void scan_view(
|
||||
REGSAM wow64_flag,
|
||||
bool is_64bit,
|
||||
std::vector<AsioDriverScanEntry> &entries) {
|
||||
|
||||
HKEY hkEnum = nullptr;
|
||||
if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, ASIO_REG_PATH, 0,
|
||||
KEY_READ | wow64_flag, &hkEnum) != ERROR_SUCCESS) {
|
||||
return;
|
||||
}
|
||||
|
||||
char key_name[256];
|
||||
for (DWORD index = 0;
|
||||
RegEnumKeyA(hkEnum, index, key_name, sizeof(key_name)) == ERROR_SUCCESS;
|
||||
index++) {
|
||||
|
||||
// read description (display name), fall back to the key name
|
||||
char desc[256] = { 0 };
|
||||
DWORD size = sizeof(desc);
|
||||
std::string name = key_name;
|
||||
if (RegGetValueA(hkEnum,
|
||||
key_name,
|
||||
ASIO_REG_DESC,
|
||||
RRF_RT_REG_SZ | wow64_flag,
|
||||
nullptr,
|
||||
desc,
|
||||
&size) == ERROR_SUCCESS && desc[0]) {
|
||||
name = desc;
|
||||
}
|
||||
|
||||
// merge with an existing entry from the other view (match by name)
|
||||
const std::string name_lower = strtolower(name);
|
||||
auto it = std::find_if(entries.begin(), entries.end(), [&](const auto &e) {
|
||||
return strtolower(e.name) == name_lower;
|
||||
});
|
||||
if (it == entries.end()) {
|
||||
entries.push_back({ name });
|
||||
it = entries.end() - 1;
|
||||
}
|
||||
it->found_32bit |= !is_64bit;
|
||||
it->found_64bit |= is_64bit;
|
||||
}
|
||||
|
||||
RegCloseKey(hkEnum);
|
||||
}
|
||||
|
||||
std::vector<AsioDriverScanEntry> scan_asio_drivers() {
|
||||
std::vector<AsioDriverScanEntry> entries;
|
||||
|
||||
// 64-bit view first so it wins ordering when present in both
|
||||
scan_view(KEY_WOW64_64KEY, true, entries);
|
||||
scan_view(KEY_WOW64_32KEY, false, entries);
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace hooks::audio {
|
||||
|
||||
struct AsioDriverScanEntry {
|
||||
std::string name;
|
||||
bool found_32bit = false;
|
||||
bool found_64bit = false;
|
||||
};
|
||||
|
||||
std::vector<AsioDriverScanEntry> scan_asio_drivers();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#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();
|
||||
|
||||
// selects which source channel pair of a multichannel ASIO output reaches the device's
|
||||
// 2.0 front pair. when not None, the proxy presents the game's expected multichannel
|
||||
// layout to the host so it proceeds to create_buffers, then opens only a two-channel
|
||||
// stream on the real device and routes the selected pair onto it (see create_buffers).
|
||||
// Front is the plain "force two channel" case (forward the device's own front pair);
|
||||
// the others copy a different pair onto 0/1. assumes a standard 7.1 layout (0-indexed).
|
||||
// set once at boot, before any wrapper exists, so it needs no synchronization
|
||||
enum class StereoDownmix {
|
||||
None, // feature disabled - full multichannel passthrough
|
||||
Front, // channels 0/1 - the device front pair is forwarded as-is (no copy)
|
||||
Center, // channel 2 duplicated to both 0 and 1
|
||||
Rear, // channels 4/5 -> 0/1
|
||||
Side, // channels 6/7 -> 0/1
|
||||
};
|
||||
static StereoDownmix STEREO_DOWNMIX;
|
||||
|
||||
// true when a stereo extraction is configured, i.e. the real device should open a 2.0
|
||||
// stream and only the selected pair should reach it. the former standalone
|
||||
// FORCE_TWO_CHANNELS flag is now just the Front case of this
|
||||
static bool force_two_channels() {
|
||||
return STEREO_DOWNMIX != StereoDownmix::None;
|
||||
}
|
||||
|
||||
// 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 a
|
||||
// stereo extraction is active
|
||||
static constexpr long FORCED_OUTPUT_CHANNELS = 8;
|
||||
|
||||
// maps an option string ("front", "center", "rear", "side") to a StereoDownmix value,
|
||||
// returning None for anything unrecognized
|
||||
static StereoDownmix name_to_stereo_downmix(const char *name);
|
||||
|
||||
#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 a stereo extraction 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 any post-processing effect (volume boost or stereo downmix) is active, saves the
|
||||
// game's callbacks and returns a proxy callback set (our buffer-switch trampolines) to
|
||||
// hand the real driver instead, so we can rework 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_proxy_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);
|
||||
|
||||
// the real device's output sample format, queried from its first output channel. all
|
||||
// output channels of a device share one format, so this characterizes them all. returns
|
||||
// ASIOSTLastEntry if the device has no output channels or the query fails
|
||||
AsioSampleType device_output_sample_type();
|
||||
|
||||
// locates the destination pair (device channels 0/1) and the configured source channels
|
||||
// in the game's buffer set so the realtime path can copy the selected pair onto 0/1.
|
||||
// a no-op unless STEREO_DOWNMIX selects a non-front pair. called at create_buffers time
|
||||
void record_downmix_channels(AsioBufferInfo *buffer_infos, long num_channels, long buffer_size);
|
||||
|
||||
// publishes the captured post-process state to the realtime thread once the buffers
|
||||
// exist, making our trampolines start reworking output. called at the end of either
|
||||
// create_buffers path
|
||||
void publish_post_process(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_post_process();
|
||||
|
||||
// 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);
|
||||
|
||||
// copies the configured source channel pair onto device channels 0/1 for the given
|
||||
// double-buffer index. runs on the driver's realtime thread from our buffer switch
|
||||
void apply_downmix(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 active_instance, call the game's original, then rework output.
|
||||
// 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 proxy_buffer_switch(long double_buffer_index, AsioBool direct_process);
|
||||
static AsioTime * __cdecl proxy_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<WrappedAsio *> 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<ULONG> ref_count {1};
|
||||
|
||||
// throwaway double buffers handed to the channels we discard when a stereo extraction
|
||||
// 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<std::unique_ptr<uint8_t[]>> dummy_buffers;
|
||||
|
||||
// one device output channel scaled by the volume boost in our buffer switch
|
||||
struct VolumeOutputChannel {
|
||||
void *buffers[2];
|
||||
AsioSampleType type;
|
||||
};
|
||||
|
||||
// the game's original callbacks (captured when we install our proxy set) and the proxy
|
||||
// set we hand the real driver; the realtime trampolines reach the game's buffer_switch
|
||||
// through game_callbacks regardless of which effect is active
|
||||
AsioCallbacks game_callbacks {};
|
||||
AsioCallbacks proxy_callbacks {};
|
||||
|
||||
// volume boost state, captured at create_buffers time and published to the realtime
|
||||
// thread via active_instance once fully built; untouched while the stream runs.
|
||||
// volume_active gates whether the realtime path scales any buffers
|
||||
bool volume_active = false;
|
||||
float volume_gain = 1.0f;
|
||||
long volume_buffer_size = 0;
|
||||
std::vector<VolumeOutputChannel> volume_channels;
|
||||
|
||||
// one device channel (0 or 1) fed by a source channel during stereo downmix; both
|
||||
// buffer pointers are indexed by the ASIO double-buffer index, the same as the channels
|
||||
struct DownmixCopy {
|
||||
void *dst[2];
|
||||
void *src[2];
|
||||
};
|
||||
|
||||
// stereo downmix state, captured at create_buffers time and published alongside the
|
||||
// volume state; untouched while the stream runs. downmix_active gates whether the
|
||||
// realtime path copies the selected source pair onto device channels 0/1. copies[0]
|
||||
// feeds device channel 0, copies[1] feeds device channel 1
|
||||
bool downmix_active = false;
|
||||
DownmixCopy downmix_copies[2] {};
|
||||
size_t downmix_bytes = 0;
|
||||
};
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
#include "audio_private.h"
|
||||
#include "acm.h"
|
||||
#include "asio_proxy.h"
|
||||
|
||||
#ifdef _MSC_VER
|
||||
DEFINE_GUID(CLSID_MMDeviceEnumerator,
|
||||
@@ -41,9 +42,13 @@ namespace hooks::audio {
|
||||
bool VOLUME_HOOK_ENABLED = true;
|
||||
std::optional<DownmixAlgorithm> DOWNMIX_ALGORITHM = std::nullopt;
|
||||
float VOLUME_BOOST = 1.0f;
|
||||
std::optional<uint32_t> RESAMPLE_RATE = std::nullopt;
|
||||
std::optional<uint32_t> EXCLUSIVE_BUFFER_MS = std::nullopt;
|
||||
bool WASAPI_COMPATIBILITY_MODE = false;
|
||||
bool USE_DUMMY = false;
|
||||
WAVEFORMATEXTENSIBLE FORMAT {};
|
||||
std::optional<Backend> BACKEND = std::nullopt;
|
||||
bool INJECT_FAKE_REALTEK_AUDIO = false;
|
||||
std::optional<size_t> ASIO_DRIVER_ID = std::nullopt;
|
||||
std::string ASIO_DRIVER_NAME = "";
|
||||
bool ASIO_FORCE_UNLOAD_ON_STOP = false;
|
||||
@@ -93,6 +98,10 @@ static HRESULT STDAPICALLTYPE CoCreateInstance_hook(
|
||||
// wrap object
|
||||
auto mmde = reinterpret_cast<IMMDeviceEnumerator **>(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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
@@ -29,9 +30,27 @@ namespace hooks::audio {
|
||||
extern bool VOLUME_HOOK_ENABLED;
|
||||
extern std::optional<DownmixAlgorithm> DOWNMIX_ALGORITHM;
|
||||
extern float VOLUME_BOOST;
|
||||
|
||||
// target sample rate the hooked output is resampled to, if set
|
||||
extern std::optional<uint32_t> RESAMPLE_RATE;
|
||||
|
||||
// minimum WASAPI exclusive buffer duration (milliseconds), if set. enlarges the device buffer
|
||||
// to avoid underrun crackle on endpoints that cannot service a tiny buffer in time.
|
||||
extern std::optional<uint32_t> EXCLUSIVE_BUFFER_MS;
|
||||
|
||||
// when true, WASAPI compatibility mode is active: exclusive-mode streams are redirected to
|
||||
// shared mode. the Windows audio engine performs any required sample-rate / channel / bit-depth
|
||||
// conversion, so the game's format is passed through unchanged and other applications can play
|
||||
// audio simultaneously.
|
||||
extern bool WASAPI_COMPATIBILITY_MODE;
|
||||
extern bool USE_DUMMY;
|
||||
extern WAVEFORMATEXTENSIBLE FORMAT;
|
||||
extern std::optional<Backend> 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 INJECT_FAKE_REALTEK_AUDIO;
|
||||
extern std::optional<size_t> ASIO_DRIVER_ID;
|
||||
extern std::string ASIO_DRIVER_NAME;
|
||||
extern bool ASIO_FORCE_UNLOAD_ON_STOP;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
#include <mmdeviceapi.h>
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#include "null_device.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
|
||||
#include <audioclient.h>
|
||||
|
||||
#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::INJECT_FAKE_REALTEK_AUDIO;
|
||||
}
|
||||
|
||||
// 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<LPWSTR>(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<ULONG> 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<IAudioClient *>(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
|
||||
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
|
||||
#include <mmdeviceapi.h>
|
||||
|
||||
// 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<ULONG> ref_cnt = 1;
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
#include "null_discard_backend.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
|
||||
#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<uint32_t>(1, static_cast<uint32_t>(
|
||||
static_cast<double>(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<uint32_t> &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<size_t>(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<steady_clock::duration>(
|
||||
duration<double>(this->period_reftime / 10000000.0));
|
||||
|
||||
while (this->running.load()) {
|
||||
if (this->relay_handle) {
|
||||
SetEvent(this->relay_handle);
|
||||
}
|
||||
timer.sleep(period);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <optional>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <audioclient.h>
|
||||
|
||||
#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<uint32_t> &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<BYTE> scratch;
|
||||
std::thread pacing_thread;
|
||||
std::atomic<bool> running = false;
|
||||
};
|
||||
@@ -167,27 +167,51 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize(
|
||||
fix_rec_format(const_cast<WAVEFORMATEX *>(pFormat));
|
||||
}
|
||||
|
||||
// when downmixing, open the real device as stereo while the game keeps writing its native
|
||||
// multi-channel format into the scratch buffer.
|
||||
// apply the -wasapishared option: redirect an exclusive request to shared mode. once redirected,
|
||||
// spice's own downmix/resample paths below are skipped (gated on redirected_from_exclusive) and
|
||||
// the shared engine handles any format conversion via AUTOCONVERTPCM (PCM / float only).
|
||||
if (hooks::audio::SharedRedirect::wants(ShareMode, pFormat)) {
|
||||
this->shared.apply(&ShareMode, &StreamFlags, &hnsPeriodicity);
|
||||
} else if (hooks::audio::WASAPI_COMPATIBILITY_MODE && ShareMode == AUDCLNT_SHAREMODE_SHARED) {
|
||||
log_warning(
|
||||
"audio::wasapi",
|
||||
"-wasapishared is enabled but the game is already opening a shared-mode stream; "
|
||||
"the option has no effect");
|
||||
}
|
||||
|
||||
WAVEFORMATEXTENSIBLE stereo_storage = {};
|
||||
WAVEFORMATEXTENSIBLE resample_storage = {};
|
||||
const WAVEFORMATEX *device_format = pFormat;
|
||||
|
||||
if (auto algorithm = resolve_downmix(pFormat)) {
|
||||
this->downmix.setup(pFormat, &stereo_storage, *algorithm);
|
||||
device_format = reinterpret_cast<const WAVEFORMATEX *>(&stereo_storage);
|
||||
log_info("audio::wasapi", "downmix enabled: {} channels -> 2 channels ({})",
|
||||
pFormat->nChannels, hooks::audio::Downmix::algorithm_name(*algorithm));
|
||||
} else if (games::gitadora::is_arena_model()) {
|
||||
games::gitadora::fix_audio_channel_mask(const_cast<WAVEFORMATEX *>(pFormat));
|
||||
if (!this->shared.redirected_from_exclusive) {
|
||||
|
||||
// when downmixing, open the real device as stereo while the game keeps writing its native
|
||||
// multi-channel format into the scratch buffer.
|
||||
if (auto algorithm = resolve_downmix(pFormat)) {
|
||||
this->downmix.setup(pFormat, &stereo_storage, *algorithm);
|
||||
device_format = reinterpret_cast<const WAVEFORMATEX *>(&stereo_storage);
|
||||
log_info("audio::wasapi", "downmix enabled: {} channels -> 2 channels ({})",
|
||||
pFormat->nChannels, hooks::audio::Downmix::algorithm_name(*algorithm));
|
||||
} else if (games::gitadora::is_arena_model()) {
|
||||
games::gitadora::fix_audio_channel_mask(const_cast<WAVEFORMATEX *>(pFormat));
|
||||
}
|
||||
|
||||
// when resampling, open the real device at the target rate while the game keeps writing its
|
||||
// native-rate audio into the scratch buffer. this runs on whatever device_format is now: the
|
||||
// game's native format, or the stereo format produced above when downmix is also active, so
|
||||
// the two stages chain as multi-channel -> stereo -> resampled stereo.
|
||||
if (auto target_rate = hooks::audio::Resampler::resolve(device_format)) {
|
||||
const uint32_t src_rate = device_format->nSamplesPerSec;
|
||||
this->resample.setup(device_format, &resample_storage, *target_rate);
|
||||
device_format = reinterpret_cast<const WAVEFORMATEX *>(&resample_storage);
|
||||
log_info("audio::wasapi", "resample enabled: {} Hz -> {} Hz{}",
|
||||
src_rate, *target_rate, this->downmix.enabled ? " (after downmix)" : "");
|
||||
}
|
||||
}
|
||||
|
||||
// verbose output
|
||||
log_info("audio::wasapi", "IAudioClient::Initialize hook hit");
|
||||
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(ShareMode));
|
||||
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(StreamFlags));
|
||||
log_info("audio::wasapi", "... hnsBufferDuration : {}", hnsBufferDuration);
|
||||
log_info("audio::wasapi", "... hnsPeriodicity : {}", hnsPeriodicity);
|
||||
print_format(device_format);
|
||||
print_format(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, device_format);
|
||||
|
||||
if (this->backend) {
|
||||
SAFE_CALL("AudioBackend", "on_initialize", this->backend->on_initialize(
|
||||
@@ -199,22 +223,48 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize(
|
||||
AudioSessionGuid));
|
||||
|
||||
log_info("audio::wasapi", "AudioBackend::on_initialize call finished");
|
||||
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(ShareMode));
|
||||
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(StreamFlags));
|
||||
log_info("audio::wasapi", "... hnsBufferDuration : {}", hnsBufferDuration);
|
||||
log_info("audio::wasapi", "... hnsPeriodicity : {}", hnsPeriodicity);
|
||||
print_format(pFormat);
|
||||
print_format(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, pFormat);
|
||||
}
|
||||
|
||||
// check for exclusive mode
|
||||
if (ShareMode == AUDCLNT_SHAREMODE_EXCLUSIVE) {
|
||||
this->exclusive_mode = true;
|
||||
this->frame_size = device_format->nChannels * (device_format->wBitsPerSample / 8);
|
||||
|
||||
// optionally enlarge the exclusive buffer. games request a very small buffer (e.g. 3 ms)
|
||||
// which some endpoints (notably NVIDIA HDMI/DP display audio) cannot service in time,
|
||||
// underrunning mid-period and crackling. a larger buffer gives the device slack. exclusive
|
||||
// mode requires periodicity == buffer_duration, so raise both together; the initialize
|
||||
// paths below handle any required buffer-size realignment.
|
||||
if (hooks::audio::EXCLUSIVE_BUFFER_MS.has_value()) {
|
||||
const REFERENCE_TIME min_duration =
|
||||
(REFERENCE_TIME) hooks::audio::EXCLUSIVE_BUFFER_MS.value() * 10000;
|
||||
if (hnsBufferDuration < min_duration) {
|
||||
log_info("audio::wasapi",
|
||||
"raising exclusive buffer from {} hns to {} hns ({} ms)",
|
||||
hnsBufferDuration, min_duration, hooks::audio::EXCLUSIVE_BUFFER_MS.value());
|
||||
hnsBufferDuration = min_duration;
|
||||
if (hnsPeriodicity != 0) {
|
||||
hnsPeriodicity = min_duration;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// call next
|
||||
// call next. the resampler owns the device interaction whenever it is active (including when
|
||||
// chained after the downmix), otherwise the downmix does, otherwise the device is opened
|
||||
// directly.
|
||||
HRESULT ret;
|
||||
if (this->downmix.enabled) {
|
||||
if (this->resample.enabled) {
|
||||
ret = this->resample.initialize(
|
||||
pReal,
|
||||
ShareMode,
|
||||
StreamFlags,
|
||||
hnsBufferDuration,
|
||||
hnsPeriodicity,
|
||||
device_format,
|
||||
AudioSessionGuid);
|
||||
} else if (this->downmix.enabled) {
|
||||
ret = this->downmix.initialize(
|
||||
pReal,
|
||||
ShareMode,
|
||||
@@ -224,7 +274,9 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize(
|
||||
device_format,
|
||||
AudioSessionGuid);
|
||||
} else {
|
||||
ret = pReal->Initialize(
|
||||
ret = initialize_with_alignment_retry(
|
||||
pReal,
|
||||
"audio::wasapi",
|
||||
ShareMode,
|
||||
StreamFlags,
|
||||
hnsBufferDuration,
|
||||
@@ -263,7 +315,21 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetBufferSize(UINT32 *pNumBufferF
|
||||
}
|
||||
}
|
||||
|
||||
CHECK_RESULT(pReal->GetBufferSize(pNumBufferFrames));
|
||||
HRESULT ret = pReal->GetBufferSize(pNumBufferFrames);
|
||||
|
||||
// report the buffer size at the game's native rate; the real device buffer is at the
|
||||
// resampled rate, so translate it back so the game paces its writes correctly.
|
||||
if (SUCCEEDED(ret) && this->resample.enabled && pNumBufferFrames) {
|
||||
*pNumBufferFrames = this->resample.frames_device_to_game(*pNumBufferFrames);
|
||||
}
|
||||
|
||||
// redirected to shared mode: clamp the reported buffer to one device period (see SharedRedirect).
|
||||
if (SUCCEEDED(ret) && this->shared.redirected_from_exclusive && pNumBufferFrames) {
|
||||
*pNumBufferFrames = this->shared.clamp_buffer_size(
|
||||
pReal, this->device_format.Format.nSamplesPerSec, *pNumBufferFrames);
|
||||
}
|
||||
|
||||
CHECK_RESULT(ret);
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetStreamLatency(REFERENCE_TIME *phnsLatency) {
|
||||
static std::once_flag printed;
|
||||
@@ -305,7 +371,15 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetCurrentPadding(UINT32 *pNumPad
|
||||
}
|
||||
}
|
||||
|
||||
CHECK_RESULT(pReal->GetCurrentPadding(pNumPaddingFrames));
|
||||
HRESULT ret = pReal->GetCurrentPadding(pNumPaddingFrames);
|
||||
|
||||
// the device buffer is at the resampled rate; report padding at the game's native rate so the
|
||||
// game's free-space calculation stays paced correctly.
|
||||
if (SUCCEEDED(ret) && this->resample.enabled && pNumPaddingFrames) {
|
||||
*pNumPaddingFrames = this->resample.padding_device_to_game(*pNumPaddingFrames);
|
||||
}
|
||||
|
||||
CHECK_RESULT(ret);
|
||||
}
|
||||
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsFormatSupported(
|
||||
AUDCLNT_SHAREMODE ShareMode,
|
||||
@@ -323,16 +397,52 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsFormatSupported(
|
||||
fix_rec_format(const_cast<WAVEFORMATEX *>(pFormat));
|
||||
}
|
||||
|
||||
// log the format the game is asking about
|
||||
log_info("audio::wasapi", "IAudioClient::IsFormatSupported hook hit");
|
||||
print_format(ShareMode, pFormat);
|
||||
|
||||
// under the exclusive->shared redirect, report the exclusive format as supported so the game
|
||||
// doesn't fall back before reaching Initialize.
|
||||
if (hooks::audio::SharedRedirect::wants(ShareMode, pFormat)) {
|
||||
log_info("audio::wasapi", "... reporting supported (will redirect to shared mode)");
|
||||
if (ppClosestMatch) {
|
||||
*ppClosestMatch = nullptr;
|
||||
}
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
// when downmixing, the real device is opened as stereo, so check whether the equivalent
|
||||
// stereo format is supported instead of the multi-channel one.
|
||||
// stereo format is supported instead of the multi-channel one. when resampling is also active
|
||||
// it chains onto that stereo format, so check the resampled stereo format.
|
||||
if (resolve_downmix(pFormat)) {
|
||||
WAVEFORMATEXTENSIBLE stereo_storage = {};
|
||||
hooks::audio::Downmix::make_stereo_format(pFormat, &stereo_storage);
|
||||
const auto stereo_format = reinterpret_cast<const WAVEFORMATEX *>(&stereo_storage);
|
||||
const WAVEFORMATEX *check_format = reinterpret_cast<const WAVEFORMATEX *>(&stereo_storage);
|
||||
|
||||
CHECK_RESULT(pReal->IsFormatSupported(ShareMode, stereo_format, ppClosestMatch));
|
||||
WAVEFORMATEXTENSIBLE resample_storage = {};
|
||||
if (auto target_rate = hooks::audio::Resampler::resolve(check_format)) {
|
||||
hooks::audio::Resampler::make_device_format(check_format, &resample_storage, *target_rate);
|
||||
check_format = reinterpret_cast<const WAVEFORMATEX *>(&resample_storage);
|
||||
}
|
||||
|
||||
log_info("audio::wasapi", "... checking device format instead (after downmix/resample):");
|
||||
print_format(check_format);
|
||||
|
||||
CHECK_RESULT(pReal->IsFormatSupported(ShareMode, check_format, ppClosestMatch));
|
||||
} else if (games::gitadora::is_arena_model()) {
|
||||
games::gitadora::fix_audio_channel_mask(const_cast<WAVEFORMATEX *>(pFormat));
|
||||
} else if (auto target_rate = hooks::audio::Resampler::resolve(pFormat)) {
|
||||
|
||||
// when resampling, the real device is opened at the target rate, so check whether the
|
||||
// equivalent format at that rate is supported instead of the game's native rate.
|
||||
WAVEFORMATEXTENSIBLE resample_storage = {};
|
||||
hooks::audio::Resampler::make_device_format(pFormat, &resample_storage, *target_rate);
|
||||
const auto resample_format = reinterpret_cast<const WAVEFORMATEX *>(&resample_storage);
|
||||
|
||||
log_info("audio::wasapi", "... checking device format instead (after resample):");
|
||||
print_format(resample_format);
|
||||
|
||||
CHECK_RESULT(pReal->IsFormatSupported(ShareMode, resample_format, ppClosestMatch));
|
||||
}
|
||||
|
||||
if (this->backend) {
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
#include "util/logging.h"
|
||||
|
||||
#include "downmix.h"
|
||||
#include "resample.h"
|
||||
#include "shared.h"
|
||||
|
||||
#include "audio_render_client.h"
|
||||
|
||||
// {1FBC8530-AF3E-4128-B418-115DE72F76B6}
|
||||
static const GUID IID_WrappedIAudioClient = {
|
||||
0x1fbc8530, 0xaf3e, 0x4128, { 0xb4, 0x18, 0x11, 0x5d, 0xe7, 0x2f, 0x76, 0xb6 }
|
||||
@@ -94,6 +95,10 @@ struct WrappedIAudioClient : IAudioClient3 {
|
||||
IAudioClient3 *const pReal3;
|
||||
AudioBackend *const backend;
|
||||
bool exclusive_mode = false;
|
||||
|
||||
// -wasapishared redirect state: when an exclusive request was redirected to shared mode, the
|
||||
// engine converts the native format and the reported buffer size is clamped (see SharedRedirect).
|
||||
hooks::audio::SharedRedirect shared;
|
||||
int frame_size = 0;
|
||||
|
||||
// the format the real device was opened with (after any downmix). used to scale the final
|
||||
@@ -103,4 +108,9 @@ struct WrappedIAudioClient : IAudioClient3 {
|
||||
// surround -> stereo downmix. the real device is opened as stereo while the game keeps
|
||||
// writing multi-channel audio into a scratch buffer that we downmix in the render client.
|
||||
hooks::audio::Downmix downmix;
|
||||
|
||||
// native-rate -> target-rate sample-rate conversion. the real device is opened at the target
|
||||
// rate while the game keeps writing its native-rate audio into a scratch buffer that we
|
||||
// resample in the render client.
|
||||
hooks::audio::Resampler resample;
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
#include "audio_client.h"
|
||||
#include "hooks/audio/audio.h"
|
||||
#include "util.h"
|
||||
#include "wasapi_private.h"
|
||||
|
||||
const char CLASS_NAME[] = "WrappedIAudioRenderClient";
|
||||
@@ -17,9 +18,7 @@ static void apply_gain(BYTE *buffer, UINT32 frames, const WAVEFORMATEXTENSIBLE &
|
||||
const WAVEFORMATEX &f = fmt.Format;
|
||||
const size_t samples = (size_t) frames * f.nChannels;
|
||||
|
||||
// KSDATAFORMAT_SUBTYPE_IEEE_FLOAT has Data1 == 3, _PCM has Data1 == 1
|
||||
bool is_float = f.wFormatTag == WAVE_FORMAT_IEEE_FLOAT
|
||||
|| (f.wFormatTag == WAVE_FORMAT_EXTENSIBLE && fmt.SubFormat.Data1 == 0x00000003);
|
||||
bool is_float = is_ieee_float(&f);
|
||||
|
||||
if (is_float && f.wBitsPerSample == 32) {
|
||||
auto p = reinterpret_cast<float *>(buffer);
|
||||
@@ -113,10 +112,23 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::GetBuffer(UINT32 NumFramesR
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
// downmix + resample chained: the game writes its multi-channel native-rate audio into the
|
||||
// downmix scratch, which is downmixed to stereo and then resampled on release. size the
|
||||
// resampler's (stereo) input scratch now and hand the game the multi-channel downmix scratch.
|
||||
if (this->client->downmix.enabled && this->client->resample.enabled) {
|
||||
BYTE *resample_scratch = nullptr;
|
||||
this->client->resample.get_buffer(NumFramesRequested, &resample_scratch);
|
||||
CHECK_RESULT(this->client->downmix.get_scratch(NumFramesRequested, ppData));
|
||||
|
||||
// surround downmix: reserve the real (stereo) device buffer, but hand the game a
|
||||
// multi-channel scratch buffer that we downmix on release
|
||||
if (this->client->downmix.enabled) {
|
||||
} else if (this->client->downmix.enabled) {
|
||||
CHECK_RESULT(this->client->downmix.get_buffer(pReal, NumFramesRequested, ppData));
|
||||
|
||||
// resample: hand the game a native-rate scratch buffer that we convert on release. the real
|
||||
// device buffer is acquired in ReleaseBuffer once the converted frame count is known.
|
||||
} else if (this->client->resample.enabled) {
|
||||
CHECK_RESULT(this->client->resample.get_buffer(NumFramesRequested, ppData));
|
||||
}
|
||||
|
||||
// call original
|
||||
@@ -143,6 +155,34 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::ReleaseBuffer(UINT32 NumFra
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
// downmix + resample chained: downmix the game's multi-channel scratch into the resampler's
|
||||
// stereo input scratch, then let the resampler convert and push it to the device. a silent
|
||||
// buffer skips the downmix and feeds silence straight through.
|
||||
if (this->client->downmix.enabled && this->client->resample.enabled) {
|
||||
if ((dwFlags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) {
|
||||
this->client->downmix.downmix_into(
|
||||
this->client->resample.input_data(), NumFramesWritten);
|
||||
}
|
||||
return this->client->resample.flush(
|
||||
pReal,
|
||||
this->client->pReal,
|
||||
NumFramesWritten,
|
||||
dwFlags,
|
||||
hooks::audio::VOLUME_BOOST);
|
||||
}
|
||||
|
||||
// resample: convert the game's native-rate scratch and push as many output frames as the
|
||||
// device has room for, applying the volume boost to the converted output. handles acquiring
|
||||
// and releasing the real device buffer itself.
|
||||
if (this->client->resample.enabled) {
|
||||
return this->client->resample.flush(
|
||||
pReal,
|
||||
this->client->pReal,
|
||||
NumFramesWritten,
|
||||
dwFlags,
|
||||
hooks::audio::VOLUME_BOOST);
|
||||
}
|
||||
|
||||
// resolve the real device buffer for whichever path produced the audio
|
||||
BYTE *device_buffer;
|
||||
if (this->client->downmix.enabled) {
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
#include "util/logging.h"
|
||||
|
||||
#include "util.h"
|
||||
|
||||
namespace hooks::audio {
|
||||
|
||||
namespace {
|
||||
@@ -45,69 +47,6 @@ namespace hooks::audio {
|
||||
}
|
||||
}
|
||||
|
||||
// read one sample at `p` as a normalized float in [-1, 1]
|
||||
static inline float read_sample(const BYTE *p, int bytes, bool is_float) {
|
||||
if (is_float) {
|
||||
float v;
|
||||
memcpy(&v, p, sizeof(float));
|
||||
return v;
|
||||
}
|
||||
switch (bytes) {
|
||||
case 2: {
|
||||
int16_t v;
|
||||
memcpy(&v, p, sizeof(v));
|
||||
return v * (1.0f / 32768.0f);
|
||||
}
|
||||
case 3: {
|
||||
int32_t v = p[0] | (p[1] << 8) | (p[2] << 16);
|
||||
if (v & 0x800000) {
|
||||
v |= ~0xFFFFFF; // sign extend
|
||||
}
|
||||
return v * (1.0f / 8388608.0f);
|
||||
}
|
||||
case 4: {
|
||||
int32_t v;
|
||||
memcpy(&v, p, sizeof(v));
|
||||
return (float) (v * (1.0 / 2147483648.0));
|
||||
}
|
||||
default:
|
||||
return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
// write the normalized float `value` to the sample at `p`, clamping to the format's range
|
||||
static inline void write_sample(BYTE *p, int bytes, bool is_float, float value) {
|
||||
if (is_float) {
|
||||
float v = std::clamp(value, -1.0f, 1.0f);
|
||||
memcpy(p, &v, sizeof(v));
|
||||
return;
|
||||
}
|
||||
switch (bytes) {
|
||||
case 2: {
|
||||
int16_t v = (int16_t) std::clamp(
|
||||
(int) std::lround(value * 32768.0f), -32768, 32767);
|
||||
memcpy(p, &v, sizeof(v));
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
int32_t v = (int32_t) std::clamp<int64_t>(
|
||||
std::llround((double) value * 8388608.0), -8388608, 8388607);
|
||||
p[0] = v & 0xFF;
|
||||
p[1] = (v >> 8) & 0xFF;
|
||||
p[2] = (v >> 16) & 0xFF;
|
||||
break;
|
||||
}
|
||||
case 4: {
|
||||
int32_t v = (int32_t) std::clamp<int64_t>(
|
||||
std::llround((double) value * 2147483648.0), INT32_MIN, INT32_MAX);
|
||||
memcpy(p, &v, sizeof(v));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void Downmix::setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *stereo_out,
|
||||
DownmixAlgorithm algorithm) {
|
||||
this->enabled = true;
|
||||
@@ -115,11 +54,7 @@ namespace hooks::audio {
|
||||
this->bytes_per_sample = game_format->wBitsPerSample / 8;
|
||||
this->game_frame_size = game_format->nChannels * this->bytes_per_sample;
|
||||
|
||||
// KSDATAFORMAT_SUBTYPE_IEEE_FLOAT has Data1 == 3 (matches apply_gain detection)
|
||||
this->is_float = game_format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT
|
||||
|| (game_format->wFormatTag == WAVE_FORMAT_EXTENSIBLE
|
||||
&& reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(game_format)
|
||||
->SubFormat.Data1 == 0x00000003);
|
||||
this->is_float = is_ieee_float(game_format);
|
||||
|
||||
// supported: 16/24/32-bit integer PCM and 32-bit float; anything else mixes to silence
|
||||
const bool supported = this->is_float
|
||||
@@ -155,28 +90,10 @@ namespace hooks::audio {
|
||||
REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity,
|
||||
const WAVEFORMATEX *device_format, LPCGUID session_guid) {
|
||||
|
||||
HRESULT ret = real->Initialize(share_mode, stream_flags, buffer_duration, periodicity,
|
||||
device_format, session_guid);
|
||||
|
||||
// the smaller stereo buffer can end up unaligned for the device when the game sized the
|
||||
// duration for its larger multi-channel format. recover by asking the device for the next
|
||||
// aligned buffer size and re-initializing with a matching duration.
|
||||
if (ret == AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED) {
|
||||
UINT32 aligned_frames = 0;
|
||||
if (SUCCEEDED(real->GetBufferSize(&aligned_frames)) && aligned_frames > 0) {
|
||||
REFERENCE_TIME aligned_duration = (REFERENCE_TIME)
|
||||
(10000.0 * 1000 / device_format->nSamplesPerSec * aligned_frames + 0.5);
|
||||
|
||||
log_info("audio::downmix",
|
||||
"buffer not aligned, retrying with {} frames ({} hns)",
|
||||
aligned_frames, aligned_duration);
|
||||
|
||||
ret = real->Initialize(share_mode, stream_flags, aligned_duration,
|
||||
periodicity != 0 ? aligned_duration : 0, device_format, session_guid);
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
// duration for its larger multi-channel format; the helper recovers from that.
|
||||
return initialize_with_alignment_retry(real, "audio::downmix", share_mode, stream_flags,
|
||||
buffer_duration, periodicity, device_format, session_guid);
|
||||
}
|
||||
|
||||
void Downmix::add_channel(int channel, DWORD speaker, float gain) {
|
||||
@@ -313,6 +230,21 @@ namespace hooks::audio {
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
HRESULT Downmix::get_scratch(UINT32 frames, BYTE **ppData) {
|
||||
const size_t needed = (size_t) frames * this->game_frame_size;
|
||||
if (this->scratch.size() < needed) {
|
||||
this->scratch.resize(needed);
|
||||
}
|
||||
|
||||
*ppData = this->scratch.data();
|
||||
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
void Downmix::downmix_into(BYTE *dst, UINT32 frames) const {
|
||||
this->process(dst, this->scratch.data(), frames);
|
||||
}
|
||||
|
||||
void Downmix::write_device_buffer(UINT32 frames, DWORD flags) {
|
||||
const int bps = this->bytes_per_sample;
|
||||
const int dst_stride = 2 * bps;
|
||||
|
||||
@@ -100,6 +100,14 @@ namespace hooks::audio {
|
||||
// grab the real stereo device buffer and hand the game the scratch buffer to write into.
|
||||
HRESULT get_buffer(IAudioRenderClient *real, UINT32 frames, BYTE **ppData);
|
||||
|
||||
// size the scratch and hand it to the game without acquiring a device buffer. used when a
|
||||
// later stage (the resampler) owns the device interaction.
|
||||
HRESULT get_scratch(UINT32 frames, BYTE **ppData);
|
||||
|
||||
// downmix the scratch the game wrote into the caller's stereo buffer, without touching the
|
||||
// device. used to feed the resampler when the two stages are chained.
|
||||
void downmix_into(BYTE *dst, UINT32 frames) const;
|
||||
|
||||
// mix the scratch buffer into the stereo device buffer held since get_buffer. the caller
|
||||
// owns releasing the device buffer afterwards (see current_buffer / buffer_released).
|
||||
void write_device_buffer(UINT32 frames, DWORD flags);
|
||||
|
||||
@@ -73,11 +73,7 @@ HRESULT STDMETHODCALLTYPE DummyIAudioClient::Initialize(
|
||||
|
||||
// verbose output
|
||||
log_info("audio::wasapi", "IAudioClient::Initialize hook hit");
|
||||
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(ShareMode));
|
||||
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(StreamFlags));
|
||||
log_info("audio::wasapi", "... hnsBufferDuration : {}", hnsBufferDuration);
|
||||
log_info("audio::wasapi", "... hnsPeriodicity : {}", hnsPeriodicity);
|
||||
print_format(pFormat);
|
||||
print_format(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, pFormat);
|
||||
|
||||
CHECK_RESULT(this->backend->on_initialize(
|
||||
&ShareMode,
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
#include "resample.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
|
||||
#include <audioclient.h>
|
||||
|
||||
#include "util/logging.h"
|
||||
|
||||
#include "util.h"
|
||||
|
||||
namespace hooks::audio {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr double PI = 3.14159265358979323846;
|
||||
|
||||
// normalized sinc: sin(pi*x) / (pi*x), with the removable singularity at 0 filled in
|
||||
inline double sinc(double x) {
|
||||
if (x == 0.0) {
|
||||
return 1.0;
|
||||
}
|
||||
const double px = PI * x;
|
||||
return std::sin(px) / px;
|
||||
}
|
||||
|
||||
// Blackman window across the kernel radius; zero at +/- radius
|
||||
inline double blackman(double x, double radius) {
|
||||
const double n = (x + radius) / (2.0 * radius);
|
||||
if (n <= 0.0 || n >= 1.0) {
|
||||
return 0.0;
|
||||
}
|
||||
return 0.42 - 0.5 * std::cos(2.0 * PI * n) + 0.08 * std::cos(4.0 * PI * n);
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<uint32_t> Resampler::resolve(const WAVEFORMATEX *game_format) {
|
||||
if (game_format == nullptr || !RESAMPLE_RATE.has_value()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (game_format->nSamplesPerSec == 0
|
||||
|| game_format->nSamplesPerSec == RESAMPLE_RATE.value()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return RESAMPLE_RATE;
|
||||
}
|
||||
|
||||
void Resampler::setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *device_out,
|
||||
uint32_t target_rate) {
|
||||
this->enabled = true;
|
||||
this->channels = game_format->nChannels;
|
||||
this->bytes_per_sample = game_format->wBitsPerSample / 8;
|
||||
this->game_frame_size = this->channels * this->bytes_per_sample;
|
||||
|
||||
this->is_float = is_ieee_float(game_format);
|
||||
|
||||
const bool supported = this->is_float
|
||||
? this->bytes_per_sample == 4
|
||||
: (this->bytes_per_sample >= 2 && this->bytes_per_sample <= 4);
|
||||
if (!supported) {
|
||||
log_fatal(
|
||||
"audio::resample",
|
||||
"unsupported sample format ({}-bit {}) for -resample",
|
||||
game_format->wBitsPerSample, this->is_float ? "float" : "int");
|
||||
}
|
||||
|
||||
this->src_rate = game_format->nSamplesPerSec;
|
||||
this->dst_rate = target_rate;
|
||||
|
||||
// anti-alias cutoff: full bandwidth when upsampling, scaled down when decimating
|
||||
this->cutoff = std::min(1.0, (double) this->dst_rate / (double) this->src_rate);
|
||||
this->half_taps = 16;
|
||||
|
||||
// precompute the windowed-sinc kernel now that cutoff is known
|
||||
this->build_kernel();
|
||||
|
||||
// prime the queue with half a window of silence so the first outputs have left history
|
||||
this->in_queue.assign((size_t) this->half_taps * this->channels, 0.0f);
|
||||
this->in_pos = this->half_taps;
|
||||
|
||||
this->make_device_format(game_format, device_out, target_rate);
|
||||
}
|
||||
|
||||
void Resampler::make_device_format(const WAVEFORMATEX *game_format,
|
||||
WAVEFORMATEXTENSIBLE *device_out, uint32_t target_rate) {
|
||||
const size_t src_size = sizeof(WAVEFORMATEX) + game_format->cbSize;
|
||||
|
||||
memset(device_out, 0, sizeof(WAVEFORMATEXTENSIBLE));
|
||||
memcpy(device_out, game_format, std::min(src_size, sizeof(WAVEFORMATEXTENSIBLE)));
|
||||
|
||||
device_out->Format.nSamplesPerSec = target_rate;
|
||||
device_out->Format.nAvgBytesPerSec = target_rate * device_out->Format.nBlockAlign;
|
||||
}
|
||||
|
||||
HRESULT Resampler::initialize(IAudioClient *real, AUDCLNT_SHAREMODE share_mode,
|
||||
DWORD stream_flags, REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity,
|
||||
const WAVEFORMATEX *device_format, LPCGUID session_guid) {
|
||||
|
||||
// the resampler bypasses the OS mixer and talks to the device directly, so it only makes
|
||||
// sense (and only works) for exclusive streams. shared streams are already resampled by
|
||||
// the Windows audio engine, so refuse loudly rather than silently doing nothing.
|
||||
if (share_mode != AUDCLNT_SHAREMODE_EXCLUSIVE) {
|
||||
log_fatal("audio::resample",
|
||||
"-resample requires WASAPI exclusive mode, but this stream is shared "
|
||||
"(Windows already resamples shared streams)");
|
||||
}
|
||||
|
||||
// record the pacing model. event-driven streams fill the whole device buffer each period
|
||||
// (produce_exact); timer-driven streams poll padding and write variable partial chunks, so
|
||||
// they drain the pending output to the device's free space each call (flush_timer).
|
||||
this->event_driven = (stream_flags & AUDCLNT_STREAMFLAGS_EVENTCALLBACK) != 0;
|
||||
|
||||
return initialize_with_alignment_retry(real, "audio::resample", share_mode, stream_flags,
|
||||
buffer_duration, periodicity, device_format, session_guid);
|
||||
}
|
||||
|
||||
UINT32 Resampler::frames_device_to_game(UINT32 device_frames) const {
|
||||
if (this->dst_rate == 0) {
|
||||
return device_frames;
|
||||
}
|
||||
// round down so the game never believes it has more room than the device can hold
|
||||
return (UINT32) (((double) device_frames * this->src_rate) / this->dst_rate);
|
||||
}
|
||||
|
||||
UINT32 Resampler::padding_device_to_game(UINT32 device_padding) const {
|
||||
if (this->dst_rate == 0) {
|
||||
return device_padding;
|
||||
}
|
||||
// round up so the reported free space stays conservative
|
||||
return (UINT32) std::ceil(((double) device_padding * this->src_rate) / this->dst_rate);
|
||||
}
|
||||
|
||||
HRESULT Resampler::get_buffer(UINT32 frames, BYTE **ppData) {
|
||||
const size_t needed = (size_t) frames * this->game_frame_size;
|
||||
if (this->scratch.size() < needed) {
|
||||
this->scratch.resize(needed);
|
||||
}
|
||||
|
||||
*ppData = this->scratch.data();
|
||||
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
void Resampler::enqueue_input(UINT32 frames, bool silent) {
|
||||
const int bps = this->bytes_per_sample;
|
||||
const int ch = this->channels;
|
||||
const size_t base = this->in_queue.size();
|
||||
|
||||
this->in_queue.resize(base + (size_t) frames * ch);
|
||||
|
||||
if (silent || bps <= 0 || ch <= 0) {
|
||||
std::fill(this->in_queue.begin() + base, this->in_queue.end(), 0.0f);
|
||||
return;
|
||||
}
|
||||
|
||||
const BYTE *src = this->scratch.data();
|
||||
for (UINT32 f = 0; f < frames; f++) {
|
||||
for (int c = 0; c < ch; c++) {
|
||||
const size_t s = (size_t) f * ch + c;
|
||||
this->in_queue[base + s] = read_sample(src + s * bps, bps, this->is_float);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Resampler::build_kernel() {
|
||||
const int taps = 2 * this->half_taps;
|
||||
const int phases = this->kernel_phases;
|
||||
const double cut = this->cutoff;
|
||||
const double radius = (double) this->half_taps;
|
||||
|
||||
// one extra row at frac == 1.0 so emit_frame can interpolate against row p + 1 safely
|
||||
this->kernel_table.resize((size_t) (phases + 1) * taps);
|
||||
|
||||
for (int p = 0; p <= phases; p++) {
|
||||
const double frac = (double) p / (double) phases;
|
||||
for (int k = 0; k < taps; k++) {
|
||||
// tap k maps to input offset t = k - (half_taps - 1), matching emit_frame
|
||||
const double x = frac - (double) (k - (this->half_taps - 1));
|
||||
this->kernel_table[(size_t) p * taps + k] =
|
||||
(float) (cut * sinc(cut * x) * blackman(x, radius));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Resampler::emit_frame() {
|
||||
const int ch = this->channels;
|
||||
const int radius = this->half_taps;
|
||||
const int taps = 2 * radius;
|
||||
const long avail = (long) (this->in_queue.size() / ch);
|
||||
const long center = (long) std::floor(this->in_pos);
|
||||
|
||||
// pick the two kernel rows bracketing this fractional position and the blend between them
|
||||
const double frac = this->in_pos - (double) center;
|
||||
const double fp = frac * (double) this->kernel_phases;
|
||||
const int p0 = (int) fp;
|
||||
const float blend = (float) (fp - (double) p0);
|
||||
const float *row0 = &this->kernel_table[(size_t) p0 * taps];
|
||||
const float *row1 = &this->kernel_table[(size_t) (p0 + 1) * taps];
|
||||
|
||||
// base input index for tap 0 (t = -(radius - 1))
|
||||
const long base = center - (radius - 1);
|
||||
|
||||
for (int c = 0; c < ch; c++) {
|
||||
double acc = 0.0;
|
||||
for (int k = 0; k < taps; k++) {
|
||||
const long idx = base + k;
|
||||
if (idx < 0 || idx >= avail) {
|
||||
continue;
|
||||
}
|
||||
const float w = row0[k] + blend * (row1[k] - row0[k]);
|
||||
acc += (double) this->in_queue[(size_t) idx * ch + c] * w;
|
||||
}
|
||||
this->out_float.push_back((float) acc);
|
||||
}
|
||||
}
|
||||
|
||||
void Resampler::drop_consumed() {
|
||||
const int ch = this->channels;
|
||||
const long drop = (long) std::floor(this->in_pos) - this->half_taps;
|
||||
if (drop > 0) {
|
||||
const size_t drop_samples = (size_t) drop * ch;
|
||||
if (drop_samples <= this->in_queue.size()) {
|
||||
this->in_queue.erase(this->in_queue.begin(),
|
||||
this->in_queue.begin() + drop_samples);
|
||||
this->in_pos -= drop;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UINT32 Resampler::produce_exact(UINT32 out_frames) {
|
||||
const int ch = this->channels;
|
||||
this->out_float.clear();
|
||||
if (ch <= 0 || out_frames == 0) {
|
||||
return 0;
|
||||
}
|
||||
this->out_float.reserve((size_t) out_frames * ch);
|
||||
|
||||
// resample ratio. drive it from the buffer size actually advertised to the game rather
|
||||
// than the nominal src/dst ratio: GetBufferSize reports floor(dev_buf * src/dst) game
|
||||
// frames, so the game only ever delivers that many input frames per device period.
|
||||
// consuming at the nominal ratio would eat slightly more input than arrives on any device
|
||||
// where dev_buf * src/dst is non-integer (e.g. 144 -> 132.3, floored to 132), slowly
|
||||
// draining the queue until it underruns to permanent silence. using the advertised integer
|
||||
// ratio keeps input and output exactly balanced; the resulting pitch error is below 0.3%
|
||||
// and inaudible, and it collapses to the exact ratio when the division is integer (160 ->
|
||||
// 147 stays 147/160 = 44100/48000).
|
||||
const double step = (double) this->frames_device_to_game(this->device_buffer_frames)
|
||||
/ (double) this->device_buffer_frames;
|
||||
|
||||
// input frames the block will touch: from in_pos through the right edge of the sinc kernel
|
||||
// at the final output sample. if the queue is short of this, the kernel tail reads past the
|
||||
// end and distorts every buffer, so buffer one extra block of input before the first output
|
||||
// (emitting silence without consuming) to build a cushion the kernel can always reach into.
|
||||
const long avail = (long) (this->in_queue.size() / ch);
|
||||
const long need = (long) std::ceil(this->in_pos + step * (double) out_frames)
|
||||
+ this->half_taps;
|
||||
|
||||
if (this->priming) {
|
||||
if (avail < need + (long) out_frames) {
|
||||
this->out_float.assign((size_t) out_frames * ch, 0.0f);
|
||||
return out_frames;
|
||||
}
|
||||
this->priming = false;
|
||||
}
|
||||
|
||||
for (UINT32 o = 0; o < out_frames; o++) {
|
||||
this->emit_frame();
|
||||
this->in_pos += step;
|
||||
}
|
||||
|
||||
this->drop_consumed();
|
||||
return out_frames;
|
||||
}
|
||||
|
||||
UINT32 Resampler::produce_variable() {
|
||||
const int ch = this->channels;
|
||||
if (ch <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// input frames consumed per output frame. timer-driven streams write variable partial
|
||||
// chunks, so produce however many output frames the currently queued input can fully
|
||||
// support and leave the rest for the next call; this keeps input and output balanced at
|
||||
// the exact src/dst ratio over time without depending on the device buffer size.
|
||||
const double step = (double) this->src_rate / (double) this->dst_rate;
|
||||
const long avail = (long) (this->in_queue.size() / ch);
|
||||
|
||||
// emit only while the sinc kernel's right edge stays within the queued input. the kernel
|
||||
// reaches from in_pos out to half_taps frames ahead, so stop once that would read past the
|
||||
// end; the remaining input becomes the next block's lookahead.
|
||||
UINT32 produced = 0;
|
||||
while ((long) std::ceil(this->in_pos) + this->half_taps < avail) {
|
||||
this->emit_frame();
|
||||
this->in_pos += step;
|
||||
produced++;
|
||||
}
|
||||
|
||||
this->drop_consumed();
|
||||
return produced;
|
||||
}
|
||||
|
||||
void Resampler::write_output(BYTE *dst, UINT32 frames, float gain) const {
|
||||
const int bps = this->bytes_per_sample;
|
||||
const int ch = this->channels;
|
||||
const size_t count = (size_t) frames * ch;
|
||||
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
write_sample(dst + i * bps, bps, this->is_float, this->out_float[i] * gain);
|
||||
}
|
||||
}
|
||||
|
||||
HRESULT Resampler::flush(IAudioRenderClient *real, IAudioClient *client, UINT32 frames,
|
||||
DWORD flags, float boost) {
|
||||
if (!this->enabled) {
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
// cache the device buffer size once
|
||||
if (this->device_buffer_frames == 0) {
|
||||
client->GetBufferSize(&this->device_buffer_frames);
|
||||
}
|
||||
if (this->device_buffer_frames == 0) {
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
const bool silent = (flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0;
|
||||
this->enqueue_input(frames, silent);
|
||||
|
||||
// confirm once that conversion actually started producing output
|
||||
static std::once_flag active_printed;
|
||||
std::call_once(active_printed, [this]() {
|
||||
log_info("audio::resample", "resample active: {} Hz -> {} Hz ({} ch, {})",
|
||||
this->src_rate, this->dst_rate, this->channels,
|
||||
this->event_driven ? "event-driven" : "timer-driven");
|
||||
});
|
||||
|
||||
// the boost is applied here (inside write_output) rather than in the standard ReleaseBuffer
|
||||
// path, so log it once for parity with that path's "volume boost active" line.
|
||||
if (boost != 1.0f) {
|
||||
static std::once_flag boost_printed;
|
||||
std::call_once(boost_printed, [boost]() {
|
||||
log_info("audio::resample", "volume boost active (resample): gain={}", boost);
|
||||
});
|
||||
}
|
||||
|
||||
return this->event_driven
|
||||
? this->flush_event(real, boost)
|
||||
: this->flush_timer(real, client, boost);
|
||||
}
|
||||
|
||||
HRESULT Resampler::flush_event(IAudioRenderClient *real, float boost) {
|
||||
|
||||
// event-driven exclusive streams must hand the device a full buffer every period and may
|
||||
// not push partial counts. resample the whole input block into exactly the device buffer
|
||||
// size.
|
||||
const UINT32 produced = this->produce_exact(this->device_buffer_frames);
|
||||
if (produced == 0) {
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
BYTE *dev = nullptr;
|
||||
HRESULT ret = real->GetBuffer(produced, &dev);
|
||||
if (FAILED(ret) || dev == nullptr) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
// mute the first few buffers to avoid a pop on stream start
|
||||
float gain = boost;
|
||||
if (this->buffers_to_mute > 0) {
|
||||
gain = 0.0f;
|
||||
this->buffers_to_mute--;
|
||||
}
|
||||
|
||||
this->write_output(dev, produced, gain);
|
||||
|
||||
return real->ReleaseBuffer(produced, 0);
|
||||
}
|
||||
|
||||
HRESULT Resampler::flush_timer(IAudioRenderClient *real, IAudioClient *client, float boost) {
|
||||
|
||||
// convert everything currently queued into the pending output FIFO (out_float). timer-
|
||||
// driven games write variable partial chunks, so produce only what the queued input can
|
||||
// fully support and keep the remainder for the next call.
|
||||
this->produce_variable();
|
||||
|
||||
const int ch = this->channels;
|
||||
if (ch <= 0) {
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
const UINT32 pending = (UINT32) (this->out_float.size() / ch);
|
||||
if (pending == 0) {
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
// push as many frames as the device currently has free, keeping the rest queued for the
|
||||
// next call. timer-driven games poll padding and write whenever there is room, so matching
|
||||
// the device's free space here avoids overflowing the ring while staying device-paced.
|
||||
UINT32 padding = 0;
|
||||
if (FAILED(client->GetCurrentPadding(&padding))) {
|
||||
return S_OK;
|
||||
}
|
||||
const UINT32 device_free = this->device_buffer_frames > padding
|
||||
? this->device_buffer_frames - padding
|
||||
: 0;
|
||||
if (device_free == 0) {
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
const UINT32 to_write = std::min(pending, device_free);
|
||||
|
||||
BYTE *dev = nullptr;
|
||||
HRESULT ret = real->GetBuffer(to_write, &dev);
|
||||
if (FAILED(ret) || dev == nullptr) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
// mute the first few buffers to avoid a pop on stream start
|
||||
float gain = boost;
|
||||
if (this->buffers_to_mute > 0) {
|
||||
gain = 0.0f;
|
||||
this->buffers_to_mute--;
|
||||
}
|
||||
|
||||
this->write_output(dev, to_write, gain);
|
||||
ret = real->ReleaseBuffer(to_write, 0);
|
||||
|
||||
// drop the frames just written from the front of the pending FIFO
|
||||
this->out_float.erase(this->out_float.begin(),
|
||||
this->out_float.begin() + (size_t) to_write * ch);
|
||||
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
#include <windows.h>
|
||||
#include <mmreg.h>
|
||||
#include <audioclient.h>
|
||||
|
||||
#include "hooks/audio/audio.h"
|
||||
|
||||
struct IAudioClient;
|
||||
struct IAudioRenderClient;
|
||||
|
||||
namespace hooks::audio {
|
||||
|
||||
// Streaming sample-rate converter for the WASAPI render path. The real device is opened at the
|
||||
// target rate while the game keeps writing its native-rate audio into a scratch buffer; on
|
||||
// release that buffer is converted with a windowed-sinc kernel and pushed to the device.
|
||||
// Channel count and sample format are preserved; only the sample rate changes.
|
||||
//
|
||||
// Frame counts differ between the two rates, so unlike the per-frame downmix this is stateful:
|
||||
// a fractional read position and a window of input history carry across ReleaseBuffer calls,
|
||||
// and the device buffer is only filled up to the space the device currently has free.
|
||||
struct Resampler {
|
||||
|
||||
// whether the resampler is active for the current stream
|
||||
bool enabled = false;
|
||||
|
||||
// whether the stream is event-driven (AUDCLNT_STREAMFLAGS_EVENTCALLBACK). timer-driven
|
||||
// streams instead poll padding and write variable partial chunks, so they drain the
|
||||
// pending output to the device's free space rather than pushing a full buffer per period.
|
||||
bool event_driven = true;
|
||||
|
||||
// decide whether the stream should be resampled and to which rate. returns the target rate
|
||||
// when RESAMPLE_RATE is set and differs from the game's rate, otherwise nullopt.
|
||||
static std::optional<uint32_t> resolve(const WAVEFORMATEX *game_format);
|
||||
|
||||
// enable resampling for game_format and fill device_out with the equivalent format at the
|
||||
// target rate to open the real device with.
|
||||
void setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *device_out,
|
||||
uint32_t target_rate);
|
||||
|
||||
// build the device format equivalent to game_format at target_rate (same channels/depth).
|
||||
static void make_device_format(const WAVEFORMATEX *game_format,
|
||||
WAVEFORMATEXTENSIBLE *device_out, uint32_t target_rate);
|
||||
|
||||
// initialize the real device at the target rate, performing the standard WASAPI buffer
|
||||
// realignment retry on AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED.
|
||||
HRESULT initialize(IAudioClient *real, AUDCLNT_SHAREMODE share_mode, DWORD stream_flags,
|
||||
REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity,
|
||||
const WAVEFORMATEX *device_format, LPCGUID session_guid);
|
||||
|
||||
// translate a device-rate frame count to the equivalent game-rate count, so the buffer-size
|
||||
// and padding values reported to the game stay paced at the game's native rate.
|
||||
UINT32 frames_device_to_game(UINT32 device_frames) const;
|
||||
UINT32 padding_device_to_game(UINT32 device_padding) const;
|
||||
|
||||
// hand the game a scratch buffer sized for `frames` of its native format to write into.
|
||||
HRESULT get_buffer(UINT32 frames, BYTE **ppData);
|
||||
|
||||
// pointer to the input scratch (sized by get_buffer). when chained after the downmix, the
|
||||
// downmix writes its stereo output here for the resampler to consume on the next flush.
|
||||
BYTE *input_data() { return this->scratch.data(); }
|
||||
|
||||
// convert the `frames` the game wrote and push output to the real render client. `boost`
|
||||
// is applied to the converted output. event-driven streams fill exactly one device buffer
|
||||
// per period; timer-driven streams push as many converted frames as the device has free.
|
||||
HRESULT flush(IAudioRenderClient *real, IAudioClient *client, UINT32 frames, DWORD flags,
|
||||
float boost);
|
||||
|
||||
private:
|
||||
|
||||
// append `frames` of the scratch buffer (native format), or silence, to the input queue
|
||||
void enqueue_input(UINT32 frames, bool silent);
|
||||
|
||||
// event-driven path: produce exactly one full device buffer and push it.
|
||||
HRESULT flush_event(IAudioRenderClient *real, float boost);
|
||||
|
||||
// timer-driven path: convert all queued input into the pending output FIFO, then push as
|
||||
// many frames as the device currently has free, keeping the remainder for the next call.
|
||||
HRESULT flush_timer(IAudioRenderClient *real, IAudioClient *client, float boost);
|
||||
|
||||
// produce exactly out_frames output frames using the fixed src/dst ratio. event-driven
|
||||
// exclusive streams must fill the whole device buffer every period; a small input cushion
|
||||
// is buffered first (see priming) so the sinc kernel always has lookahead.
|
||||
UINT32 produce_exact(UINT32 out_frames);
|
||||
|
||||
// convert all input the kernel can fully support into the pending output FIFO (out_float),
|
||||
// appending without clearing. returns the number of frames produced. used by the
|
||||
// timer-driven path where output is drained to the device in device-paced chunks.
|
||||
UINT32 produce_variable();
|
||||
|
||||
// convolve the windowed-sinc kernel at the current in_pos and append the resulting frame
|
||||
// (one sample per channel) to out_float
|
||||
void emit_frame();
|
||||
|
||||
// precompute the windowed-sinc kernel sampled at kernel_phases sub-sample positions, so
|
||||
// emit_frame is a table lookup instead of recomputing sin/cos per tap (which is far too
|
||||
// expensive to run per sample on the audio callback thread and causes underrun crackle).
|
||||
void build_kernel();
|
||||
|
||||
// drop input frames that in_pos has advanced past, keeping a window of history for the
|
||||
// next block's left context
|
||||
void drop_consumed();
|
||||
|
||||
// convert the first `frames` of out_float to the device format, scaled by `gain`
|
||||
void write_output(BYTE *dst, UINT32 frames, float gain) const;
|
||||
|
||||
// sample format of the stream
|
||||
int channels = 0;
|
||||
int bytes_per_sample = 0;
|
||||
bool is_float = false;
|
||||
int game_frame_size = 0;
|
||||
|
||||
uint32_t src_rate = 0;
|
||||
uint32_t dst_rate = 0;
|
||||
|
||||
// sinc low-pass cutoff (1.0 when upsampling, dst/src when downsampling) and window radius
|
||||
double cutoff = 1.0;
|
||||
int half_taps = 16;
|
||||
|
||||
// precomputed kernel: (kernel_phases + 1) rows of 2*half_taps weights, indexed by the
|
||||
// fractional sample position (linearly interpolated between adjacent rows in emit_frame)
|
||||
std::vector<float> kernel_table;
|
||||
int kernel_phases = 1024;
|
||||
|
||||
// interleaved float input queue and the fractional read position within it (in frames)
|
||||
std::vector<float> in_queue;
|
||||
double in_pos = 0.0;
|
||||
|
||||
// emit silence until a full block of input lookahead has accumulated, so the sinc kernel
|
||||
// never reads past the end of the queue (which would distort the tail of every buffer)
|
||||
bool priming = true;
|
||||
|
||||
// interleaved float scratch for produced output
|
||||
std::vector<float> out_float;
|
||||
|
||||
// buffer the game writes its native-rate audio into between get_buffer / flush
|
||||
std::vector<BYTE> scratch;
|
||||
|
||||
// cached device buffer size (frames); a full buffer is produced every period
|
||||
UINT32 device_buffer_frames = 0;
|
||||
|
||||
// leading buffers to silence to avoid a pop on stream start
|
||||
int buffers_to_mute = 16;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
#include "shared.h"
|
||||
|
||||
#include <audioclient.h>
|
||||
|
||||
#include "hooks/audio/audio.h"
|
||||
#include "util/logging.h"
|
||||
|
||||
#include "defs.h"
|
||||
|
||||
namespace hooks::audio {
|
||||
|
||||
// whether the engine's PCM converter can handle this format. PCM / float only; non-PCM
|
||||
// bitstream (AC-3 / DTS passthrough) must be left alone.
|
||||
static bool is_pcm_or_float(const WAVEFORMATEX *format) {
|
||||
if (format == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (format->wFormatTag) {
|
||||
case WAVE_FORMAT_PCM:
|
||||
case WAVE_FORMAT_IEEE_FLOAT:
|
||||
return true;
|
||||
case WAVE_FORMAT_EXTENSIBLE: {
|
||||
|
||||
// SubFormat is only valid when the extra-bytes block is large enough
|
||||
if (format->cbSize < sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
|
||||
return false;
|
||||
}
|
||||
const auto *ext = reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(format);
|
||||
return ext->SubFormat == GUID_KSDATAFORMAT_SUBTYPE_PCM
|
||||
|| ext->SubFormat == GUID_KSDATAFORMAT_SUBTYPE_IEEE_FLOAT;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool SharedRedirect::wants(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *format) {
|
||||
|
||||
// only redirect PCM / float exclusive streams: the engine converter (AUTOCONVERTPCM) can
|
||||
// handle those, but non-PCM bitstream (AC-3 / DTS passthrough) would fail in shared mode,
|
||||
// so leave it in exclusive untouched.
|
||||
return hooks::audio::WASAPI_COMPATIBILITY_MODE
|
||||
&& share_mode == AUDCLNT_SHAREMODE_EXCLUSIVE
|
||||
&& is_pcm_or_float(format);
|
||||
}
|
||||
|
||||
void SharedRedirect::apply(AUDCLNT_SHAREMODE *share_mode, DWORD *stream_flags,
|
||||
REFERENCE_TIME *periodicity) {
|
||||
|
||||
// shared mode requires periodicity == 0; AUTOCONVERTPCM lets the engine accept the game's
|
||||
// native format (else shared Initialize returns AUDCLNT_E_UNSUPPORTED_FORMAT).
|
||||
log_info("audio::wasapi", "redirecting exclusive WASAPI to shared mode");
|
||||
*share_mode = AUDCLNT_SHAREMODE_SHARED;
|
||||
*periodicity = 0;
|
||||
*stream_flags |= AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY;
|
||||
this->redirected_from_exclusive = true;
|
||||
}
|
||||
|
||||
UINT32 SharedRedirect::clamp_buffer_size(IAudioClient *real, uint32_t sample_rate,
|
||||
UINT32 device_frames) const {
|
||||
if (!this->redirected_from_exclusive || real == nullptr || sample_rate == 0 || device_frames == 0) {
|
||||
return device_frames;
|
||||
}
|
||||
|
||||
// GetDevicePeriod returns REFERENCE_TIME units (100 ns), 10^7 per second, so
|
||||
// period_frames = period * sample_rate / 10^7.
|
||||
REFERENCE_TIME period = 0;
|
||||
if (SUCCEEDED(real->GetDevicePeriod(&period, nullptr)) && period > 0) {
|
||||
const UINT32 period_frames = (UINT32) ((period * sample_rate) / 10000000);
|
||||
if (period_frames > 0 && period_frames < device_frames) {
|
||||
return period_frames;
|
||||
}
|
||||
}
|
||||
|
||||
return device_frames;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include <windows.h>
|
||||
#include <audioclient.h>
|
||||
|
||||
namespace hooks::audio {
|
||||
|
||||
// The -wasapishared option redirects an exclusive WASAPI stream to shared mode. lets other apps
|
||||
// play sound and works on devices that can't open the exclusive format, at the cost of some
|
||||
// latency. Only PCM / float is converted; bitstream (AC-3 / DTS) is left alone.
|
||||
struct SharedRedirect {
|
||||
|
||||
// true once apply() has redirected an exclusive request. gates the buffer clamp; stays false
|
||||
// for a natively-shared stream (it paces itself, so must not be clamped).
|
||||
bool redirected_from_exclusive = false;
|
||||
|
||||
// whether an exclusive-mode request should be redirected, given the -wasapishared option.
|
||||
// only PCM / float is eligible; bitstream (AC-3 / DTS) is left in exclusive mode.
|
||||
static bool wants(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *format);
|
||||
|
||||
// redirect an exclusive request to shared mode. caller must have checked wants() first.
|
||||
void apply(AUDCLNT_SHAREMODE *share_mode, DWORD *stream_flags, REFERENCE_TIME *periodicity);
|
||||
|
||||
// clamp a reported buffer size to one device period. some games write the whole buffer per
|
||||
// event, which overflows shared-mode buffering (AUDCLNT_E_BUFFER_TOO_LARGE -> stutter); one
|
||||
// period always fits. a no-op unless an exclusive request was redirected.
|
||||
UINT32 clamp_buffer_size(IAudioClient *real, uint32_t sample_rate, UINT32 device_frames) const;
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
#include <audioclient.h>
|
||||
|
||||
#include "hooks/audio/util.h"
|
||||
#include "util/flags_helper.h"
|
||||
#include "util/logging.h"
|
||||
|
||||
#include "defs.h"
|
||||
|
||||
@@ -18,3 +20,46 @@ std::string stream_flags_str(DWORD flags) {
|
||||
FLAG(flags, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY);
|
||||
FLAGS_END(flags);
|
||||
}
|
||||
|
||||
void print_format(AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration,
|
||||
REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format) {
|
||||
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(share_mode));
|
||||
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(stream_flags));
|
||||
log_info("audio::wasapi", "... hnsBufferDuration : {} ({:.3f} ms)",
|
||||
buffer_duration, buffer_duration / 10000.0);
|
||||
log_info("audio::wasapi", "... hnsPeriodicity : {} ({:.3f} ms)",
|
||||
periodicity, periodicity / 10000.0);
|
||||
print_format(device_format);
|
||||
}
|
||||
|
||||
void print_format(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *device_format) {
|
||||
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(share_mode));
|
||||
print_format(device_format);
|
||||
}
|
||||
|
||||
HRESULT initialize_with_alignment_retry(IAudioClient *client, const char *log_group,
|
||||
AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration,
|
||||
REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format, LPCGUID session_guid) {
|
||||
|
||||
HRESULT ret = client->Initialize(share_mode, stream_flags, buffer_duration, periodicity,
|
||||
device_format, session_guid);
|
||||
|
||||
// the requested buffer size can end up unaligned for the device; recover by asking for the next
|
||||
// aligned buffer size and re-initializing with a matching duration.
|
||||
if (ret == AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED) {
|
||||
UINT32 aligned_frames = 0;
|
||||
if (SUCCEEDED(client->GetBufferSize(&aligned_frames)) && aligned_frames > 0) {
|
||||
REFERENCE_TIME aligned_duration = (REFERENCE_TIME)
|
||||
(10000.0 * 1000 / device_format->nSamplesPerSec * aligned_frames + 0.5);
|
||||
|
||||
log_info(log_group, "buffer not aligned, retrying with {} frames ({} hns)",
|
||||
aligned_frames, aligned_duration);
|
||||
|
||||
ret = client->Initialize(share_mode, stream_flags, aligned_duration,
|
||||
periodicity != 0 ? aligned_duration : 0, device_format, session_guid);
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,100 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include <windows.h>
|
||||
#include <mmreg.h>
|
||||
#include <audioclient.h>
|
||||
|
||||
std::string stream_flags_str(DWORD flags);
|
||||
|
||||
// log the stream parameters (share mode, flags, buffer duration, periodicity) followed by the wave
|
||||
// format, matching the block printed at the top of IAudioClient::Initialize.
|
||||
void print_format(AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration,
|
||||
REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format);
|
||||
|
||||
// log the share mode followed by the wave format, for paths that only have a share mode (e.g.
|
||||
// IAudioClient::IsFormatSupported).
|
||||
void print_format(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *device_format);
|
||||
|
||||
// detect IEEE float samples: WAVE_FORMAT_IEEE_FLOAT, or WAVE_FORMAT_EXTENSIBLE whose SubFormat is
|
||||
// KSDATAFORMAT_SUBTYPE_IEEE_FLOAT (Data1 == 3; _PCM has Data1 == 1)
|
||||
inline bool is_ieee_float(const WAVEFORMATEX *fmt) {
|
||||
return fmt->wFormatTag == WAVE_FORMAT_IEEE_FLOAT
|
||||
|| (fmt->wFormatTag == WAVE_FORMAT_EXTENSIBLE
|
||||
&& reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(fmt)->SubFormat.Data1 == 0x00000003);
|
||||
}
|
||||
|
||||
// read one sample at `p` as a normalized float in [-1, 1]
|
||||
inline float read_sample(const BYTE *p, int bytes, bool is_float) {
|
||||
if (is_float) {
|
||||
float v;
|
||||
memcpy(&v, p, sizeof(float));
|
||||
return v;
|
||||
}
|
||||
switch (bytes) {
|
||||
case 2: {
|
||||
int16_t v;
|
||||
memcpy(&v, p, sizeof(v));
|
||||
return v * (1.0f / 32768.0f);
|
||||
}
|
||||
case 3: {
|
||||
int32_t v = p[0] | (p[1] << 8) | (p[2] << 16);
|
||||
if (v & 0x800000) {
|
||||
v |= ~0xFFFFFF; // sign extend
|
||||
}
|
||||
return v * (1.0f / 8388608.0f);
|
||||
}
|
||||
case 4: {
|
||||
int32_t v;
|
||||
memcpy(&v, p, sizeof(v));
|
||||
return (float) (v * (1.0 / 2147483648.0));
|
||||
}
|
||||
default:
|
||||
return 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
// write the normalized float `value` to the sample at `p`, clamping to the format's range
|
||||
inline void write_sample(BYTE *p, int bytes, bool is_float, float value) {
|
||||
if (is_float) {
|
||||
float v = std::clamp(value, -1.0f, 1.0f);
|
||||
memcpy(p, &v, sizeof(v));
|
||||
return;
|
||||
}
|
||||
switch (bytes) {
|
||||
case 2: {
|
||||
int16_t v = (int16_t) std::clamp(
|
||||
(int) std::lround(value * 32768.0f), -32768, 32767);
|
||||
memcpy(p, &v, sizeof(v));
|
||||
break;
|
||||
}
|
||||
case 3: {
|
||||
int32_t v = (int32_t) std::clamp<int64_t>(
|
||||
std::llround((double) value * 8388608.0), -8388608, 8388607);
|
||||
p[0] = v & 0xFF;
|
||||
p[1] = (v >> 8) & 0xFF;
|
||||
p[2] = (v >> 16) & 0xFF;
|
||||
break;
|
||||
}
|
||||
case 4: {
|
||||
int32_t v = (int32_t) std::clamp<int64_t>(
|
||||
std::llround((double) value * 2147483648.0), INT32_MIN, INT32_MAX);
|
||||
memcpy(p, &v, sizeof(v));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// initialize the real audio client, recovering from AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED by asking the
|
||||
// device for the next aligned buffer size and re-initializing with a matching duration. log_group
|
||||
// names the subsystem in the retry log line.
|
||||
HRESULT initialize_with_alignment_retry(IAudioClient *client, const char *log_group,
|
||||
AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration,
|
||||
REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format, LPCGUID session_guid);
|
||||
|
||||
@@ -48,33 +48,33 @@ void copy_wave_format(WAVEFORMATEXTENSIBLE *destination, const WAVEFORMATEX *sou
|
||||
}
|
||||
|
||||
void print_format(const WAVEFORMATEX *pFormat) {
|
||||
log_info("audio", "Wave Format:");
|
||||
log_info("audio::wasapi", "Wave Format:");
|
||||
|
||||
// format specific
|
||||
if (pFormat->wFormatTag == WAVE_FORMAT_EXTENSIBLE) {
|
||||
auto format = reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(pFormat);
|
||||
log_info("audio", "... SubFormat : {}", guid2s(format->SubFormat));
|
||||
log_info("audio::wasapi", "... SubFormat : {}", guid2s(format->SubFormat));
|
||||
} else {
|
||||
log_info("audio", "... wFormatTag : {}", pFormat->wFormatTag);
|
||||
log_info("audio::wasapi", "... wFormatTag : {}", pFormat->wFormatTag);
|
||||
}
|
||||
|
||||
// generic
|
||||
log_info("audio", "... nChannels : {}", pFormat->nChannels);
|
||||
log_info("audio", "... nSamplesPerSec : {}", pFormat->nSamplesPerSec);
|
||||
log_info("audio", "... nAvgBytesPerSec : {}", pFormat->nAvgBytesPerSec);
|
||||
log_info("audio", "... nBlockAlign : {}", pFormat->nBlockAlign);
|
||||
log_info("audio", "... wBitsPerSample : {}", pFormat->wBitsPerSample);
|
||||
log_info("audio::wasapi", "... nChannels : {}", pFormat->nChannels);
|
||||
log_info("audio::wasapi", "... nSamplesPerSec : {}", pFormat->nSamplesPerSec);
|
||||
log_info("audio::wasapi", "... nAvgBytesPerSec : {}", pFormat->nAvgBytesPerSec);
|
||||
log_info("audio::wasapi", "... nBlockAlign : {}", pFormat->nBlockAlign);
|
||||
log_info("audio::wasapi", "... wBitsPerSample : {}", pFormat->wBitsPerSample);
|
||||
|
||||
// format specific
|
||||
if (pFormat->wFormatTag == WAVE_FORMAT_EXTENSIBLE) {
|
||||
auto format = reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(pFormat);
|
||||
|
||||
if (pFormat->wBitsPerSample == 0) {
|
||||
log_info("audio", "... wSamplesPerBlock : {}", format->Samples.wSamplesPerBlock);
|
||||
log_info("audio::wasapi", "... wSamplesPerBlock : {}", format->Samples.wSamplesPerBlock);
|
||||
} else {
|
||||
log_info("audio", "... wValidBitsPerSample : {}", format->Samples.wValidBitsPerSample);
|
||||
log_info("audio::wasapi", "... wValidBitsPerSample : {}", format->Samples.wValidBitsPerSample);
|
||||
}
|
||||
|
||||
log_info("audio", "... dwChannelMask : {}", channel_mask_str(format->dwChannelMask));
|
||||
log_info("audio::wasapi", "... dwChannelMask : {}", channel_mask_str(format->dwChannelMask));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,27 @@ void poll_thread() {
|
||||
}
|
||||
}
|
||||
|
||||
// the overlay's imgui dx11 backend needs D3DCompile (d3dcompiler_XX.dll) to
|
||||
// build its shaders. _43 ships with the DX June 2010 redist on stock Win7;
|
||||
// _46/_47 come with newer Windows.
|
||||
bool d3dcompiler_available() {
|
||||
static const wchar_t *names[] = {
|
||||
L"d3dcompiler_47.dll",
|
||||
L"d3dcompiler_46.dll",
|
||||
L"d3dcompiler_43.dll",
|
||||
};
|
||||
for (auto name : names) {
|
||||
HMODULE mod = GetModuleHandleW(name);
|
||||
if (!mod) {
|
||||
mod = LoadLibraryW(name);
|
||||
}
|
||||
if (mod && GetProcAddress(mod, "D3DCompile")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void graphics_d3d11_init() {
|
||||
@@ -223,6 +244,14 @@ void graphics_d3d11_init() {
|
||||
return;
|
||||
}
|
||||
|
||||
// no d3dcompiler -> overlay can't build shaders; skip dx11 overlay
|
||||
if (!d3dcompiler_available()) {
|
||||
log_warning(
|
||||
"graphics::d3d11",
|
||||
"d3dcompiler not found; dx11 overlay disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(g_init_mutex);
|
||||
if (g_poll_thread.joinable()) {
|
||||
return; // already initialized
|
||||
|
||||
@@ -1535,6 +1535,9 @@ void graphics_d3d9_on_present(
|
||||
log_warning("graphics::d3d9",
|
||||
"failed to get back buffer, hr={}",
|
||||
FMT_HRESULT(hr));
|
||||
if (capture) {
|
||||
graphics_capture_skip(capture_screen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1545,6 +1548,9 @@ void graphics_d3d9_on_present(
|
||||
"failed to acquire back buffer descriptor, hr={}",
|
||||
FMT_HRESULT(hr));
|
||||
buffer->Release();
|
||||
if (capture) {
|
||||
graphics_capture_skip(capture_screen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1558,6 +1564,9 @@ void graphics_d3d9_on_present(
|
||||
"failed to acquire temporary surface, hr={}",
|
||||
FMT_HRESULT(hr));
|
||||
buffer->Release();
|
||||
if (capture) {
|
||||
graphics_capture_skip(capture_screen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1568,6 +1577,9 @@ void graphics_d3d9_on_present(
|
||||
FMT_HRESULT(hr));
|
||||
temp_surface->Release();
|
||||
buffer->Release();
|
||||
if (capture) {
|
||||
graphics_capture_skip(capture_screen);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include "graphics.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <set>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
@@ -61,6 +62,19 @@ static std::mutex GRAPHICS_CAPTURE_SCREENS_M {};
|
||||
static CaptureData GRAPHICS_CAPTURE_BUFFER[GRAPHICS_CAPTURE_SCREEN_NO] {};
|
||||
static std::mutex GRAPHICS_CAPTURE_BUFFER_M[GRAPHICS_CAPTURE_SCREEN_NO] {};
|
||||
static std::condition_variable GRAPHICS_CAPTURE_CV[GRAPHICS_CAPTURE_SCREEN_NO] {};
|
||||
static bool GRAPHICS_CAPTURE_SKIP_SIGNAL[GRAPHICS_CAPTURE_SCREEN_NO] {};
|
||||
static constexpr std::chrono::milliseconds GRAPHICS_CAPTURE_RECEIVE_TIMEOUT {2000};
|
||||
|
||||
static void graphics_capture_cancel_pending(int screen) {
|
||||
std::lock_guard<std::mutex> lock(GRAPHICS_CAPTURE_SCREENS_M);
|
||||
|
||||
for (auto it = GRAPHICS_CAPTURE_SCREENS.rbegin(); it != GRAPHICS_CAPTURE_SCREENS.rend(); ++it) {
|
||||
if (*it == screen) {
|
||||
GRAPHICS_CAPTURE_SCREENS.erase(std::next(it).base());
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static std::optional<graphics_orientation> target_orientation_on_boot;
|
||||
static UINT target_refresh_rate_on_boot = 0;
|
||||
@@ -1209,18 +1223,20 @@ void graphics_capture_trigger(int screen) {
|
||||
}
|
||||
|
||||
bool graphics_capture_consume(int *screen) {
|
||||
auto flag = !GRAPHICS_CAPTURE_SCREENS.empty();
|
||||
if (flag) {
|
||||
std::lock_guard<std::mutex> lock(GRAPHICS_CAPTURE_SCREENS_M);
|
||||
std::lock_guard<std::mutex> lock(GRAPHICS_CAPTURE_SCREENS_M);
|
||||
|
||||
*screen = GRAPHICS_CAPTURE_SCREENS.back();
|
||||
GRAPHICS_CAPTURE_SCREENS.pop_back();
|
||||
if (GRAPHICS_CAPTURE_SCREENS.empty()) {
|
||||
return false;
|
||||
}
|
||||
return flag;
|
||||
|
||||
*screen = GRAPHICS_CAPTURE_SCREENS.back();
|
||||
GRAPHICS_CAPTURE_SCREENS.pop_back();
|
||||
return true;
|
||||
}
|
||||
|
||||
void graphics_capture_enqueue(int screen, uint8_t *data, size_t width, size_t height) {
|
||||
GRAPHICS_CAPTURE_BUFFER_M[screen].lock();
|
||||
GRAPHICS_CAPTURE_SKIP_SIGNAL[screen] = false;
|
||||
auto &capture = GRAPHICS_CAPTURE_BUFFER[screen];
|
||||
capture.data.reset(data);
|
||||
capture.width = width;
|
||||
@@ -1231,6 +1247,14 @@ void graphics_capture_enqueue(int screen, uint8_t *data, size_t width, size_t he
|
||||
}
|
||||
|
||||
void graphics_capture_skip(int screen) {
|
||||
if (screen < 0 || screen >= static_cast<int>(GRAPHICS_CAPTURE_SCREEN_NO)) {
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(GRAPHICS_CAPTURE_BUFFER_M[screen]);
|
||||
GRAPHICS_CAPTURE_SKIP_SIGNAL[screen] = true;
|
||||
}
|
||||
GRAPHICS_CAPTURE_CV[screen].notify_one();
|
||||
}
|
||||
|
||||
@@ -1238,11 +1262,32 @@ bool graphics_capture_receive_jpeg(int screen, TooJpeg::WRITE_ONE_BYTE receiver,
|
||||
bool rgb, int quality, bool downsample, int divide, uint64_t *timestamp,
|
||||
int *width, int *height) {
|
||||
|
||||
// wait for capture event
|
||||
if (screen < 0 || screen >= static_cast<int>(GRAPHICS_CAPTURE_SCREEN_NO)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// wait for capture event (with timeout)
|
||||
std::unique_lock<std::mutex> lock(GRAPHICS_CAPTURE_BUFFER_M[screen]);
|
||||
GRAPHICS_CAPTURE_CV[screen].wait(lock, [screen] {
|
||||
return GRAPHICS_CAPTURE_BUFFER[screen].data != nullptr;
|
||||
});
|
||||
const bool ready = GRAPHICS_CAPTURE_CV[screen].wait_for(
|
||||
lock,
|
||||
GRAPHICS_CAPTURE_RECEIVE_TIMEOUT,
|
||||
[screen] {
|
||||
return GRAPHICS_CAPTURE_BUFFER[screen].data != nullptr
|
||||
|| GRAPHICS_CAPTURE_SKIP_SIGNAL[screen];
|
||||
});
|
||||
|
||||
if (!ready) {
|
||||
lock.unlock();
|
||||
graphics_capture_cancel_pending(screen);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (GRAPHICS_CAPTURE_SKIP_SIGNAL[screen]) {
|
||||
GRAPHICS_CAPTURE_SKIP_SIGNAL[screen] = false;
|
||||
lock.unlock();
|
||||
return false;
|
||||
}
|
||||
|
||||
auto &capture = GRAPHICS_CAPTURE_BUFFER[screen];
|
||||
auto capture_data = capture.data;
|
||||
auto capture_width = capture.width;
|
||||
|
||||
@@ -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::STEREO_DOWNMIX = WrappedAsio::StereoDownmix::Front;
|
||||
}
|
||||
if (options[launcher::Options::spice2x_SDVXSubPos].is_active()) {
|
||||
auto txt = options[launcher::Options::spice2x_SDVXSubPos].value_text();
|
||||
if (txt == "top") {
|
||||
@@ -683,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::GitaDoraArenaRealtekAccess].value_bool()) {
|
||||
games::gitadora::ALLOW_REALTEK_AUDIO = true;
|
||||
}
|
||||
if (options[launcher::Options::LoadNostalgiaModule].value_bool()) {
|
||||
attach_nostalgia = true;
|
||||
}
|
||||
@@ -703,6 +710,7 @@ int main_implementation(int argc, char *argv[]) {
|
||||
}
|
||||
if (options[launcher::Options::GitaDoraTwoChannelAudio].value_bool()) {
|
||||
games::gitadora::TWOCHANNEL = true;
|
||||
WrappedAsio::STEREO_DOWNMIX = WrappedAsio::StereoDownmix::Center;
|
||||
}
|
||||
if (options[launcher::Options::GitaDoraLefty].is_active()) {
|
||||
const auto text = options[launcher::Options::GitaDoraLefty].value_text();
|
||||
@@ -1099,6 +1107,12 @@ int main_implementation(int argc, char *argv[]) {
|
||||
auto &name = options[launcher::Options::DownmixAudioToStereo].value_text();
|
||||
hooks::audio::DOWNMIX_ALGORITHM = hooks::audio::Downmix::name_to_algorithm(name.c_str());
|
||||
}
|
||||
if (options[launcher::Options::AsioDownmixToStereo].is_active()) {
|
||||
// routes the selected pair onto the device's 2.0 front output; a non-None selection
|
||||
// implies force-two-channel (see WrappedAsio::force_two_channels)
|
||||
auto &name = options[launcher::Options::AsioDownmixToStereo].value_text();
|
||||
WrappedAsio::STEREO_DOWNMIX = WrappedAsio::name_to_stereo_downmix(name.c_str());
|
||||
}
|
||||
if (options[launcher::Options::VolumeBoost].is_active()) {
|
||||
const double decibels = std::strtod(
|
||||
options[launcher::Options::VolumeBoost].value_text().c_str(), nullptr);
|
||||
@@ -1106,6 +1120,21 @@ int main_implementation(int argc, char *argv[]) {
|
||||
hooks::audio::VOLUME_BOOST = (float) std::pow(10.0, decibels / 20.0);
|
||||
}
|
||||
}
|
||||
if (options[launcher::Options::AudioResample].is_active()) {
|
||||
const uint32_t rate = options[launcher::Options::AudioResample].value_uint32();
|
||||
if (rate > 0) {
|
||||
hooks::audio::RESAMPLE_RATE = rate;
|
||||
}
|
||||
}
|
||||
if (options[launcher::Options::AudioExclusiveBuffer].is_active()) {
|
||||
const uint32_t ms = options[launcher::Options::AudioExclusiveBuffer].value_uint32();
|
||||
if (ms > 0) {
|
||||
hooks::audio::EXCLUSIVE_BUFFER_MS = ms;
|
||||
}
|
||||
}
|
||||
if (options[launcher::Options::AudioShared].value_bool()) {
|
||||
hooks::audio::WASAPI_COMPATIBILITY_MODE = true;
|
||||
}
|
||||
|
||||
if (options[launcher::Options::AudioBackend].is_active()) {
|
||||
auto &name = options[launcher::Options::AudioBackend].value_text();
|
||||
@@ -1156,7 +1185,20 @@ int main_implementation(int argc, char *argv[]) {
|
||||
overlay::AUTO_SHOW_FPS = true;
|
||||
}
|
||||
if (options[launcher::Options::spice2x_FpsOpposite].value_bool()) {
|
||||
overlay::FPS_SHOULD_FLIP = true;
|
||||
// deprecated flag: equivalent to anchoring the FPS window top-left
|
||||
overlay::FPS_LOCATION = overlay::FpsLocation::TopLeft;
|
||||
}
|
||||
if (options[launcher::Options::FpsLocation].is_active()) {
|
||||
const auto txt = options[launcher::Options::FpsLocation].value_text();
|
||||
if (txt == "topright") {
|
||||
overlay::FPS_LOCATION = overlay::FpsLocation::TopRight;
|
||||
} else if (txt == "topleft") {
|
||||
overlay::FPS_LOCATION = overlay::FpsLocation::TopLeft;
|
||||
} else if (txt == "bottomleft") {
|
||||
overlay::FPS_LOCATION = overlay::FpsLocation::BottomLeft;
|
||||
} else if (txt == "bottomright") {
|
||||
overlay::FPS_LOCATION = overlay::FpsLocation::BottomRight;
|
||||
}
|
||||
}
|
||||
if (options[launcher::Options::spice2x_SubScreenAutoShow].value_bool()) {
|
||||
overlay::AUTO_SHOW_SUBSCREEN = true;
|
||||
@@ -1446,10 +1488,15 @@ int main_implementation(int argc, char *argv[]) {
|
||||
#else
|
||||
#ifdef SPICE64
|
||||
log_info("launcher", "SpiceTools Bootstrap (x64) (spice2x)");
|
||||
#elif SPICE32_LARGE_ADDRESS_AWARE
|
||||
log_info("launcher", "SpiceTools Bootstrap (x32 - Large Address Aware) (spice2x)");
|
||||
#else
|
||||
log_info("launcher", "SpiceTools Bootstrap (x32) (spice2x)");
|
||||
// spice.exe and spice_laa.exe share the same compiled objects; the only
|
||||
// difference is the large-address-aware bit set at link time. detect it
|
||||
// at runtime so the log line stays accurate without a separate compile.
|
||||
if (sysutils::is_large_address_aware()) {
|
||||
log_info("launcher", "SpiceTools Bootstrap (x32 - Large Address Aware) (spice2x)");
|
||||
} else {
|
||||
log_info("launcher", "SpiceTools Bootstrap (x32) (spice2x)");
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -1541,6 +1588,13 @@ int main_implementation(int argc, char *argv[]) {
|
||||
"!!! errors and loss of functionality. !!!\n"
|
||||
"!!! !!!\n"
|
||||
);
|
||||
deferredlogs::defer_error_messages({
|
||||
"-exec option disables all game-specific hooks and I/O emulation",
|
||||
" you must combine this with other flags or have appropriate arcade hardware",
|
||||
" this also turns off many auto-troubleshooter checks",
|
||||
" which means that this analysis will be incomplete",
|
||||
" you need to manually check the logs for failures"
|
||||
});
|
||||
}
|
||||
|
||||
if (launcher::signal::DISABLE && !cfg::CONFIGURATOR_STANDALONE) {
|
||||
@@ -1590,12 +1644,23 @@ int main_implementation(int argc, char *argv[]) {
|
||||
GRAPHICS_FS_ORIENTATION_SWAP = true;
|
||||
}
|
||||
|
||||
|
||||
// for cab usage - set environment variables (outside of -iidx module)
|
||||
if (games::iidx::DISABLE_CAMS.has_value() &&
|
||||
games::iidx::DISABLE_CAMS.value() &&
|
||||
!cfg::CONFIGURATOR_STANDALONE) {
|
||||
log_misc("launcher", "CONNECT_CAMERA env var set to 0");
|
||||
log_misc("launcher::iidx", "CONNECT_CAMERA env var set to 0");
|
||||
SetEnvironmentVariable("CONNECT_CAMERA", "0");
|
||||
}
|
||||
if (games::iidx::SOUND_OUTPUT_DEVICE.has_value() &&
|
||||
games::iidx::SOUND_OUTPUT_DEVICE.value() != "auto" &&
|
||||
!cfg::CONFIGURATOR_STANDALONE) {
|
||||
log_info(
|
||||
"launcher::iidx",
|
||||
"using user-supplied \"{}\" for SOUND_OUTPUT_DEVICE",
|
||||
games::iidx::SOUND_OUTPUT_DEVICE.value());
|
||||
SetEnvironmentVariable("SOUND_OUTPUT_DEVICE", games::iidx::SOUND_OUTPUT_DEVICE.value().c_str());
|
||||
}
|
||||
|
||||
// deleted options
|
||||
if (options[launcher::Options::OpenKFControl].value_bool() && !cfg::CONFIGURATOR_STANDALONE) {
|
||||
|
||||
+426
-215
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ namespace launcher {
|
||||
spice2x_Dx9On12,
|
||||
NoLegacy,
|
||||
RichPresence,
|
||||
DiscordAppID,
|
||||
SmartEAmusement,
|
||||
EAmusementMaintenance,
|
||||
spice2x_EAmusementMaintenance,
|
||||
@@ -56,6 +57,7 @@ namespace launcher {
|
||||
NotificationPosition,
|
||||
spice2x_FpsAutoShow,
|
||||
spice2x_FpsOpposite,
|
||||
FpsLocation,
|
||||
spice2x_SubScreenAutoShow,
|
||||
spice2x_IOPanelAutoShow,
|
||||
spice2x_KeypadAutoShow,
|
||||
@@ -94,6 +96,7 @@ namespace launcher {
|
||||
spice2x_SDVXDigitalKnobSensitivity,
|
||||
SDVXDigitalKnobSocd,
|
||||
spice2x_SDVXAsioDriver,
|
||||
SDVXAsioTwoChannel,
|
||||
spice2x_SDVXSubPos,
|
||||
SDVXSubMonitorOverride,
|
||||
LoadDDRModule,
|
||||
@@ -116,6 +119,7 @@ namespace launcher {
|
||||
GitaDoraArenaSingleWindow,
|
||||
GitaDoraArenaWindowLayout,
|
||||
GitaDoraArenaAsioDriver,
|
||||
GitaDoraArenaRealtekAccess,
|
||||
LoadJubeatModule,
|
||||
LoadReflecBeatModule,
|
||||
LoadShogikaiModule,
|
||||
@@ -195,12 +199,17 @@ namespace launcher {
|
||||
spice2x_NvapiProfile,
|
||||
DisableAudioHooks,
|
||||
spice2x_DisableVolumeHook,
|
||||
AudioShared,
|
||||
spice2x_LowLatencySharedAudio,
|
||||
AudioBackend,
|
||||
AsioDriverId,
|
||||
AsioDriverName,
|
||||
AudioDummy,
|
||||
DownmixAudioToStereo,
|
||||
VolumeBoost,
|
||||
AudioResample,
|
||||
AudioExclusiveBuffer,
|
||||
AsioDownmixToStereo,
|
||||
DelayBy5Seconds,
|
||||
spice2x_DelayByNSeconds,
|
||||
LoadStubs,
|
||||
@@ -210,7 +219,6 @@ namespace launcher {
|
||||
LogLevel,
|
||||
EAAutomap,
|
||||
EANetdump,
|
||||
DiscordAppID,
|
||||
BlockingLogger,
|
||||
DebugCreateFile,
|
||||
VerboseGraphicsLogging,
|
||||
@@ -266,7 +274,6 @@ namespace launcher {
|
||||
IIDXSubMonitorOverride,
|
||||
spice2x_IIDXEmulateSubscreenKeypadTouch,
|
||||
spice2x_AutoCard,
|
||||
spice2x_LowLatencySharedAudio,
|
||||
spice2x_TapeLedAlgorithm,
|
||||
spice2x_NoNVAPI,
|
||||
spice2x_NoD3D9DeviceHook,
|
||||
@@ -302,7 +309,11 @@ namespace launcher {
|
||||
|
||||
enum class OptionsCategory {
|
||||
Everything,
|
||||
Basic,
|
||||
GameOptions,
|
||||
Display,
|
||||
Audio,
|
||||
Network,
|
||||
Overlay,
|
||||
Advanced,
|
||||
Dev,
|
||||
API
|
||||
@@ -312,7 +323,9 @@ namespace launcher {
|
||||
extern bool USE_CMD_OVERRIDE;
|
||||
|
||||
const std::vector<std::string> &get_categories(Options::OptionsCategory category);
|
||||
const std::vector<std::string> &get_quick_setting_categories();
|
||||
const std::vector<OptionDefinition> &get_option_definitions();
|
||||
void validate_option_categories();
|
||||
std::unique_ptr<std::vector<Option>> parse_options(int argc, char *argv[]);
|
||||
std::vector<Option> merge_options(const std::vector<Option> &options, const std::vector<Option> &overrides);
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ namespace ImGui {
|
||||
|
||||
void DummyMarker() {
|
||||
// dummy marker that is the same width as HelpMarker/WarnMarker.
|
||||
ImGui::Dummy(ImVec2(22, 0));
|
||||
// "(?)" and "(!)" render to the same width, so calc it so the spacing
|
||||
// tracks the current font size/scale instead of a fixed pixel count.
|
||||
ImGui::Dummy(ImVec2(ImGui::CalcTextSize("(?)").x, 0));
|
||||
}
|
||||
|
||||
void Knob(float fraction, float size, float thickness, float pos_x, float pos_y) {
|
||||
|
||||
@@ -146,6 +146,17 @@ namespace overlay::notifications {
|
||||
|
||||
float height = 0.f;
|
||||
if (ImGui::Begin(window_id.c_str(), nullptr, TOAST_FLAGS)) {
|
||||
// keep toasts above other overlay windows (e.g. the persistent FPS
|
||||
// window, which may be toggled on after a toast already exists), but
|
||||
// tuck them behind a blocking modal so they get dimmed/occluded by the
|
||||
// modal backdrop instead of floating on top of it.
|
||||
ImGuiWindow *toast_window = ImGui::GetCurrentWindow();
|
||||
if (ImGuiWindow *modal = ImGui::GetTopMostPopupModal()) {
|
||||
ImGui::BringWindowToDisplayBehind(toast_window, modal);
|
||||
} else {
|
||||
ImGui::BringWindowToDisplayFront(toast_window);
|
||||
}
|
||||
|
||||
const ImVec2 win_pos = ImGui::GetWindowPos();
|
||||
const ImVec2 win_size = ImGui::GetWindowSize();
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ namespace overlay {
|
||||
bool AUTO_SHOW_KEYPAD_P1 = false;
|
||||
bool AUTO_SHOW_KEYPAD_P2 = false;
|
||||
bool USE_WM_CHAR_FOR_IMGUI_CHAR_INPUT = false;
|
||||
bool FPS_SHOULD_FLIP = false;
|
||||
FpsLocation FPS_LOCATION = FpsLocation::TopRight;
|
||||
std::optional<uint32_t> UI_SCALE_PERCENT;
|
||||
|
||||
// global
|
||||
@@ -298,6 +298,7 @@ void overlay::SpiceOverlay::init() {
|
||||
colors[ImGuiCol_Separator] = ImVec4(0.32f, 0.22f, 0.22f, 1.00f);
|
||||
colors[ImGuiCol_SeparatorHovered] = ImVec4(0.42f, 0.22f, 0.22f, 1.00f);
|
||||
colors[ImGuiCol_SeparatorActive] = ImVec4(0.52f, 0.22f, 0.22f, 1.00f);
|
||||
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(1.00f, 1.00f, 1.00f, 0.5f);
|
||||
|
||||
#ifdef IMGUI_HAS_DOCK
|
||||
colors[ImGuiCol_DockingPreview] = ImVec4(0.85f, 0.15f, 0.15f, 0.40f);
|
||||
@@ -307,16 +308,10 @@ void overlay::SpiceOverlay::init() {
|
||||
// configure IO
|
||||
auto &io = ImGui::GetIO();
|
||||
io.UserData = this;
|
||||
io.ConfigFlags = ImGuiConfigFlags_NavEnableKeyboard
|
||||
| ImGuiConfigFlags_NavEnableGamepad
|
||||
| ImGuiConfigFlags_NavEnableSetMousePos;
|
||||
|
||||
io.ConfigFlags = ImGuiConfigFlags_None;
|
||||
if (!cfg::CONFIGURATOR_STANDALONE) {
|
||||
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
|
||||
}
|
||||
if (is_touch_available("SpiceOverlay::init")) {
|
||||
io.ConfigFlags |= ImGuiConfigFlags_IsTouchScreen;
|
||||
}
|
||||
|
||||
// temporarily turn this off as it can cause crashes during font load failures
|
||||
// turns back on in ImGui_ImplSpice_Init
|
||||
@@ -396,14 +391,14 @@ void overlay::SpiceOverlay::init() {
|
||||
|
||||
bool set_overlay_active = false;
|
||||
|
||||
// referenced windows
|
||||
this->window_add(window_fps = new overlay::windows::FPS(this));
|
||||
// owned separately from `windows` so it never affects overlay activation/input gating
|
||||
window_fps = std::make_unique<overlay::windows::FPS>(this);
|
||||
if (!cfg::CONFIGURATOR_STANDALONE && AUTO_SHOW_FPS) {
|
||||
window_fps->set_active(true);
|
||||
set_overlay_active = true;
|
||||
}
|
||||
|
||||
this->window_add(window_main_menu = new overlay::windows::ExitPrompt(this));
|
||||
// owned separately from `windows` so it is not part of the overlay window layer
|
||||
window_main_menu = std::make_unique<overlay::windows::ExitPrompt>(this);
|
||||
|
||||
// add default windows
|
||||
this->window_add(window_config = new overlay::windows::Config(this));
|
||||
@@ -544,9 +539,13 @@ void overlay::SpiceOverlay::new_frame() {
|
||||
const bool draw_notifications = this->renderer != OverlayRenderer::SOFTWARE
|
||||
&& overlay::notifications::has_pending();
|
||||
|
||||
// persistent FPS window: drawn whenever active, independent of the overlay
|
||||
const bool draw_fps_persistent = this->renderer != OverlayRenderer::SOFTWARE
|
||||
&& this->window_fps->get_active();
|
||||
|
||||
// check if there is nothing to draw
|
||||
this->has_pending_frame = false;
|
||||
if (!this->active && !draw_notifications) {
|
||||
if (!this->active && !draw_notifications && !draw_fps_persistent) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -577,11 +576,17 @@ void overlay::SpiceOverlay::new_frame() {
|
||||
window->build();
|
||||
}
|
||||
|
||||
// draw the main menu on top of the overlay windows
|
||||
this->window_main_menu->build();
|
||||
|
||||
if (SHOW_DEBUG_LOG_WINDOW) {
|
||||
ImGui::ShowDebugLogWindow(&SHOW_DEBUG_LOG_WINDOW);
|
||||
}
|
||||
}
|
||||
|
||||
if (draw_fps_persistent) {
|
||||
this->window_fps->build();
|
||||
}
|
||||
// draw notifications last so they paint on top of any overlay windows
|
||||
if (draw_notifications) {
|
||||
overlay::notifications::draw();
|
||||
@@ -750,13 +755,19 @@ void overlay::SpiceOverlay::d3d9_render_draw(const bool force_submit) {
|
||||
|
||||
void overlay::SpiceOverlay::update() {
|
||||
|
||||
// check overlay toggle
|
||||
// there are three layers -
|
||||
// bottommost layer - FPS, notifications (non-interactable)
|
||||
// overlay layer - most windows
|
||||
// topmost layer - main menu (popup)
|
||||
|
||||
auto overlay_buttons = games::get_buttons_overlay(eamuse_get_game());
|
||||
bool toggle_down_new = overlay_buttons
|
||||
|
||||
// check overlay toggle
|
||||
const bool toggle_down_new = overlay_buttons
|
||||
&& this->hotkeys_triggered()
|
||||
&& GameAPI::Buttons::getState(RI_MGR, overlay_buttons->at(games::OverlayButtons::ToggleOverlay));
|
||||
&& GameAPI::Buttons::getState(RI_MGR, overlay_buttons->at(games::OverlayButtons::ToggleAllWindows));
|
||||
if (toggle_down_new && !this->toggle_down) {
|
||||
toggle_active(true);
|
||||
toggle_active();
|
||||
}
|
||||
this->toggle_down = toggle_down_new;
|
||||
|
||||
@@ -769,17 +780,35 @@ void overlay::SpiceOverlay::update() {
|
||||
}
|
||||
this->main_menu_down = main_menu_down_new;
|
||||
|
||||
// check FPS toggle - controls the persistent FPS window only, never the overlay
|
||||
const auto fps_down_new = overlay_buttons
|
||||
&& this->hotkeys_triggered()
|
||||
&& GameAPI::Buttons::getState(RI_MGR, overlay_buttons->at(games::OverlayButtons::ToggleFps));
|
||||
if (fps_down_new && !this->fps_down) {
|
||||
this->window_fps->toggle_active();
|
||||
}
|
||||
this->fps_down = fps_down_new;
|
||||
|
||||
// update windows
|
||||
for (auto &window : this->windows) {
|
||||
window->update();
|
||||
}
|
||||
|
||||
// deactivate if no windows are shown
|
||||
bool window_active = false;
|
||||
for (auto &window : this->windows) {
|
||||
if (window->get_active()) {
|
||||
window_active = true;
|
||||
break;
|
||||
// FPS window
|
||||
this->window_fps->update();
|
||||
|
||||
// main menu (owned separately from the overlay window layer)
|
||||
this->window_main_menu->update();
|
||||
|
||||
// deactivate if nothing is shown - the main menu keeps the overlay active
|
||||
// while open even though it is not part of `windows`
|
||||
bool window_active = this->window_main_menu->get_active();
|
||||
if (!window_active) {
|
||||
for (auto &window : this->windows) {
|
||||
if (window->get_active()) {
|
||||
window_active = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!window_active) {
|
||||
@@ -791,20 +820,9 @@ bool overlay::SpiceOverlay::update_cursor() {
|
||||
return ImGui_ImplSpice_UpdateMouseCursor();
|
||||
}
|
||||
|
||||
void overlay::SpiceOverlay::toggle_active(bool overlay_key) {
|
||||
|
||||
void overlay::SpiceOverlay::toggle_active() {
|
||||
// invert active state
|
||||
this->active = !this->active;
|
||||
|
||||
// get rid of main menu if it was visible
|
||||
if (this->window_main_menu) {
|
||||
this->window_main_menu->set_active(false);
|
||||
}
|
||||
|
||||
// show FPS window if toggled with overlay key
|
||||
if (overlay_key) {
|
||||
this->window_fps->set_active(this->active);
|
||||
}
|
||||
}
|
||||
|
||||
void overlay::SpiceOverlay::show_main_menu() {
|
||||
@@ -814,12 +832,20 @@ void overlay::SpiceOverlay::show_main_menu() {
|
||||
if (this->window_main_menu->get_active()) {
|
||||
// window already visible - close the window
|
||||
this->window_main_menu->set_active(false);
|
||||
return;
|
||||
}
|
||||
if (ImGui::IsPopupOpen(0, ImGuiPopupFlags_AnyPopup)) {
|
||||
// if the overlay was not visible when this came into view, turn off overlay as well
|
||||
if (!this->overlay_active_when_main_menu_shown) {
|
||||
this->set_active(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// don't open on top of another genuinely-visible popup, but ONLY guard while
|
||||
// the overlay is active
|
||||
if (this->get_active() && ImGui::IsPopupOpen(0, ImGuiPopupFlags_AnyPopup)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this->overlay_active_when_main_menu_shown = this->get_active();
|
||||
if (this->get_active()) {
|
||||
if (!ImGui::IsAnyItemActive() && !ImGui::IsAnyItemFocused()) {
|
||||
this->window_main_menu->set_active(true);
|
||||
@@ -847,9 +873,8 @@ bool overlay::SpiceOverlay::has_focus() {
|
||||
}
|
||||
|
||||
bool overlay::SpiceOverlay::hotkeys_triggered() {
|
||||
|
||||
// check if disabled first
|
||||
if (!this->hotkeys_enable) {
|
||||
// prevent hotkeys in spicecfg
|
||||
if (cfg::CONFIGURATOR_STANDALONE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,14 @@ namespace overlay {
|
||||
SOFTWARE,
|
||||
};
|
||||
|
||||
// corner of the screen the FPS / clock / timer window is anchored to
|
||||
enum class FpsLocation {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
};
|
||||
|
||||
// settings
|
||||
extern bool ENABLED;
|
||||
extern bool AUTO_SHOW_FPS;
|
||||
@@ -37,7 +45,7 @@ namespace overlay {
|
||||
extern bool AUTO_SHOW_KEYPAD_P1;
|
||||
extern bool AUTO_SHOW_KEYPAD_P2;
|
||||
extern bool USE_WM_CHAR_FOR_IMGUI_CHAR_INPUT;
|
||||
extern bool FPS_SHOULD_FLIP;
|
||||
extern FpsLocation FPS_LOCATION;
|
||||
extern bool SHOW_DEBUG_LOG_WINDOW;
|
||||
extern std::optional<uint32_t> UI_SCALE_PERCENT;
|
||||
|
||||
@@ -57,10 +65,8 @@ namespace overlay {
|
||||
|
||||
D3DDEVICE_CREATION_PARAMETERS creation_parameters {};
|
||||
D3DADAPTER_IDENTIFIER9 adapter_identifier {};
|
||||
bool hotkeys_enable = true;
|
||||
|
||||
// windows
|
||||
Window *window_fps = nullptr;
|
||||
Window *window_iopanel = nullptr;
|
||||
Window *window_config = nullptr;
|
||||
Window *window_keypad1 = nullptr;
|
||||
@@ -72,6 +78,15 @@ namespace overlay {
|
||||
Window *window_sub = nullptr;
|
||||
Window *window_log = nullptr;
|
||||
|
||||
// not part of `windows`: drawn/updated on the persistent layer (like
|
||||
// notifications), independent of the overlay's active state and input gates.
|
||||
std::unique_ptr<Window> window_fps;
|
||||
|
||||
// not part of `windows`: the main menu / launcher. owned and drawn
|
||||
// separately from the overlay window layer; it drives the overlay's
|
||||
// active state while shown so its buttons still receive input.
|
||||
std::unique_ptr<Window> window_main_menu;
|
||||
|
||||
explicit SpiceOverlay(HWND hWnd, IDirect3D9 *d3d, IDirect3DDevice9 *device);
|
||||
#ifdef SPICE_D3D11
|
||||
explicit SpiceOverlay(HWND hWnd, ID3D11Device *d3d11_device,
|
||||
@@ -87,7 +102,7 @@ namespace overlay {
|
||||
// after render() when the frame is being presented. In-game overlay draws in render().
|
||||
void d3d9_render_draw(bool force_submit = false);
|
||||
void update();
|
||||
void toggle_active(bool overlay_key = false);
|
||||
void toggle_active();
|
||||
void show_main_menu();
|
||||
void set_active(bool active);
|
||||
bool get_active();
|
||||
@@ -182,13 +197,13 @@ namespace overlay {
|
||||
|
||||
std::vector<std::unique_ptr<Window>> windows;
|
||||
|
||||
Window *window_main_menu = nullptr;
|
||||
|
||||
std::function<bool(LONG *, LONG *)> subscreen_mouse_handler = nullptr;
|
||||
|
||||
bool active = false;
|
||||
bool toggle_down = false;
|
||||
bool main_menu_down = false;
|
||||
bool overlay_active_when_main_menu_shown = false;
|
||||
bool fps_down = false;
|
||||
bool hotkey_toggle = false;
|
||||
bool hotkey_toggle_last = false;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "util/logging.h"
|
||||
#include "games/io.h"
|
||||
#include "misc/eamuse.h"
|
||||
#include "external/imgui/imgui_internal.h"
|
||||
|
||||
|
||||
overlay::Window::Window(SpiceOverlay *overlay) : overlay(overlay) {
|
||||
@@ -30,11 +31,23 @@ void overlay::Window::update() {
|
||||
|
||||
// if the overlay is hidden just reactivate it
|
||||
if (!this->overlay->get_active()) {
|
||||
|
||||
// but don't let the main menu occlude it if it was previously visible
|
||||
if (this->overlay->window_main_menu) {
|
||||
this->overlay->window_main_menu->set_active(false);
|
||||
}
|
||||
|
||||
this->active = true;
|
||||
this->overlay->set_active(true);
|
||||
} else {
|
||||
this->toggle_active();
|
||||
}
|
||||
|
||||
// raise to the top, but only because the user pressed the hotkey and
|
||||
// the window is now visible
|
||||
if (this->active) {
|
||||
this->bring_to_front();
|
||||
}
|
||||
}
|
||||
this->toggle_button_state = toggle_button_new;
|
||||
}
|
||||
@@ -81,14 +94,29 @@ void overlay::Window::build() {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
||||
}
|
||||
|
||||
// create window
|
||||
if (ImGui::Begin(
|
||||
(this->title + "###" + to_string(this)).c_str(),
|
||||
&this->active,
|
||||
this->flags)) {
|
||||
const bool custom_window_padding =
|
||||
!this->remove_window_padding && this->window_padding.x >= 0.f;
|
||||
if (custom_window_padding) {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, this->window_padding);
|
||||
}
|
||||
|
||||
// window attributes
|
||||
this->calculate_initial_window();
|
||||
// create window
|
||||
// NoFocusOnAppearing: when the overlay is re-shown every window reappears at
|
||||
// once and ImGui would auto-focus whichever is submitted last, stealing the
|
||||
// top spot. suppress that so only an explicit focus request (see below)
|
||||
// decides what comes to the front.
|
||||
const std::string window_id = this->title + "###" + to_string(this);
|
||||
if (ImGui::Begin(
|
||||
window_id.c_str(),
|
||||
&this->active,
|
||||
this->flags | ImGuiWindowFlags_NoFocusOnAppearing)) {
|
||||
|
||||
// window attributes - init_pos / init_size are only honored once
|
||||
// (ImGuiCond_Once), so compute them a single time instead of every frame
|
||||
if (!this->initial_window_calculated) {
|
||||
this->initial_window_calculated = true;
|
||||
this->calculate_initial_window();
|
||||
}
|
||||
ImGui::SetWindowPos(this->init_pos, ImGuiCond_Once);
|
||||
ImGui::SetWindowSize(this->init_size, ImGuiCond_Once);
|
||||
|
||||
@@ -103,12 +131,27 @@ void overlay::Window::build() {
|
||||
// end window
|
||||
ImGui::End();
|
||||
|
||||
// apply an explicit focus request now that the window exists. FocusWindow
|
||||
// with UnlessBelowModal raises it to the front, but keeps it right below
|
||||
// any blocking popup/modal instead of jumping in front of it (this also
|
||||
// re-orders brand-new windows that ImGui places on top by default).
|
||||
if (this->request_focus) {
|
||||
this->request_focus = false;
|
||||
if (ImGuiWindow *w = ImGui::FindWindowByName(window_id.c_str())) {
|
||||
ImGui::FocusWindow(w, ImGuiFocusRequestFlags_UnlessBelowModal);
|
||||
}
|
||||
}
|
||||
|
||||
if (this->remove_window_padding) {
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleVar();
|
||||
}
|
||||
|
||||
if (custom_window_padding) {
|
||||
ImGui::PopStyleVar();
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
// add raw content
|
||||
@@ -142,6 +185,10 @@ void overlay::Window::set_active(bool active) {
|
||||
}
|
||||
}
|
||||
|
||||
void overlay::Window::bring_to_front() {
|
||||
this->request_focus = true;
|
||||
}
|
||||
|
||||
bool overlay::Window::get_active() {
|
||||
|
||||
// check for active children
|
||||
|
||||
@@ -24,15 +24,26 @@ namespace overlay {
|
||||
void toggle_active();
|
||||
void set_active(bool active);
|
||||
bool get_active();
|
||||
|
||||
// raise this window to the top of the z-order on its next build. only
|
||||
// call in response to an explicit user action (keyboard toggle, UI
|
||||
// button) - not for auto-show / programmatic shows.
|
||||
void bring_to_front();
|
||||
protected:
|
||||
|
||||
// state
|
||||
SpiceOverlay *overlay;
|
||||
bool active = false;
|
||||
// set when the window transitions to visible so build() raises it to the
|
||||
// top of the z-order on the next frame
|
||||
bool request_focus = false;
|
||||
std::vector<Window*> children;
|
||||
|
||||
// settings
|
||||
bool remove_window_padding = false;
|
||||
// custom inner window padding, applied before Begin() (preserving border /
|
||||
// rounding) when x >= 0; ignored if remove_window_padding is set
|
||||
ImVec2 window_padding = ImVec2(-1, -1);
|
||||
bool draws_window = true;
|
||||
ImGuiSizeCallback resize_callback = nullptr;
|
||||
std::string title = "Title";
|
||||
@@ -41,6 +52,8 @@ namespace overlay {
|
||||
bool toggle_button_state = false;
|
||||
|
||||
// init settings
|
||||
// calculate_initial_window() runs only once; results feed ImGuiCond_Once
|
||||
bool initial_window_calculated = false;
|
||||
ImVec2 init_pos = ImVec2(0, 0);
|
||||
ImVec2 init_size = ImVec2(0, 0);
|
||||
ImVec2 size_min = ImVec2(0, 0);
|
||||
|
||||
@@ -166,7 +166,7 @@ namespace overlay::windows {
|
||||
if (ImGui::BeginPopupModal("Card Editor", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
// card ID field (only editable for new cards)
|
||||
ImGui::BeginDisabled(this->current_card);
|
||||
ImGui::InputTextWithHint("Card ID", "E0040123456789AB",
|
||||
ImGui::InputTextWithHint("Card ID", "E0040100FFFFFFFF",
|
||||
this->card_buffer,
|
||||
std::size(this->card_buffer),
|
||||
ImGuiInputTextFlags_CharsHexadecimal | ImGuiInputTextFlags_CharsUppercase);
|
||||
@@ -385,10 +385,16 @@ namespace overlay::windows {
|
||||
ImGui::Spacing();
|
||||
|
||||
// cards list
|
||||
// use all available vertical space, minus height footer (a row of buttons and separator)
|
||||
// use all available vertical space, minus the footer that follows in
|
||||
// build_content(): a separator + spacing, then a row of buttons
|
||||
const ImGuiStyle &style = ImGui::GetStyle();
|
||||
const float footer_height =
|
||||
style.ItemSpacing.y * 3.f // spacing around the separator and before the footer
|
||||
+ 1.f // separator line
|
||||
+ ImGui::GetFrameHeightWithSpacing(); // footer button row
|
||||
if (ImGui::BeginChild(
|
||||
"cards",
|
||||
ImVec2(0, ImGui::GetContentRegionAvail().y - ImGui::GetFrameHeightWithSpacing() - 8.f))) {
|
||||
ImVec2(0, ImGui::GetContentRegionAvail().y - footer_height))) {
|
||||
|
||||
// -card0 / -card1 override
|
||||
for (size_t i = 0; i < 2; i++) {
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace overlay::windows {
|
||||
|
||||
struct CardEntry {
|
||||
std::string name = "unnamed";
|
||||
std::string id = "E004010000000000";
|
||||
std::string id = "E0040100FFFFFFFF";
|
||||
std::string search_string = "";
|
||||
bool read_only = false;
|
||||
float color[3] {};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,20 +12,24 @@
|
||||
|
||||
namespace overlay::windows {
|
||||
|
||||
// top-level tabs in the configuration window
|
||||
enum class ConfigTab {
|
||||
CONFIG_TAB_INVALID,
|
||||
CONFIG_TAB_BUTTONS,
|
||||
CONFIG_TAB_ANALOGS,
|
||||
CONFIG_TAB_OVERLAY,
|
||||
CONFIG_TAB_LIGHTS,
|
||||
CONFIG_TAB_CONTROLLER,
|
||||
CONFIG_TAB_CARDS,
|
||||
CONFIG_TAB_PATCHES,
|
||||
CONFIG_TAB_API,
|
||||
CONFIG_TAB_OPTIONS,
|
||||
CONFIG_TAB_ADVANCED,
|
||||
CONFIG_TAB_DEV,
|
||||
CONFIG_TAB_PRESETS,
|
||||
CONFIG_TAB_SEARCH,
|
||||
};
|
||||
|
||||
// sub-pages shown in the Controller tab's left navigation
|
||||
enum class ControllerPage {
|
||||
CONTROLLER_PAGE_INVALID,
|
||||
CONTROLLER_PAGE_BUTTONS,
|
||||
CONTROLLER_PAGE_KEYPADS,
|
||||
CONTROLLER_PAGE_ANALOGS,
|
||||
CONTROLLER_PAGE_OVERLAY,
|
||||
CONTROLLER_PAGE_LIGHTS,
|
||||
CONTROLLER_PAGE_PRESETS,
|
||||
};
|
||||
|
||||
struct MatchEntry {
|
||||
@@ -45,8 +49,9 @@ namespace overlay::windows {
|
||||
std::vector<Game> games_list;
|
||||
std::vector<const char *> games_names;
|
||||
|
||||
// tabs ui
|
||||
// currently selected top-level tab and Controller sub-page
|
||||
ConfigTab tab_selected = ConfigTab::CONFIG_TAB_INVALID;
|
||||
ControllerPage controller_page_selected = ControllerPage::CONTROLLER_PAGE_INVALID;
|
||||
|
||||
// buttons tab
|
||||
bool buttons_keyboard_state[0xFF];
|
||||
@@ -134,6 +139,18 @@ namespace overlay::windows {
|
||||
std::string search_filter = "";
|
||||
std::string search_filter_in_lower_case = "";
|
||||
|
||||
// Options tab left-nav: selected group, currently highlighted category, and a pending scroll
|
||||
std::string options_group_selected = "";
|
||||
std::string options_category_selected = "";
|
||||
bool options_scroll_pending = false;
|
||||
bool options_scroll_top = false;
|
||||
|
||||
// Controller tab left-nav: selected sub-page
|
||||
std::string controller_page_label = "";
|
||||
|
||||
// Cards tab left-nav: selected sub-page
|
||||
std::string cards_page_label = "";
|
||||
|
||||
std::filesystem::path file_picker_path;
|
||||
std::thread *file_picker_thread = nullptr;
|
||||
bool file_picker_done = false;
|
||||
@@ -186,11 +203,18 @@ namespace overlay::windows {
|
||||
const std::string &target_device);
|
||||
ControllerTemplate capture_current_bindings(const std::string &name);
|
||||
|
||||
void build_cards();
|
||||
void build_cards_tab(float page_offset);
|
||||
void build_cards_virtual();
|
||||
void build_cards_reader();
|
||||
void build_cards_manager();
|
||||
std::string build_option_value_picker_title(const OptionDefinition& option);
|
||||
void build_option_value_picker(Option& option);
|
||||
void build_options(
|
||||
std::vector<Option> *options, const std::string &category, const std::string *filter=nullptr);
|
||||
std::vector<Option> *options, const std::string &category, const std::string *filter=nullptr,
|
||||
bool quick_only=false);
|
||||
void build_options_tab(float page_offset);
|
||||
void build_controller_tab(float page_offset, ControllerPage *page_selected_new);
|
||||
bool build_nav_header(const char *label, bool active);
|
||||
void build_about();
|
||||
void build_launcher();
|
||||
void build_keypad_warning();
|
||||
|
||||
@@ -467,7 +467,7 @@ namespace overlay::windows {
|
||||
|
||||
void Control::cards_view() {
|
||||
if (ImGui::CollapsingHeader("Cards")) {
|
||||
ImGui::InputTextWithHint("Card ID", "E0040123456789AB",
|
||||
ImGui::InputTextWithHint("Card ID", "E0040100FFFFFFFF",
|
||||
this->card_input,
|
||||
std::size(this->card_input),
|
||||
ImGuiInputTextFlags_CharsHexadecimal
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "exitprompt.h"
|
||||
#include "avs/game.h"
|
||||
#include "build/defs.h"
|
||||
#include "misc/eamuse.h"
|
||||
#include "util/logging.h"
|
||||
#include "games/iidx/iidx.h"
|
||||
@@ -9,7 +10,7 @@
|
||||
namespace overlay::windows {
|
||||
|
||||
ExitPrompt::ExitPrompt(SpiceOverlay *overlay) : Window(overlay) {
|
||||
this->title = "spice2x";
|
||||
this->title = "spice2x (" + to_string(VERSION_STRING_CFG) + ")";
|
||||
this->init_size = ImVec2(
|
||||
(ImGui::GetFontSize() * 14) + (ImGui::GetStyle().ItemSpacing.x * 2),
|
||||
overlay::apply_scaling(120));
|
||||
@@ -20,22 +21,37 @@ namespace overlay::windows {
|
||||
this->flags = ImGuiWindowFlags_NoResize
|
||||
| ImGuiWindowFlags_NoCollapse
|
||||
| ImGuiWindowFlags_AlwaysAutoResize
|
||||
| ImGuiWindowFlags_NoDocking;
|
||||
| ImGuiWindowFlags_NoDocking
|
||||
| ImGuiWindowFlags_NoMove;
|
||||
|
||||
// the menu renders itself as a popup, not a normal overlay window
|
||||
this->draws_window = false;
|
||||
}
|
||||
|
||||
void ExitPrompt::update() {
|
||||
Window::update();
|
||||
|
||||
// allow the popup to be reopened the next time the menu is shown.
|
||||
// also reset while the overlay is hidden: new_frame() doesn't submit the
|
||||
// popup then, so ImGui closes it and it must be re-opened when shown again
|
||||
if (!this->active || !this->overlay->get_active()) {
|
||||
this->popup_opened = false;
|
||||
}
|
||||
}
|
||||
|
||||
void ExitPrompt::build_button(
|
||||
Window *window, std::string label, const ImVec2 &size, NextItem next, bool is_toggle) {
|
||||
Window *window, std::string label, const ImVec2 &size, NextItem next) {
|
||||
|
||||
if (window == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ImGui::Button(label.c_str(), size)) {
|
||||
if (is_toggle) {
|
||||
window->toggle_active();
|
||||
} else {
|
||||
window->set_active(true);
|
||||
this->set_active(false);
|
||||
window->toggle_active();
|
||||
|
||||
// raise to the top when the user toggled it visible from the menu
|
||||
if (window->get_active()) {
|
||||
window->bring_to_front();
|
||||
}
|
||||
}
|
||||
if (next == NextItem::NEW_LINE) {
|
||||
@@ -46,42 +62,64 @@ namespace overlay::windows {
|
||||
}
|
||||
|
||||
void ExitPrompt::build_content() {
|
||||
// use ### so the visible label is the full title (with version) while the
|
||||
// popup keeps a stable identifier regardless of the title text
|
||||
const std::string popup_id_str = this->title + "###mainmenu";
|
||||
const char *popup_id = popup_id_str.c_str();
|
||||
|
||||
// open the popup once when the menu becomes active; popup_opened is reset
|
||||
// in update() while inactive so it reopens the next time it is shown
|
||||
if (!this->popup_opened) {
|
||||
ImGui::OpenPopup(popup_id);
|
||||
this->popup_opened = true;
|
||||
}
|
||||
|
||||
// position at the top center of the screen
|
||||
const ImVec2 menu_pos(
|
||||
ImGui::GetIO().DisplaySize.x / 2 - this->init_size.x / 2,
|
||||
overlay::apply_scaling(10));
|
||||
ImGui::SetNextWindowPos(menu_pos, ImGuiCond_Appearing);
|
||||
|
||||
bool open = true;
|
||||
if (!ImGui::BeginPopupModal(popup_id, &open, this->flags)) {
|
||||
// dismissed via escape - close the menu
|
||||
this->set_active(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const ImVec2 size(ImGui::GetFontSize() * 14, ImGui::GetFontSize() * 1.9f);
|
||||
const ImVec2 size_half(
|
||||
(size.x - ImGui::GetStyle().ItemSpacing.x) / 2,
|
||||
ImGui::GetFontSize() * 1.9f);
|
||||
const ImVec2 size_third(
|
||||
(size.x - (ImGui::GetStyle().ItemSpacing.x * 2)) / 3,
|
||||
ImGui::GetFontSize() * 2.5f);
|
||||
ImGui::GetFontSize() * 1.9f);
|
||||
|
||||
if (ImGui::Button("Hide overlay", size)) {
|
||||
overlay::OVERLAY->set_active(false);
|
||||
}
|
||||
ImGui::Spacing();
|
||||
build_button(this->overlay->window_config, "Show Config", size, NextItem::NEW_LINE, false);
|
||||
build_button(this->overlay->window_config, "Options", size, NextItem::NEW_LINE);
|
||||
|
||||
std::string sub = "Show Subscreen";
|
||||
std::string sub = "Subscreen";
|
||||
if (avs::game::is_model("LDJ")) {
|
||||
if (games::iidx::TDJ_MODE) {
|
||||
sub = "Show TDJ Subscreen";
|
||||
sub = "IIDX TDJ Subscreen";
|
||||
} else {
|
||||
sub = "Show LDJ LED Ticker";
|
||||
sub = "IIDX LDJ LED Ticker";
|
||||
}
|
||||
} else if (avs::game::is_model("KFC")) {
|
||||
sub = "Show Valkyrie Subscreen";
|
||||
sub = "SDVX Valkyrie Subscreen";
|
||||
} else if (avs::game::is_model("REC")) {
|
||||
sub = "Show DRS Dance Floor";
|
||||
sub = "DRS Dance Floor";
|
||||
} else if (games::gitadora::is_arena_model()) {
|
||||
sub = "Show GITADORA Subscreen";
|
||||
sub = "GITADORA Subscreen";
|
||||
} else if (games::popn::is_pikapika_model()) {
|
||||
sub = "Show Pop'n Subscreen";
|
||||
sub = "Pop'n Subscreen";
|
||||
}
|
||||
|
||||
build_button(this->overlay->window_sub, sub, size, NextItem::NEW_LINE, false);
|
||||
build_button(this->overlay->window_sub, sub, size, NextItem::NEW_LINE);
|
||||
|
||||
ImGui::TextDisabled("Graphics");
|
||||
build_button(this->overlay->window_camera, "Camera control", size, NextItem::NEW_LINE);
|
||||
build_button(this->overlay->window_fps, "FPS", size_half, NextItem::SAME_LINE);
|
||||
|
||||
build_button(this->overlay->window_fps.get(), "FPS", size_half, NextItem::SAME_LINE);
|
||||
build_button(this->overlay->window_resize, "Resize", size_half, NextItem::NEW_LINE);
|
||||
|
||||
ImGui::TextDisabled("I/O");
|
||||
@@ -92,9 +130,9 @@ namespace overlay::windows {
|
||||
build_button(this->overlay->window_iopanel, "I/O panel", size_half, NextItem::NEW_LINE);
|
||||
} else {
|
||||
// 1p and 2p
|
||||
build_button(this->overlay->window_keypad1, "Keypad\n P1", size_third, NextItem::SAME_LINE);
|
||||
build_button(this->overlay->window_iopanel, " I/O\npanel", size_third, NextItem::SAME_LINE);
|
||||
build_button(this->overlay->window_keypad2, "Keypad\n P2", size_third, NextItem::NEW_LINE);
|
||||
build_button(this->overlay->window_keypad1, "P1 #", size_third, NextItem::SAME_LINE);
|
||||
build_button(this->overlay->window_iopanel, "I/O", size_third, NextItem::SAME_LINE);
|
||||
build_button(this->overlay->window_keypad2, "P2 #", size_third, NextItem::NEW_LINE);
|
||||
}
|
||||
|
||||
ImGui::TextDisabled("Debug");
|
||||
@@ -157,5 +195,12 @@ namespace overlay::windows {
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::EndPopup();
|
||||
|
||||
// title-bar X was clicked - close the menu
|
||||
if (!open) {
|
||||
this->set_active(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,13 @@ namespace overlay::windows {
|
||||
public:
|
||||
ExitPrompt(SpiceOverlay *overlay);
|
||||
void build_content() override;
|
||||
void update() override;
|
||||
|
||||
private:
|
||||
void build_button(Window *window, std::string label, const ImVec2 &size, NextItem next, bool is_toggle=true);
|
||||
void build_button(Window *window, std::string label, const ImVec2 &size, NextItem next);
|
||||
|
||||
// latch so the popup is opened once per activation; reset in update()
|
||||
// whenever the menu is inactive
|
||||
bool popup_opened = false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,58 +1,124 @@
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include "external/fmt/include/fmt/chrono.h"
|
||||
#include "fps.h"
|
||||
|
||||
namespace overlay::windows {
|
||||
|
||||
// tighter internal cell padding than the imgui default (4, 2)
|
||||
static const ImVec2 FPS_CELL_PADDING(4.0f, 1.0f);
|
||||
|
||||
// tighter window padding than the imgui default (8, 8)
|
||||
static const ImVec2 FPS_WINDOW_PADDING(6.0f, 4.0f);
|
||||
|
||||
FPS::FPS(SpiceOverlay *overlay) : Window(overlay) {
|
||||
this->title = "Stats";
|
||||
this->flags = ImGuiWindowFlags_NoTitleBar
|
||||
| ImGuiWindowFlags_NoResize
|
||||
| ImGuiWindowFlags_NoCollapse
|
||||
| ImGuiWindowFlags_AlwaysAutoResize
|
||||
| ImGuiWindowFlags_NoFocusOnAppearing
|
||||
| ImGuiWindowFlags_NoBringToFrontOnFocus
|
||||
| ImGuiWindowFlags_NoNavFocus
|
||||
| ImGuiWindowFlags_NoNavInputs
|
||||
| ImGuiWindowFlags_NoNav
|
||||
| ImGuiWindowFlags_NoMove
|
||||
| ImGuiWindowFlags_NoInputs
|
||||
| ImGuiWindowFlags_NoDocking;
|
||||
this->bg_alpha = 0.5f;
|
||||
this->window_padding = FPS_WINDOW_PADDING;
|
||||
this->start_time =
|
||||
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
|
||||
}
|
||||
|
||||
void FPS::calculate_initial_window() {
|
||||
// width is 114x82 px with window decoration, 98x47 for the content
|
||||
int pos_x =
|
||||
overlay::FPS_SHOULD_FLIP ?
|
||||
overlay::apply_scaling(8) :
|
||||
ImGui::GetIO().DisplaySize.x - overlay::apply_scaling(122);
|
||||
this->init_pos = ImVec2(pos_x, overlay::apply_scaling(8));
|
||||
// size the window explicitly (no AlwaysAutoResize) so the corner anchoring
|
||||
// below is exact; the footprint mirrors the fixed-fit table in build_content()
|
||||
const float line_h = ImGui::GetTextLineHeight();
|
||||
const int rows = 3;
|
||||
|
||||
// widest label and widest value drive the two fixed-fit columns
|
||||
const float label_w = (std::max)(
|
||||
ImGui::CalcTextSize("Time").x,
|
||||
ImGui::CalcTextSize("Game").x);
|
||||
const float value_w = ImGui::CalcTextSize("00:00:00").x;
|
||||
|
||||
const float win_w = label_w + value_w
|
||||
+ FPS_CELL_PADDING.x * 2
|
||||
+ FPS_WINDOW_PADDING.x * 2;
|
||||
const float win_h = (line_h + FPS_CELL_PADDING.y * 2) * rows
|
||||
+ FPS_WINDOW_PADDING.y * 2;
|
||||
this->init_size = ImVec2(win_w, win_h);
|
||||
|
||||
// bottom-anchored windows use a larger edge margin (matching notification
|
||||
// toasts) since they overlap the same on-screen UI; other edges hug closer
|
||||
const float edge_margin = overlay::apply_scaling(4);
|
||||
const float bottom_margin = overlay::apply_scaling(20);
|
||||
const ImVec2 &display = ImGui::GetIO().DisplaySize;
|
||||
|
||||
const bool right =
|
||||
overlay::FPS_LOCATION == overlay::FpsLocation::TopRight ||
|
||||
overlay::FPS_LOCATION == overlay::FpsLocation::BottomRight;
|
||||
const bool bottom =
|
||||
overlay::FPS_LOCATION == overlay::FpsLocation::BottomLeft ||
|
||||
overlay::FPS_LOCATION == overlay::FpsLocation::BottomRight;
|
||||
|
||||
const float pos_x = right ? display.x - win_w - edge_margin : edge_margin;
|
||||
const float pos_y = bottom ? display.y - win_h - bottom_margin : edge_margin;
|
||||
this->init_pos = ImVec2(pos_x, pos_y);
|
||||
}
|
||||
|
||||
void FPS::build_content() {
|
||||
|
||||
// frame timers
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
ImGui::Text("FPS: %.1f", io.Framerate);
|
||||
// ImGui::Text("FT: %.2fms", 1000 / io.Framerate);
|
||||
|
||||
const auto now = std::chrono::system_clock::now();
|
||||
const auto now_s = std::chrono::floor<std::chrono::seconds>(now);
|
||||
|
||||
// current time
|
||||
{
|
||||
const std::time_t tt = std::chrono::system_clock::to_time_t(now_s);
|
||||
std::tm local_tm{};
|
||||
localtime_s(&local_tm, &tt);
|
||||
ImGui::TextUnformatted(fmt::format("Time: {:%H:%M:%S}", local_tm).c_str());
|
||||
}
|
||||
const std::time_t tt = std::chrono::system_clock::to_time_t(now_s);
|
||||
std::tm local_tm{};
|
||||
localtime_s(&local_tm, &tt);
|
||||
|
||||
// elapsed time
|
||||
{
|
||||
const auto uptime = now_s - this->start_time;
|
||||
const auto uptime = now_s - this->start_time;
|
||||
|
||||
// right-align a label within the current cell so the label column reads
|
||||
// flush against the value column instead of looking ragged. the label is
|
||||
// only slightly dimmer than normal text (not the much darker "disabled" tone)
|
||||
const ImGuiStyle &style = ImGui::GetStyle();
|
||||
ImVec4 label_col = style.Colors[ImGuiCol_Text];
|
||||
label_col.w *= 0.7f;
|
||||
const auto label = [&label_col](const char *text) {
|
||||
const float avail = ImGui::GetContentRegionAvail().x;
|
||||
const float text_w = ImGui::CalcTextSize(text).x;
|
||||
if (avail > text_w) {
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (avail - text_w));
|
||||
}
|
||||
ImGui::TextColored(label_col, "%s", text);
|
||||
};
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_CellPadding, FPS_CELL_PADDING);
|
||||
if (ImGui::BeginTable("##fps_stats", 2, ImGuiTableFlags_SizingFixedFit)) {
|
||||
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableSetColumnIndex(0);
|
||||
label("FPS");
|
||||
ImGui::TableSetColumnIndex(1);
|
||||
ImGui::Text("%.2f", io.Framerate);
|
||||
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableSetColumnIndex(0);
|
||||
label("Time");
|
||||
ImGui::TableSetColumnIndex(1);
|
||||
ImGui::TextUnformatted(fmt::format("{:%H:%M:%S}", local_tm).c_str());
|
||||
|
||||
ImGui::TableNextRow();
|
||||
ImGui::TableSetColumnIndex(0);
|
||||
label("Game");
|
||||
ImGui::TableSetColumnIndex(1);
|
||||
ImGui::TextUnformatted(
|
||||
fmt::format("Up: {:%H:%M:%S}",
|
||||
fmt::format("{:%H:%M:%S}",
|
||||
std::chrono::floor<std::chrono::seconds>(uptime)).c_str());
|
||||
|
||||
ImGui::EndTable();
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,5 @@ namespace overlay::windows {
|
||||
|
||||
void calculate_initial_window() override;
|
||||
void build_content() override;
|
||||
|
||||
bool should_flip = false;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
namespace deferredlogs {
|
||||
|
||||
const std::initializer_list<std::string> SUPERSTEP_SOUND_ERROR_MESSAGE = {
|
||||
"audio initialization error was previously detected during boot!",
|
||||
"audio initialization error was detected during boot!",
|
||||
" this crash is most likely related to audio init failure",
|
||||
" * try enabling WASAPI Force Shared Mode (-wasapishared)",
|
||||
" * check if the default audio device has changed",
|
||||
" * fix your audio device settings (e.g., sample rate)",
|
||||
" * double check your spice audio options and patches",
|
||||
" * ensure no other application is using the device in exclusive mode"
|
||||
};
|
||||
|
||||
@@ -589,6 +589,24 @@ namespace sysutils {
|
||||
(void**)&EnumDisplayDevicesA_orig);
|
||||
}
|
||||
|
||||
bool is_large_address_aware() {
|
||||
auto image_base = reinterpret_cast<const uint8_t *>(GetModuleHandleW(nullptr));
|
||||
if (image_base == nullptr) {
|
||||
return false;
|
||||
}
|
||||
auto dos_header = reinterpret_cast<const IMAGE_DOS_HEADER *>(image_base);
|
||||
if (dos_header->e_magic != IMAGE_DOS_SIGNATURE) {
|
||||
return false;
|
||||
}
|
||||
auto nt_headers = reinterpret_cast<const IMAGE_NT_HEADERS *>(
|
||||
image_base + dos_header->e_lfanew);
|
||||
if (nt_headers->Signature != IMAGE_NT_SIGNATURE) {
|
||||
return false;
|
||||
}
|
||||
return (nt_headers->FileHeader.Characteristics
|
||||
& IMAGE_FILE_LARGE_ADDRESS_AWARE) != 0;
|
||||
}
|
||||
|
||||
#if !SPICE_XP
|
||||
|
||||
bool is_running_as_admin() {
|
||||
|
||||
@@ -20,6 +20,10 @@ namespace sysutils {
|
||||
|
||||
const std::vector<MonitorEntry> &enumerate_monitors();
|
||||
|
||||
// reads the running module's PE header to determine whether the
|
||||
// IMAGE_FILE_LARGE_ADDRESS_AWARE bit is set (i.e. spice_laa.exe)
|
||||
bool is_large_address_aware();
|
||||
|
||||
extern std::string SECOND_MONITOR_OVERRIDE;
|
||||
void hook_EnumDisplayDevicesA();
|
||||
|
||||
|
||||
@@ -115,9 +115,9 @@ void generate_ea_card(char card[17]) {
|
||||
std::uniform_int_distribution<> uniform(0, 15);
|
||||
|
||||
// randomize card
|
||||
strcpy(card, "E00401");
|
||||
strcpy(card, "E0040100");
|
||||
char hex[] = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
|
||||
for (int i = 6; i < 16; i++) {
|
||||
for (int i = 8; i < 16; i++) {
|
||||
card[i] = hex[uniform(generator)];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user