Compare commits

...

31 Commits

Author SHA1 Message Date
bicarus 4879a35eb8 overlay: remove keyboard / gamepad tab navigation (#755)
Tired of constantly fighting unintentional keyboard navigation behavior.

Also, remove `ImGuiConfigFlags_IsTouchScreen` which literally does
nothing.
2026-06-12 21:28:34 -07:00
bicarus fd7e850789 ccj: fix mouse wheel input in overlay (#754)
## Link to GitHub Issue or related Pull Request, if one exists
#251 

## Description of change
CCJ registers for rawinput mouse. We allowed the rawinput registration
to go through to the OS, which prevents spice's rawinput stack from
using the mouse. This caused the overlay to not accept mouse input. Fix
it by not letting the rawinput registration through. Luckily, mouse
input continues to work properly in CCJ (for touch).

Also, fix a bug in DX11 overlay when toggling the main menu.

## Testing
2026-06-12 03:42:59 -07:00
bicarus 0cf07c38da overlay: left nav for cards tab (#753)
Enable left nav for Card tab, splitting the single page into multiple
sub tabs.

Card Manager is now embedded in this UI (but can still be launched as a
separate window)
2026-06-12 00:57:52 -07:00
bicarus 1f073b2d75 overlay: combine buttons/analogs/overlay/lights (#752)
Same idea as #747 except for input/output binding.
2026-06-11 23:38:09 -07:00
jessemarthin 3037214932 Fix capture.get_jpg blocking during game load for Companion Mirror (#750)
Add a timeout and skip signalling to the D3D9 capture path so the API
thread no longer waits indefinitely when Present stops during loading.
Return the last successful JPEG frame as a fallback.

## Link to GitHub Issue or related Pull Request, if one exists
#746

## Description of change
* Fix `capture.get_jpg` blocking indefinitely when D3D9 screen capture
cannot
  complete during game loading (e.g. no Present calls).
* Add a 2-second timeout to `graphics_capture_receive_jpeg()` and cancel
  pending capture requests on timeout.
* Signal capture skip from D3D9 failure paths via
`graphics_capture_skip()`.
* Cache the last successful JPEG per screen and return it as a fallback
  when a new capture fails.
Tested with **stock iOS Spice Companion** only. I have not tested
Android or
other Companion clients. The iOS client implementation differs from the
others; this fix is server-side only.
No config file, CLI option, or API schema changes.

## Testing
* Built locally with llvm-mingw cross-compiler (WSL).
* Docker build (`src/spice2x/build_docker.sh`): Pending
* Tested with stock iOS Spice Companion on iPad: Mirror remains
connected
  during game loading instead of returning to KeyPad.
2026-06-11 19:14:47 -07:00
drmext 18d6d9783a overlay: fix Options tab contents jitter (#751)
## Link to GitHub Issue or related Pull Request, if one exists
#747

## Description of change
The old approach called ImGui’s `SetScrollHereY()` while the options
list was still being laid out. That caused one-frame scroll corrections
and edge-snap jitter near scroll boundaries.

The new approach in `build_options_tab()`:

1. **On click** - set a flag (`options_scroll_pending` for categories,
`options_scroll_top` for group headers).
2. **During layout** - when building the target category, record its
content position with `GetCursorPosY()` (don’t scroll yet).
3. **After all options are built** - apply scroll once by setting
`window->Scroll.y` directly, and only if the position actually needs to
change (0.5px threshold).

That deferred, single-pass scroll avoids the shake while still
supporting scroll-up, scroll-down, and re-click-to-recenter.


## Testing
In spicecfg.exe and in-game overlay, Options tab:

Open groups with many categories

Scroll content down manually, then click a higher category in the left
nav - content scrolls up to that section

Re-click the same category after manual scroll - jumps back without
shake

Rapid re-clicks when already aligned - no jitter

Switch group headers - still scrolls to top; re-click at top - no shake
2026-06-11 14:03:33 -07:00
bicarus 267add3227 iidx: set SOUND_OUTPUT_DEVICE even when -iidx is not enabled (#749)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
For cab usage, when `-exec bm2dx.dll` is used without `-iidx` then
SOUND_OUTPUT_DEVICE never gets set.

Change this so that `SOUND_OUTPUT_DEVICE` gets set unconditionally if
`-iidxsounddevice` is set.

## Testing
2026-06-11 00:40:53 -07:00
bicarus a170627f52 overlay: redesign Options tab(s) (#747)
## Link to GitHub Issue or related Pull Request, if one exists
#0

## Description of change
Combine API/Options/Advanced/Development/Search tabs into one Options
tab and add a left nav for navigation categories.

Reduce information density for options table by making each row double
the height and slightly increasing widget sizes.

## Testing
2026-06-11 00:17:54 -07:00
drmext 5c244eaca7 improve E004 card generation and placeholder pcbid (#748)
## Link to GitHub Issue or related Pull Request, if one exists
#73

## Description of change
Generated cards now start with E0040100 to fully match real cards. The
placeholder example `E004010000000000` is replaced with
`E0040100FFFFFFFF`.

The invalid placeholder/fallback PCBID `04040000000000000000` is
replaced with `01201000000000010101`, which contains a valid
header/checksum, making it closer resemble a real PCBID while still
clearly being an example.
2026-06-10 23:24:43 -07:00
bicarus 6bcadccf09 audio: WASAPI Force Shared Mode option (#745)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
This new option, `-wasapishared`, detects when the game opens WASAPI
Exclusive stream and forcibly opens a shared mode stream instead on the
real device.

#### Benefits of this:

1. No need for `force wasapi shared` patches
2. No need to change sample rate of audio devices before starting up
games
3. Adds ability to boot in shared mode even for games that do not have
shared wasapi changes (old GITADORA, popn HC)

As a result, this option is strictly better than `shared wasapi` patches
available for many games.

#### Downsides:
1. OS resampling will add a small latency, but not big enough to be
noticeable.
2. Using shared mode instead of exclusive mode adds latency, of course.

(If you cared about latency, you would use the low latency option, or
use exclusive or asio)

Basically it's the "make things work" button for audio that should work
for almost all games that we support.

The option has no effect if the game opens in shared mode.

## Testing
SDVX7 - ok
GITADORA GW - ok
popn HC - ok
IIDX - ok
2026-06-10 00:20:59 -07:00
bicarus-dev ccb3bac48c defer error for -exec 2026-06-09 00:09:00 -07:00
bicarus 0a7ecf82a9 build: produce shared objects for spice.exe / spice_laa.exe, disable 64-bit winxp builds (#744)
to speed up clean builds. GitHub CI time went from 20 minutes -> 10
minutes.
2026-06-08 17:42:41 -07:00
bicarus-dev 1c6929dd84 Move full screen Monitor options to Graphics (Full Screen) 2026-06-08 15:53:54 -07:00
bicarus 1d3d9040f2 Update bug_report.md 2026-06-08 00:51:48 -07:00
bicarus 8f1069628e Update bug_report.md 2026-06-08 00:50:33 -07:00
bicarus-dev f8c585a9d1 update main menu layout 2026-06-08 00:27:42 -07:00
bicarus 9651051990 overlay: make overlay toggle behavior more consistent (#743)
Clean up how various overlay toggle shortcuts work with the main menu
visible.
2026-06-07 21:56:06 -07:00
bicarus a54a62d0d7 overlay: fix dummy marker spacing (#741)
Fix alignment in patches tab
2026-06-07 19:14:43 -07:00
bicarus 100caa0f5c overlay: refactor overlay layering (#739)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change

Create three distinct layers for the overlay:

1. Bottommost persistent layer - non-interactable layer that is always
on. This was only for notifications, but now the FPS widget lives here
as well.
2. Overlay windows layer - most interactable windows go here.
3. Topmost main menu - this is reserved for the main menu (escape key)
and this is a modal dialog that occludes the layers below.

Why? 

- `toggle overlay` behavior with FPS widget *also* toggling on/off was a
bit confusing (now they're two separate keys)
- FPS widget is popular, but it caused the entire overlay to be active,
which affects how input is handled
- the main menu being a standalone window was a little awkward (now it's
a modal)

## Testing
2026-06-07 17:23:08 -07:00
bicarus-dev bb87aa2944 add placeholder text for ea url option 2026-06-07 01:48:52 -07:00
bicarus 2e2968d1bc overlay: remove dependency on d3dcompiler (#738)
## Link to GitHub Issue or related Pull Request, if one exists
Fixes #737, regressed by #707

## Description of change
ImGui backend for DX11 pulled in dependency on a specific version of
`d3dcompiler_47.dll` which is not present on stock Win7 OS image.

Change this to a runtime load of the DLL and disable the overlay if we
can't find one.

## Testing
- [x] Win11, DX11 overlay
- [x] Win11, DX9 overlay
- [x] Win7, DX9 overlay
2026-06-06 14:06:37 -07:00
bicarus 0457262472 audio: asio "downmix" 7.1 to stereo (#736)
## Link to GitHub Issue or related Pull Request, if one exists
#730 

## Description of change
Add an option that extracts channels from 7.1 ASIO and presents to 2-ch
ASIO. Same idea as the SDVX 2ch fix except for gitadora we are
duplicating the center channel to front.

## Testing
Tested SDVX and GFDM Arena.
2026-06-06 03:11:40 -07:00
bicarus-dev f23c359114 minify patches json again 2026-06-05 18:05:48 -07:00
bicarus-dev 81e74dcb97 remove museca difficulty patches 2026-06-05 17:45:36 -07:00
bicarus-dev 8ead941c6c format patches json 2026-06-05 17:22:38 -07:00
bicarus ece03cabba cfg: asio driver selector must list both 32-bit and 64-bit drivers (#734)
## Link to GitHub Issue or related Pull Request, if one exists
#730 

## Description of change
spicecfg is a 32-bit application. When it uses the ASIO SDK it only saw
the 32-bit drivers.

There are some ASIO drivers that have different names under 32-bit and
64-bit (Xonar AE is one of them)... so we have to manually scan the
registry and surface both the WOW32 and WOW64 nodes.

## Testing
<img width="329" height="309" alt="image"
src="https://github.com/user-attachments/assets/7abecf76-6835-4df5-8c2e-e7b425130f4c"
/>

Checked both 32-bit and 64-bit configurator, identical results.
2026-06-05 02:05:03 -07:00
bicarus 4b7f68f920 gitadora: (arena model) flip realtek option (#733)
## Link to GitHub Issue or related Pull Request, if one exists
#730 

## Description of change
Flip the option - enable the Realtek hack by default, unless the user
chooses not to.

## Testing
Tested ASIO path and WASAPI path. WASAPI path should be unaffected as
the game just picks the default audio device.
2026-06-05 01:38:46 -07:00
bicarus 2dae86a6f2 gitadora: (arena model) simulate Realtek device, fix asio redirect hooks (#732)
## Link to GitHub Issue or related Pull Request, if one exists
#730, #718

## Description of change

Part 1)

When ASIO is in use, the game also looks for a Realtek device so that it
can open a WASAPI Exclusive mode stream for headphones.

Add an option to fake that, in case the user doesn't have a Realtek
device.

Part 2)

`-gdaasio` wasn't workign properly - fix the logic.

## Testing
Seems to work with FlexASIO, and Xonar with no real Realtek device.
2026-06-05 00:08:52 -07:00
bicarus 85058c2156 audio: create a wrapper for asio drivers (#731)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Create `WrappedAsio`, similar to how we wrap `IAudioEndpoint`

So far, the wrapper does these things:

1. logging (for diagnosis, since iidx and gfdm don't produce any logs
when asio succeeds)
2. iidx32+ hack to work around refcount mismatch issue
3. sdvx valk cab hack to force 2-channel audio ("downmixing" by taking
only the front channels)
4. honor volume boost

As a result of `#2` the mempatch was removed from `iidx.cpp` since we
can tackle it cleanly in the hook. `#3` also removes the need for manual
patches.

Real downmixing is hard & expensive on the CPU so it was not
implemented.

## Testing
Tested iidx/sdvx/gfdm with xonar and flexasio
2026-06-04 09:24:44 -07:00
bicarus ea2f4c5572 gitadora: (arena model) complain loudly about bad prop files (#729)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Refuse to load if `libaio.dll` exists but `<spec>` is set incorrectly
(almost certainly because the user copied old prop files from downlevel
version)

## Testing
2026-06-02 17:47:51 -07:00
bicarus 48033816a8 audio: WASAPI exclusive resampling, buffer size increase options (#727)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change

Resampler:
Implement resampler for exclusive mode streams, as we are seeing more
and more devices - not just laptops but onboard audio devices - that
only support 48khz and not 44.1khz. Should work with volume boost (gain
calculated inside resample) and also downmixer (hands off intermediate
scratch buffers).

Buffer size increase:
By default many of these games request a tiny buffer when in shared mode
(TDJ uses 3ms). On some audio setup this results in crackling due to
underflow. Add an option to forcibly increase the buffer size.

## Testing
With resampler set to 48kHz and buffer set to 20ms I can reliably boot
and play IIDX on my display port monitor's speakers; previously this
wasn't possible. IIDX is event-driven.

Tested SDVX7 as well at 48kHz, which opens timer-driven streams.
2026-06-02 01:55:50 -07:00
68 changed files with 4766 additions and 1009 deletions
+5 -3
View File
@@ -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
View File
@@ -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)
+59 -15
View File
@@ -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, &timestamp, &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);
}
}
+2 -2
View File
@@ -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
View File
@@ -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
-1
View File
@@ -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
+2
View File
@@ -46,6 +46,8 @@ struct OptionDefinition {
// for OptionPickerType::FilePath
std::string file_extension = "";
std::string quick_setting_category = "";
};
class Option {
+1 -2
View File
@@ -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
View File
@@ -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)
{
+12 -3
View File
@@ -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;
}
+9 -4
View File
@@ -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(
+27 -1
View File
@@ -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;
@@ -623,7 +631,25 @@ 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
+1
View File
@@ -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:
+5 -37
View File
@@ -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
+3 -1
View File
@@ -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);
+2 -1
View File
@@ -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
+216
View File
@@ -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;
};
+9
View File
@@ -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
+19
View File
@@ -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);
+11 -11
View File
@@ -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;
}
+55 -10
View File
@@ -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;
+70 -5
View File
@@ -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) {
File diff suppressed because it is too large Load Diff
+16 -3
View File
@@ -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);
+3 -1
View File
@@ -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) {
+11
View File
@@ -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 -41
View File
@@ -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;
}
+21 -6
View File
@@ -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;
+54 -7
View File
@@ -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
+13
View File
@@ -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);
+9 -3
View File
@@ -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++) {
+1 -1
View File
@@ -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
+36 -12
View File
@@ -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();
+1 -1
View File
@@ -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
+71 -26
View File
@@ -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);
}
}
}
+6 -1
View File
@@ -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;
};
}
+89 -23
View File
@@ -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();
}
}
-2
View File
@@ -15,7 +15,5 @@ namespace overlay::windows {
void calculate_initial_window() override;
void build_content() override;
bool should_flip = false;
};
}
+2 -2
View File
@@ -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"
};
+18
View File
@@ -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() {
+4
View File
@@ -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();
+2 -2
View File
@@ -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)];
}