Compare commits

20 Commits
Author SHA1 Message Date
REDCODE 09ac50f2d5 Merge branch 'github-main' into r3d-spice2x
Continuous Integration / Build (push) Failing after 5m3s
2026-08-21 14:26:23 +02:00
bicarusandGitHub 2dc3c0cbe4 patcher: make the checkbox label toggleable (#882)
Requested by a certain sea creature. Don't know why ImGui doesn't do
this by default.

Also, use ImGui internal mixed state (tri-state) checkbox instead
rendering our own.
2026-08-20 23:48:11 -07:00
bicarusandGitHub 4c2a70bab6 build: improve dockerfile (#881)
## Link to GitHub Issue or related Pull Request, if one exists
#0

## Description of change

`mingw-w64-libjpeg-turbo` and `mingw-w64-x264` are AUR packages, so the
deps image compiles them from source, once per mingw target arch. Stock
`makepkg.conf` leaves `MAKEFLAGS` commented out and both PKGBUILDs call
bare `make`, so all of that was building single-threaded.

Adds a `~/.makepkg.conf` for the build user setting
`MAKEFLAGS="-j$(nproc)"`. It is kept literal so it evaluates when
makepkg sources the file, rather than baking in the core count of
whichever machine built the image. Using `~/.makepkg.conf` rather than
`/etc` leaves the pacman-owned system file untouched.

Measured on 20 cores, compiling both libraries from scratch: **310s to
64s, a 4.8x improvement.**

Also merges the two `yay` invocations into a single layer.

## Testing
2026-08-20 23:25:00 -07:00
bicarusandGitHub 46f76597fc cfg: active only filter for options search (#879)
Requested by a sea creature
2026-08-20 22:38:30 -07:00
bicarusandGitHub b23640222c api: add CORS policy (#878)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Adds `Access-Control-Allow-Origin: *` to the video stream server's
responses.

## Testing
Tested with substream project.
2026-08-20 02:58:22 -07:00
bicarusandGitHub 3f6862908e api: drop stale websocket / stream connections (#877) 2026-08-19 13:37:41 -07:00
bicarusandGitHub 0934cce225 api: h.264 video stream (#876)
## Link to GitHub Issue or related Pull Request, if one exists
fixes #875

## Description of change
Adds `-apistream`, an optional HTTP video stream of the mirrored screen.
It listens on the API port +2.

Two endpoints, sharing the same `screen`, `fps` and `q` parameters:

    /stream.mjpg    JPEG frames, for clients with no container support
/stream.h264 H.264 annex-b, for an app driving MediaCodec or
VideoToolbox itself

One encoder per connection, fed by a per-screen pump that always hands
over the newest frame, so a slow reader drops frames instead of building
a backlog. `capture.get_jpg` behaviour is unchanged.

Additional documentation for developers:
https://github.com/spice2x/spice2x.github.io/wiki/Video-Stream

## Testing
2026-08-19 03:10:02 -07:00
bicarusandGitHub 7c50fcc79e graphics: remove libjpeg from XP builds, fetch at build time (#874)
Replaces the vendored `external/libjpeg-turbo` tree (191 files) with the
`mingw-w64-libjpeg-turbo` AUR package, already built into the Docker
deps image.

The WinXP toolchains set `CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY`
against their own sysroot, so they cannot see AUR mingw packages. Those
targets now build without JPEG support: `jpeg_encoder::encode` compiles
to a stub returning `false`, which callers already treat as "no frame
available". The practical effect is that `capture.get_jpg` over the API
is unavailable on WinXP builds; every other target is unchanged.

CMake names `libjpeg.a` outright, since the package also ships an import
library and linking that would add a runtime DLL dependency.
2026-08-18 08:57:18 -07:00
0f4ab63101 patchmanager: fix signature patches reporting neither on or off (#873)
## Link to GitHub Issue or related Pull Request, if one exists
None

## Description of change
Patch Manager showed valid signature patches as broken: **"Bad patch;
patch is neither on or off"** instead of Enabled/Disabled, so they could
not be toggled.

`SignaturePatch::to_memory` had two bugs. In the configurator it cached
a file offset as `data_offset_ptr`, so status checks `memcmp`'d a fake
address. It also passed JSON `offset` into `find_pattern` while still
indexing the signature/replacement from 0, which mis-aligned every patch
with `offset != 0`. This change locates the signature start, applies
`offset` afterward, compares only the replacement window, and leaves the
pointer null so `is_patch_active` re-resolves from `data_offset`.

Not proposed as built-in patches. The JSON below is the reproduction
case: each entry uses `offset > 0` and a replacement shorter than the
signature.

## Testing
Reproduced in Patch Manager against `bm2dx.dll` using the signature JSON
below. Before the fix, every patch reported "neither on or off". After
the fix, each patch locates, shows Disabled/Enabled, and toggling writes
only the replacement bytes at `signature_match + offset`.

## Demo
<details>
<summary>Signature JSON used to reproduce (offset + short
replacement)</summary>

```json
[
  {
    "info": "streaming / getcm patches (type=signature)",
    "gameCode": "LDJ",
    "notes": "Each site uses a unique signature (usage=0 only)."
  },
  {
    "type": "group",
    "id": "streaming-getcm",
    "name": "Streaming getcm",
    "description": "Enable all children so streaming.common merges without Banner FS and getcm can fire without visiting Test Mode.",
    "gameCode": "LDJ"
  },
  {
    "name": "Streaming: merge common without Banner",
    "description": "NOP jz in streaming.common callback so CM work table is filled even when Banner FS is still null.",
    "caution": "Required. Without this, early common responses are discarded and getcm stays empty.",
    "gameCode": "LDJ",
    "type": "signature",
    "group": "streaming-getcm",
    "dllName": "bm2dx.dll",
    "signature": "E8????????4885C00F84????????488D0D????????488D15????????41B848080000",
    "replacement": "909090909090",
    "offset": 8,
    "usage": 0
  },
  {
    "name": "Streaming: scheduler without Banner (common)",
    "description": "NOP jz after Banner getter on the common branch of the periodic scheduler.",
    "caution": "Enable with the getcm scheduler sibling. Unique via imul of common-interval dword.",
    "gameCode": "LDJ",
    "type": "signature",
    "group": "streaming-getcm",
    "dllName": "bm2dx.dll",
    "signature": "E8????????4885C0742469057E791C0AE8030000",
    "replacement": "9090",
    "offset": 8,
    "usage": 0
  },
  {
    "name": "Streaming: scheduler without Banner (getcm)",
    "description": "NOP jz after Banner getter on the getcm branch of the periodic scheduler.",
    "caution": "Enable with the common scheduler sibling. Unique via imul of getcm-interval dword. Without this, getcm never schedules while Banner FS is null.",
    "gameCode": "LDJ",
    "type": "signature",
    "group": "streaming-getcm",
    "dllName": "bm2dx.dll",
    "signature": "E8????????4885C07424690574B91D0AE8030000",
    "replacement": "9090",
    "offset": 8,
    "usage": 0
  },
  {
    "name": "Streaming: fall into getcm after common",
    "description": "NOP jmp-after-common so the same scheduler tick can evaluate getcm instead of returning early.",
    "caution": "Pair with Banner scheduler skips (or a live Banner FS).",
    "gameCode": "LDJ",
    "type": "signature",
    "group": "streaming-getcm",
    "dllName": "bm2dx.dll",
    "signature": "891D????????E9????????E8????????4885C074246905",
    "replacement": "9090909090",
    "offset": 6,
    "usage": 0
  },
  {
    "name": "Streaming: getcm interval 1s #1",
    "description": "Default getcm poll interval 1800s to 1s (first init store).",
    "caution": "Enable #1 and #2 together. Trailing BF3C000000 distinguishes this init site.",
    "gameCode": "LDJ",
    "type": "signature",
    "group": "streaming-getcm",
    "dllName": "bm2dx.dll",
    "signature": "C705????????18150000C705????????08070000C705????????201C0000BF3C000000",
    "replacement": "01000000",
    "offset": 16,
    "usage": 0
  },
  {
    "name": "Streaming: getcm interval 1s #2",
    "description": "Default getcm poll interval 1800s to 1s (second init store).",
    "caution": "Enable #1 and #2 together. Trailing 448925 distinguishes this init site.",
    "gameCode": "LDJ",
    "type": "signature",
    "group": "streaming-getcm",
    "dllName": "bm2dx.dll",
    "signature": "C705????????18150000C705????????08070000C705????????201C0000448925",
    "replacement": "01000000",
    "offset": 16,
    "usage": 0
  }
]
```
</details>

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 08:16:12 -07:00
bicarusandGitHub 8b2f38307b graphics: rewrite screenshot and api capture image processing (#870)
## Link to GitHub Issue or related Pull Request, if one exists
#0

## Description of change
Significantly speeds up API screen capture and D3D9 screenshots saving.
Two reasons for doing this:

1. We now have a 4K game (GITADORA) and existing capture code was taking
multiple seconds.
2. Renewed user interest on streaming as we have a couple more companion
apps in active development.

**API screen capture (streaming), 1280x720:** 14.3ms -> 6.3ms per frame.
Back buffer copies go to pooled `D3DPOOL_SYSTEMMEM` surfaces via
`GetRenderTargetData` instead of allocating a lockable render target
every frame, and TooJpeg is replaced with libjpeg-turbo (encode 9.8ms ->
3.0ms). MSAA remains unsupported

**Screenshots for GITADORA arena model, across 4 screens with one of
them 4K**: 4068ms -> 124ms. `D3DXSaveSurfaceToFileA` is replaced with
fpng (encode 4043ms -> 76ms) and the screens encode in parallel.
Dropping D3DX also removes the `d3dx9_43.dll` ... `d3dx9_24.dll` probing
loop, so screenshots no longer fail outright on machines with no D3DX9
runtime installed.

Screenshot surfaces are read on the present thread, so no D3D call
reaches another thread for screenshots. This fixes a hang in DDR X2
introduced earlier in the branch: its device has no internal locking,
and reading the surface on a pool thread while the present thread sat
inside `GetRenderTargetData` left the game's own render thread
deadlocked.


## Testing

- **GITADORA** (arena model, D3D9Ex, 4K main plus three subscreens,
windowed) with
`-screenshotsub`: three sets of four screenshots, images verified
correct. Completion
order differs between sets, so the screens really are encoding in
parallel.
- **LovePlus** (KLP, plain D3D9, 768x1360): covers the inline path used
by games whose
  image processing must not leave the present thread. 
- **API screen capture** through a companion app: live video correct
throughout.
- **Print Screen** bound as the screenshot key: the clipboard copy
succeeded on every shot.
- Quitting the game after capturing leaves no `IDirect3DDevice9`
reference count warning,
  so the pooled readback surfaces are released along with the device.
2026-08-18 00:22:45 -07:00
bicarusandGitHub 3863d5a4ed misc: various clean up for diagnosing launch failures (#872)
## Link to GitHub Issue or related Pull Request, if one exists
#345 

## Description of change

**IIDX TDJ rom probe no longer touches removable media** —
`C:\000rom.txt` and `D:\001rom.txt` are not emulated paths; they hit
whatever is actually mounted on the user's machine. `D:` is commonly an
optical drive or card reader, and the launcher clears
`SEM_FAILCRITICALERRORS` process-wide before attach, so an empty drive
raises the modal *"insert a disk"* dialog and blocks the attaching
thread. The probe now checks `GetDriveTypeW` and only reads fixed and
RAM disks.

**`iat_find` no longer calls `log_fatal` on an unparseable module** —
`iat_try(nullptr)` walks every loaded module, including foreign ones
(injected, manually mapped, header wiped by AV/EDR/overlays). A non-`MZ`
DOS header called `log_fatal`. There is nothing to hook in such a
module, so it is skipped.

**`logger::stop()` can no longer hang forever** — hook installation
suspends every other thread, including the logging thread. `stop()`
unconditionally joined that thread, so `log_fatal` and the 30-second
`show_popup` watchdog both wedged instead of terminating, and logging is
asynchronous so nothing reached log.txt either. It now waits with a
timeout, then detaches and flushes synchronously.

**`GetFileSizeEx` was never hooked** — the hook was registered under the
name `"GetFileSize"`, so it re-patched that slot instead.

**Warn when `-modules` is set** — it changes where the game is run from,
and is usually set accidentally.

## Testing
*how was the code tested?*
2026-08-17 22:41:07 -07:00
bicarus-dev 94574c485a exlude line ending enforcement for external dir 2026-08-16 21:27:03 -07:00
bicarus-dev f857926ec3 fix line endings 2026-08-16 21:23:41 -07:00
bicarusandGitHub adf4cccd4a overlay: fix subscreen overlay rendering for popn (#869)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
For popn HC, some transparency effects, like the one for touch feedback,
rendered incorrectly; this is due to how alpha channel is handled for
subscreen overlay drawing on top of ImGui.

## Testing
Tested popn and iidx.
2026-08-16 04:24:14 -07:00
bicarusandGitHub bf8e194685 sdvx: fix api touch and -sdvxnosub sub window (#868)
## Link to GitHub Issue or related Pull Request, if one exists
Related: #859 and #862.

## Description of change

Three SDVX (Valkyrie model) fixes:

* **API touch went to the wrong window.** SDVX registers touch on both
its Main Screen and Sub Screen windows, and the native injector kept
whichever attached last. The touch surface is now published explicitly:
sub screen window when windowed, main window in fullscreen.
* **Landscape never rotated API coordinates.** Synthetic contacts bypass
`transform::hardware_to_game`, so they missed the rotation a real finger
gets. Applied before injection now, as portrait already did. Extracted
to `sdvx_landscape_rotate` so both paths share it; gated on the native
path since `wintouchemu` rotates via the subscreen overlay instead.
* **`-sdvxnosub` didn't hide the sub window in fullscreen.**
`ShowWindow_hook` had branches for GITADORA, pop'n and IIDX but not
SDVX. Added the missing one.

## Testing
Tested Nabla
2026-08-16 04:03:42 -07:00
bicarusandGitHub a2e508208c gitadora: fix synthetic touch (mouse and api) (#867)
## Link to GitHub Issue or related Pull Request, if one exists
#0

## Description of change
Fix mouse and touch API handling for gitadora.

* hook native touch in every arena window mode (previously only using
wintouchemu for 1 windowed mode)
* publish the touch window (SMALL panel, or the main window drawing the
overlay) and only attach injection there
* route touch on the SMALL window straight through instead of the
overlay transform, and accept the mouse only there
* fix spiceapi by forcing 800x1280 touch canvas size
* run API capture before the subscreen present to fix api not showing
sub image


## Testing
Test:

gitadora 1/2/4 windowed mode, fullscreen

iidx /sdvx / popn windowed/fullscreen for regressions
2026-08-16 01:43:20 -07:00
bicarusandGitHub f5888609a8 graphics: option to include subscreens in screenshot (#866)
## Link to GitHub Issue or related Pull Request, if one exists
#0

## Description of change
Adds `-screenshotsub`. Off by default. When on, screenshots include the
subscreens. All subscreens are captured, even if they are hidden from
view. Works for all four screens of gitadora arena model as well.

This should also fix an issue with API / companion app not capturing
gitadora subscreen correctly.

Note: only done for DX9... DX11 will need another PR to make this work.

Unrelated to this PR, there seems to be a bug with gitadora not
accepting mouse or api touch input.

## Testing
2026-08-14 04:05:50 -07:00
bicarusandGitHub c6cd72c528 graphics: exclude overlay from screenshots (#865)
## Link to GitHub Issue or related Pull Request, if one exists
#0

## Description of change

Adds the `-screenshotoverlay` option.

By default, file screenshots exclude the Spice overlay. Enabling this
option captures screenshots after the overlay is rendered.

Also updates DX11 backend to allow screenshots even when overlay is
disabled.

## Testing
2026-08-13 23:21:46 -07:00
bicarusandGitHub 82e0c053d0 overlay: create a dedicated thread for polling hotkeys (#864)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Create a new thread that polls for the following hotkeys:

* super exit (both alt+f4 and bound key)
* coin insert
* screenshot

The goal is to make the capture of these more reliable, because before
this PR it wasn't.

## Testing
2026-08-12 20:04:59 -07:00
James LiuandGitHub 3b29227dbc gitadora: fix XG2 gibberish text showing (#863)
> [!NOTE]
> Before submitting code changes... 
> * Please do note that this is a GPL v3.0 open source project.
> * Please read the
[CONTRIBUTING](https://github.com/spice2x/spice2x.github.io/blob/main/CONTRIBUTING.md)
guide.
> * Maintainers reserve the right to reject or modify your submission
without reason.
> * No new compiler warnings must be introduced. Check the CI build
results.
> 
> Feel free to remove this section after you have read it.

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

## Description of change
Fix gibberish in dynamic Japanese text in GuitarFreaks/DrumMania XG2
(K32/K33) when Windows uses a non-Japanese system code page.

XG2 converts UTF-8 property strings through WideCharToMultiByte(CP_ACP)
before rendering. On systems where the active code page is not
Shift-JIS, these strings are converted using the system code page and
become corrupted or turn into question marks.

Spice2x already hooks WideCharToMultiByte and redirects these
conversions to CP932 for newer 64-bit Gitadora models. This change makes
the existing hook available to 32-bit builds and enables it specifically
for XG2 models K32 and K33.

The existing 64-bit Gitadora Arena and T44 conditions remain unchanged.

## Testing
Before
<img width="1280" height="745" alt="image"
src="https://github.com/user-attachments/assets/34aa2c93-89e8-4c4f-b267-6cbc236f19de"
/>
<img width="2054" height="1188" alt="image"
src="https://github.com/user-attachments/assets/d88b5770-5545-4402-b9e7-3126fe048fb9"
/>

After
<img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/d43b4e47-b6af-4b97-8898-53849c9a0ca4"
/>
<img width="1280" height="720" alt="image"
src="https://github.com/user-attachments/assets/884301e2-1edd-4ad2-92ac-ece656853fe7"
/>

- Tested GuitarFreaks XG2 on a non-Japanese (English US) Windows.
- Verified that Community Log preset comments render correctly in
Japanese.
- Verified that Cooperation Challenge descriptions, rewards, and
progress text render correctly.
- Confirmed that the previous mojibake and question marks no longer
appear.
- GitHub Actions build completed successfully for both architectures.
2026-08-12 00:08:05 -07:00
135 changed files with 31883 additions and 26642 deletions
+46
View File
@@ -0,0 +1,46 @@
# Normalize every text file to LF in the repository.
# The build runs under Linux/MinGW containers, so LF is also used in the
# working tree; Windows editors and toolchains handle LF fine.
* text=auto eol=lf
# Windows-only files that must keep CRLF in the working tree.
*.bat text eol=crlf
*.cmd text eol=crlf
*.sln text eol=crlf
*.vcproj text eol=crlf
*.vcxproj text eol=crlf
*.props text eol=crlf
*.filters text eol=crlf
# Files that must keep LF even if a Windows editor rewrites them.
*.sh text eol=lf
*.in text eol=lf
*.cmake text eol=lf
*.mk text eol=lf
Makefile text eol=lf
Dockerfile text eol=lf
# Binary files - never touch the contents.
*.bin binary
*.ico binary
*.ttf binary
*.otf binary
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.bmp binary
*.zip binary
*.7z binary
*.gz binary
*.dll binary
*.exe binary
*.lib binary
*.a binary
*.o binary
*.obj binary
*.pdb binary
# Vendored code is stored and checked out byte-for-byte as upstream ships it,
# so re-importing a library never produces line-ending-only diffs.
src/spice2x/external/** -text
+49 -49
View File
@@ -1,49 +1,49 @@
name: Draft Release
on:
workflow_dispatch:
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
release:
name: Build and Draft Release
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./src/spice2x
steps:
- uses: actions/checkout@v5
with:
ref: main
fetch-depth: 0
- name: Clean leftover build artifacts
run: |
rm -rf .ccache dist bin cmake-build-*
- name: Compile
run: ./build_docker.sh
- name: Determine release name from dist filename
run: |
dist=$(basename "$(ls dist/spice2x-*.zip | grep -v -- '-full.zip')")
# strip the ".zip" to get the base name, e.g. spice2x-26-06-28
name="${dist%.zip}"
# the tag is the date portion, e.g. 26-06-28
tag="${name#spice2x-}"
echo "RELEASE_NAME=$name" >> $GITHUB_ENV
echo "RELEASE_TAG=$tag" >> $GITHUB_ENV
- name: Create draft release
uses: softprops/action-gh-release@v3
with:
draft: true
prerelease: true
tag_name: ${{ env.RELEASE_TAG }}
name: ${{ env.RELEASE_NAME }}
target_commitish: main
generate_release_notes: true
files: |
src/spice2x/dist/spice2x-*.zip
name: Draft Release
on:
workflow_dispatch:
permissions:
contents: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
release:
name: Build and Draft Release
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./src/spice2x
steps:
- uses: actions/checkout@v5
with:
ref: main
fetch-depth: 0
- name: Clean leftover build artifacts
run: |
rm -rf .ccache dist bin cmake-build-*
- name: Compile
run: ./build_docker.sh
- name: Determine release name from dist filename
run: |
dist=$(basename "$(ls dist/spice2x-*.zip | grep -v -- '-full.zip')")
# strip the ".zip" to get the base name, e.g. spice2x-26-06-28
name="${dist%.zip}"
# the tag is the date portion, e.g. 26-06-28
tag="${name#spice2x-}"
echo "RELEASE_NAME=$name" >> $GITHUB_ENV
echo "RELEASE_TAG=$tag" >> $GITHUB_ENV
- name: Create draft release
uses: softprops/action-gh-release@v3
with:
draft: true
prerelease: true
tag_name: ${{ env.RELEASE_TAG }}
name: ${{ env.RELEASE_NAME }}
target_commitish: main
generate_release_notes: true
files: |
src/spice2x/dist/spice2x-*.zip
+65 -6
View File
@@ -254,6 +254,57 @@ add_subdirectory(external/imgui EXCLUDE_FROM_ALL)
add_subdirectory(external/minhook EXCLUDE_FROM_ALL)
add_subdirectory(external/cpu_features EXCLUDE_FROM_ALL)
# libjpeg-turbo, prebuilt into the deps image. The WinXP toolchains have their own
# sysroot and cannot see it, so those targets build without JPEG support.
add_library(spice_jpeg INTERFACE)
if(NOT SPICE_XP)
# search static archives only: the mingw package also ships an import library,
# and linking that one would pull in a libjpeg DLL at runtime
set(SPICE_JPEG_SUFFIXES ${CMAKE_FIND_LIBRARY_SUFFIXES})
set(CMAKE_FIND_LIBRARY_SUFFIXES ${CMAKE_STATIC_LIBRARY_SUFFIX})
find_package(JPEG)
set(CMAKE_FIND_LIBRARY_SUFFIXES ${SPICE_JPEG_SUFFIXES})
if(JPEG_FOUND)
target_link_libraries(spice_jpeg INTERFACE JPEG::JPEG)
target_compile_definitions(spice_jpeg INTERFACE SPICE_JPEG=1)
else()
message(WARNING
"libjpeg-turbo not found: screen capture over the API is disabled")
endif()
endif()
# x264 for the API H.264 video stream, installed into the mingw sysroots by the
# deps image. The WinXP toolchains deliberately go without it and serve MJPEG only.
add_library(spice_x264 INTERFACE)
if(NOT SPICE_XP)
# search static archives only: the mingw package also ships an import library,
# and linking that one would pull in a libx264 DLL at runtime
set(SPICE_X264_SUFFIXES ${CMAKE_FIND_LIBRARY_SUFFIXES})
set(CMAKE_FIND_LIBRARY_SUFFIXES ${CMAKE_STATIC_LIBRARY_SUFFIX})
find_library(X264_LIBRARY NAMES x264 libx264)
set(CMAKE_FIND_LIBRARY_SUFFIXES ${SPICE_X264_SUFFIXES})
find_path(X264_INCLUDE_DIR NAMES x264.h)
if(X264_LIBRARY AND X264_INCLUDE_DIR)
target_include_directories(spice_x264 INTERFACE "${X264_INCLUDE_DIR}")
target_link_libraries(spice_x264 INTERFACE "${X264_LIBRARY}")
target_compile_definitions(spice_x264 INTERFACE SPICE_H264=1)
else()
message(WARNING
"x264 not found: the api video stream will only offer MJPEG")
endif()
endif()
# fpng's SIMD needs the whole unit built for SSE4.1, which its runtime CPU check
# cannot undo, so keep it scalar rather than raising the CPU baseline
set_source_files_properties(external/fpng/fpng.cpp PROPERTIES
COMPILE_DEFINITIONS "FPNG_NO_SSE=1")
if(NOT MSVC)
set_source_files_properties(external/fpng/fpng.cpp PROPERTIES
COMPILE_OPTIONS "-fno-strict-aliasing")
endif()
# set link time optimizations (disabled for Debug builds for speed, disabled
# for RelWithDebInfo builds due to "lto1: error: two or more sections for"
# errors)
@@ -328,6 +379,10 @@ set(SOURCE_FILES ${SOURCE_FILES}
# api
api/controller.cpp
api/websocket.cpp
api/capture_pump.cpp
api/h264_stream.cpp
api/stream_format.cpp
api/stream_server.cpp
api/request.cpp
api/response.cpp
api/module.cpp
@@ -391,7 +446,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
external/tinyxml2/tinyxml2.cpp
external/http-parser/http_parser.c
external/usbhidusage/usb-hid-usage.c
external/toojpeg/toojpeg.cpp
external/fpng/fpng.cpp
external/scard/scard.cpp
# games
@@ -548,10 +603,12 @@ set(SOURCE_FILES ${SOURCE_FILES}
hooks/devicehook.cpp
hooks/graphics/graphics.cpp
hooks/graphics/graphics_windowed.cpp
hooks/graphics/jpeg_encoder.cpp
hooks/graphics/nvapi_impl.cpp
hooks/graphics/nvapi_hook.cpp
hooks/graphics/nvenc_hook.cpp
hooks/graphics/backends/d3d9/d3d9_backend.cpp
hooks/graphics/backends/d3d9/d3d9_readback.cpp
hooks/graphics/backends/d3d9/d3d9_screenshot.cpp
hooks/graphics/backends/d3d9/d3d9_device.cpp
hooks/graphics/backends/d3d9/d3d9_gfdm.cpp
@@ -594,6 +651,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
misc/device.cpp
misc/eamuse.cpp
misc/extdev.cpp
misc/hotkeys.cpp
misc/sciunit.cpp
misc/sde.cpp
misc/wintouchemu.cpp
@@ -755,7 +813,7 @@ endfunction()
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)
PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features spice_jpeg spice_x264)
target_link_libraries(spicetools_spice_objs PUBLIC winscard)
if(NOT MSVC)
@@ -795,7 +853,7 @@ set(RESOURCE_FILES build/manifest.manifest build/manifest.rc build/icon.rc cfg/W
add_executable(spicetools_spice_linux ${SOURCE_FILES} ${RESOURCE_FILES})
target_link_libraries(spicetools_spice_linux
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)
PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features spice_jpeg spice_x264)
set_target_properties(spicetools_spice_linux PROPERTIES PREFIX "")
set_target_properties(spicetools_spice_linux PROPERTIES OUTPUT_NAME "spice_linux")
target_compile_definitions(spicetools_spice_linux PRIVATE NO_SCARD=1 PRIVATE SPICE_LINUX=1)
@@ -813,8 +871,8 @@ add_executable(spicetools_spice64 ${SOURCE_FILES} ${RESOURCE_FILES})
# do NOT link against: mf, mfplat, mfreadwrite; otherwise unity games will break
target_link_libraries(spicetools_spice64
PUBLIC d3d9 ws2_32 version comctl32 shlwapi iphlpapi hid secur32 setupapi psapi winmm winhttp mfuuid strmiids dxva2
PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features)
PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features spice_jpeg spice_x264)
target_link_libraries(spicetools_spice64 PUBLIC winscard)
set_target_properties(spicetools_spice64 PROPERTIES PREFIX "")
set_target_properties(spicetools_spice64 PROPERTIES OUTPUT_NAME "spice64")
target_compile_definitions(spicetools_spice64 PRIVATE SPICE64=1)
@@ -836,7 +894,7 @@ add_executable(spicetools_spice64_linux ${SOURCE_FILES} ${RESOURCE_FILES})
# do NOT link against: mf, mfplat, mfreadwrite; otherwise unity games will break
target_link_libraries(spicetools_spice64_linux
PUBLIC d3d9 ws2_32 version comctl32 shlwapi iphlpapi hid secur32 setupapi psapi winmm winhttp mfuuid strmiids dxva2
PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features)
PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features spice_jpeg spice_x264)
set_target_properties(spicetools_spice64_linux PROPERTIES PREFIX "")
set_target_properties(spicetools_spice64_linux PROPERTIES OUTPUT_NAME "spice64_linux")
target_compile_definitions(spicetools_spice64_linux PRIVATE SPICE64=1)
@@ -855,6 +913,7 @@ endif()
set(SOURCE_FILES ${SOURCE_FILES} launcher/options.h launcher/options.cpp)
set(RESOURCE_FILES cfg/manifest.manifest cfg/manifest.rc cfg/icon.rc cfg/Win32D.rc)
add_executable(spicetools_cfg WIN32 ${SOURCE_FILES} ${RESOURCE_FILES})
# the configurator serves neither the API nor the video stream, so it needs no codecs
target_link_libraries(spicetools_cfg
PUBLIC d3d9 ws2_32 version comctl32 shlwapi iphlpapi hid secur32 setupapi psapi winmm winhttp strmiids
PRIVATE fmt::fmt-header-only discord-rpc imgui hash-library minhook imm32 dwmapi CpuFeatures::cpu_features)
+36
View File
@@ -291,6 +291,42 @@ which also means that your hex edits are applicable directly.
- image_resize_set_scene(scene: int)
- sets the active scene for image resize state; set to 0 to disable resize
## Video Stream
Separate from the JSON API, spice can serve the mirrored screen as a video
stream over plain HTTP. Enable it with `-apistream`. It listens on the API port
plus two, in the same way the WebSocket server uses the API port plus one, so
`-api 1337` puts the stream on 1339. This means `-api` has to be enabled too.
Two formats are served:
http://host:1339/stream.mjpg JPEG frames, multipart/x-mixed-replace
http://host:1339/stream.h264 H.264 annex-b, no container
All accept the same optional query parameters:
- `screen` - which screen to mirror, 0-3. Defaults to the subscreen when the
game has one, otherwise the main screen.
- `fps` - frames per second, 1-60. Default 30.
- `q` - quality, 1-100. Default 70. This is the JPEG quality for `stream.mjpg`
and is mapped onto the H.264 rate factor for `stream.h264`, so the same number
does not mean the same thing for both.
For example:
http://host:1339/stream.h264?screen=1&fps=30&q=70
See the wiki for format tradeoffs, latency tuning, testing commands and client
notes.
The stream is view only. Touch and other input still go through the JSON API,
so a companion app needs both. There is no authentication on the stream port -
anyone who can reach it can watch the screen.
WinXP builds have no video stream. Neither encoder is compiled in, so every
endpoint returns 404, and the JSON API's JPEG screen capture is unavailable for
the same reason.
## Native wrapper libraries
Spicetools provides wrapper libraries in: Arduino, C++, Dart, and Python.
Python is the only one that is fully spec compliant.
+58
View File
@@ -0,0 +1,58 @@
#include "capture_pump.h"
#include <array>
#include <mutex>
#include "hooks/graphics/graphics.h"
namespace api::capture_pump {
namespace {
std::array<std::mutex, GRAPHICS_CAPTURE_SCREEN_NO> CONSUMER_M;
std::mutex CLAIMED_M;
std::array<bool, GRAPHICS_CAPTURE_SCREEN_NO> CLAIMED {};
bool valid_screen(int screen) {
return 0 <= screen && screen < static_cast<int>(GRAPHICS_CAPTURE_SCREEN_NO);
}
}
bool capture_direct(int screen, std::shared_ptr<uint8_t[]> &out, int divide,
uint64_t *timestamp, int *width, int *height) {
if (!valid_screen(screen)) {
return false;
}
std::lock_guard<std::mutex> lock(CONSUMER_M[screen]);
graphics_capture_trigger(screen);
return graphics_capture_receive_raw(
screen, out, divide, timestamp, width, height);
}
bool claim_screen(int screen) {
if (!valid_screen(screen)) {
return false;
}
std::lock_guard<std::mutex> lock(CLAIMED_M);
if (CLAIMED[screen]) {
return false;
}
CLAIMED[screen] = true;
return true;
}
void release_screen(int screen) {
if (!valid_screen(screen)) {
return;
}
std::lock_guard<std::mutex> lock(CLAIMED_M);
CLAIMED[screen] = false;
}
}
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include <cstdint>
#include <memory>
namespace api::capture_pump {
struct Frame {
// packed 24bpp RGB, width * height * 3 bytes
std::shared_ptr<uint8_t[]> pixels;
uint64_t timestamp = 0;
int width = 0;
int height = 0;
};
// the graphics layer has one capture slot per screen, so concurrent waiters would steal
// each other's frames; everything that captures goes through here to keep it serialized
bool capture_direct(int screen, std::shared_ptr<uint8_t[]> &out, int divide,
uint64_t *timestamp = nullptr, int *width = nullptr, int *height = nullptr);
// a screen carries one stream at a time; false when another connection already holds it
bool claim_screen(int screen);
void release_screen(int screen);
}
+224
View File
@@ -0,0 +1,224 @@
#include "h264_stream.h"
#ifdef SPICE_H264
#include <vector>
#include <x264.h>
#include "util/logging.h"
namespace api {
namespace {
// BT.601 limited range, the range every decoder assumes for H.264 without
// explicit colour metadata
inline uint8_t rgb_to_y(int r, int g, int b) {
return static_cast<uint8_t>(((66 * r + 129 * g + 25 * b + 128) >> 8) + 16);
}
inline uint8_t rgb_to_u(int r, int g, int b) {
return static_cast<uint8_t>(((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128);
}
inline uint8_t rgb_to_v(int r, int g, int b) {
return static_cast<uint8_t>(((112 * r - 94 * g - 18 * b + 128) >> 8) + 128);
}
// a bare annex-b elementary stream, one encoder per connection so every client
// starts on its own keyframe. no container, so nothing here keeps a media clock
class H264Writer : public StreamWriter {
public:
H264Writer(int quality, int fps) : quality(quality), fps(fps) {}
~H264Writer() override {
this->close();
}
std::string content_type() const override {
return "video/h264";
}
bool write(const StreamSend &send, const capture_pump::Frame &frame) override {
// I420 needs even dimensions
const int width = frame.width & ~1;
const int height = frame.height & ~1;
if (width <= 0 || height <= 0) {
return true;
}
if (this->encoder == nullptr) {
if (!this->open(width, height)) {
return false;
}
} else if (width != this->width || height != this->height) {
// the encoder is fixed at the size it opened with; let the client reconnect
log_info("api::stream", "capture size changed, ending H.264 client");
return false;
}
this->convert(frame.pixels.get(), frame.width);
this->picture.i_pts = this->frame_index;
x264_nal_t *nals = nullptr;
int nal_count = 0;
x264_picture_t picture_out;
const int size = x264_encoder_encode(
this->encoder, &nals, &nal_count, &this->picture, &picture_out);
if (size < 0) {
log_warning("api::stream", "H.264 encode failed");
return false;
}
this->frame_index++;
if (size == 0) {
return true;
}
// x264 lays every NAL of the frame out back to back. an SEI or delimiter
// carries no picture, so only the parameter sets and the slice go through
this->annexb.clear();
for (int i = 0; i < nal_count; i++) {
switch (nals[i].i_type) {
case NAL_SEI:
case NAL_AUD:
case NAL_FILLER:
continue;
default:
break;
}
this->annexb.insert(this->annexb.end(),
nals[i].p_payload, nals[i].p_payload + nals[i].i_payload);
}
if (this->annexb.empty()) {
return true;
}
return send(this->annexb.data(), this->annexb.size());
}
private:
bool open(int width, int height) {
x264_param_t param;
if (x264_param_default_preset(&param, "ultrafast", "zerolatency") < 0) {
return false;
}
param.i_csp = X264_CSP_I420;
param.i_width = width;
param.i_height = height;
param.i_fps_num = this->fps;
param.i_fps_den = 1;
param.i_threads = 1;
param.b_annexb = 1;
// SPS/PPS ahead of every IDR, so a client can start decoding cold
param.b_repeat_headers = 1;
// a keyframe every two seconds bounds how long a new client waits
param.i_keyint_max = this->fps * 2;
param.i_log_level = X264_LOG_NONE;
param.rc.i_rc_method = X264_RC_CRF;
param.rc.f_rf_constant = 40.0f - (this->quality * 0.25f);
// baseline keeps hardware decode available on the widest range of phones
if (x264_param_apply_profile(&param, "baseline") < 0) {
return false;
}
this->encoder = x264_encoder_open(&param);
if (this->encoder == nullptr) {
log_warning("api::stream", "could not open the H.264 encoder");
return false;
}
if (x264_picture_alloc(&this->picture, X264_CSP_I420, width, height) < 0) {
this->close();
return false;
}
this->picture_ready = true;
this->width = width;
this->height = height;
return true;
}
void close() {
if (this->picture_ready) {
x264_picture_clean(&this->picture);
this->picture_ready = false;
}
if (this->encoder != nullptr) {
x264_encoder_close(this->encoder);
this->encoder = nullptr;
}
}
// packed 24bpp RGB to I420, averaging each 2x2 block for the chroma planes
void convert(const uint8_t *rgb, int source_width) {
uint8_t *plane_y = this->picture.img.plane[0];
uint8_t *plane_u = this->picture.img.plane[1];
uint8_t *plane_v = this->picture.img.plane[2];
const int stride_y = this->picture.img.i_stride[0];
const int stride_u = this->picture.img.i_stride[1];
const int stride_v = this->picture.img.i_stride[2];
for (int y = 0; y < this->height; y++) {
const uint8_t *row = rgb + static_cast<size_t>(y) * source_width * 3;
uint8_t *out_y = plane_y + static_cast<size_t>(y) * stride_y;
for (int x = 0; x < this->width; x++) {
const uint8_t *pixel = row + x * 3;
out_y[x] = rgb_to_y(pixel[0], pixel[1], pixel[2]);
}
}
for (int y = 0; y < this->height / 2; y++) {
const uint8_t *row0 = rgb + static_cast<size_t>(y * 2) * source_width * 3;
const uint8_t *row1 = row0 + static_cast<size_t>(source_width) * 3;
uint8_t *out_u = plane_u + static_cast<size_t>(y) * stride_u;
uint8_t *out_v = plane_v + static_cast<size_t>(y) * stride_v;
for (int x = 0; x < this->width / 2; x++) {
const uint8_t *p00 = row0 + (x * 2) * 3;
const uint8_t *p01 = p00 + 3;
const uint8_t *p10 = row1 + (x * 2) * 3;
const uint8_t *p11 = p10 + 3;
const int r = (p00[0] + p01[0] + p10[0] + p11[0] + 2) / 4;
const int g = (p00[1] + p01[1] + p10[1] + p11[1] + 2) / 4;
const int b = (p00[2] + p01[2] + p10[2] + p11[2] + 2) / 4;
out_u[x] = rgb_to_u(r, g, b);
out_v[x] = rgb_to_v(r, g, b);
}
}
}
int quality;
int fps;
int width = 0;
int height = 0;
int64_t frame_index = 0;
std::vector<uint8_t> annexb;
x264_t *encoder = nullptr;
x264_picture_t picture {};
bool picture_ready = false;
};
}
std::unique_ptr<StreamWriter> make_h264_writer(int quality, int fps) {
return std::make_unique<H264Writer>(quality, fps);
}
}
#endif // SPICE_H264
+11
View File
@@ -0,0 +1,11 @@
#pragma once
#include <memory>
#include "stream_format.h"
namespace api {
// bare annex-b H.264; null when the build has no encoder
std::unique_ptr<StreamWriter> make_h264_writer(int quality, int fps);
}
+12 -6
View File
@@ -2,8 +2,10 @@
#include <functional>
#include <mutex>
#include <unordered_map>
#include "api/capture_pump.h"
#include "external/rapidjson/document.h"
#include "hooks/graphics/graphics.h"
#include "hooks/graphics/jpeg_encoder.h"
#include "util/crypt.h"
using namespace std::placeholders;
@@ -93,8 +95,6 @@ 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
int screen = 0;
@@ -120,10 +120,16 @@ namespace api::modules {
uint64_t timestamp = 0;
int width = 0;
int height = 0;
graphics_capture_trigger(screen);
bool success = graphics_capture_receive_jpeg(screen, [] (uint8_t byte) {
CAPTURE_BUFFER.push_back(byte);
}, true, quality, true, divide, &timestamp, &width, &height);
std::shared_ptr<uint8_t[]> pixels;
bool success = capture_pump::capture_direct(
screen, pixels, divide, &timestamp, &width, &height);
if (success) {
CAPTURE_BUFFER.clear();
success = jpeg_encoder::encode(
CAPTURE_BUFFER, pixels.get(), width, height, quality);
}
if (success) {
add_jpeg_response(screen, timestamp, width, height, CAPTURE_BUFFER, res);
+54 -54
View File
@@ -1,54 +1,54 @@
#include "ddr.h"
#include <functional>
#include "external/rapidjson/document.h"
#include "games/ddr/ddr.h"
using namespace std::placeholders;
using namespace rapidjson;
namespace api::modules {
DDR::DDR() : Module("ddr") {
functions["tapeled_get"] = std::bind(&DDR::tapeled_get, this, _1, _2);
}
/**
* Allows fetching of the RGB LED strips that are gold cabinets, via SpiceAPI
*/
void DDR::tapeled_get(Request &req, Response &res) {
static const char* device_names[11] = {
"p1_foot_up",
"p1_foot_right",
"p1_foot_left",
"p1_foot_down",
"p2_foot_up",
"p2_foot_right",
"p2_foot_left",
"p2_foot_down",
"top_panel",
"monitor_left",
"monitor_right"
};
Value response_object(kObjectType);
// Iterate through each device and dump its lights data into the response
for (size_t device = 0; device < 11; device++) {
size_t num_leds = 25;
if (device > 7)
num_leds = 50;
Value light_state(kArrayType);
light_state.Reserve(num_leds * 3, res.doc()->GetAllocator());
for (size_t led = 0; led < num_leds; led++) {
light_state.PushBack(games::ddr::DDR_TAPELEDS[device][led][0], res.doc()->GetAllocator());
light_state.PushBack(games::ddr::DDR_TAPELEDS[device][led][1], res.doc()->GetAllocator());
light_state.PushBack(games::ddr::DDR_TAPELEDS[device][led][2], res.doc()->GetAllocator());
}
response_object.AddMember(StringRef(device_names[device]), light_state, res.doc()->GetAllocator());
}
res.add_data(response_object);
}
}
#include "ddr.h"
#include <functional>
#include "external/rapidjson/document.h"
#include "games/ddr/ddr.h"
using namespace std::placeholders;
using namespace rapidjson;
namespace api::modules {
DDR::DDR() : Module("ddr") {
functions["tapeled_get"] = std::bind(&DDR::tapeled_get, this, _1, _2);
}
/**
* Allows fetching of the RGB LED strips that are gold cabinets, via SpiceAPI
*/
void DDR::tapeled_get(Request &req, Response &res) {
static const char* device_names[11] = {
"p1_foot_up",
"p1_foot_right",
"p1_foot_left",
"p1_foot_down",
"p2_foot_up",
"p2_foot_right",
"p2_foot_left",
"p2_foot_down",
"top_panel",
"monitor_left",
"monitor_right"
};
Value response_object(kObjectType);
// Iterate through each device and dump its lights data into the response
for (size_t device = 0; device < 11; device++) {
size_t num_leds = 25;
if (device > 7)
num_leds = 50;
Value light_state(kArrayType);
light_state.Reserve(num_leds * 3, res.doc()->GetAllocator());
for (size_t led = 0; led < num_leds; led++) {
light_state.PushBack(games::ddr::DDR_TAPELEDS[device][led][0], res.doc()->GetAllocator());
light_state.PushBack(games::ddr::DDR_TAPELEDS[device][led][1], res.doc()->GetAllocator());
light_state.PushBack(games::ddr::DDR_TAPELEDS[device][led][2], res.doc()->GetAllocator());
}
response_object.AddMember(StringRef(device_names[device]), light_state, res.doc()->GetAllocator());
}
res.add_data(response_object);
}
}
+17 -17
View File
@@ -1,17 +1,17 @@
#pragma once
#include <vector>
#include "api/module.h"
#include "api/request.h"
namespace api::modules {
class DDR : public Module {
public:
DDR();
private:
// function definitions
void tapeled_get(Request &req, Response &res);
};
}
#pragma once
#include <vector>
#include "api/module.h"
#include "api/request.h"
namespace api::modules {
class DDR : public Module {
public:
DDR();
private:
// function definitions
void tapeled_get(Request &req, Response &res);
};
}
+41 -11
View File
@@ -12,7 +12,9 @@
#include "touch/touch.h"
#include "touch/native/inject.h"
#include "touch/native/nativetouchhook.h"
#include "touch/native/transform.h"
#include "util/utils.h"
#include "games/gitadora/gitadora.h"
#include "games/iidx/iidx.h"
using namespace std::placeholders;
@@ -40,6 +42,35 @@ namespace api::modules {
return nativetouch::inject::inject_synthetic_touch(position, true);
}
// map API coordinates onto the touch space SDVX reads, which depends on how it is displayed
static void sdvx_touch_errata(
int &x, int &y, bool use_native, int canvas_w, int canvas_h) {
// windowed coordinates already match the sub screen window they land on
if (GRAPHICS_WINDOWED) {
return;
}
// landscape mode: native injection hands the game these coordinates
// unchanged, so apply the rotation the touchscreen gets, while wintouchemu instead
// rotates them later through the subscreen overlay
if (GRAPHICS_FS_ORIENTATION_SWAP) {
if (use_native) {
POINT position { x, y };
if (nativetouch::transform::sdvx_landscape_rotate(&position, canvas_w, canvas_h)) {
x = position.x;
y = position.y;
}
}
return;
}
// rotate into the portrait touch space
const int x_raw = x;
x = canvas_w - y;
y = x_raw;
}
Touch::Touch() : Module("touch") {
is_sdvx = avs::game::is_model("KFC");
@@ -55,8 +86,8 @@ namespace api::modules {
native_canvas_w = 0;
native_canvas_h = 0;
if (is_sdvx) {
// windowed and landscape API coordinates already match the primary screen orientation;
// fullscreen portrait coordinates are rotated by apply_touch_errata
// windowed API coordinates land on the sub screen window as-is; fullscreen
// coordinates are rotated into the game's touch space by apply_touch_errata
const bool landscape_coordinates =
GRAPHICS_WINDOWED || GRAPHICS_FS_ORIENTATION_SWAP;
native_canvas_w = landscape_coordinates ? 1920 : 1080;
@@ -69,6 +100,10 @@ namespace api::modules {
// pop'n music API touch surface
native_canvas_w = 1280;
native_canvas_h = 800;
} else if (games::gitadora::is_arena_model()) {
// GITADORA arena SMALL subscreen, either in its own window or in the overlay
native_canvas_w = games::gitadora::ARENA_SUBSCREEN_WIDTH;
native_canvas_h = games::gitadora::ARENA_SUBSCREEN_HEIGHT;
}
functions["read"] = std::bind(&Touch::read, this, _1, _2);
@@ -212,19 +247,14 @@ namespace api::modules {
}
void Touch::apply_touch_errata(int &x, int &y) {
int x_raw = x;
int y_raw = y;
if (is_tdj_fhd) {
// deal with TDJ FHD resolution mismatch (upgrade 720p to 1080p)
// we don't know what screen is being shown on the companion and the API doesn't specify
// the target of the touch events so just assume it's the sub screen
x = x_raw * 1920 / 1280;
y = y_raw * 1080 / 720;
} else if (is_sdvx && !GRAPHICS_WINDOWED && !GRAPHICS_FS_ORIENTATION_SWAP) {
// rotate API coordinates into SDVX's portrait touch space
x = 1080 - y_raw;
y = x_raw;
x = x * 1920 / 1280;
y = y * 1080 / 720;
} else if (is_sdvx) {
sdvx_touch_errata(x, y, use_native, native_canvas_w, native_canvas_h);
}
}
}
+70
View File
@@ -0,0 +1,70 @@
#include "stream_format.h"
#include <vector>
#include "h264_stream.h"
#include "hooks/graphics/jpeg_encoder.h"
namespace api {
namespace {
#ifdef SPICE_JPEG
constexpr const char *MJPEG_BOUNDARY = "spice2xframe";
// multipart/x-mixed-replace: every frame is a standalone JPEG, no inter-frame state
class MjpegWriter : public StreamWriter {
public:
explicit MjpegWriter(int quality) : quality(quality) {}
std::string content_type() const override {
return std::string("multipart/x-mixed-replace; boundary=") + MJPEG_BOUNDARY;
}
bool write(const StreamSend &send, const capture_pump::Frame &frame) override {
this->jpeg.clear();
if (!jpeg_encoder::encode(
this->jpeg, frame.pixels.get(),
frame.width, frame.height, this->quality)) {
// a frame the encoder rejects is not worth dropping the client over
return true;
}
const std::string part =
"--" + std::string(MJPEG_BOUNDARY) + "\r\n"
"Content-Type: image/jpeg\r\n"
"Content-Length: " + std::to_string(this->jpeg.size()) + "\r\n"
"\r\n";
return send(part.data(), part.size())
&& send(this->jpeg.data(), this->jpeg.size())
&& send("\r\n", 2);
}
private:
int quality;
std::vector<uint8_t> jpeg;
};
#endif
}
// both parameters go unused on toolchains that compile in neither format
std::unique_ptr<StreamWriter> make_stream_writer(
const std::string &path, [[maybe_unused]] int quality, [[maybe_unused]] int fps) {
#ifdef SPICE_JPEG
if (path == "/stream.mjpg") {
return std::make_unique<MjpegWriter>(quality);
}
#endif
#ifdef SPICE_H264
if (path == "/stream.h264") {
return make_h264_writer(quality, fps);
}
#endif
return nullptr;
}
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <cstddef>
#include <functional>
#include <memory>
#include <string>
#include "capture_pump.h"
namespace api {
// writes bytes to the client; false once the connection is gone
using StreamSend = std::function<bool(const void *, size_t)>;
// one wire format, instantiated per connection so it can keep encoder state across frames
class StreamWriter {
public:
virtual ~StreamWriter() = default;
StreamWriter(const StreamWriter &) = delete;
StreamWriter &operator=(const StreamWriter &) = delete;
// value for the HTTP Content-Type response header
virtual std::string content_type() const = 0;
// for formats that open with an init segment; runs once before any frame
virtual bool begin(const StreamSend &send) { return true; }
virtual bool write(const StreamSend &send, const capture_pump::Frame &frame) = 0;
protected:
StreamWriter() = default;
};
// null when the path does not name a format this build supports
std::unique_ptr<StreamWriter> make_stream_writer(
const std::string &path, int quality, int fps);
}
+476
View File
@@ -0,0 +1,476 @@
#include <winsock2.h>
#include <ws2tcpip.h>
#include "stream_server.h"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <limits>
#include <map>
#include <string>
#include <thread>
#include <vector>
#include "capture_pump.h"
#include "hooks/graphics/graphics.h"
#include "stream_format.h"
#include "util/logging.h"
#include "util/utils.h"
namespace api {
namespace {
struct HttpRequest {
std::string method;
std::string path;
std::map<std::string, std::string> query;
};
bool send_all(SOCKET socket, const void *data, size_t size) {
auto cursor = reinterpret_cast<const char *>(data);
size_t remaining = size;
while (remaining > 0) {
const int sent = send(socket, cursor, static_cast<int>(remaining), 0);
if (sent <= 0) {
return false;
}
cursor += sent;
remaining -= static_cast<size_t>(sent);
}
return true;
}
bool send_all(SOCKET socket, const std::string &text) {
return send_all(socket, text.data(), text.size());
}
// a viewer leaving is normally noticed by a failing send, so a stream with no frame
// to push has to ask the socket instead
bool client_gone(SOCKET socket) {
fd_set read_set;
FD_ZERO(&read_set);
FD_SET(socket, &read_set);
// the socket is blocking with a receive timeout, so poll before touching it
timeval immediately {};
const int ready = select(0, &read_set, nullptr, nullptr, &immediately);
if (ready == 0) {
return false;
}
if (ready < 0) {
return true;
}
// consumed rather than peeked: a stray byte would otherwise sit in front of the
// FIN and keep hiding it for as long as the stream runs
char discard[256];
return recv(socket, discard, sizeof(discard), 0) <= 0;
}
std::string url_decode(const std::string &input) {
std::string out;
out.reserve(input.size());
for (size_t i = 0; i < input.size(); i++) {
if (input[i] == '+') {
out.push_back(' ');
} else if (input[i] == '%' && i + 2 < input.size()
&& isxdigit(static_cast<unsigned char>(input[i + 1]))
&& isxdigit(static_cast<unsigned char>(input[i + 2]))) {
out.push_back(static_cast<char>(
std::stoi(input.substr(i + 1, 2), nullptr, 16)));
i += 2;
} else {
out.push_back(input[i]);
}
}
return out;
}
void parse_query(const std::string &query, HttpRequest &request) {
size_t pos = 0;
while (pos < query.size()) {
auto end = query.find('&', pos);
if (end == std::string::npos) {
end = query.size();
}
const auto pair = query.substr(pos, end - pos);
const auto split = pair.find('=');
if (split != std::string::npos && split > 0) {
request.query[url_decode(pair.substr(0, split))] =
url_decode(pair.substr(split + 1));
}
pos = end + 1;
}
}
// reads the request head only; anything oversized or malformed is refused
bool read_request(SOCKET socket, size_t size_limit, HttpRequest &request) {
std::string head;
char buffer[1024];
while (head.find("\r\n\r\n") == std::string::npos) {
if (head.size() >= size_limit) {
return false;
}
// read no further than the limit, so the head cannot overshoot it
const size_t budget = std::min(sizeof(buffer), size_limit - head.size());
const int received = recv(socket, buffer, static_cast<int>(budget), 0);
if (received <= 0) {
return false;
}
head.append(buffer, static_cast<size_t>(received));
}
const auto line_end = head.find("\r\n");
const auto line = head.substr(0, line_end);
const auto method_end = line.find(' ');
if (method_end == std::string::npos) {
return false;
}
const auto target_end = line.find(' ', method_end + 1);
if (target_end == std::string::npos) {
return false;
}
request.method = line.substr(0, method_end);
auto target = line.substr(method_end + 1, target_end - method_end - 1);
const auto query_start = target.find('?');
if (query_start != std::string::npos) {
parse_query(target.substr(query_start + 1), request);
target = target.substr(0, query_start);
}
request.path = url_decode(target);
return true;
}
int query_int(const HttpRequest &request, const std::string &name, int fallback,
int min, int max) {
const auto pos = request.query.find(name);
if (pos == request.query.end()) {
return fallback;
}
try {
return std::clamp(std::stoi(pos->second), min, max);
} catch (const std::exception &) {
return fallback;
}
}
// an <img> can show a cross-origin stream without this, but a browser client that
// decodes the bytes itself has to fetch() them, and fetch is subject to CORS. errors
// carry it too, or the client sees an opaque failure instead of the status.
constexpr const char *cors_header = "Access-Control-Allow-Origin: *\r\n";
void send_error(SOCKET socket, const char *status) {
const std::string response =
std::string("HTTP/1.0 ") + status + "\r\n"
+ cors_header +
"Content-Length: 0\r\n"
"Connection: close\r\n"
"\r\n";
send_all(socket, response);
}
}
StreamServer::StreamServer(unsigned short port)
: port(port)
{
if (!this->open_listener()) {
// the stream was asked for explicitly, so say plainly that it is not there
log_warning("api::stream",
"the video stream is not available on port {}", this->port);
return;
}
this->running = true;
this->acceptor = std::thread([this] {
this->accept_worker();
});
// deliberately not logging a full URL; local IPs would leak into shared logs
log_info("api::stream", "video stream is listening on port: {}", this->port);
log_warning("api::stream",
"the video stream is unauthenticated - anyone who can reach port {} can watch "
"the game screen", this->port);
}
bool StreamServer::open_listener() {
WSADATA wsa_data;
const int error = WSAStartup(MAKEWORD(2, 2), &wsa_data);
if (error != 0) {
log_warning("api::stream", "WSAStartup() returned {}", error);
return false;
}
this->wsa_started = true;
this->listener = socket(AF_INET, SOCK_STREAM, 0);
if (this->listener == INVALID_SOCKET) {
log_warning("api::stream", "could not create listener socket: {}",
get_last_error_string());
return false;
}
int opt_enable = 1;
if (setsockopt(this->listener, SOL_SOCKET, SO_REUSEADDR,
reinterpret_cast<const char *>(&opt_enable), sizeof(int)) == -1) {
log_warning("api::stream", "could not set socket option SO_REUSEADDR: {}",
get_last_error_string());
}
sockaddr_in server_address {};
server_address.sin_family = AF_INET;
server_address.sin_port = htons(this->port);
server_address.sin_addr.s_addr = INADDR_ANY;
if (bind(this->listener, (sockaddr *) &server_address, sizeof(sockaddr)) == -1) {
log_warning("api::stream", "could not bind socket on port {}: {}",
this->port, get_last_error_string());
closesocket(this->listener);
this->listener = INVALID_SOCKET;
return false;
}
if (listen(this->listener, server_backlog) == -1) {
log_warning("api::stream", "could not listen on port {}: {}",
this->port, get_last_error_string());
closesocket(this->listener);
this->listener = INVALID_SOCKET;
return false;
}
return true;
}
StreamServer::~StreamServer() {
this->running = false;
if (this->listener != INVALID_SOCKET) {
closesocket(this->listener);
this->listener = INVALID_SOCKET;
}
// drops the client threads out of their blocking send/recv
{
std::lock_guard<std::mutex> lock(this->clients_m);
for (auto &client : this->clients) {
if (client.socket != INVALID_SOCKET) {
::shutdown(client.socket, SD_BOTH);
}
}
}
if (this->acceptor.joinable()) {
this->acceptor.join();
}
// joining is what guarantees no client thread outlives this object
for (auto &client : this->clients) {
if (client.thread.joinable()) {
client.thread.join();
}
}
if (this->wsa_started) {
WSACleanup();
}
}
void StreamServer::accept_worker() {
while (this->running) {
sockaddr_in client_address {};
int client_address_size = sizeof(sockaddr_in);
const SOCKET client = accept(
this->listener, (sockaddr *) &client_address, &client_address_size);
if (client == INVALID_SOCKET) {
// on shutdown the listener is closed under us; otherwise do not spin
if (this->running) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
continue;
}
if (!this->running) {
closesocket(client);
break;
}
char address_data[INET_ADDRSTRLEN] {};
inet_ntop(AF_INET, &client_address.sin_addr, address_data, INET_ADDRSTRLEN);
std::string address(address_data);
// every client costs an encode and real bandwidth, so the cap protects the game
int slot = -1;
{
std::lock_guard<std::mutex> lock(this->clients_m);
for (size_t i = 0; i < this->clients.size(); i++) {
if (!this->clients[i].active) {
this->clients[i].active = true;
this->clients[i].socket = client;
slot = static_cast<int>(i);
break;
}
}
}
if (slot < 0) {
log_warning("api::stream", "client limit of {} hit", client_limit);
send_error(client, "503 Service Unavailable");
closesocket(client);
continue;
}
// this thread is the only one that touches the thread objects, so the slot's
// previous occupant gets reaped here rather than being detached
if (this->clients[slot].thread.joinable()) {
this->clients[slot].thread.join();
}
this->clients[slot].thread = std::thread([this, slot, client, address] {
this->client_worker(slot, client, address);
});
}
}
void StreamServer::client_worker(int slot, SOCKET socket, std::string address) {
DWORD timeout = request_timeout_ms;
setsockopt(socket, SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char *>(&timeout), sizeof(timeout));
timeout = send_timeout_ms;
setsockopt(socket, SOL_SOCKET, SO_SNDTIMEO,
reinterpret_cast<const char *>(&timeout), sizeof(timeout));
int opt_enable = 1;
setsockopt(socket, IPPROTO_TCP, TCP_NODELAY,
reinterpret_cast<const char *>(&opt_enable), sizeof(int));
// whatever sits in the send buffer is already stale, and the default holds about a
// third of a second of H.264 because the bitrate is so low. keeping it small makes a
// slow reader block the sender, which then skips to the newest frame instead of
// handing over a backlog
int send_buffer = send_buffer_bytes;
setsockopt(socket, SOL_SOCKET, SO_SNDBUF,
reinterpret_cast<const char *>(&send_buffer), sizeof(send_buffer));
HttpRequest request;
if (read_request(socket, request_size_limit, request)) {
if (request.method != "GET") {
send_error(socket, "405 Method Not Allowed");
} else {
const int fps = query_int(request, "fps", 30, 1, fps_limit);
const int quality = query_int(request, "q", 70, 1, 100);
auto writer = make_stream_writer(request.path, quality, fps);
if (!writer) {
send_error(socket, "404 Not Found");
} else {
std::vector<int> screens;
graphics_screens_get(screens);
// registration takes a raw swapchain index and never bounds it, so the
// capture range has to be enforced here rather than assumed
const auto streamable = [&screens](int screen) {
return screen < static_cast<int>(GRAPHICS_CAPTURE_SCREEN_NO)
&& std::find(screens.begin(), screens.end(), screen)
!= screens.end();
};
// screen 1 is the subscreen in every game that has one; single-screen games
// only ever register screen 0, so resolve the default against what exists.
// left unclamped so a nonsense screen is reported as what was asked for
int screen = query_int(request, "screen", -1, 0,
std::numeric_limits<int>::max());
if (screen < 0) {
screen = streamable(1) ? 1 : 0;
}
// the default always lands on a screen that exists, so this is only ever
// an explicit request for one that cannot be captured
if (!streamable(screen)) {
log_warning("api::stream",
"screen {} is not available, refusing {}", screen, address);
send_error(socket, "404 Not Found");
} else if (!capture_pump::claim_screen(screen)) {
log_warning("api::stream",
"screen {} is already being streamed, refusing {}",
screen, address);
send_error(socket, "503 Service Unavailable");
} else {
log_info("api::stream",
"client connected: {} ({}, screen={}, fps={}, quality={})",
address, request.path, screen, fps, quality);
const std::string header =
"HTTP/1.0 200 OK\r\n"
"Connection: close\r\n"
+ std::string(cors_header) +
"Cache-Control: no-store, no-cache, must-revalidate\r\n"
"Pragma: no-cache\r\n"
"Content-Type: " + writer->content_type() + "\r\n"
"\r\n";
const StreamSend stream_send = [socket](const void *data, size_t size) {
return send_all(socket, data, size);
};
if (send_all(socket, header) && writer->begin(stream_send)) {
const auto interval = std::chrono::microseconds(1000000 / fps);
while (this->running) {
const auto started = std::chrono::steady_clock::now();
capture_pump::Frame frame;
const bool ok = capture_pump::capture_direct(
screen, frame.pixels, 1,
&frame.timestamp, &frame.width, &frame.height);
if (ok && frame.pixels) {
if (!writer->write(stream_send, frame)) {
break;
}
} else if (client_gone(socket)) {
break;
}
// a failed capture still paces, or a stalled game spins this
std::this_thread::sleep_until(started + interval);
}
}
capture_pump::release_screen(screen);
log_info("api::stream", "client disconnected: {}", address);
}
}
}
}
{
std::lock_guard<std::mutex> lock(this->clients_m);
this->clients[slot].socket = INVALID_SOCKET;
this->clients[slot].active = false;
}
closesocket(socket);
}
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <array>
#include <atomic>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <winsock2.h>
namespace api {
class StreamServer {
public:
explicit StreamServer(unsigned short port);
~StreamServer();
StreamServer(const StreamServer &) = delete;
StreamServer &operator=(const StreamServer &) = delete;
private:
// configuration
static constexpr int server_backlog = 4;
static constexpr int client_limit = 4;
static constexpr int request_size_limit = 8 * 1024;
static constexpr int request_timeout_ms = 5000;
static constexpr int send_timeout_ms = 5000;
// small enough that a low bitrate stream cannot hide a backlog of stale frames in it
static constexpr int send_buffer_bytes = 16 * 1024;
static constexpr int fps_limit = 60;
struct Client {
std::thread thread;
SOCKET socket = INVALID_SOCKET;
bool active = false;
};
void accept_worker();
bool open_listener();
void client_worker(int slot, SOCKET socket, std::string address);
unsigned short port;
SOCKET listener = INVALID_SOCKET;
bool wsa_started = false;
std::atomic_bool running { false };
std::thread acceptor;
std::mutex clients_m;
// socket and active are guarded by clients_m; only the acceptor touches thread
std::array<Client, client_limit> clients;
};
}
+29
View File
@@ -12,6 +12,20 @@ using namespace headsocket;
namespace api {
namespace {
// how long a single handshake read may stall before the connection is dropped;
// headsocket reads the request a byte at a time, so this is an idle timeout between
// bytes rather than a deadline for the whole handshake
constexpr int handshake_timeout_ms = 5000;
void set_recv_timeout(connection &conn, int milliseconds) {
DWORD timeout = static_cast<DWORD>(milliseconds);
setsockopt(conn.impl()->socket, SOL_SOCKET, SO_RCVTIMEO,
reinterpret_cast<const char *>(&timeout), sizeof(timeout));
}
}
/*
* Client class declaration
*/
@@ -37,6 +51,21 @@ namespace api {
HEADSOCKET_SERVER(WebSocketServer, web_socket_server);
public:
WebSocketController *websocket;
protected:
bool handshake(connection &conn) override {
// headsocket runs the handshake on its single accept thread with a blocking
// recv, so a peer that connects and then says nothing would park that thread and
// leave every later connection sitting unaccepted in the backlog
set_recv_timeout(conn, handshake_timeout_ms);
const bool accepted = base_t::handshake(conn);
// from here the client thread owns the socket and wants to block on reads
set_recv_timeout(conn, 0);
return accepted;
}
};
void api::WebSocketServer::init() {}
@@ -1,72 +1,72 @@
# fails the build if a PE binary statically imports a forbidden DLL.
#
# some DLLs must never end up in spice's static import table, for two reasons:
#
# 1. user-overridable DLLs (e.g. DXVK's d3d9.dll): users drop their own copy
# into the modules directory to replace the system one. a static import
# forces the loader to load the SYSTEM copy at process startup - before the
# modules directory is added to the DLL search path and before the game DLL
# loads - so the user-supplied override never takes effect (see issue #779).
#
# 2. DLLs that break games when present (e.g. Media Foundation: mf/mfplat/
# mfreadwrite): a static import loads them eagerly and breaks Unity games.
#
# in both cases the DLL must instead be loaded dynamically (libutils::try_library
# / GetProcAddress / delay load) so it is only pulled in when actually needed.
#
# invoked via `cmake -P` from a POST_BUILD step. required -D variables:
# OBJDUMP - path to objdump (CMAKE_OBJDUMP)
# TARGET_FILE - path to the PE binary to inspect
# FORBIDDEN - semicolon-separated list of lowercase DLL names to reject
if(NOT OBJDUMP OR NOT EXISTS "${OBJDUMP}")
message(WARNING
"check_no_static_dll_imports: objdump not found, skipping import check for ${TARGET_FILE}")
return()
endif()
execute_process(
COMMAND "${OBJDUMP}" -p "${TARGET_FILE}"
OUTPUT_VARIABLE dump_output
RESULT_VARIABLE dump_result
ERROR_VARIABLE dump_error)
if(NOT dump_result EQUAL 0)
message(WARNING
"check_no_static_dll_imports: objdump failed for ${TARGET_FILE}: ${dump_error}")
return()
endif()
# both GNU objdump and llvm-objdump print one "DLL Name: <name>" line per
# statically imported DLL in their PE private-header dump.
string(REGEX MATCHALL "DLL Name:[ \t]*[^\n\r]+" dll_lines "${dump_output}")
set(violations "")
foreach(line IN LISTS dll_lines)
string(REGEX REPLACE "DLL Name:[ \t]*" "" dll_name "${line}")
string(STRIP "${dll_name}" dll_name)
string(TOLOWER "${dll_name}" dll_name_lower)
if(dll_name_lower IN_LIST FORBIDDEN)
list(APPEND violations "${dll_name}")
endif()
endforeach()
if(violations)
list(REMOVE_DUPLICATES violations)
string(REPLACE ";" ", " violations_str "${violations}")
message(FATAL_ERROR
"static DLL import check FAILED for ${TARGET_FILE}\n"
" forbidden static imports found: ${violations_str}\n"
"\n"
" these DLLs must never be statically imported by spice:\n"
" * user-overridable DLLs (e.g. DXVK d3d9.dll) - a static import loads the\n"
" system copy at startup and preempts the modules override (issue #779).\n"
" * Media Foundation DLLs (mf/mfplat/mfreadwrite) - a static import breaks\n"
" Unity games.\n"
"\n"
" fix: load the DLL dynamically instead - replace the direct API call with a\n"
" libutils::try_library() + libutils::try_proc() lookup (or a delay load), then\n"
" call through the resolved function pointer.")
endif()
message(STATUS "static DLL import check passed for ${TARGET_FILE}")
# fails the build if a PE binary statically imports a forbidden DLL.
#
# some DLLs must never end up in spice's static import table, for two reasons:
#
# 1. user-overridable DLLs (e.g. DXVK's d3d9.dll): users drop their own copy
# into the modules directory to replace the system one. a static import
# forces the loader to load the SYSTEM copy at process startup - before the
# modules directory is added to the DLL search path and before the game DLL
# loads - so the user-supplied override never takes effect (see issue #779).
#
# 2. DLLs that break games when present (e.g. Media Foundation: mf/mfplat/
# mfreadwrite): a static import loads them eagerly and breaks Unity games.
#
# in both cases the DLL must instead be loaded dynamically (libutils::try_library
# / GetProcAddress / delay load) so it is only pulled in when actually needed.
#
# invoked via `cmake -P` from a POST_BUILD step. required -D variables:
# OBJDUMP - path to objdump (CMAKE_OBJDUMP)
# TARGET_FILE - path to the PE binary to inspect
# FORBIDDEN - semicolon-separated list of lowercase DLL names to reject
if(NOT OBJDUMP OR NOT EXISTS "${OBJDUMP}")
message(WARNING
"check_no_static_dll_imports: objdump not found, skipping import check for ${TARGET_FILE}")
return()
endif()
execute_process(
COMMAND "${OBJDUMP}" -p "${TARGET_FILE}"
OUTPUT_VARIABLE dump_output
RESULT_VARIABLE dump_result
ERROR_VARIABLE dump_error)
if(NOT dump_result EQUAL 0)
message(WARNING
"check_no_static_dll_imports: objdump failed for ${TARGET_FILE}: ${dump_error}")
return()
endif()
# both GNU objdump and llvm-objdump print one "DLL Name: <name>" line per
# statically imported DLL in their PE private-header dump.
string(REGEX MATCHALL "DLL Name:[ \t]*[^\n\r]+" dll_lines "${dump_output}")
set(violations "")
foreach(line IN LISTS dll_lines)
string(REGEX REPLACE "DLL Name:[ \t]*" "" dll_name "${line}")
string(STRIP "${dll_name}" dll_name)
string(TOLOWER "${dll_name}" dll_name_lower)
if(dll_name_lower IN_LIST FORBIDDEN)
list(APPEND violations "${dll_name}")
endif()
endforeach()
if(violations)
list(REMOVE_DUPLICATES violations)
string(REPLACE ";" ", " violations_str "${violations}")
message(FATAL_ERROR
"static DLL import check FAILED for ${TARGET_FILE}\n"
" forbidden static imports found: ${violations_str}\n"
"\n"
" these DLLs must never be statically imported by spice:\n"
" * user-overridable DLLs (e.g. DXVK d3d9.dll) - a static import loads the\n"
" system copy at startup and preempts the modules override (issue #779).\n"
" * Media Foundation DLLs (mf/mfplat/mfreadwrite) - a static import breaks\n"
" Unity games.\n"
"\n"
" fix: load the DLL dynamically instead - replace the direct API call with a\n"
" libutils::try_library() + libutils::try_proc() lookup (or a delay load), then\n"
" call through the resolved function pointer.")
endif()
message(STATUS "static DLL import check passed for ${TARGET_FILE}")
+10 -1
View File
@@ -7,7 +7,7 @@ RUN pacman --noconfirm -Syu git \
ninja \
cmake \
unzip \
wget \
nasm \
mingw-w64-crt \
mingw-w64-winpthreads \
mingw-w64-gcc \
@@ -24,3 +24,12 @@ ENV PATH="$PATH:/opt/llvm-mingw-xp/bin"
RUN curl -fsSL "https://github.com/mon/windows-dll-compat-checker/releases/download/v1.3/windows_dll_compat_checker-linux-x86_64.tar.xz" \
| tar -xJ -C /usr/local/bin
# Stock makepkg.conf builds serially; this makes the AUR compiles below parallel.
RUN printf '%s\n' 'MAKEFLAGS="-j$(nproc)"' > /home/user/.makepkg.conf \
&& chown user: /home/user/.makepkg.conf
# libjpeg-turbo for JPEG encoding, x264 for the API H.264 video stream. Only the
# mingw-w64 toolchains get these; the WinXP targets build without JPEG support
# and without the stream encoder.
RUN su user -c "yay --noconfirm -S mingw-w64-libjpeg-turbo mingw-w64-x264"
+95 -95
View File
@@ -1,95 +1,95 @@
Copyright (c) 2017, keshikan (http://www.keshikan.net),
with Reserved Font Name "DSEG".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Copyright (c) 2017, keshikan (http://www.keshikan.net),
with Reserved Font Name "DSEG".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+73 -73
View File
@@ -1,73 +1,73 @@
#ifndef EASYWSCLIENT_HPP_20120819_MIOFVASDTNUASZDQPLFD
#define EASYWSCLIENT_HPP_20120819_MIOFVASDTNUASZDQPLFD
// This code comes from:
// https://github.com/dhbaird/easywsclient
//
// To get the latest version:
// wget https://raw.github.com/dhbaird/easywsclient/master/easywsclient.hpp
// wget https://raw.github.com/dhbaird/easywsclient/master/easywsclient.cpp
#include <string>
#include <vector>
#include <cstdint>
namespace easywsclient {
struct Callback_Imp { virtual void operator()(const std::string& message) = 0; };
struct BytesCallback_Imp { virtual void operator()(const std::vector<uint8_t>& message) = 0; };
class WebSocket {
public:
typedef WebSocket * pointer;
typedef enum readyStateValues { CLOSING, CLOSED, CONNECTING, OPEN } readyStateValues;
// Factories:
static pointer create_dummy();
static pointer from_url(const std::string& url, const std::string& origin = std::string());
static pointer from_url_no_mask(const std::string& url, const std::string& origin = std::string());
// Interfaces:
virtual ~WebSocket() { }
virtual void poll(int timeout = 0) = 0; // timeout in milliseconds
virtual void send(const std::string& message) = 0;
virtual void sendBinary(const std::string& message) = 0;
virtual void sendBinary(const std::vector<uint8_t>& message) = 0;
virtual void sendPing() = 0;
virtual void close() = 0;
virtual readyStateValues getReadyState() const = 0;
template<class Callable>
void dispatch(Callable callable)
// For callbacks that accept a string argument.
{ // N.B. this is compatible with both C++11 lambdas, functors and C function pointers
struct _Callback : public Callback_Imp {
Callable& callable;
_Callback(Callable& callable) : callable(callable) { }
void operator()(const std::string& message) { callable(message); }
};
_Callback callback(callable);
_dispatch(callback);
}
template<class Callable>
void dispatchBinary(Callable callable)
// For callbacks that accept a std::vector<uint8_t> argument.
{ // N.B. this is compatible with both C++11 lambdas, functors and C function pointers
struct _Callback : public BytesCallback_Imp {
Callable& callable;
_Callback(Callable& callable) : callable(callable) { }
void operator()(const std::vector<uint8_t>& message) { callable(message); }
};
_Callback callback(callable);
_dispatchBinary(callback);
}
protected:
virtual void _dispatch(Callback_Imp& callable) = 0;
virtual void _dispatchBinary(BytesCallback_Imp& callable) = 0;
};
} // namespace easywsclient
#endif /* EASYWSCLIENT_HPP_20120819_MIOFVASDTNUASZDQPLFD */
#ifndef EASYWSCLIENT_HPP_20120819_MIOFVASDTNUASZDQPLFD
#define EASYWSCLIENT_HPP_20120819_MIOFVASDTNUASZDQPLFD
// This code comes from:
// https://github.com/dhbaird/easywsclient
//
// To get the latest version:
// wget https://raw.github.com/dhbaird/easywsclient/master/easywsclient.hpp
// wget https://raw.github.com/dhbaird/easywsclient/master/easywsclient.cpp
#include <string>
#include <vector>
#include <cstdint>
namespace easywsclient {
struct Callback_Imp { virtual void operator()(const std::string& message) = 0; };
struct BytesCallback_Imp { virtual void operator()(const std::vector<uint8_t>& message) = 0; };
class WebSocket {
public:
typedef WebSocket * pointer;
typedef enum readyStateValues { CLOSING, CLOSED, CONNECTING, OPEN } readyStateValues;
// Factories:
static pointer create_dummy();
static pointer from_url(const std::string& url, const std::string& origin = std::string());
static pointer from_url_no_mask(const std::string& url, const std::string& origin = std::string());
// Interfaces:
virtual ~WebSocket() { }
virtual void poll(int timeout = 0) = 0; // timeout in milliseconds
virtual void send(const std::string& message) = 0;
virtual void sendBinary(const std::string& message) = 0;
virtual void sendBinary(const std::vector<uint8_t>& message) = 0;
virtual void sendPing() = 0;
virtual void close() = 0;
virtual readyStateValues getReadyState() const = 0;
template<class Callable>
void dispatch(Callable callable)
// For callbacks that accept a string argument.
{ // N.B. this is compatible with both C++11 lambdas, functors and C function pointers
struct _Callback : public Callback_Imp {
Callable& callable;
_Callback(Callable& callable) : callable(callable) { }
void operator()(const std::string& message) { callable(message); }
};
_Callback callback(callable);
_dispatch(callback);
}
template<class Callable>
void dispatchBinary(Callable callable)
// For callbacks that accept a std::vector<uint8_t> argument.
{ // N.B. this is compatible with both C++11 lambdas, functors and C function pointers
struct _Callback : public BytesCallback_Imp {
Callable& callable;
_Callback(Callable& callable) : callable(callable) { }
void operator()(const std::vector<uint8_t>& message) { callable(message); }
};
_Callback callback(callable);
_dispatchBinary(callback);
}
protected:
virtual void _dispatch(Callback_Imp& callable) = 0;
virtual void _dispatchBinary(BytesCallback_Imp& callable) = 0;
};
} // namespace easywsclient
#endif /* EASYWSCLIENT_HPP_20120819_MIOFVASDTNUASZDQPLFD */
+3222
View File
File diff suppressed because it is too large Load Diff
+122
View File
@@ -0,0 +1,122 @@
// fpng.h - unlicense (see end of fpng.cpp)
#pragma once
#include <stdlib.h>
#include <stdint.h>
#include <vector>
#ifndef FPNG_TRAIN_HUFFMAN_TABLES
// Set to 1 when using the -t (training) option in fpng_test to generate new opaque/alpha Huffman tables for the single pass encoder.
#define FPNG_TRAIN_HUFFMAN_TABLES (0)
#endif
namespace fpng
{
// ---- Library initialization - call once to identify if the processor supports SSE.
// Otherwise you'll only get scalar fallbacks.
void fpng_init();
// ---- Useful Utilities
// Returns true if the CPU supports SSE 4.1, and SSE support wasn't disabled by setting FPNG_NO_SSE=1.
// fpng_init() must have been called first, or it'll assert and return false.
bool fpng_cpu_supports_sse41();
// Fast CRC-32 SSE4.1+pclmul or a scalar fallback (slice by 4)
const uint32_t FPNG_CRC32_INIT = 0;
uint32_t fpng_crc32(const void* pData, size_t size, uint32_t prev_crc32 = FPNG_CRC32_INIT);
// Fast Adler32 SSE4.1 Adler-32 with a scalar fallback.
const uint32_t FPNG_ADLER32_INIT = 1;
uint32_t fpng_adler32(const void* pData, size_t size, uint32_t adler = FPNG_ADLER32_INIT);
// ---- Compression
enum
{
// Enables computing custom Huffman tables for each file, instead of using the custom global tables.
// Results in roughly 6% smaller files on average, but compression is around 40% slower.
FPNG_ENCODE_SLOWER = 1,
// Only use raw Deflate blocks (no compression at all). Intended for testing.
FPNG_FORCE_UNCOMPRESSED = 2,
};
// Fast PNG encoding. The resulting file can be decoded either using a standard PNG decoder or by the fpng_decode_memory() function below.
// pImage: pointer to RGB or RGBA image pixels, R first in memory, B/A last.
// w/h - image dimensions. Image's row pitch in bytes must is w*num_chans.
// num_chans must be 3 or 4.
bool fpng_encode_image_to_memory(const void* pImage, uint32_t w, uint32_t h, uint32_t num_chans, std::vector<uint8_t>& out_buf, uint32_t flags = 0);
#ifndef FPNG_NO_STDIO
// Fast PNG encoding to the specified file.
bool fpng_encode_image_to_file(const char* pFilename, const void* pImage, uint32_t w, uint32_t h, uint32_t num_chans, uint32_t flags = 0);
#endif
// ---- Decompression
enum
{
FPNG_DECODE_SUCCESS = 0, // file is a valid PNG file and written by FPNG and the decode succeeded
FPNG_DECODE_NOT_FPNG, // file is a valid PNG file, but it wasn't written by FPNG so you should try decoding it with a general purpose PNG decoder
FPNG_DECODE_INVALID_ARG, // invalid function parameter
FPNG_DECODE_FAILED_NOT_PNG, // file cannot be a PNG file
FPNG_DECODE_FAILED_HEADER_CRC32, // a chunk CRC32 check failed, file is likely corrupted or not PNG
FPNG_DECODE_FAILED_INVALID_DIMENSIONS, // invalid image dimensions in IHDR chunk (0 or too large)
FPNG_DECODE_FAILED_DIMENSIONS_TOO_LARGE, // decoding the file fully into memory would likely require too much memory (only on 32bpp builds)
FPNG_DECODE_FAILED_CHUNK_PARSING, // failed while parsing the chunk headers, or file is corrupted
FPNG_DECODE_FAILED_INVALID_IDAT, // IDAT data length is too small and cannot be valid, file is either corrupted or it's a bug
// fpng_decode_file() specific errors
FPNG_DECODE_FILE_OPEN_FAILED,
FPNG_DECODE_FILE_TOO_LARGE,
FPNG_DECODE_FILE_READ_FAILED,
FPNG_DECODE_FILE_SEEK_FAILED
};
// Fast PNG decoding of files ONLY created by fpng_encode_image_to_memory() or fpng_encode_image_to_file().
// If fpng_get_info() or fpng_decode_memory() returns FPNG_DECODE_NOT_FPNG, you should decode the PNG by falling back to a general purpose decoder.
//
// fpng_get_info() parses the PNG header and iterates through all chunks to determine if it's a file written by FPNG, but does not decompress the actual image data so it's relatively fast.
//
// pImage, image_size: Pointer to PNG image data and its size
// width, height: output image's dimensions
// channels_in_file: will be 3 or 4
//
// Returns FPNG_DECODE_SUCCESS on success, otherwise one of the failure codes above.
// If FPNG_DECODE_NOT_FPNG is returned, you must decompress the file with a general purpose PNG decoder.
// If another error occurs, the file is likely corrupted or invalid, but you can still try to decompress the file with another decoder (which will likely fail).
int fpng_get_info(const void* pImage, uint32_t image_size, uint32_t& width, uint32_t& height, uint32_t& channels_in_file);
// fpng_decode_memory() decompresses 24/32bpp PNG files ONLY encoded by this module.
// If the image was written by FPNG, it will decompress the image data, otherwise it will return FPNG_DECODE_NOT_FPNG in which case you should fall back to a general purpose PNG decoder (lodepng, stb_image, libpng, etc.)
//
// pImage, image_size: Pointer to PNG image data and its size
// out: Output 24/32bpp image buffer
// width, height: output image's dimensions
// channels_in_file: will be 3 or 4
// desired_channels: must be 3 or 4
//
// If the image is 24bpp and 32bpp is requested, the alpha values will be set to 0xFF.
// If the image is 32bpp and 24bpp is requested, the alpha values will be discarded.
//
// Returns FPNG_DECODE_SUCCESS on success, otherwise one of the failure codes above.
// If FPNG_DECODE_NOT_FPNG is returned, you must decompress the file with a general purpose PNG decoder.
// If another error occurs, the file is likely corrupted or invalid, but you can still try to decompress the file with another decoder (which will likely fail).
int fpng_decode_memory(const void* pImage, uint32_t image_size, std::vector<uint8_t>& out, uint32_t& width, uint32_t& height, uint32_t& channels_in_file, uint32_t desired_channels);
#ifndef FPNG_NO_STDIO
int fpng_decode_file(const char* pFilename, std::vector<uint8_t>& out, uint32_t& width, uint32_t& height, uint32_t& channels_in_file, uint32_t desired_channels);
#endif
// ---- Internal API used for Huffman table training purposes
#if FPNG_TRAIN_HUFFMAN_TABLES
const uint32_t HUFF_COUNTS_SIZE = 288;
extern uint64_t g_huff_counts[HUFF_COUNTS_SIZE];
bool create_dynamic_block_prefix(uint64_t* pFreq, uint32_t num_chans, std::vector<uint8_t>& prefix, uint64_t& bit_buf, int& bit_buf_size, uint32_t *pCodes, uint8_t *pCodesizes);
#endif
} // namespace fpng
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,10 +0,0 @@
zlib License
Copyright (c) 2011-2016 Stephan Brumme
This software is provided 'as-is', without any express or implied warranty. In no event will the authors be held liable for any damages arising from the use of this software.
Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions:
1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software.
If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
-665
View File
@@ -1,665 +0,0 @@
// //////////////////////////////////////////////////////////
// toojpeg.cpp
// written by Stephan Brumme, 2018-2019
// see https://create.stephan-brumme.com/toojpeg/
//
#include "toojpeg.h"
// - the "official" specifications: https://www.w3.org/Graphics/JPEG/itu-t81.pdf and https://www.w3.org/Graphics/JPEG/jfif3.pdf
// - Wikipedia has a short description of the JFIF/JPEG file format: https://en.wikipedia.org/wiki/JPEG_File_Interchange_Format
// - the popular STB Image library includes Jon's JPEG encoder as well: https://github.com/nothings/stb/blob/master/stb_image_write.h
// - the most readable JPEG book (from a developer's perspective) is Miano's "Compressed Image File Formats" (1999, ISBN 0-201-60443-4),
// used copies are really cheap nowadays and include a CD with C++ sources as well (plus great format descriptions of GIF & PNG)
// - much more detailled is Mitchell/Pennebaker's "JPEG: Still Image Data Compression Standard" (1993, ISBN 0-442-01272-1)
// which contains the official JPEG standard, too - fun fact: I bought a signed copy in a second-hand store without noticing
namespace // anonymous namespace to hide local functions / constants / etc.
{
// ////////////////////////////////////////
// data types
using uint8_t = unsigned char;
using uint16_t = unsigned short;
using int16_t = short;
using int32_t = int; // at least four bytes
// ////////////////////////////////////////
// constants
// quantization tables from JPEG Standard, Annex K
const uint8_t DefaultQuantLuminance[8*8] =
{ 16, 11, 10, 16, 24, 40, 51, 61, // there are a few experts proposing slightly more efficient values,
12, 12, 14, 19, 26, 58, 60, 55, // e.g. https://www.imagemagick.org/discourse-server/viewtopic.php?t=20333
14, 13, 16, 24, 40, 57, 69, 56, // btw: Google's Guetzli project optimizes the quantization tables per image
14, 17, 22, 29, 51, 87, 80, 62,
18, 22, 37, 56, 68,109,103, 77,
24, 35, 55, 64, 81,104,113, 92,
49, 64, 78, 87,103,121,120,101,
72, 92, 95, 98,112,100,103, 99 };
const uint8_t DefaultQuantChrominance[8*8] =
{ 17, 18, 24, 47, 99, 99, 99, 99,
18, 21, 26, 66, 99, 99, 99, 99,
24, 26, 56, 99, 99, 99, 99, 99,
47, 66, 99, 99, 99, 99, 99, 99,
99, 99, 99, 99, 99, 99, 99, 99,
99, 99, 99, 99, 99, 99, 99, 99,
99, 99, 99, 99, 99, 99, 99, 99,
99, 99, 99, 99, 99, 99, 99, 99 };
// 8x8 blocks are processed in zig-zag order
// most encoders use a zig-zag "forward" table, I switched to its inverse for performance reasons
// note: ZigZagInv[ZigZag[i]] = i
const uint8_t ZigZagInv[8*8] =
{ 0, 1, 8,16, 9, 2, 3,10, // ZigZag[] = 0, 1, 5, 6,14,15,27,28,
17,24,32,25,18,11, 4, 5, // 2, 4, 7,13,16,26,29,42,
12,19,26,33,40,48,41,34, // 3, 8,12,17,25,30,41,43,
27,20,13, 6, 7,14,21,28, // 9,11,18,24,31,40,44,53,
35,42,49,56,57,50,43,36, // 10,19,23,32,39,45,52,54,
29,22,15,23,30,37,44,51, // 20,22,33,38,46,51,55,60,
58,59,52,45,38,31,39,46, // 21,34,37,47,50,56,59,61,
53,60,61,54,47,55,62,63 }; // 35,36,48,49,57,58,62,63
// static Huffman code tables from JPEG standard Annex K
// - CodesPerBitsize tables define how many Huffman codes will have a certain bitsize (plus 1 because there nothing with zero bits),
// e.g. DcLuminanceCodesPerBitsize[2] = 5 because there are 5 Huffman codes being 2+1=3 bits long
// - Values tables are a list of values ordered by their Huffman code bitsize,
// e.g. AcLuminanceValues => Huffman(0x01,0x02 and 0x03) will have 2 bits, Huffman(0x00) will have 3 bits, Huffman(0x04,0x11 and 0x05) will have 4 bits, ...
// Huffman definitions for first DC/AC tables (luminance / Y channel)
const uint8_t DcLuminanceCodesPerBitsize[16] = { 0,1,5,1,1,1,1,1,1,0,0,0,0,0,0,0 }; // sum = 12
const uint8_t DcLuminanceValues [12] = { 0,1,2,3,4,5,6,7,8,9,10,11 }; // => 12 codes
const uint8_t AcLuminanceCodesPerBitsize[16] = { 0,2,1,3,3,2,4,3,5,5,4,4,0,0,1,125 }; // sum = 162
const uint8_t AcLuminanceValues [162] = // => 162 codes
{ 0x01,0x02,0x03,0x00,0x04,0x11,0x05,0x12,0x21,0x31,0x41,0x06,0x13,0x51,0x61,0x07,0x22,0x71,0x14,0x32,0x81,0x91,0xA1,0x08, // 16*10+2 symbols because
0x23,0x42,0xB1,0xC1,0x15,0x52,0xD1,0xF0,0x24,0x33,0x62,0x72,0x82,0x09,0x0A,0x16,0x17,0x18,0x19,0x1A,0x25,0x26,0x27,0x28, // upper 4 bits can be 0..F
0x29,0x2A,0x34,0x35,0x36,0x37,0x38,0x39,0x3A,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0x4A,0x53,0x54,0x55,0x56,0x57,0x58,0x59, // while lower 4 bits can be 1..A
0x5A,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6A,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7A,0x83,0x84,0x85,0x86,0x87,0x88,0x89, // plus two special codes 0x00 and 0xF0
0x8A,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9A,0xA2,0xA3,0xA4,0xA5,0xA6,0xA7,0xA8,0xA9,0xAA,0xB2,0xB3,0xB4,0xB5,0xB6, // order of these symbols was determined empirically by JPEG committee
0xB7,0xB8,0xB9,0xBA,0xC2,0xC3,0xC4,0xC5,0xC6,0xC7,0xC8,0xC9,0xCA,0xD2,0xD3,0xD4,0xD5,0xD6,0xD7,0xD8,0xD9,0xDA,0xE1,0xE2,
0xE3,0xE4,0xE5,0xE6,0xE7,0xE8,0xE9,0xEA,0xF1,0xF2,0xF3,0xF4,0xF5,0xF6,0xF7,0xF8,0xF9,0xFA };
// Huffman definitions for second DC/AC tables (chrominance / Cb and Cr channels)
const uint8_t DcChrominanceCodesPerBitsize[16] = { 0,3,1,1,1,1,1,1,1,1,1,0,0,0,0,0 }; // sum = 12
const uint8_t DcChrominanceValues [12] = { 0,1,2,3,4,5,6,7,8,9,10,11 }; // => 12 codes (identical to DcLuminanceValues)
const uint8_t AcChrominanceCodesPerBitsize[16] = { 0,2,1,2,4,4,3,4,7,5,4,4,0,1,2,119 }; // sum = 162
const uint8_t AcChrominanceValues [162] = // => 162 codes
{ 0x00,0x01,0x02,0x03,0x11,0x04,0x05,0x21,0x31,0x06,0x12,0x41,0x51,0x07,0x61,0x71,0x13,0x22,0x32,0x81,0x08,0x14,0x42,0x91, // same number of symbol, just different order
0xA1,0xB1,0xC1,0x09,0x23,0x33,0x52,0xF0,0x15,0x62,0x72,0xD1,0x0A,0x16,0x24,0x34,0xE1,0x25,0xF1,0x17,0x18,0x19,0x1A,0x26, // (which is more efficient for AC coding)
0x27,0x28,0x29,0x2A,0x35,0x36,0x37,0x38,0x39,0x3A,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0x4A,0x53,0x54,0x55,0x56,0x57,0x58,
0x59,0x5A,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6A,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7A,0x82,0x83,0x84,0x85,0x86,0x87,
0x88,0x89,0x8A,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9A,0xA2,0xA3,0xA4,0xA5,0xA6,0xA7,0xA8,0xA9,0xAA,0xB2,0xB3,0xB4,
0xB5,0xB6,0xB7,0xB8,0xB9,0xBA,0xC2,0xC3,0xC4,0xC5,0xC6,0xC7,0xC8,0xC9,0xCA,0xD2,0xD3,0xD4,0xD5,0xD6,0xD7,0xD8,0xD9,0xDA,
0xE2,0xE3,0xE4,0xE5,0xE6,0xE7,0xE8,0xE9,0xEA,0xF2,0xF3,0xF4,0xF5,0xF6,0xF7,0xF8,0xF9,0xFA };
const int16_t CodeWordLimit = 2048; // +/-2^11, maximum value after DCT
// ////////////////////////////////////////
// structs
// represent a single Huffman code
struct BitCode
{
BitCode() = default; // undefined state, must be initialized at a later time
BitCode(uint16_t code_, uint8_t numBits_)
: code(code_), numBits(numBits_) {}
uint16_t code; // JPEG's Huffman codes are limited to 16 bits
uint8_t numBits; // number of valid bits
};
// wrapper for bit output operations
struct BitWriter
{
// user-supplied callback that writes/stores one byte
TooJpeg::WRITE_ONE_BYTE output;
// initialize writer
explicit BitWriter(TooJpeg::WRITE_ONE_BYTE output_) : output(output_) {}
// store the most recently encoded bits that are not written yet
struct BitBuffer
{
int32_t data = 0; // actually only at most 24 bits are used
uint8_t numBits = 0; // number of valid bits (the right-most bits)
} buffer;
// write Huffman bits stored in BitCode, keep excess bits in BitBuffer
BitWriter& operator<<(const BitCode& data)
{
// append the new bits to those bits leftover from previous call(s)
buffer.numBits += data.numBits;
buffer.data <<= data.numBits;
buffer.data |= data.code;
// write all "full" bytes
while (buffer.numBits >= 8)
{
// extract highest 8 bits
buffer.numBits -= 8;
auto oneByte = uint8_t(buffer.data >> buffer.numBits);
output(oneByte);
if (oneByte == 0xFF) // 0xFF has a special meaning for JPEGs (it's a block marker)
output(0); // therefore pad a zero to indicate "nope, this one ain't a marker, it's just a coincidence"
// note: I don't clear those written bits, therefore buffer.bits may contain garbage in the high bits
// if you really want to "clean up" (e.g. for debugging purposes) then uncomment the following line
//buffer.bits &= (1 << buffer.numBits) - 1;
}
return *this;
}
// write all non-yet-written bits, fill gaps with 1s (that's a strange JPEG thing)
void flush()
{
// at most seven set bits needed to "fill" the last byte: 0x7F = binary 0111 1111
*this << BitCode(0x7F, 7); // I should set buffer.numBits = 0 but since there are no single bits written after flush() I can safely ignore it
}
// NOTE: all the following BitWriter functions IGNORE the BitBuffer and write straight to output !
// write a single byte
BitWriter& operator<<(uint8_t oneByte)
{
output(oneByte);
return *this;
}
// write an array of bytes
template <typename T, int Size>
BitWriter& operator<<(T (&manyBytes)[Size])
{
for (auto c : manyBytes)
output(c);
return *this;
}
// start a new JFIF block
void addMarker(uint8_t id, uint16_t length)
{
output(0xFF); output(id); // ID, always preceded by 0xFF
output(uint8_t(length >> 8)); // length of the block (big-endian, includes the 2 length bytes as well)
output(uint8_t(length & 0xFF));
}
};
// ////////////////////////////////////////
// functions / templates
// same as std::min()
template <typename Number>
Number minimum(Number value, Number maximum)
{
return value <= maximum ? value : maximum;
}
// restrict a value to the interval [minimum, maximum]
template <typename Number, typename Limit>
Number clamp(Number value, Limit minValue, Limit maxValue)
{
if (value <= minValue) return minValue; // never smaller than the minimum
if (value >= maxValue) return maxValue; // never bigger than the maximum
return value; // value was inside interval, keep it
}
// convert from RGB to YCbCr, constants are similar to ITU-R, see https://en.wikipedia.org/wiki/YCbCr#JPEG_conversion
float rgb2y (float r, float g, float b) { return +0.299f * r +0.587f * g +0.114f * b; }
float rgb2cb(float r, float g, float b) { return -0.16874f * r -0.33126f * g +0.5f * b; }
float rgb2cr(float r, float g, float b) { return +0.5f * r -0.41869f * g -0.08131f * b; }
// forward DCT computation "in one dimension" (fast AAN algorithm by Arai, Agui and Nakajima: "A fast DCT-SQ scheme for images")
void DCT(float block[8*8], uint8_t stride) // stride must be 1 (=horizontal) or 8 (=vertical)
{
const auto SqrtHalfSqrt = 1.306562965f; // sqrt((2 + sqrt(2)) / 2) = cos(pi * 1 / 8) * sqrt(2)
const auto InvSqrt = 0.707106781f; // 1 / sqrt(2) = cos(pi * 2 / 8)
const auto HalfSqrtSqrt = 0.382683432f; // sqrt(2 - sqrt(2)) / 2 = cos(pi * 3 / 8)
const auto InvSqrtSqrt = 0.541196100f; // 1 / sqrt(2 - sqrt(2)) = cos(pi * 3 / 8) * sqrt(2)
// modify in-place
auto& block0 = block[0 ];
auto& block1 = block[1 * stride];
auto& block2 = block[2 * stride];
auto& block3 = block[3 * stride];
auto& block4 = block[4 * stride];
auto& block5 = block[5 * stride];
auto& block6 = block[6 * stride];
auto& block7 = block[7 * stride];
// based on https://dev.w3.org/Amaya/libjpeg/jfdctflt.c , the original variable names can be found in my comments
auto add07 = block0 + block7; auto sub07 = block0 - block7; // tmp0, tmp7
auto add16 = block1 + block6; auto sub16 = block1 - block6; // tmp1, tmp6
auto add25 = block2 + block5; auto sub25 = block2 - block5; // tmp2, tmp5
auto add34 = block3 + block4; auto sub34 = block3 - block4; // tmp3, tmp4
auto add0347 = add07 + add34; auto sub07_34 = add07 - add34; // tmp10, tmp13 ("even part" / "phase 2")
auto add1256 = add16 + add25; auto sub16_25 = add16 - add25; // tmp11, tmp12
block0 = add0347 + add1256; block4 = add0347 - add1256; // "phase 3"
auto z1 = (sub16_25 + sub07_34) * InvSqrt; // all temporary z-variables kept their original names
block2 = sub07_34 + z1; block6 = sub07_34 - z1; // "phase 5"
auto sub23_45 = sub25 + sub34; // tmp10 ("odd part" / "phase 2")
auto sub12_56 = sub16 + sub25; // tmp11
auto sub01_67 = sub16 + sub07; // tmp12
auto z5 = (sub23_45 - sub01_67) * HalfSqrtSqrt;
auto z2 = sub23_45 * InvSqrtSqrt + z5;
auto z3 = sub12_56 * InvSqrt;
auto z4 = sub01_67 * SqrtHalfSqrt + z5;
auto z6 = sub07 + z3; // z11 ("phase 5")
auto z7 = sub07 - z3; // z13
block1 = z6 + z4; block7 = z6 - z4; // "phase 6"
block5 = z7 + z2; block3 = z7 - z2;
}
// run DCT, quantize and write Huffman bit codes
int16_t encodeBlock(BitWriter& writer, float block[8][8], const float scaled[8*8], int16_t lastDC,
const BitCode huffmanDC[256], const BitCode huffmanAC[256], const BitCode* codewords)
{
// "linearize" the 8x8 block, treat it as a flat array of 64 floats
auto block64 = (float*) block;
// DCT: rows
for (auto offset = 0; offset < 8; offset++)
DCT(block64 + offset*8, 1);
// DCT: columns
for (auto offset = 0; offset < 8; offset++)
DCT(block64 + offset*1, 8);
// scale
for (auto i = 0; i < 8*8; i++)
block64[i] *= scaled[i];
// encode DC (the first coefficient is the "average color" of the 8x8 block)
auto DC = int(block64[0] + (block64[0] >= 0 ? +0.5f : -0.5f)); // C++11's nearbyint() achieves a similar effect
// quantize and zigzag the other 63 coefficients
auto posNonZero = 0; // find last coefficient which is not zero (because trailing zeros are encoded differently)
int16_t quantized[8*8];
for (auto i = 1; i < 8*8; i++) // start at 1 because block64[0]=DC was already processed
{
auto value = block64[ZigZagInv[i]];
// round to nearest integer
quantized[i] = int(value + (value >= 0 ? +0.5f : -0.5f)); // C++11's nearbyint() achieves a similar effect
// remember offset of last non-zero coefficient
if (quantized[i] != 0)
posNonZero = i;
}
// same "average color" as previous block ?
auto diff = DC - lastDC;
if (diff == 0)
writer << huffmanDC[0x00]; // yes, write a special short symbol
else
{
auto bits = codewords[diff]; // nope, encode the difference to previous block's average color
writer << huffmanDC[bits.numBits] << bits;
}
// encode ACs (quantized[1..63])
auto offset = 0; // upper 4 bits count the number of consecutive zeros
for (auto i = 1; i <= posNonZero; i++) // quantized[0] was already written, skip all trailing zeros, too
{
// zeros are encoded in a special way
while (quantized[i] == 0) // found another zero ?
{
offset += 0x10; // add 1 to the upper 4 bits
// split into blocks of at most 16 consecutive zeros
if (offset > 0xF0) // remember, the counter is in the upper 4 bits, 0xF = 15
{
writer << huffmanAC[0xF0]; // 0xF0 is a special code for "16 zeros"
offset = 0;
}
i++;
}
auto encoded = codewords[quantized[i]];
// combine number of zeros with the number of bits of the next non-zero value
writer << huffmanAC[offset + encoded.numBits] << encoded; // and the value itself
offset = 0;
}
// send end-of-block code (0x00), only needed if there are trailing zeros
if (posNonZero < 8*8 - 1) // = 63
writer << huffmanAC[0x00];
return DC;
}
// Jon's code includes the pre-generated Huffman codes
// I don't like these "magic constants" and compute them on my own :-)
void generateHuffmanTable(const uint8_t numCodes[16], const uint8_t* values, BitCode result[256])
{
// process all bitsizes 1 thru 16, no JPEG Huffman code is allowed to exceed 16 bits
auto huffmanCode = 0;
for (auto numBits = 1; numBits <= 16; numBits++)
{
// ... and each code of these bitsizes
for (auto i = 0; i < numCodes[numBits - 1]; i++) // note: numCodes array starts at zero, but smallest bitsize is 1
result[*values++] = BitCode(huffmanCode++, numBits);
// next Huffman code needs to be one bit wider
huffmanCode <<= 1;
}
}
} // end of anonymous namespace
// -------------------- externally visible code --------------------
namespace TooJpeg
{
// the only exported function ...
bool writeJpeg(WRITE_ONE_BYTE output, const void* pixels_, unsigned short width, unsigned short height,
bool isRGB, unsigned char quality_, bool downsample, const char* comment)
{
// reject invalid pointers
if (output == nullptr || pixels_ == nullptr)
return false;
// check image format
if (width == 0 || height == 0)
return false;
// number of components
const auto numComponents = isRGB ? 3 : 1;
// note: if there is just one component (=grayscale), then only luminance needs to be stored in the file
// thus everything related to chrominance need not to be written to the JPEG
// I still compute a few things, like quantization tables to avoid a complete code mess
// grayscale images can't be downsampled (because there are no Cb + Cr channels)
if (!isRGB)
downsample = false;
// wrapper for all output operations
BitWriter bitWriter(output);
// ////////////////////////////////////////
// JFIF headers
const uint8_t HeaderJfif[2+2+16] =
{ 0xFF,0xD8, // SOI marker (start of image)
0xFF,0xE0, // JFIF APP0 tag
0,16, // length: 16 bytes (14 bytes payload + 2 bytes for this length field)
'J','F','I','F',0, // JFIF identifier, zero-terminated
1,1, // JFIF version 1.1
0, // no density units specified
0,1,0,1, // density: 1 pixel "per pixel" horizontally and vertically
0,0 }; // no thumbnail (size 0 x 0)
bitWriter << HeaderJfif;
// ////////////////////////////////////////
// comment (optional)
if (comment != nullptr)
{
// look for zero terminator
auto length = 0; // = strlen(comment);
while (comment[length] != 0)
length++;
// write COM marker
bitWriter.addMarker(0xFE, 2+length); // block size is number of bytes (without zero terminator) + 2 bytes for this length field
// ... and write the comment itself
for (auto i = 0; i < length; i++)
bitWriter << comment[i];
}
// ////////////////////////////////////////
// adjust quantization tables to desired quality
// quality level must be in 1 ... 100
auto quality = clamp<uint16_t>(quality_, 1, 100);
// convert to an internal JPEG quality factor, formula taken from libjpeg
quality = quality < 50 ? 5000 / quality : 200 - quality * 2;
uint8_t quantLuminance [8*8];
uint8_t quantChrominance[8*8];
for (auto i = 0; i < 8*8; i++)
{
int luminance = (DefaultQuantLuminance [ZigZagInv[i]] * quality + 50) / 100;
int chrominance = (DefaultQuantChrominance[ZigZagInv[i]] * quality + 50) / 100;
// clamp to 1..255
quantLuminance [i] = clamp(luminance, 1, 255);
quantChrominance[i] = clamp(chrominance, 1, 255);
}
// write quantization tables
bitWriter.addMarker(0xDB, 2 + (isRGB ? 2 : 1) * (1 + 8*8)); // length: 65 bytes per table + 2 bytes for this length field
// each table has 64 entries and is preceded by an ID byte
bitWriter << 0x00 << quantLuminance; // first quantization table
if (isRGB)
bitWriter << 0x01 << quantChrominance; // second quantization table, only relevant for color images
// ////////////////////////////////////////
// write image infos (SOF0 - start of frame)
bitWriter.addMarker(0xC0, 2+6+3*numComponents); // length: 6 bytes general info + 3 per channel + 2 bytes for this length field
// 8 bits per channel
bitWriter << 0x08
// image dimensions (big-endian)
<< (height >> 8) << (height & 0xFF)
<< (width >> 8) << (width & 0xFF);
// sampling and quantization tables for each component
bitWriter << numComponents; // 1 component (grayscale, Y only) or 3 components (Y,Cb,Cr)
for (auto id = 1; id <= numComponents; id++)
bitWriter << id // component ID (Y=1, Cb=2, Cr=3)
// bitmasks for sampling: highest 4 bits: horizontal, lowest 4 bits: vertical
<< (id == 1 && downsample ? 0x22 : 0x11) // 0x11 is default YCbCr 4:4:4 and 0x22 stands for YCbCr 4:2:0
<< (id == 1 ? 0 : 1); // use quantization table 0 for Y, table 1 for Cb and Cr
// ////////////////////////////////////////
// Huffman tables
// DHT marker - define Huffman tables
bitWriter.addMarker(0xC4, isRGB ? (2+208+208) : (2+208));
// 2 bytes for the length field, store chrominance only if needed
// 1+16+12 for the DC luminance
// 1+16+162 for the AC luminance (208 = 1+16+12 + 1+16+162)
// 1+16+12 for the DC chrominance
// 1+16+162 for the AC chrominance (208 = 1+16+12 + 1+16+162, same as above)
// store luminance's DC+AC Huffman table definitions
bitWriter << 0x00 // highest 4 bits: 0 => DC, lowest 4 bits: 0 => Y (baseline)
<< DcLuminanceCodesPerBitsize
<< DcLuminanceValues;
bitWriter << 0x10 // highest 4 bits: 1 => AC, lowest 4 bits: 0 => Y (baseline)
<< AcLuminanceCodesPerBitsize
<< AcLuminanceValues;
// compute actual Huffman code tables (see Jon's code for precalculated tables)
BitCode huffmanLuminanceDC[256];
BitCode huffmanLuminanceAC[256];
generateHuffmanTable(DcLuminanceCodesPerBitsize, DcLuminanceValues, huffmanLuminanceDC);
generateHuffmanTable(AcLuminanceCodesPerBitsize, AcLuminanceValues, huffmanLuminanceAC);
// chrominance is only relevant for color images
BitCode huffmanChrominanceDC[256];
BitCode huffmanChrominanceAC[256];
if (isRGB)
{
// store luminance's DC+AC Huffman table definitions
bitWriter << 0x01 // highest 4 bits: 0 => DC, lowest 4 bits: 1 => Cr,Cb (baseline)
<< DcChrominanceCodesPerBitsize
<< DcChrominanceValues;
bitWriter << 0x11 // highest 4 bits: 1 => AC, lowest 4 bits: 1 => Cr,Cb (baseline)
<< AcChrominanceCodesPerBitsize
<< AcChrominanceValues;
// compute actual Huffman code tables (see Jon's code for precalculated tables)
generateHuffmanTable(DcChrominanceCodesPerBitsize, DcChrominanceValues, huffmanChrominanceDC);
generateHuffmanTable(AcChrominanceCodesPerBitsize, AcChrominanceValues, huffmanChrominanceAC);
}
// ////////////////////////////////////////
// start of scan (there is only a single scan for baseline JPEGs)
bitWriter.addMarker(0xDA, 2+1+2*numComponents+3); // 2 bytes for the length field, 1 byte for number of components,
// then 2 bytes for each component and 3 bytes for spectral selection
// assign Huffman tables to each component
bitWriter << numComponents;
for (auto id = 1; id <= numComponents; id++)
// highest 4 bits: DC Huffman table, lowest 4 bits: AC Huffman table
bitWriter << id << (id == 1 ? 0x00 : 0x11); // Y: tables 0 for DC and AC; Cb + Cr: tables 1 for DC and AC
// constant values for our baseline JPEGs (which have a single sequential scan)
static const uint8_t Spectral[3] = { 0, 63, 0 }; // spectral selection: must be from 0 to 63; successive approximation must be 0
bitWriter << Spectral;
// ////////////////////////////////////////
// adjust quantization tables with AAN scaling factors to simplify DCT
float scaledLuminance [8*8];
float scaledChrominance[8*8];
for (auto i = 0; i < 8*8; i++)
{
auto row = ZigZagInv[i] / 8; // same as ZigZagInv[i] >> 3
auto column = ZigZagInv[i] % 8; // same as ZigZagInv[i] & 7
// scaling constants for AAN DCT algorithm: AanScaleFactors[0] = 1, AanScaleFactors[k=1..7] = cos(k*PI/16) * sqrt(2)
static const float AanScaleFactors[8] = { 1, 1.387039845f, 1.306562965f, 1.175875602f, 1, 0.785694958f, 0.541196100f, 0.275899379f };
auto factor = 1 / (AanScaleFactors[row] * AanScaleFactors[column] * 8);
scaledLuminance [ZigZagInv[i]] = factor / quantLuminance [i];
scaledChrominance[ZigZagInv[i]] = factor / quantChrominance[i];
// if you really want JPEGs that are bitwise identical to Jon Olick's code then you need slightly different formulas (note: sqrt(8) = 2.828427125f)
//static const float aasf[] = { 1.0f * 2.828427125f, 1.387039845f * 2.828427125f, 1.306562965f * 2.828427125f, 1.175875602f * 2.828427125f, 1.0f * 2.828427125f, 0.785694958f * 2.828427125f, 0.541196100f * 2.828427125f, 0.275899379f * 2.828427125f }; // line 240 of jo_jpeg.cpp
//scaledLuminance [ZigZagInv[i]] = 1 / (quantLuminance [i] * aasf[row] * aasf[column]); // lines 266-267 of jo_jpeg.cpp
//scaledChrominance[ZigZagInv[i]] = 1 / (quantChrominance[i] * aasf[row] * aasf[column]);
}
// ////////////////////////////////////////
// precompute JPEG codewords for quantized DCT
BitCode codewordsArray[2 * CodeWordLimit]; // note: quantized[i] is found at codewordsArray[quantized[i] + CodeWordLimit]
BitCode* codewords = &codewordsArray[CodeWordLimit]; // allow negative indices, so quantized[i] is at codewords[quantized[i]]
uint8_t numBits = 1; // each codeword has at least one bit (value == 0 is undefined)
int32_t mask = 1; // mask is always 2^numBits - 1, initial value 2^1-1 = 2-1 = 1
for (int16_t value = 1; value < CodeWordLimit; value++)
{
// numBits = position of highest set bit (ignoring the sign)
// mask = (2^numBits) - 1
if (value > mask) // one more bit ?
{
numBits++;
mask = (mask << 1) | 1; // append a set bit
}
codewords[-value] = BitCode(mask - value, numBits); // note that I use a negative index => codewords[-value] = codewordsArray[CodeWordLimit value]
codewords[+value] = BitCode( value, numBits);
}
// just convert image data from void*
auto pixels = (const uint8_t*)pixels_;
// the next two variables are frequently used when checking for image borders
const auto maxWidth = width - 1; // "last row"
const auto maxHeight = height - 1; // "bottom line"
// process MCUs (minimum codes units) => image is subdivided into a grid of 8x8 or 16x16 tiles
const auto sampling = downsample ? 2 : 1; // 1x1 or 2x2 sampling
const auto mcuSize = 8 * sampling;
// average color of the previous MCU
int16_t lastYDC = 0, lastCbDC = 0, lastCrDC = 0;
// convert from RGB to YCbCr
float Y[8][8], Cb[8][8], Cr[8][8];
for (auto mcuY = 0; mcuY < height; mcuY += mcuSize) // each step is either 8 or 16 (=mcuSize)
for (auto mcuX = 0; mcuX < width; mcuX += mcuSize)
{
// YCbCr 4:4:4 format: each MCU is a 8x8 block - the same applies to grayscale images, too
// YCbCr 4:2:0 format: each MCU represents a 16x16 block, stored as 4x 8x8 Y-blocks plus 1x 8x8 Cb and 1x 8x8 Cr block)
for (auto blockY = 0; blockY < mcuSize; blockY += 8) // iterate once (YCbCr444 and grayscale) or twice (YCbCr420)
for (auto blockX = 0; blockX < mcuSize; blockX += 8)
{
// now we finally have an 8x8 block ...
for (auto deltaY = 0; deltaY < 8; deltaY++)
{
auto column = minimum(mcuX + blockX , maxWidth); // must not exceed image borders, replicate last row/column if needed
auto row = minimum(mcuY + blockY + deltaY, maxHeight);
for (auto deltaX = 0; deltaX < 8; deltaX++)
{
// find actual pixel position within the current image
auto pixelPos = row * int(width) + column; // the cast ensures that we don't run into multiplication overflows
if (column < maxWidth)
column++;
// grayscale images have solely a Y channel which can be easily derived from the input pixel by shifting it by 128
if (!isRGB)
{
Y[deltaY][deltaX] = pixels[pixelPos] - 128.f;
continue;
}
// RGB: 3 bytes per pixel (whereas grayscale images have only 1 byte per pixel)
auto r = pixels[3 * pixelPos ];
auto g = pixels[3 * pixelPos + 1];
auto b = pixels[3 * pixelPos + 2];
Y [deltaY][deltaX] = rgb2y (r, g, b) - 128; // again, the JPEG standard requires Y to be shifted by 128
// YCbCr444 is easy - the more complex YCbCr420 has to be computed about 20 lines below in a second pass
if (!downsample)
{
Cb[deltaY][deltaX] = rgb2cb(r, g, b); // standard RGB-to-YCbCr conversion
Cr[deltaY][deltaX] = rgb2cr(r, g, b);
}
}
}
// encode Y channel
lastYDC = encodeBlock(bitWriter, Y, scaledLuminance, lastYDC, huffmanLuminanceDC, huffmanLuminanceAC, codewords);
// Cb and Cr are encoded about 50 lines below
}
// grayscale images don't need any Cb and Cr information
if (!isRGB)
continue;
// ////////////////////////////////////////
// the following lines are only relevant for YCbCr420:
// average/downsample chrominance of four pixels while respecting the image borders
if (downsample)
for (short deltaY = 7; downsample && deltaY >= 0; deltaY--) // iterating loop in reverse increases cache read efficiency
{
auto row = minimum(mcuY + 2*deltaY, maxHeight); // each deltaX/Y step covers a 2x2 area
auto column = mcuX; // column is updated inside next loop
auto pixelPos = (row * int(width) + column) * 3; // numComponents = 3
// deltas (in bytes) to next row / column, must not exceed image borders
auto rowStep = (row < maxHeight) ? 3 * int(width) : 0; // always numComponents*width except for bottom line
auto columnStep = (column < maxWidth ) ? 3 : 0; // always numComponents except for rightmost pixel
for (short deltaX = 0; deltaX < 8; deltaX++)
{
// let's add all four samples (2x2 area)
auto right = pixelPos + columnStep;
auto down = pixelPos + rowStep;
auto downRight = pixelPos + columnStep + rowStep;
// note: cast from 8 bits to >8 bits to avoid overflows when adding
auto r = short(pixels[pixelPos ]) + pixels[right ] + pixels[down ] + pixels[downRight ];
auto g = short(pixels[pixelPos + 1]) + pixels[right + 1] + pixels[down + 1] + pixels[downRight + 1];
auto b = short(pixels[pixelPos + 2]) + pixels[right + 2] + pixels[down + 2] + pixels[downRight + 2];
// convert to Cb and Cr
Cb[deltaY][deltaX] = rgb2cb(r, g, b) / 4; // I still have to divide r,g,b by 4 to get their average values
Cr[deltaY][deltaX] = rgb2cr(r, g, b) / 4; // it's a bit faster if done AFTER CbCr conversion
// step forward to next 2x2 area
pixelPos += 2*3; // 2 pixels => 6 bytes (2*numComponents)
column += 2;
// reached right border ?
if (column >= maxWidth)
{
columnStep = 0;
pixelPos = ((row + 1) * int(width) - 1) * 3; // same as (row * width + maxWidth) * numComponents => current's row last pixel
}
}
} // end of YCbCr420 code for Cb and Cr
// encode Cb and Cr
lastCbDC = encodeBlock(bitWriter, Cb, scaledChrominance, lastCbDC, huffmanChrominanceDC, huffmanChrominanceAC, codewords);
lastCrDC = encodeBlock(bitWriter, Cr, scaledChrominance, lastCrDC, huffmanChrominanceDC, huffmanChrominanceAC, codewords);
}
bitWriter.flush(); // now image is completely encoded, write any bits still left in the buffer
// ///////////////////////////
// EOI marker
bitWriter << 0xFF << 0xD9; // this marker has no length, therefore I can't use addMarker()
return true;
} // writeJpeg()
} // namespace TooJpeg
-62
View File
@@ -1,62 +0,0 @@
// //////////////////////////////////////////////////////////
// toojpeg.h
// written by Stephan Brumme, 2018-2019
// see https://create.stephan-brumme.com/toojpeg/
//
// This is a compact baseline JPEG/JFIF writer, written in C++ (but looks like C for the most part).
// Its interface has only one function: writeJpeg() - and that's it !
//
// basic example:
// => create an image with any content you like, e.g. 1024x768, RGB = 3 bytes per pixel
// auto pixels = new unsigned char[1024*768*3];
// => you need to define a callback that receives the compressed data byte-by-byte from my JPEG writer
// void myOutput(unsigned char oneByte) { fputc(oneByte, myFileHandle); } // save byte to file
// => let's go !
// TooJpeg::writeJpeg(myOutput, mypixels, 1024, 768);
#pragma once
namespace TooJpeg
{
// write one byte (to disk, memory, ...)
typedef void (*WRITE_ONE_BYTE)(unsigned char);
// this callback is called for every byte generated by the encoder and behaves similar to fputc
// if you prefer stylish C++11 syntax then it can be a lambda, too:
// auto myOutput = [](unsigned char oneByte) { fputc(oneByte, output); };
// output - callback that stores a single byte (writes to disk, memory, ...)
// pixels - stored in RGB format or grayscale, stored from upper-left to lower-right
// width,height - image size
// isRGB - true if RGB format (3 bytes per pixel); false if grayscale (1 byte per pixel)
// quality - between 1 (worst) and 100 (best)
// downsample - if true then YCbCr 4:2:0 format is used (smaller size, minor quality loss) instead of 4:4:4, not relevant for grayscale
// comment - optional JPEG comment (0/NULL if no comment), must not contain ASCII code 0xFF
bool writeJpeg(WRITE_ONE_BYTE output, const void* pixels, unsigned short width, unsigned short height,
bool isRGB = true, unsigned char quality = 90, bool downsample = false, const char* comment = nullptr);
} // namespace TooJpeg
// My main inspiration was Jon Olick's Minimalistic JPEG writer
// ( https://www.jonolick.com/code.html => direct link is https://www.jonolick.com/uploads/7/9/2/1/7921194/jo_jpeg.cpp ).
// However, his code documentation is quite sparse - probably because it wasn't written from scratch and is (quote:) "based on a javascript jpeg writer",
// most likely Andreas Ritter's code: https://github.com/eugeneware/jpeg-js/blob/master/lib/encoder.js
//
// Therefore I wrote the whole lib from scratch and tried hard to add tons of comments to my code, especially describing where all those magic numbers come from.
// And I managed to remove the need for any external includes ...
// yes, that's right: my library has no (!) includes at all, not even #include <stdlib.h>
// Depending on your callback WRITE_ONE_BYTE, the library writes either to disk, or in-memory, or wherever you wish.
// Moreover, no dynamic memory allocations are performed, just a few bytes on the stack.
//
// In contrast to Jon's code, compression can be significantly improved in many use cases:
// a) grayscale JPEG images need just a single Y channel, no need to save the superfluous Cb + Cr channels
// b) YCbCr 4:2:0 downsampling is often about 20% more efficient (=smaller) than the default YCbCr 4:4:4 with only little visual loss
//
// TooJpeg 1.2+ compresses about twice as fast as jo_jpeg (and about half as fast as libjpeg-turbo).
// A few benchmark numbers can be found on my website https://create.stephan-brumme.com/toojpeg/#benchmark
//
// Last but not least you can optionally add a JPEG comment.
//
// Your C++ compiler needs to support a reasonable subset of C++11 (g++ 4.7 or Visual C++ 2013 are sufficient).
// I haven't tested the code on big-endian systems or anything that smells like an apple.
//
// USE AT YOUR OWN RISK. Because you are a brave soul :-)
+171 -171
View File
@@ -1,171 +1,171 @@
#include "asio.h"
#include <windows.h>
#include <cstring>
#include "avs/game.h"
#include "gitadora.h"
#include "util/detour.h"
#include "util/logging.h"
namespace games::gitadora {
// Redirects the game's hard-coded "XONAR" ASIO driver lookup to the
// driver name in ASIO_DRIVER by intercepting registry calls to
// HKLM\SOFTWARE\ASIO. Sentinel HKEY values mark the redirected handles
// so we can recognise them on subsequent reg* calls.
static const HKEY PARENT_ASIO_REG_HANDLE = reinterpret_cast<HKEY>(0x4001);
static const HKEY DEVICE_ASIO_REG_HANDLE = reinterpret_cast<HKEY>(0x4002);
static const char *FAKE_ASIO_DEVICE_NAME = "XONAR";
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;
static HKEY real_asio_reg_handle = nullptr;
static HKEY real_asio_device_reg_handle = nullptr;
static LONG WINAPI RegOpenKeyExA_hook(HKEY hKey, LPCSTR lpSubKey, DWORD ulOptions, REGSAM samDesired,
PHKEY phkResult)
{
if (ASIO_DRIVER.has_value() &&
lpSubKey != nullptr &&
phkResult != nullptr &&
hKey == PARENT_ASIO_REG_HANDLE &&
_stricmp(lpSubKey, FAKE_ASIO_DEVICE_NAME) == 0) {
*phkResult = DEVICE_ASIO_REG_HANDLE;
log_info("gitadora::asio", "replacing '{}' with '{}'", lpSubKey, ASIO_DRIVER.value());
const auto result = RegOpenKeyExA_orig(
real_asio_reg_handle,
ASIO_DRIVER.value().c_str(),
ulOptions,
samDesired,
&real_asio_device_reg_handle);
if (result != ERROR_SUCCESS) {
log_warning(
"gitadora::asio",
"failed to open registry subkey '{}', error=0x{:x}",
ASIO_DRIVER.value(), result);
log_warning(
"gitadora::asio",
"due to improper ASIO setting, audio init will fail");
}
return result;
}
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 &&
hKey == HKEY_LOCAL_MACHINE &&
_stricmp(lpSubKey, "software\\asio") == 0)
{
*phkResult = PARENT_ASIO_REG_HANDLE;
return RegOpenKeyA_orig(hKey, lpSubKey, &real_asio_reg_handle);
}
return RegOpenKeyA_orig(hKey, lpSubKey, phkResult);
}
static LONG WINAPI RegEnumKeyA_hook(HKEY hKey, DWORD dwIndex, LPSTR lpName, DWORD cchName) {
if (hKey == PARENT_ASIO_REG_HANDLE && ASIO_DRIVER.has_value()) {
if (dwIndex == 0) {
// forward to real handle just to verify the key exists; we
// overwrite the name with our fake driver string regardless
auto ret = RegEnumKeyA_orig(real_asio_reg_handle, dwIndex, lpName, cchName);
if (ret == ERROR_SUCCESS && lpName != nullptr && cchName > 0) {
log_info("gitadora::asio", "stubbing '{}' with '{}'", lpName, FAKE_ASIO_DEVICE_NAME);
strncpy(lpName, FAKE_ASIO_DEVICE_NAME, cchName);
lpName[cchName - 1] = '\0';
}
return ret;
} else {
return ERROR_NO_MORE_ITEMS;
}
}
return RegEnumKeyA_orig(hKey, dwIndex, lpName, cchName);
}
static LONG WINAPI RegQueryValueExA_hook(HKEY hKey, LPCSTR lpValueName, LPDWORD lpReserved, LPDWORD lpType,
LPBYTE lpData, LPDWORD lpcbData)
{
HKEY target = hKey;
if (ASIO_DRIVER.has_value() &&
lpValueName != nullptr &&
lpData != nullptr &&
lpcbData != nullptr &&
hKey == DEVICE_ASIO_REG_HANDLE) {
if (_stricmp(lpValueName, "Description") == 0) {
// engine may verify the driver name after open; ensure it still
// sees something containing "XONAR" so the substring check passes
const size_t len = strlen(FAKE_ASIO_DEVICE_NAME) + 1;
if (*lpcbData < len) {
*lpcbData = static_cast<DWORD>(len);
return ERROR_MORE_DATA;
}
memcpy(lpData, FAKE_ASIO_DEVICE_NAME, len);
*lpcbData = static_cast<DWORD>(len);
if (lpType != nullptr) {
*lpType = REG_SZ;
}
return ERROR_SUCCESS;
}
// for everything else (CLSID etc.) defer to the real driver subkey
target = real_asio_device_reg_handle;
}
return RegQueryValueExA_orig(target, lpValueName, lpReserved, lpType, lpData, lpcbData);
}
static LONG WINAPI RegCloseKey_hook(HKEY hKey) {
if (hKey == PARENT_ASIO_REG_HANDLE) {
if (real_asio_reg_handle != nullptr) {
RegCloseKey_orig(real_asio_reg_handle);
real_asio_reg_handle = nullptr;
}
return ERROR_SUCCESS;
}
if (hKey == DEVICE_ASIO_REG_HANDLE) {
if (real_asio_device_reg_handle != nullptr) {
RegCloseKey_orig(real_asio_device_reg_handle);
real_asio_device_reg_handle = nullptr;
}
return ERROR_SUCCESS;
}
return RegCloseKey_orig(hKey);
}
void asio_hook_init() {
if (!ASIO_DRIVER.has_value()) {
return;
}
log_info("gitadora::asio", "installing ASIO driver redirect: XONAR -> {}", ASIO_DRIVER.value());
RegCloseKey_orig = detour::iat_try(
"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(
"RegQueryValueExA", RegQueryValueExA_hook, avs::game::DLL_INSTANCE);
}
}
#include "asio.h"
#include <windows.h>
#include <cstring>
#include "avs/game.h"
#include "gitadora.h"
#include "util/detour.h"
#include "util/logging.h"
namespace games::gitadora {
// Redirects the game's hard-coded "XONAR" ASIO driver lookup to the
// driver name in ASIO_DRIVER by intercepting registry calls to
// HKLM\SOFTWARE\ASIO. Sentinel HKEY values mark the redirected handles
// so we can recognise them on subsequent reg* calls.
static const HKEY PARENT_ASIO_REG_HANDLE = reinterpret_cast<HKEY>(0x4001);
static const HKEY DEVICE_ASIO_REG_HANDLE = reinterpret_cast<HKEY>(0x4002);
static const char *FAKE_ASIO_DEVICE_NAME = "XONAR";
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;
static HKEY real_asio_reg_handle = nullptr;
static HKEY real_asio_device_reg_handle = nullptr;
static LONG WINAPI RegOpenKeyExA_hook(HKEY hKey, LPCSTR lpSubKey, DWORD ulOptions, REGSAM samDesired,
PHKEY phkResult)
{
if (ASIO_DRIVER.has_value() &&
lpSubKey != nullptr &&
phkResult != nullptr &&
hKey == PARENT_ASIO_REG_HANDLE &&
_stricmp(lpSubKey, FAKE_ASIO_DEVICE_NAME) == 0) {
*phkResult = DEVICE_ASIO_REG_HANDLE;
log_info("gitadora::asio", "replacing '{}' with '{}'", lpSubKey, ASIO_DRIVER.value());
const auto result = RegOpenKeyExA_orig(
real_asio_reg_handle,
ASIO_DRIVER.value().c_str(),
ulOptions,
samDesired,
&real_asio_device_reg_handle);
if (result != ERROR_SUCCESS) {
log_warning(
"gitadora::asio",
"failed to open registry subkey '{}', error=0x{:x}",
ASIO_DRIVER.value(), result);
log_warning(
"gitadora::asio",
"due to improper ASIO setting, audio init will fail");
}
return result;
}
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 &&
hKey == HKEY_LOCAL_MACHINE &&
_stricmp(lpSubKey, "software\\asio") == 0)
{
*phkResult = PARENT_ASIO_REG_HANDLE;
return RegOpenKeyA_orig(hKey, lpSubKey, &real_asio_reg_handle);
}
return RegOpenKeyA_orig(hKey, lpSubKey, phkResult);
}
static LONG WINAPI RegEnumKeyA_hook(HKEY hKey, DWORD dwIndex, LPSTR lpName, DWORD cchName) {
if (hKey == PARENT_ASIO_REG_HANDLE && ASIO_DRIVER.has_value()) {
if (dwIndex == 0) {
// forward to real handle just to verify the key exists; we
// overwrite the name with our fake driver string regardless
auto ret = RegEnumKeyA_orig(real_asio_reg_handle, dwIndex, lpName, cchName);
if (ret == ERROR_SUCCESS && lpName != nullptr && cchName > 0) {
log_info("gitadora::asio", "stubbing '{}' with '{}'", lpName, FAKE_ASIO_DEVICE_NAME);
strncpy(lpName, FAKE_ASIO_DEVICE_NAME, cchName);
lpName[cchName - 1] = '\0';
}
return ret;
} else {
return ERROR_NO_MORE_ITEMS;
}
}
return RegEnumKeyA_orig(hKey, dwIndex, lpName, cchName);
}
static LONG WINAPI RegQueryValueExA_hook(HKEY hKey, LPCSTR lpValueName, LPDWORD lpReserved, LPDWORD lpType,
LPBYTE lpData, LPDWORD lpcbData)
{
HKEY target = hKey;
if (ASIO_DRIVER.has_value() &&
lpValueName != nullptr &&
lpData != nullptr &&
lpcbData != nullptr &&
hKey == DEVICE_ASIO_REG_HANDLE) {
if (_stricmp(lpValueName, "Description") == 0) {
// engine may verify the driver name after open; ensure it still
// sees something containing "XONAR" so the substring check passes
const size_t len = strlen(FAKE_ASIO_DEVICE_NAME) + 1;
if (*lpcbData < len) {
*lpcbData = static_cast<DWORD>(len);
return ERROR_MORE_DATA;
}
memcpy(lpData, FAKE_ASIO_DEVICE_NAME, len);
*lpcbData = static_cast<DWORD>(len);
if (lpType != nullptr) {
*lpType = REG_SZ;
}
return ERROR_SUCCESS;
}
// for everything else (CLSID etc.) defer to the real driver subkey
target = real_asio_device_reg_handle;
}
return RegQueryValueExA_orig(target, lpValueName, lpReserved, lpType, lpData, lpcbData);
}
static LONG WINAPI RegCloseKey_hook(HKEY hKey) {
if (hKey == PARENT_ASIO_REG_HANDLE) {
if (real_asio_reg_handle != nullptr) {
RegCloseKey_orig(real_asio_reg_handle);
real_asio_reg_handle = nullptr;
}
return ERROR_SUCCESS;
}
if (hKey == DEVICE_ASIO_REG_HANDLE) {
if (real_asio_device_reg_handle != nullptr) {
RegCloseKey_orig(real_asio_device_reg_handle);
real_asio_device_reg_handle = nullptr;
}
return ERROR_SUCCESS;
}
return RegCloseKey_orig(hKey);
}
void asio_hook_init() {
if (!ASIO_DRIVER.has_value()) {
return;
}
log_info("gitadora::asio", "installing ASIO driver redirect: XONAR -> {}", ASIO_DRIVER.value());
RegCloseKey_orig = detour::iat_try(
"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(
"RegQueryValueExA", RegQueryValueExA_hook, avs::game::DLL_INSTANCE);
}
}
+12 -12
View File
@@ -1,12 +1,12 @@
#pragma once
namespace games::gitadora {
// installs IAT registry hooks in gfdm.dll that redirect the game's
// ASIO driver lookup (hard-coded "XONAR" substring) to a user-chosen
// driver name read from games::gitadora::ASIO_DRIVER.
//
// safe to call unconditionally; if ASIO_DRIVER is unset the hooks
// forward every call straight through to advapi32.
void asio_hook_init();
}
#pragma once
namespace games::gitadora {
// installs IAT registry hooks in gfdm.dll that redirect the game's
// ASIO driver lookup (hard-coded "XONAR" substring) to a user-chosen
// driver name read from games::gitadora::ASIO_DRIVER.
//
// safe to call unconditionally; if ASIO_DRIVER is unset the hooks
// forward every call straight through to advapi32.
void asio_hook_init();
}
+11 -11
View File
@@ -41,6 +41,7 @@ namespace games::gitadora {
bool ARENA_TWO_HEAD_EXCLUSIVE = false;
std::optional<std::string> ASIO_DRIVER = std::nullopt;
bool ALLOW_REALTEK_AUDIO = false;
bool NATIVE_TOUCH = false;
/*
* Prevent GitaDora from creating folders on F drive
@@ -809,17 +810,16 @@ namespace games::gitadora {
hooks::audio::INJECT_FAKE_REALTEK_AUDIO = true;
}
// Single-window mode needs touch injection for its overlay. Two-head fullscreen
// mode uses the real SMALL output, so it only needs the display-topology shim.
if (GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
// enable touch hook for subscreen overlay
const auto native_touch_ready = !wintouchemu::FORCE &&
nativetouch::hook(avs::game::DLL_INSTANCE);
if (!native_touch_ready) {
wintouchemu::FORCE = true;
wintouchemu::INJECT_MOUSE_AS_WM_TOUCH = true;
wintouchemu::hook("GITADORA", avs::game::DLL_INSTANCE);
}
// touch injection drives mouse-as-touch and API touch for the subscreen,
// no matter whether it is drawn by the overlay (single-window mode) or by
// the dedicated SMALL window
NATIVE_TOUCH = !wintouchemu::FORCE &&
nativetouch::hook(avs::game::DLL_INSTANCE);
if (!NATIVE_TOUCH && GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
// the legacy fallback can only feed the subscreen overlay
wintouchemu::FORCE = true;
wintouchemu::INJECT_MOUSE_AS_WM_TOUCH = true;
wintouchemu::hook("GITADORA", avs::game::DLL_INSTANCE);
}
#if !SPICE_XP
+5
View File
@@ -23,6 +23,11 @@ namespace games::gitadora {
extern bool ARENA_TWO_HEAD_EXCLUSIVE;
extern std::optional<std::string> ASIO_DRIVER;
extern bool ALLOW_REALTEK_AUDIO;
extern bool NATIVE_TOUCH;
// arena SMALL subscreen (touch panel) resolution
static constexpr int ARENA_SUBSCREEN_WIDTH = 800;
static constexpr int ARENA_SUBSCREEN_HEIGHT = 1280;
class GitaDoraGame : public games::Game {
public:
+24 -2
View File
@@ -279,6 +279,26 @@ namespace games::iidx {
return nullptr;
}
// best-effort read of a TDJ ROM file to determine if the game is running in TDJ mode
//
// these paths are not emulated - they hit whatever is actually mounted at that drive letter.
// an empty optical or removable drive raises the modal "insert a disk" error (the launcher
// clears SEM_FAILCRITICALERRORS process-wide) and a downed network drive stalls on redirector
// timeouts, so only probe what a TDJ cabinet would actually be laid out on.
// drive_path must be absolute and start with a drive letter.
static bool tdj_rom_matches(const char *drive_path, const char *expected) {
const wchar_t root[] = { (wchar_t) drive_path[0], L':', L'\\', L'\0' };
const auto drive_type = GetDriveTypeW(root);
if (drive_type != DRIVE_FIXED && drive_type != DRIVE_RAMDISK) {
log_misc("iidx", "not probing '{}' for TDJ, not a local disk (drive type {})",
drive_path, drive_type);
return false;
}
return fileutils::text_read(drive_path) == expected;
}
#endif
IIDXGame::IIDXGame() : Game("Beatmania IIDX") {
@@ -339,8 +359,10 @@ namespace games::iidx {
HAS_LIBAIO = true;
// check TDJ mode
TDJ_MODE |= fileutils::text_read("C:\\000rom.txt") == "TDJ-JA";
TDJ_MODE |= fileutils::text_read("D:\\001rom.txt") == "TDJ";
if (!TDJ_MODE) {
TDJ_MODE = tdj_rom_matches("C:\\000rom.txt", "TDJ-JA")
|| tdj_rom_matches("D:\\001rom.txt", "TDJ");
}
// force TDJ mode
if (TDJ_MODE) {
+131 -131
View File
@@ -1,132 +1,132 @@
#include "mf_wrappers.h"
#include "util/libutils.h"
#include "util/logging.h"
namespace games::iidx {
static bool INITIALIZED = false;
static HMODULE mf_dll = nullptr;
static HMODULE mfreadwrite_dll = nullptr;
static HMODULE mfplat_dll = nullptr;
typedef HRESULT (__stdcall * MFCreateAttributes_t)(
_Out_ IMFAttributes** ppMFAttributes,
_In_ UINT32 cInitialSize
);
typedef HRESULT (__stdcall * MFCreateMediaType_t)(
_Out_ IMFMediaType** ppMFType
);
typedef HRESULT (__stdcall * MFEnumDeviceSources_t)(
_In_ IMFAttributes* pAttributes,
_Outptr_result_buffer_(*pcSourceActivate) IMFActivate*** pppSourceActivate,
_Out_ UINT32* pcSourceActivate
);
typedef HRESULT (__stdcall * MFCreateSourceReaderFromMediaSource_t)(
_In_ IMFMediaSource *pMediaSource,
_In_opt_ IMFAttributes *pAttributes,
_Out_ IMFSourceReader **ppSourceReader
);
typedef HRESULT (__stdcall * MFGetService_t)(
IUnknown* punkObject,
REFGUID guidService,
REFIID riid,
_Outptr_ LPVOID* ppvObject
);
static MFCreateAttributes_t MFCreateAttributes = nullptr;
static MFCreateMediaType_t MFCreateMediaType = nullptr;
static MFEnumDeviceSources_t MFEnumDeviceSources = nullptr;
static MFCreateSourceReaderFromMediaSource_t MFCreateSourceReaderFromMediaSource = nullptr;
static MFGetService_t MFGetService = nullptr;
void init_mf_library() {
// why was all of this needed?
//
// when iidx camhook was initially implemented, we linked to mf.lib, mfreadwrite.lib, and mfplat.lib
// this made Unity-based really unhappy, causing them to skip over the logic that loads mf library
// causing videos to not play ("Initializing Microsoft Media Foundation failed." in the cmd prompt)
//
// as a result, the static linking to mf libs were removed, and we are now doing the mess that is this file
if (INITIALIZED) {
return;
}
INITIALIZED = true;
log_misc("mf_wrappers", "creating delay-loaded wrappers for MF routines - BEGIN");
mf_dll = libutils::load_library("mf.dll", true);
mfreadwrite_dll = libutils::load_library("mfreadwrite.dll", true);
mfplat_dll = libutils::load_library("mfplat.dll", true);
MFCreateAttributes = (MFCreateAttributes_t)
libutils::get_proc(mfplat_dll, "MFCreateAttributes");
if (!MFCreateAttributes) {
log_fatal("mf_wrappers", "MFCreateAttributes failed to hook");
}
MFCreateMediaType = (MFCreateMediaType_t)
libutils::get_proc(mfplat_dll, "MFCreateMediaType");
if (!MFCreateMediaType) {
log_fatal("mf_wrappers", "MFCreateMediaType failed to hook");
}
MFEnumDeviceSources = (MFEnumDeviceSources_t)
libutils::get_proc(mf_dll, "MFEnumDeviceSources");
if (!MFEnumDeviceSources) {
log_fatal("mf_wrappers", "MFEnumDeviceSources failed to hook");
}
MFCreateSourceReaderFromMediaSource = (MFCreateSourceReaderFromMediaSource_t)
libutils::get_proc(mfreadwrite_dll, "MFCreateSourceReaderFromMediaSource");
if (!MFCreateSourceReaderFromMediaSource) {
log_fatal("mf_wrappers", "MFCreateSourceReaderFromMediaSource failed to hook");
}
MFGetService = (MFGetService_t)libutils::get_proc(mf_dll, "MFGetService");
if (!MFGetService) {
log_fatal("mf_wrappers", "MFGetService failed to hook");
}
log_misc("mf_wrappers", "creating delay-loaded wrappers for MF routines - DONE");
}
HRESULT WrappedMFCreateAttributes (
_Out_ IMFAttributes** ppMFAttributes,
_In_ UINT32 cInitialSize) {
return MFCreateAttributes(ppMFAttributes, cInitialSize);
}
HRESULT WrappedMFCreateMediaType (
_Out_ IMFMediaType** ppMFType) {
return MFCreateMediaType(ppMFType);
}
HRESULT WrappedMFEnumDeviceSources (
_In_ IMFAttributes* pAttributes,
_Outptr_result_buffer_(*pcSourceActivate) IMFActivate*** pppSourceActivate,
_Out_ UINT32* pcSourceActivate) {
return MFEnumDeviceSources(pAttributes, pppSourceActivate, pcSourceActivate);
}
HRESULT WrappedMFCreateSourceReaderFromMediaSource (
_In_ IMFMediaSource *pMediaSource,
_In_opt_ IMFAttributes *pAttributes,
_Out_ IMFSourceReader **ppSourceReader) {
return MFCreateSourceReaderFromMediaSource(pMediaSource, pAttributes, ppSourceReader);
}
HRESULT WrappedMFGetService (
IUnknown* punkObject,
REFGUID guidService,
REFIID riid,
_Outptr_ LPVOID* ppvObject) {
return MFGetService(punkObject, guidService, riid, ppvObject);
}
#include "mf_wrappers.h"
#include "util/libutils.h"
#include "util/logging.h"
namespace games::iidx {
static bool INITIALIZED = false;
static HMODULE mf_dll = nullptr;
static HMODULE mfreadwrite_dll = nullptr;
static HMODULE mfplat_dll = nullptr;
typedef HRESULT (__stdcall * MFCreateAttributes_t)(
_Out_ IMFAttributes** ppMFAttributes,
_In_ UINT32 cInitialSize
);
typedef HRESULT (__stdcall * MFCreateMediaType_t)(
_Out_ IMFMediaType** ppMFType
);
typedef HRESULT (__stdcall * MFEnumDeviceSources_t)(
_In_ IMFAttributes* pAttributes,
_Outptr_result_buffer_(*pcSourceActivate) IMFActivate*** pppSourceActivate,
_Out_ UINT32* pcSourceActivate
);
typedef HRESULT (__stdcall * MFCreateSourceReaderFromMediaSource_t)(
_In_ IMFMediaSource *pMediaSource,
_In_opt_ IMFAttributes *pAttributes,
_Out_ IMFSourceReader **ppSourceReader
);
typedef HRESULT (__stdcall * MFGetService_t)(
IUnknown* punkObject,
REFGUID guidService,
REFIID riid,
_Outptr_ LPVOID* ppvObject
);
static MFCreateAttributes_t MFCreateAttributes = nullptr;
static MFCreateMediaType_t MFCreateMediaType = nullptr;
static MFEnumDeviceSources_t MFEnumDeviceSources = nullptr;
static MFCreateSourceReaderFromMediaSource_t MFCreateSourceReaderFromMediaSource = nullptr;
static MFGetService_t MFGetService = nullptr;
void init_mf_library() {
// why was all of this needed?
//
// when iidx camhook was initially implemented, we linked to mf.lib, mfreadwrite.lib, and mfplat.lib
// this made Unity-based really unhappy, causing them to skip over the logic that loads mf library
// causing videos to not play ("Initializing Microsoft Media Foundation failed." in the cmd prompt)
//
// as a result, the static linking to mf libs were removed, and we are now doing the mess that is this file
if (INITIALIZED) {
return;
}
INITIALIZED = true;
log_misc("mf_wrappers", "creating delay-loaded wrappers for MF routines - BEGIN");
mf_dll = libutils::load_library("mf.dll", true);
mfreadwrite_dll = libutils::load_library("mfreadwrite.dll", true);
mfplat_dll = libutils::load_library("mfplat.dll", true);
MFCreateAttributes = (MFCreateAttributes_t)
libutils::get_proc(mfplat_dll, "MFCreateAttributes");
if (!MFCreateAttributes) {
log_fatal("mf_wrappers", "MFCreateAttributes failed to hook");
}
MFCreateMediaType = (MFCreateMediaType_t)
libutils::get_proc(mfplat_dll, "MFCreateMediaType");
if (!MFCreateMediaType) {
log_fatal("mf_wrappers", "MFCreateMediaType failed to hook");
}
MFEnumDeviceSources = (MFEnumDeviceSources_t)
libutils::get_proc(mf_dll, "MFEnumDeviceSources");
if (!MFEnumDeviceSources) {
log_fatal("mf_wrappers", "MFEnumDeviceSources failed to hook");
}
MFCreateSourceReaderFromMediaSource = (MFCreateSourceReaderFromMediaSource_t)
libutils::get_proc(mfreadwrite_dll, "MFCreateSourceReaderFromMediaSource");
if (!MFCreateSourceReaderFromMediaSource) {
log_fatal("mf_wrappers", "MFCreateSourceReaderFromMediaSource failed to hook");
}
MFGetService = (MFGetService_t)libutils::get_proc(mf_dll, "MFGetService");
if (!MFGetService) {
log_fatal("mf_wrappers", "MFGetService failed to hook");
}
log_misc("mf_wrappers", "creating delay-loaded wrappers for MF routines - DONE");
}
HRESULT WrappedMFCreateAttributes (
_Out_ IMFAttributes** ppMFAttributes,
_In_ UINT32 cInitialSize) {
return MFCreateAttributes(ppMFAttributes, cInitialSize);
}
HRESULT WrappedMFCreateMediaType (
_Out_ IMFMediaType** ppMFType) {
return MFCreateMediaType(ppMFType);
}
HRESULT WrappedMFEnumDeviceSources (
_In_ IMFAttributes* pAttributes,
_Outptr_result_buffer_(*pcSourceActivate) IMFActivate*** pppSourceActivate,
_Out_ UINT32* pcSourceActivate) {
return MFEnumDeviceSources(pAttributes, pppSourceActivate, pcSourceActivate);
}
HRESULT WrappedMFCreateSourceReaderFromMediaSource (
_In_ IMFMediaSource *pMediaSource,
_In_opt_ IMFAttributes *pAttributes,
_Out_ IMFSourceReader **ppSourceReader) {
return MFCreateSourceReaderFromMediaSource(pMediaSource, pAttributes, ppSourceReader);
}
HRESULT WrappedMFGetService (
IUnknown* punkObject,
REFGUID guidService,
REFIID riid,
_Outptr_ LPVOID* ppvObject) {
return MFGetService(punkObject, guidService, riid, ppvObject);
}
}
+32 -32
View File
@@ -1,33 +1,33 @@
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mfobjects.h>
#pragma once
namespace games::iidx {
void init_mf_library();
HRESULT WrappedMFCreateAttributes (
_Out_ IMFAttributes** ppMFAttributes,
_In_ UINT32 cInitialSize);
HRESULT WrappedMFCreateMediaType (
_Out_ IMFMediaType** ppMFType);
HRESULT WrappedMFEnumDeviceSources (
_In_ IMFAttributes* pAttributes,
_Outptr_result_buffer_(*pcSourceActivate) IMFActivate*** pppSourceActivate,
_Out_ UINT32* pcSourceActivate);
HRESULT WrappedMFCreateSourceReaderFromMediaSource (
_In_ IMFMediaSource *pMediaSource,
_In_opt_ IMFAttributes *pAttributes,
_Out_ IMFSourceReader **ppSourceReader);
HRESULT WrappedMFGetService (
IUnknown* punkObject,
REFGUID guidService,
REFIID riid,
_Outptr_ LPVOID* ppvObject);
#include <mfapi.h>
#include <mfidl.h>
#include <mfreadwrite.h>
#include <mfobjects.h>
#pragma once
namespace games::iidx {
void init_mf_library();
HRESULT WrappedMFCreateAttributes (
_Out_ IMFAttributes** ppMFAttributes,
_In_ UINT32 cInitialSize);
HRESULT WrappedMFCreateMediaType (
_Out_ IMFMediaType** ppMFType);
HRESULT WrappedMFEnumDeviceSources (
_In_ IMFAttributes* pAttributes,
_Outptr_result_buffer_(*pcSourceActivate) IMFActivate*** pppSourceActivate,
_Out_ UINT32* pcSourceActivate);
HRESULT WrappedMFCreateSourceReaderFromMediaSource (
_In_ IMFMediaSource *pMediaSource,
_In_opt_ IMFAttributes *pAttributes,
_Out_ IMFSourceReader **ppSourceReader);
HRESULT WrappedMFGetService (
IUnknown* punkObject,
REFGUID guidService,
REFIID riid,
_Outptr_ LPVOID* ppvObject);
}
+233 -233
View File
@@ -1,233 +1,233 @@
#include "touch_mode.h"
#include <atomic>
#include <mutex>
#include <unordered_map>
#include "touch/native/nativetouchhook.h"
#include "util/logging.h"
namespace games::nost::touch_mode {
// native contact positions feed piano input directly. native events reach the game
// in nav mode and are suppressed in piano mode. mode-button contacts are always
// suppressed and change that routing after release.
static constexpr LONG PIANO_LEFT_GAP = 11;
static constexpr LONG PIANO_RIGHT_GAP = 10;
static constexpr uint32_t PIANO_KEY_COUNT = 28;
struct TouchGeometry {
HWND window = nullptr;
RECT mode_button {};
LONG client_width = 0;
LONG client_height = 0;
bool valid() const {
return window != nullptr && client_width > 0 && client_height > 0;
}
};
struct NativeContact {
POINT position {};
// position contains game-client coordinates
bool client_position_valid = false;
// down began on the mode switch button; remains true through up
bool mode_button = false;
};
static std::atomic_bool accept_events { false };
static std::atomic<Mode> current_mode_state { Mode::Nav };
static std::mutex state_mutex;
static TouchGeometry touch_geometry;
// native contacts are kept by ID so each contact contributes exactly one position
static std::unordered_map<DWORD, NativeContact> active_contacts;
// a hardware button release requests one change after all contacts are released
static bool mode_change_pending = false;
static void reset_state_locked() {
current_mode_state.store(Mode::Nav, std::memory_order_release);
touch_geometry = {};
active_contacts.clear();
mode_change_pending = false;
}
// hardware contacts arrive in screen coordinates
static bool native_touch_in_button(const nativetouch::NativeTouchEvent &event) {
if (!touch_geometry.valid()) {
return false;
}
POINT position { event.x, event.y };
if (!ScreenToClient(touch_geometry.window, &position)) {
return false;
}
return PtInRect(&touch_geometry.mode_button, position) != FALSE;
}
static bool update_touch_state(const nativetouch::NativeTouchEvent &event) {
std::lock_guard<std::mutex> lock(state_mutex);
// first, process down / move events
if (event.down || event.move) {
auto contact = active_contacts.try_emplace(event.id).first;
// keep track of IDs that began as a down on the mode switch button
if (event.down) {
contact->second.mode_button = native_touch_in_button(event);
}
// check for valid position
POINT position { event.x, event.y };
if (touch_geometry.window != nullptr &&
ScreenToClient(touch_geometry.window, &position)) {
contact->second.position = position;
contact->second.client_position_valid = true;
}
}
const auto contact = active_contacts.find(event.id);
const bool mode_button_contact = contact != active_contacts.end() &&
contact->second.mode_button;
// process up events
if (event.up) {
active_contacts.erase(event.id);
// if a contact that began down event on the mode switch button has
// been released, a mode switch is now pending
if (mode_button_contact) {
mode_change_pending = true;
}
// apply the change on the final hardware up. switching earlier would
// split another contact's down and up events across different modes
if (mode_change_pending && active_contacts.empty()) {
mode_change_pending = false;
const auto next_mode = current_mode() == Mode::Nav ? Mode::Piano : Mode::Nav;
current_mode_state.store(next_mode, std::memory_order_release);
}
}
return mode_button_contact;
}
// install the Nostalgia-specific native touch interception
void enable() {
if (accept_events.exchange(true, std::memory_order_acq_rel)) {
return;
}
{
std::lock_guard<std::mutex> lock(state_mutex);
reset_state_locked();
}
nativetouch::set_input_filter(filter_native_touch);
log_info("nost::touch", "enabled");
}
void disable() {
if (!accept_events.exchange(false, std::memory_order_acq_rel)) {
return;
}
nativetouch::set_input_filter(nullptr);
std::lock_guard<std::mutex> lock(state_mutex);
reset_state_locked();
}
bool enabled() {
return accept_events.load(std::memory_order_acquire);
}
Mode current_mode() {
return current_mode_state.load(std::memory_order_acquire);
}
// publish the rendered overlay button rectangle in game-client pixels
void publish_button_bounds(HWND window, const RECT &client_bounds) {
TouchGeometry next {};
RECT client_rect {};
if (window != nullptr && GetClientRect(window, &client_rect) &&
client_rect.right > 0 && client_rect.bottom > 0) {
next.window = window;
next.mode_button = client_bounds;
next.client_width = client_rect.right;
next.client_height = client_rect.bottom;
}
std::lock_guard<std::mutex> lock(state_mutex);
touch_geometry = next;
}
// return the active 28-key piano bitfield for the PANB input update
uint32_t piano_key_state() {
if (!enabled() || current_mode() != Mode::Piano) {
return 0;
}
std::lock_guard<std::mutex> lock(state_mutex);
if (current_mode() != Mode::Piano || !touch_geometry.valid()) {
return 0;
}
uint32_t state = 0;
for (const auto &contact : active_contacts) {
// invalid position or mode-button contact; ignore these contacts
if (!contact.second.client_position_valid || contact.second.mode_button) {
continue;
}
const auto &position = contact.second.position;
// outside the client area or on the mode button; ignore these contacts
if (position.x < 0 || position.x >= touch_geometry.client_width ||
position.y < 0 || position.y >= touch_geometry.client_height ||
PtInRect(&touch_geometry.mode_button, position)) {
continue;
}
// divide the inset width evenly; touches in either side gap clamp to
// the nearest outer key so the physical screen edges remain playable
const auto piano_width =
touch_geometry.client_width - PIANO_LEFT_GAP - PIANO_RIGHT_GAP;
uint32_t key = 0;
if (position.x >= touch_geometry.client_width - PIANO_RIGHT_GAP) {
key = PIANO_KEY_COUNT - 1;
} else if (position.x >= PIANO_LEFT_GAP && piano_width > 0) {
key = static_cast<uint32_t>(
(position.x - PIANO_LEFT_GAP) * PIANO_KEY_COUNT / piano_width);
}
state |= UINT32_C(1) << key;
}
return state;
}
// update native contacts and report whether this event should be hidden from the game
bool filter_native_touch(const nativetouch::NativeTouchEvent &event) {
// synthetic events are outside this hardware-only feature
if (!enabled() || event.synthetic) {
// false leaves the event visible to the game
return false;
}
// snapshot routing before an up event can commit a pending mode switch
const bool piano_mode_before_update = current_mode() == Mode::Piano;
// update the contact lifetime and commit any pending switch when safe
const bool mode_button_contact = update_touch_state(event);
// hide every event in a contact that began on the mode switch button
if (mode_button_contact) {
return true;
}
// piano mode consumes hardware events; nav mode forwards them to the game
return piano_mode_before_update;
}
}
#include "touch_mode.h"
#include <atomic>
#include <mutex>
#include <unordered_map>
#include "touch/native/nativetouchhook.h"
#include "util/logging.h"
namespace games::nost::touch_mode {
// native contact positions feed piano input directly. native events reach the game
// in nav mode and are suppressed in piano mode. mode-button contacts are always
// suppressed and change that routing after release.
static constexpr LONG PIANO_LEFT_GAP = 11;
static constexpr LONG PIANO_RIGHT_GAP = 10;
static constexpr uint32_t PIANO_KEY_COUNT = 28;
struct TouchGeometry {
HWND window = nullptr;
RECT mode_button {};
LONG client_width = 0;
LONG client_height = 0;
bool valid() const {
return window != nullptr && client_width > 0 && client_height > 0;
}
};
struct NativeContact {
POINT position {};
// position contains game-client coordinates
bool client_position_valid = false;
// down began on the mode switch button; remains true through up
bool mode_button = false;
};
static std::atomic_bool accept_events { false };
static std::atomic<Mode> current_mode_state { Mode::Nav };
static std::mutex state_mutex;
static TouchGeometry touch_geometry;
// native contacts are kept by ID so each contact contributes exactly one position
static std::unordered_map<DWORD, NativeContact> active_contacts;
// a hardware button release requests one change after all contacts are released
static bool mode_change_pending = false;
static void reset_state_locked() {
current_mode_state.store(Mode::Nav, std::memory_order_release);
touch_geometry = {};
active_contacts.clear();
mode_change_pending = false;
}
// hardware contacts arrive in screen coordinates
static bool native_touch_in_button(const nativetouch::NativeTouchEvent &event) {
if (!touch_geometry.valid()) {
return false;
}
POINT position { event.x, event.y };
if (!ScreenToClient(touch_geometry.window, &position)) {
return false;
}
return PtInRect(&touch_geometry.mode_button, position) != FALSE;
}
static bool update_touch_state(const nativetouch::NativeTouchEvent &event) {
std::lock_guard<std::mutex> lock(state_mutex);
// first, process down / move events
if (event.down || event.move) {
auto contact = active_contacts.try_emplace(event.id).first;
// keep track of IDs that began as a down on the mode switch button
if (event.down) {
contact->second.mode_button = native_touch_in_button(event);
}
// check for valid position
POINT position { event.x, event.y };
if (touch_geometry.window != nullptr &&
ScreenToClient(touch_geometry.window, &position)) {
contact->second.position = position;
contact->second.client_position_valid = true;
}
}
const auto contact = active_contacts.find(event.id);
const bool mode_button_contact = contact != active_contacts.end() &&
contact->second.mode_button;
// process up events
if (event.up) {
active_contacts.erase(event.id);
// if a contact that began down event on the mode switch button has
// been released, a mode switch is now pending
if (mode_button_contact) {
mode_change_pending = true;
}
// apply the change on the final hardware up. switching earlier would
// split another contact's down and up events across different modes
if (mode_change_pending && active_contacts.empty()) {
mode_change_pending = false;
const auto next_mode = current_mode() == Mode::Nav ? Mode::Piano : Mode::Nav;
current_mode_state.store(next_mode, std::memory_order_release);
}
}
return mode_button_contact;
}
// install the Nostalgia-specific native touch interception
void enable() {
if (accept_events.exchange(true, std::memory_order_acq_rel)) {
return;
}
{
std::lock_guard<std::mutex> lock(state_mutex);
reset_state_locked();
}
nativetouch::set_input_filter(filter_native_touch);
log_info("nost::touch", "enabled");
}
void disable() {
if (!accept_events.exchange(false, std::memory_order_acq_rel)) {
return;
}
nativetouch::set_input_filter(nullptr);
std::lock_guard<std::mutex> lock(state_mutex);
reset_state_locked();
}
bool enabled() {
return accept_events.load(std::memory_order_acquire);
}
Mode current_mode() {
return current_mode_state.load(std::memory_order_acquire);
}
// publish the rendered overlay button rectangle in game-client pixels
void publish_button_bounds(HWND window, const RECT &client_bounds) {
TouchGeometry next {};
RECT client_rect {};
if (window != nullptr && GetClientRect(window, &client_rect) &&
client_rect.right > 0 && client_rect.bottom > 0) {
next.window = window;
next.mode_button = client_bounds;
next.client_width = client_rect.right;
next.client_height = client_rect.bottom;
}
std::lock_guard<std::mutex> lock(state_mutex);
touch_geometry = next;
}
// return the active 28-key piano bitfield for the PANB input update
uint32_t piano_key_state() {
if (!enabled() || current_mode() != Mode::Piano) {
return 0;
}
std::lock_guard<std::mutex> lock(state_mutex);
if (current_mode() != Mode::Piano || !touch_geometry.valid()) {
return 0;
}
uint32_t state = 0;
for (const auto &contact : active_contacts) {
// invalid position or mode-button contact; ignore these contacts
if (!contact.second.client_position_valid || contact.second.mode_button) {
continue;
}
const auto &position = contact.second.position;
// outside the client area or on the mode button; ignore these contacts
if (position.x < 0 || position.x >= touch_geometry.client_width ||
position.y < 0 || position.y >= touch_geometry.client_height ||
PtInRect(&touch_geometry.mode_button, position)) {
continue;
}
// divide the inset width evenly; touches in either side gap clamp to
// the nearest outer key so the physical screen edges remain playable
const auto piano_width =
touch_geometry.client_width - PIANO_LEFT_GAP - PIANO_RIGHT_GAP;
uint32_t key = 0;
if (position.x >= touch_geometry.client_width - PIANO_RIGHT_GAP) {
key = PIANO_KEY_COUNT - 1;
} else if (position.x >= PIANO_LEFT_GAP && piano_width > 0) {
key = static_cast<uint32_t>(
(position.x - PIANO_LEFT_GAP) * PIANO_KEY_COUNT / piano_width);
}
state |= UINT32_C(1) << key;
}
return state;
}
// update native contacts and report whether this event should be hidden from the game
bool filter_native_touch(const nativetouch::NativeTouchEvent &event) {
// synthetic events are outside this hardware-only feature
if (!enabled() || event.synthetic) {
// false leaves the event visible to the game
return false;
}
// snapshot routing before an up event can commit a pending mode switch
const bool piano_mode_before_update = current_mode() == Mode::Piano;
// update the contact lifetime and commit any pending switch when safe
const bool mode_button_contact = update_touch_state(event);
// hide every event in a contact that began on the mode switch button
if (mode_button_contact) {
return true;
}
// piano mode consumes hardware events; nav mode forwards them to the game
return piano_mode_before_update;
}
}
+27 -27
View File
@@ -1,27 +1,27 @@
#pragma once
#include <cstdint>
#include <windows.h>
#include "touch/native/nativetouchhook.h"
namespace games::nost::touch_mode {
// nav mode forwards contacts to the game; piano mode converts them into piano keys
enum class Mode {
Nav,
Piano,
};
void enable();
void disable();
bool enabled();
Mode current_mode();
void publish_button_bounds(HWND window, const RECT &client_bounds);
uint32_t piano_key_state();
bool filter_native_touch(const nativetouch::NativeTouchEvent &event);
}
#pragma once
#include <cstdint>
#include <windows.h>
#include "touch/native/nativetouchhook.h"
namespace games::nost::touch_mode {
// nav mode forwards contacts to the game; piano mode converts them into piano keys
enum class Mode {
Nav,
Piano,
};
void enable();
void disable();
bool enabled();
Mode current_mode();
void publish_button_bounds(HWND window, const RECT &client_bounds);
uint32_t piano_key_state();
bool filter_native_touch(const nativetouch::NativeTouchEvent &event);
}
+143 -143
View File
@@ -1,143 +1,143 @@
#include "touch_debug.h"
#include <array>
#include <atomic>
#include <cstring>
#include <mutex>
#include "external/imgui/imgui.h"
#include "games/rb/rb.h"
#include "games/rb/touch_defs.h"
namespace games::rb {
struct TouchDebugState {
std::array<unsigned char, TOUCH_PACKET_SIZE> packet {};
bool is_landscape = false;
};
std::atomic_bool TOUCH_DEBUG_OVERLAY = false;
static std::atomic_bool TOUCH_ACTIVE = false;
static std::mutex TOUCH_DEBUG_STATE_M;
static TouchDebugState TOUCH_DEBUG_STATE;
static float touch_scale_factor() {
return TOUCH_SCALING / (float) TOUCH_SCALE_DEFAULT;
}
static void clear_touch_debug_state() {
std::lock_guard<std::mutex> lock(TOUCH_DEBUG_STATE_M);
TOUCH_DEBUG_STATE = {};
}
static TouchDebugState get_touch_debug_state() {
std::lock_guard<std::mutex> lock(TOUCH_DEBUG_STATE_M);
return TOUCH_DEBUG_STATE;
}
static bool packet_bit_active(
const std::array<unsigned char, TOUCH_PACKET_SIZE> &packet, int bit) {
return (packet[TOUCH_PACKET_DATA_OFFSET + bit / 8] & (1u << (bit % 8))) != 0;
}
static int sensor_center(int sensor, int sensor_count, int extent) {
return ((sensor * 2 + 1) * extent) / (sensor_count * 2);
}
static float sensor_span_position(int sensor, int sensor_count, int extent) {
return sensor * (extent - 1) / (float) (sensor_count - 1);
}
bool touch_debug_overlay_enabled() {
return TOUCH_DEBUG_OVERLAY && TOUCH_ACTIVE.load(std::memory_order_acquire);
}
void touch_draw_debug_overlay() {
if (!touch_debug_overlay_enabled()) {
return;
}
const auto &io = ImGui::GetIO();
int width = static_cast<int>(io.DisplaySize.x);
int height = static_cast<int>(io.DisplaySize.y);
if (width <= 0 || height <= 0) {
return;
}
const float scale_factor = touch_scale_factor();
const float left = width * (1.f - scale_factor) / 2.f;
const float top = height * (1.f - scale_factor) / 2.f;
const float right = width - left;
const float bottom = height - top;
TouchDebugState state = get_touch_debug_state();
ImDrawList *draw_list = ImGui::GetBackgroundDrawList();
auto draw_line = [&](float x1, float y1, float x2, float y2) {
draw_list->AddLine(
ImVec2(x1, y1), ImVec2(x2, y2),
IM_COL32(0, 255, 64, 255), 2.f);
};
// show the valid input area when touch scaling restricts it
if (TOUCH_SCALING != TOUCH_SCALE_DEFAULT) {
draw_list->AddRect(
ImVec2(left, top), ImVec2(right, bottom),
IM_COL32(255, 255, 255, 255), 0.f, 0, 2.f);
}
// spread the usable X sensors 2..45 from edge to edge
for (int sensor = X_SENSOR_FIRST_ACTIVE; sensor <= X_SENSOR_LAST_ACTIVE; sensor++) {
if (!packet_bit_active(state.packet, X_SENSOR_FIRST_BIT + sensor)) {
continue;
}
float position = sensor_span_position(
sensor - X_SENSOR_FIRST_ACTIVE, X_SENSOR_ACTIVE_COUNT,
state.is_landscape ? height : width);
if (state.is_landscape) {
float y = top + position * scale_factor;
draw_line(left, y, right, y);
} else {
float x = left + position * scale_factor;
draw_line(x, top, x, bottom);
}
}
for (int sensor = 0; sensor < Y_SENSOR_COUNT; sensor++) {
if (!packet_bit_active(state.packet, Y_SENSOR_FIRST_BIT - sensor)) {
continue;
}
int position = sensor_center(
sensor, Y_SENSOR_COUNT,
state.is_landscape ? width : height);
if (state.is_landscape) {
float x = right - position * scale_factor;
draw_line(x, top, x, bottom);
} else {
float y = top + position * scale_factor;
draw_line(left, y, right, y);
}
}
}
void touch_debug_attach() {
clear_touch_debug_state();
TOUCH_ACTIVE.store(true, std::memory_order_release);
}
void touch_debug_detach() {
TOUCH_ACTIVE.store(false, std::memory_order_release);
clear_touch_debug_state();
}
void touch_debug_publish(const unsigned char *data, bool is_landscape) {
if (!TOUCH_DEBUG_OVERLAY) {
return;
}
std::lock_guard<std::mutex> lock(TOUCH_DEBUG_STATE_M);
memcpy(TOUCH_DEBUG_STATE.packet.data(), data, TOUCH_PACKET_SIZE);
TOUCH_DEBUG_STATE.is_landscape = is_landscape;
}
}
#include "touch_debug.h"
#include <array>
#include <atomic>
#include <cstring>
#include <mutex>
#include "external/imgui/imgui.h"
#include "games/rb/rb.h"
#include "games/rb/touch_defs.h"
namespace games::rb {
struct TouchDebugState {
std::array<unsigned char, TOUCH_PACKET_SIZE> packet {};
bool is_landscape = false;
};
std::atomic_bool TOUCH_DEBUG_OVERLAY = false;
static std::atomic_bool TOUCH_ACTIVE = false;
static std::mutex TOUCH_DEBUG_STATE_M;
static TouchDebugState TOUCH_DEBUG_STATE;
static float touch_scale_factor() {
return TOUCH_SCALING / (float) TOUCH_SCALE_DEFAULT;
}
static void clear_touch_debug_state() {
std::lock_guard<std::mutex> lock(TOUCH_DEBUG_STATE_M);
TOUCH_DEBUG_STATE = {};
}
static TouchDebugState get_touch_debug_state() {
std::lock_guard<std::mutex> lock(TOUCH_DEBUG_STATE_M);
return TOUCH_DEBUG_STATE;
}
static bool packet_bit_active(
const std::array<unsigned char, TOUCH_PACKET_SIZE> &packet, int bit) {
return (packet[TOUCH_PACKET_DATA_OFFSET + bit / 8] & (1u << (bit % 8))) != 0;
}
static int sensor_center(int sensor, int sensor_count, int extent) {
return ((sensor * 2 + 1) * extent) / (sensor_count * 2);
}
static float sensor_span_position(int sensor, int sensor_count, int extent) {
return sensor * (extent - 1) / (float) (sensor_count - 1);
}
bool touch_debug_overlay_enabled() {
return TOUCH_DEBUG_OVERLAY && TOUCH_ACTIVE.load(std::memory_order_acquire);
}
void touch_draw_debug_overlay() {
if (!touch_debug_overlay_enabled()) {
return;
}
const auto &io = ImGui::GetIO();
int width = static_cast<int>(io.DisplaySize.x);
int height = static_cast<int>(io.DisplaySize.y);
if (width <= 0 || height <= 0) {
return;
}
const float scale_factor = touch_scale_factor();
const float left = width * (1.f - scale_factor) / 2.f;
const float top = height * (1.f - scale_factor) / 2.f;
const float right = width - left;
const float bottom = height - top;
TouchDebugState state = get_touch_debug_state();
ImDrawList *draw_list = ImGui::GetBackgroundDrawList();
auto draw_line = [&](float x1, float y1, float x2, float y2) {
draw_list->AddLine(
ImVec2(x1, y1), ImVec2(x2, y2),
IM_COL32(0, 255, 64, 255), 2.f);
};
// show the valid input area when touch scaling restricts it
if (TOUCH_SCALING != TOUCH_SCALE_DEFAULT) {
draw_list->AddRect(
ImVec2(left, top), ImVec2(right, bottom),
IM_COL32(255, 255, 255, 255), 0.f, 0, 2.f);
}
// spread the usable X sensors 2..45 from edge to edge
for (int sensor = X_SENSOR_FIRST_ACTIVE; sensor <= X_SENSOR_LAST_ACTIVE; sensor++) {
if (!packet_bit_active(state.packet, X_SENSOR_FIRST_BIT + sensor)) {
continue;
}
float position = sensor_span_position(
sensor - X_SENSOR_FIRST_ACTIVE, X_SENSOR_ACTIVE_COUNT,
state.is_landscape ? height : width);
if (state.is_landscape) {
float y = top + position * scale_factor;
draw_line(left, y, right, y);
} else {
float x = left + position * scale_factor;
draw_line(x, top, x, bottom);
}
}
for (int sensor = 0; sensor < Y_SENSOR_COUNT; sensor++) {
if (!packet_bit_active(state.packet, Y_SENSOR_FIRST_BIT - sensor)) {
continue;
}
int position = sensor_center(
sensor, Y_SENSOR_COUNT,
state.is_landscape ? width : height);
if (state.is_landscape) {
float x = right - position * scale_factor;
draw_line(x, top, x, bottom);
} else {
float y = top + position * scale_factor;
draw_line(left, y, right, y);
}
}
}
void touch_debug_attach() {
clear_touch_debug_state();
TOUCH_ACTIVE.store(true, std::memory_order_release);
}
void touch_debug_detach() {
TOUCH_ACTIVE.store(false, std::memory_order_release);
clear_touch_debug_state();
}
void touch_debug_publish(const unsigned char *data, bool is_landscape) {
if (!TOUCH_DEBUG_OVERLAY) {
return;
}
std::lock_guard<std::mutex> lock(TOUCH_DEBUG_STATE_M);
memcpy(TOUCH_DEBUG_STATE.packet.data(), data, TOUCH_PACKET_SIZE);
TOUCH_DEBUG_STATE.is_landscape = is_landscape;
}
}
+14 -14
View File
@@ -1,14 +1,14 @@
#pragma once
#include <atomic>
namespace games::rb {
extern std::atomic_bool TOUCH_DEBUG_OVERLAY;
bool touch_debug_overlay_enabled();
void touch_draw_debug_overlay();
void touch_debug_attach();
void touch_debug_detach();
void touch_debug_publish(const unsigned char *data, bool is_landscape);
}
#pragma once
#include <atomic>
namespace games::rb {
extern std::atomic_bool TOUCH_DEBUG_OVERLAY;
bool touch_debug_overlay_enabled();
void touch_draw_debug_overlay();
void touch_debug_attach();
void touch_debug_detach();
void touch_debug_publish(const unsigned char *data, bool is_landscape);
}
+17 -17
View File
@@ -1,17 +1,17 @@
#pragma once
namespace games::rb {
inline constexpr int TOUCH_SCALE_DEFAULT = 1000;
inline constexpr int TOUCH_PACKET_SIZE = 20;
inline constexpr int TOUCH_PACKET_DATA_OFFSET = 3;
inline constexpr int X_SENSOR_COUNT = 48;
inline constexpr int X_SENSOR_FIRST_ACTIVE = 2;
inline constexpr int X_SENSOR_LAST_ACTIVE = 45;
inline constexpr int X_SENSOR_ACTIVE_COUNT =
X_SENSOR_LAST_ACTIVE - X_SENSOR_FIRST_ACTIVE + 1;
inline constexpr int X_SENSOR_FIRST_BIT = 88;
inline constexpr int Y_SENSOR_COUNT = 76;
inline constexpr int Y_SENSOR_FIRST_BIT = 75;
}
#pragma once
namespace games::rb {
inline constexpr int TOUCH_SCALE_DEFAULT = 1000;
inline constexpr int TOUCH_PACKET_SIZE = 20;
inline constexpr int TOUCH_PACKET_DATA_OFFSET = 3;
inline constexpr int X_SENSOR_COUNT = 48;
inline constexpr int X_SENSOR_FIRST_ACTIVE = 2;
inline constexpr int X_SENSOR_LAST_ACTIVE = 45;
inline constexpr int X_SENSOR_ACTIVE_COUNT =
X_SENSOR_LAST_ACTIVE - X_SENSOR_FIRST_ACTIVE + 1;
inline constexpr int X_SENSOR_FIRST_BIT = 88;
inline constexpr int Y_SENSOR_COUNT = 76;
inline constexpr int Y_SENSOR_FIRST_BIT = 75;
}
+72 -72
View File
@@ -1,72 +1,72 @@
#include "sdvx_live2d.h"
// only the Live2D-capable SDVX versions are 64-bit, so the whole feature is
// compiled out of 32-bit builds.
#ifdef SPICE64
#include <string>
#include "hooks/graphics/graphics.h"
#include "launcher/logger.h"
#include "util/logging.h"
namespace games::sdvx {
// Live2D in-game scene detection (for the -sdvxnolive2d "ingame" option).
//
// the game logs scene transitions as "I:Attach: in <SCENE>" / "I:Detach: in
// <SCENE>". several scenes correspond to in-song gameplay (with the heavy
// Live2D rendering); we watch those log lines and keep the shared flag
// the d3d9 backend reads up to date. the hook never alters the log output
// (always returns false).
static bool live2d_scene_log_hook(
void *user, const std::string &data, logger::Style style, std::string &out) {
// any of these scenes counts as in-song gameplay (different play modes)
static const char *const gameplay_scenes[] = {
"in ALTERNATIVE_GAME_SCENE",
"in MEGAMIX_GAME_SCENE",
"in MEGAMIX_BATTLE",
"in BATTLE_GAME_SCENE",
"in AUTOMATION_GAME_SCENE",
"in ARENA_GAME_SCENE",
};
bool in_gameplay_scene = false;
for (const auto *scene : gameplay_scenes) {
if (data.find(scene) != std::string::npos) {
in_gameplay_scene = true;
break;
}
}
if (!in_gameplay_scene) {
return false;
}
// note: log messages here must NOT contain any matched scene token, else
// this hook would re-enter itself when the message is pushed.
if (data.find("I:Attach: in ") != std::string::npos) {
if (!GRAPHICS_SDVX_LIVE2D_IN_GAMEPLAY.exchange(true, std::memory_order_relaxed)) {
log_info("sdvx", "Live2D skip: entering gameplay");
}
} else if (data.find("I:Detach: in ") != std::string::npos) {
if (GRAPHICS_SDVX_LIVE2D_IN_GAMEPLAY.exchange(false, std::memory_order_relaxed)) {
log_info("sdvx", "Live2D skip: leaving gameplay");
}
}
return false;
}
void live2d_scene_detection_init() {
static bool installed = false;
if (installed) {
return;
}
installed = true;
// the logger's hook list is a persistent static, so registering here is
// safe even though this runs before logger::start(). we intentionally do
// NOT log a confirmation now: at this point the log file isn't open yet
// and the message would be dropped. the entering/leaving-gameplay lines
// above provide runtime confirmation once the logger is running.
logger::hook_add(live2d_scene_log_hook, nullptr);
}
}
#endif // SPICE64
#include "sdvx_live2d.h"
// only the Live2D-capable SDVX versions are 64-bit, so the whole feature is
// compiled out of 32-bit builds.
#ifdef SPICE64
#include <string>
#include "hooks/graphics/graphics.h"
#include "launcher/logger.h"
#include "util/logging.h"
namespace games::sdvx {
// Live2D in-game scene detection (for the -sdvxnolive2d "ingame" option).
//
// the game logs scene transitions as "I:Attach: in <SCENE>" / "I:Detach: in
// <SCENE>". several scenes correspond to in-song gameplay (with the heavy
// Live2D rendering); we watch those log lines and keep the shared flag
// the d3d9 backend reads up to date. the hook never alters the log output
// (always returns false).
static bool live2d_scene_log_hook(
void *user, const std::string &data, logger::Style style, std::string &out) {
// any of these scenes counts as in-song gameplay (different play modes)
static const char *const gameplay_scenes[] = {
"in ALTERNATIVE_GAME_SCENE",
"in MEGAMIX_GAME_SCENE",
"in MEGAMIX_BATTLE",
"in BATTLE_GAME_SCENE",
"in AUTOMATION_GAME_SCENE",
"in ARENA_GAME_SCENE",
};
bool in_gameplay_scene = false;
for (const auto *scene : gameplay_scenes) {
if (data.find(scene) != std::string::npos) {
in_gameplay_scene = true;
break;
}
}
if (!in_gameplay_scene) {
return false;
}
// note: log messages here must NOT contain any matched scene token, else
// this hook would re-enter itself when the message is pushed.
if (data.find("I:Attach: in ") != std::string::npos) {
if (!GRAPHICS_SDVX_LIVE2D_IN_GAMEPLAY.exchange(true, std::memory_order_relaxed)) {
log_info("sdvx", "Live2D skip: entering gameplay");
}
} else if (data.find("I:Detach: in ") != std::string::npos) {
if (GRAPHICS_SDVX_LIVE2D_IN_GAMEPLAY.exchange(false, std::memory_order_relaxed)) {
log_info("sdvx", "Live2D skip: leaving gameplay");
}
}
return false;
}
void live2d_scene_detection_init() {
static bool installed = false;
if (installed) {
return;
}
installed = true;
// the logger's hook list is a persistent static, so registering here is
// safe even though this runs before logger::start(). we intentionally do
// NOT log a confirmation now: at this point the log file isn't open yet
// and the message would be dropped. the entering/leaving-gameplay lines
// above provide runtime confirmation once the logger is running.
logger::hook_add(live2d_scene_log_hook, nullptr);
}
}
#endif // SPICE64
+14 -14
View File
@@ -1,14 +1,14 @@
#pragma once
namespace games::sdvx {
#ifdef SPICE64
// installs the Live2D in-game scene-detection log hook used by the
// -sdvxnolive2d "ingame" option. does not require the SDVX game module to
// be attached, so it can be enabled purely from the launcher option.
// only the Live2D-capable SDVX versions are 64-bit, so this is compiled out
// of 32-bit builds.
void live2d_scene_detection_init();
#endif
}
#pragma once
namespace games::sdvx {
#ifdef SPICE64
// installs the Live2D in-game scene-detection log hook used by the
// -sdvxnolive2d "ingame" option. does not require the SDVX game module to
// be attached, so it can be enabled purely from the launcher option.
// only the Live2D-capable SDVX versions are 64-bit, so this is compiled out
// of 32-bit builds.
void live2d_scene_detection_init();
#endif
}
+84 -84
View File
@@ -1,84 +1,84 @@
#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.
// RegOpenKeyExA + RegQueryValueExA is used instead of RegGetValueA
// because the latter is unavailable on Windows XP.
char desc[256] = { 0 };
DWORD size = sizeof(desc);
std::string name = key_name;
HKEY hkDriver = nullptr;
if (RegOpenKeyExA(hkEnum, key_name, 0,
KEY_QUERY_VALUE | wow64_flag, &hkDriver) == ERROR_SUCCESS) {
DWORD type = 0;
if (RegQueryValueExA(hkDriver,
ASIO_REG_DESC,
nullptr,
&type,
reinterpret_cast<LPBYTE>(desc),
&size) == ERROR_SUCCESS
&& type == REG_SZ && desc[0]) {
// ensure null termination
desc[sizeof(desc) - 1] = '\0';
name = desc;
}
RegCloseKey(hkDriver);
}
// 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;
}
}
#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.
// RegOpenKeyExA + RegQueryValueExA is used instead of RegGetValueA
// because the latter is unavailable on Windows XP.
char desc[256] = { 0 };
DWORD size = sizeof(desc);
std::string name = key_name;
HKEY hkDriver = nullptr;
if (RegOpenKeyExA(hkEnum, key_name, 0,
KEY_QUERY_VALUE | wow64_flag, &hkDriver) == ERROR_SUCCESS) {
DWORD type = 0;
if (RegQueryValueExA(hkDriver,
ASIO_REG_DESC,
nullptr,
&type,
reinterpret_cast<LPBYTE>(desc),
&size) == ERROR_SUCCESS
&& type == REG_SZ && desc[0]) {
// ensure null termination
desc[sizeof(desc) - 1] = '\0';
name = desc;
}
RegCloseKey(hkDriver);
}
// 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;
}
}
+15 -15
View File
@@ -1,15 +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();
}
#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
+240 -240
View File
@@ -1,240 +1,240 @@
#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. also records it as the cached
// instance for its CLSID so later CoCreate calls can reuse it (see wrap_existing)
IUnknown *wrap(REFCLSID clsid, void *real);
// if a cached wrapper already exists for this CLSID, return it (with an added
// reference); otherwise nullptr to signal the caller to create the real driver and
// wrap it. lets the host reuse one driver instance instead of re-instantiating it
IUnknown *wrap_existing(REFCLSID clsid);
// drop the process-lifetime references taken by wrap() so cached drivers can be released
// at shutdown. only relinquishes our pin, so a real driver is torn down once the host
// has released its own references too. call from a controlled shutdown point, never from
// a static destructor (the driver DLL may already be unloaded)
void release_all_wrappers();
}
// 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
// quiesces any leftover stream/buffer state before the cached wrapper is handed back
// for reuse, without destroying the real driver (see wrap_existing)
void quiesce_for_reuse();
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;
// the real driver is initialized exactly once; repeat init() calls are a no-op success
bool initialized = false;
// whether the real driver currently has a buffer set / running stream. used to quiesce
// leftover state when the cached wrapper is reused (see quiesce_for_reuse)
bool buffers_created = false;
bool started = false;
// 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;
};
#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. also records it as the cached
// instance for its CLSID so later CoCreate calls can reuse it (see wrap_existing)
IUnknown *wrap(REFCLSID clsid, void *real);
// if a cached wrapper already exists for this CLSID, return it (with an added
// reference); otherwise nullptr to signal the caller to create the real driver and
// wrap it. lets the host reuse one driver instance instead of re-instantiating it
IUnknown *wrap_existing(REFCLSID clsid);
// drop the process-lifetime references taken by wrap() so cached drivers can be released
// at shutdown. only relinquishes our pin, so a real driver is torn down once the host
// has released its own references too. call from a controlled shutdown point, never from
// a static destructor (the driver DLL may already be unloaded)
void release_all_wrappers();
}
// 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
// quiesces any leftover stream/buffer state before the cached wrapper is handed back
// for reuse, without destroying the real driver (see wrap_existing)
void quiesce_for_reuse();
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;
// the real driver is initialized exactly once; repeat init() calls are a no-op success
bool initialized = false;
// whether the real driver currently has a buffer set / running stream. used to quiesce
// leftover state when the cached wrapper is reused (see quiesce_for_reuse)
bool buffers_created = false;
bool started = false;
// 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;
};
@@ -1,43 +1,43 @@
#pragma once
#include <stdint.h>
#include <endpointvolume.h>
struct WrappedIAudioEndpointVolume : IAudioEndpointVolume {
explicit WrappedIAudioEndpointVolume(IAudioEndpointVolume *orig) : pReal(orig) {}
WrappedIAudioEndpointVolume(const WrappedIAudioEndpointVolume &) = delete;
WrappedIAudioEndpointVolume &operator=(const WrappedIAudioEndpointVolume &) = delete;
virtual ~WrappedIAudioEndpointVolume() = default;
#pragma region IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObj) override;
ULONG STDMETHODCALLTYPE AddRef() override;
ULONG STDMETHODCALLTYPE Release() override;
#pragma endregion
#pragma region IAudioEndpointVolume
HRESULT STDMETHODCALLTYPE RegisterControlChangeNotify(IAudioEndpointVolumeCallback *pNotify) override;
HRESULT STDMETHODCALLTYPE UnregisterControlChangeNotify(IAudioEndpointVolumeCallback *pNotify) override;
HRESULT STDMETHODCALLTYPE GetChannelCount(uint32_t *pnChannelCount) override;
HRESULT STDMETHODCALLTYPE SetMasterVolumeLevel(float fLevelDB, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE SetMasterVolumeLevelScalar(float fLevel, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetMasterVolumeLevel(float *fLevelDB) override;
HRESULT STDMETHODCALLTYPE GetMasterVolumeLevelScalar(float *fLevel) override;
HRESULT STDMETHODCALLTYPE SetChannelVolumeLevel(uint32_t nChannel, float fLevelDB, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE SetChannelVolumeLevelScalar(uint32_t nChannel, float fLevel, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetChannelVolumeLevel(uint32_t nChannel, float *fLevelDB) override;
HRESULT STDMETHODCALLTYPE GetChannelVolumeLevelScalar(uint32_t nChannel, float *fLevel) override;
HRESULT STDMETHODCALLTYPE SetMute(WINBOOL bMute, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetMute(WINBOOL *bMute) override;
HRESULT STDMETHODCALLTYPE GetVolumeStepInfo(uint32_t *pnStep, uint32_t *pnStepCount) override;
HRESULT STDMETHODCALLTYPE VolumeStepUp(LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE VolumeStepDown(LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE QueryHardwareSupport(DWORD *pdwHardwareSupportMask) override;
HRESULT STDMETHODCALLTYPE GetVolumeRange(float *pflVolumeMindB, float *pflVolumeMaxdB, float *pflVolumeIncrementdB) override;
#pragma endregion
private:
IAudioEndpointVolume *const pReal;
#pragma once
#include <stdint.h>
#include <endpointvolume.h>
struct WrappedIAudioEndpointVolume : IAudioEndpointVolume {
explicit WrappedIAudioEndpointVolume(IAudioEndpointVolume *orig) : pReal(orig) {}
WrappedIAudioEndpointVolume(const WrappedIAudioEndpointVolume &) = delete;
WrappedIAudioEndpointVolume &operator=(const WrappedIAudioEndpointVolume &) = delete;
virtual ~WrappedIAudioEndpointVolume() = default;
#pragma region IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObj) override;
ULONG STDMETHODCALLTYPE AddRef() override;
ULONG STDMETHODCALLTYPE Release() override;
#pragma endregion
#pragma region IAudioEndpointVolume
HRESULT STDMETHODCALLTYPE RegisterControlChangeNotify(IAudioEndpointVolumeCallback *pNotify) override;
HRESULT STDMETHODCALLTYPE UnregisterControlChangeNotify(IAudioEndpointVolumeCallback *pNotify) override;
HRESULT STDMETHODCALLTYPE GetChannelCount(uint32_t *pnChannelCount) override;
HRESULT STDMETHODCALLTYPE SetMasterVolumeLevel(float fLevelDB, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE SetMasterVolumeLevelScalar(float fLevel, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetMasterVolumeLevel(float *fLevelDB) override;
HRESULT STDMETHODCALLTYPE GetMasterVolumeLevelScalar(float *fLevel) override;
HRESULT STDMETHODCALLTYPE SetChannelVolumeLevel(uint32_t nChannel, float fLevelDB, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE SetChannelVolumeLevelScalar(uint32_t nChannel, float fLevel, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetChannelVolumeLevel(uint32_t nChannel, float *fLevelDB) override;
HRESULT STDMETHODCALLTYPE GetChannelVolumeLevelScalar(uint32_t nChannel, float *fLevel) override;
HRESULT STDMETHODCALLTYPE SetMute(WINBOOL bMute, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetMute(WINBOOL *bMute) override;
HRESULT STDMETHODCALLTYPE GetVolumeStepInfo(uint32_t *pnStep, uint32_t *pnStepCount) override;
HRESULT STDMETHODCALLTYPE VolumeStepUp(LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE VolumeStepDown(LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE QueryHardwareSupport(DWORD *pdwHardwareSupportMask) override;
HRESULT STDMETHODCALLTYPE GetVolumeRange(float *pflVolumeMindB, float *pflVolumeMaxdB, float *pflVolumeIncrementdB) override;
#pragma endregion
private:
IAudioEndpointVolume *const pReal;
};
@@ -1,185 +1,185 @@
#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) {
auto *client = static_cast<IAudioClient *>(new DummyIAudioClient(new NullDiscardBackend()));
*ppInterface = client;
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
#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) {
auto *client = static_cast<IAudioClient *>(new DummyIAudioClient(new NullDiscardBackend()));
*ppInterface = client;
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
@@ -1,39 +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;
};
#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;
};
@@ -1,139 +1,139 @@
#include "null_discard_backend.h"
#include <algorithm>
#include <chrono>
#include <thread>
#include "hooks/audio/util.h"
#include "util/logging.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;
// 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);
}
std::this_thread::sleep_for(period);
}
}
#include "null_discard_backend.h"
#include <algorithm>
#include <chrono>
#include <thread>
#include "hooks/audio/util.h"
#include "util/logging.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;
// 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);
}
std::this_thread::sleep_for(period);
}
}
@@ -1,54 +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;
};
#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;
};
@@ -1,264 +1,264 @@
#include "downmix.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <audioclient.h>
#include <ks.h>
#include <ksmedia.h>
#include "util/logging.h"
#include "util.h"
namespace hooks::audio {
namespace {
constexpr float ATT_3DB = 0.70710678f;
// speakers routed to the left/right output; anything else (center) feeds both sides
constexpr DWORD LEFT_SPEAKERS = SPEAKER_FRONT_LEFT | SPEAKER_BACK_LEFT | SPEAKER_SIDE_LEFT
| SPEAKER_FRONT_LEFT_OF_CENTER | SPEAKER_TOP_FRONT_LEFT | SPEAKER_TOP_BACK_LEFT;
constexpr DWORD RIGHT_SPEAKERS = SPEAKER_FRONT_RIGHT | SPEAKER_BACK_RIGHT | SPEAKER_SIDE_RIGHT
| SPEAKER_FRONT_RIGHT_OF_CENTER | SPEAKER_TOP_FRONT_RIGHT | SPEAKER_TOP_BACK_RIGHT;
// the speaker mask is only present on WAVE_FORMAT_EXTENSIBLE formats
DWORD read_channel_mask(const WAVEFORMATEX *fmt) {
if (fmt->wFormatTag == WAVE_FORMAT_EXTENSIBLE
&& fmt->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
return reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(fmt)->dwChannelMask;
}
return 0;
}
// call visit(channel_index, speaker_bit) for each present speaker, in channel order
template <typename F>
void for_each_speaker(DWORD mask, int channels, F &&visit) {
int channel = 0;
for (int bit = 0; bit < 18 && channel < channels; bit++) {
const DWORD speaker = 1u << bit;
if (mask & speaker) {
visit(channel++, speaker);
}
}
}
}
void Downmix::setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *stereo_out,
DownmixAlgorithm algorithm) {
this->enabled = true;
this->algorithm = algorithm;
this->bytes_per_sample = game_format->wBitsPerSample / 8;
this->game_frame_size = game_format->nChannels * this->bytes_per_sample;
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
? this->bytes_per_sample == 4
: (this->bytes_per_sample >= 2 && this->bytes_per_sample <= 4);
if (!supported) {
log_fatal(
"audio::downmix",
"unsupported sample format ({}-bit {}), downmix will output silence",
game_format->wBitsPerSample, this->is_float ? "float" : "int");
}
this->left_mix.clear();
this->right_mix.clear();
this->build_layout_mix(game_format);
make_stereo_format(game_format, stereo_out);
}
void Downmix::make_stereo_format(const WAVEFORMATEX *game_format,
WAVEFORMATEXTENSIBLE *stereo_out) {
const int bytes_per_sample = game_format->wBitsPerSample / 8;
memcpy(stereo_out, game_format, sizeof(WAVEFORMATEXTENSIBLE));
stereo_out->Format.nChannels = 2;
stereo_out->Format.nBlockAlign = 2 * bytes_per_sample;
stereo_out->Format.nAvgBytesPerSec =
game_format->nSamplesPerSec * stereo_out->Format.nBlockAlign;
stereo_out->dwChannelMask = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT;
}
HRESULT Downmix::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 smaller stereo buffer can end up unaligned for the device when the game sized the
// 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) {
if (speaker & LEFT_SPEAKERS) {
this->left_mix.push_back({ channel, gain });
} else if (speaker & RIGHT_SPEAKERS) {
this->right_mix.push_back({ channel, gain });
} else { // center: feed both sides
this->left_mix.push_back({ channel, gain });
this->right_mix.push_back({ channel, gain });
}
}
// AC-4 stereo downmix (ETSI TS 103 190-1): front pair at unity, everything else -3 dB, LFE dropped
void Downmix::build_ac4_mix(DWORD mask, int channels) {
for_each_speaker(mask, channels, [&](int ch, DWORD speaker) {
if (speaker == SPEAKER_LOW_FREQUENCY) {
return;
}
const bool front_pair = speaker & (SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT);
this->add_channel(ch, speaker, front_pair ? 1.0f : ATT_3DB);
});
}
// keep only the channels in `keep` (front/rear/side), each at unity gain
void Downmix::build_extract_mix(DWORD mask, int channels, DWORD keep) {
for_each_speaker(mask, channels, [&](int ch, DWORD speaker) {
if (speaker & keep) {
this->add_channel(ch, speaker, 1.0f);
}
});
}
// keep every channel (LFE dropped), then average each side so its gains sum to unity
void Downmix::build_normalize_mix(DWORD mask, int channels) {
for_each_speaker(mask, channels, [&](int ch, DWORD speaker) {
if (speaker != SPEAKER_LOW_FREQUENCY) {
this->add_channel(ch, speaker, 1.0f);
}
});
for (auto *mix : { &this->left_mix, &this->right_mix }) {
if (!mix->empty()) {
const float gain = 1.0f / mix->size();
for (auto &c : *mix) {
c.gain = gain;
}
}
}
}
// fallback when no speaker mask is present: fold interleaved L/R pairs (even->left, odd->right)
void Downmix::build_pairs_mix(int channels, float gain) {
for (int ch = 0; ch < channels; ch++) {
(((ch & 1) == 0) ? this->left_mix : this->right_mix).push_back({ ch, gain });
}
}
void Downmix::build_layout_mix(const WAVEFORMATEX *game_format) {
const int channels = game_format->nChannels;
const DWORD mask = read_channel_mask(game_format);
// without a mask the layout is unknown: extract/normalize have nothing to act on, so all
// algorithms fall back to folding L/R pairs (AC-4 still attenuates by -3 dB)
if (mask == 0) {
this->build_pairs_mix(channels,
this->algorithm == DownmixAlgorithm::AC4 ? ATT_3DB : 1.0f);
return;
}
switch (this->algorithm) {
case DownmixAlgorithm::FrontOnly:
this->build_extract_mix(mask, channels,
SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT);
break;
case DownmixAlgorithm::RearOnly:
this->build_extract_mix(mask, channels,
SPEAKER_BACK_LEFT | SPEAKER_BACK_RIGHT | SPEAKER_BACK_CENTER);
break;
case DownmixAlgorithm::SideOnly:
this->build_extract_mix(mask, channels,
SPEAKER_SIDE_LEFT | SPEAKER_SIDE_RIGHT);
break;
case DownmixAlgorithm::Normalize:
this->build_normalize_mix(mask, channels);
break;
case DownmixAlgorithm::AC4:
this->build_ac4_mix(mask, channels);
break;
}
}
void Downmix::process(BYTE *dst, const BYTE *src, UINT32 frames) const {
const int bps = this->bytes_per_sample;
const int src_stride = this->game_frame_size;
const int dst_stride = 2 * bps;
if (dst == nullptr || src == nullptr || bps <= 0) {
return;
}
// sum each speaker's source channels into the matching stereo output
for (UINT32 i = 0; i < frames; i++) {
const BYTE *in = src + (size_t) i * src_stride;
BYTE *out = dst + (size_t) i * dst_stride;
float left = 0.0f;
float right = 0.0f;
for (const auto &c : this->left_mix) {
left += read_sample(in + c.channel * bps, bps, this->is_float) * c.gain;
}
for (const auto &c : this->right_mix) {
right += read_sample(in + c.channel * bps, bps, this->is_float) * c.gain;
}
write_sample(out, bps, this->is_float, left);
write_sample(out + bps, bps, this->is_float, right);
}
}
HRESULT Downmix::get_buffer(IAudioRenderClient *real, UINT32 frames, BYTE **ppData) {
const size_t needed = (size_t) frames * this->game_frame_size;
if (this->scratch.size() < needed) {
this->scratch.resize(needed);
}
HRESULT ret = real->GetBuffer(frames, &this->device_buffer);
if (FAILED(ret)) {
this->device_buffer = nullptr;
return ret;
}
*ppData = this->scratch.data();
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;
if (this->device_buffer == nullptr || frames == 0 || bps <= 0) {
return;
}
// mute the first few buffers to avoid a pop on stream start
if (this->buffers_to_mute > 0) {
memset(this->device_buffer, 0, (size_t) frames * dst_stride);
this->buffers_to_mute--;
} else if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) {
this->process(this->device_buffer, this->scratch.data(), frames);
}
}
}
#include "downmix.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <audioclient.h>
#include <ks.h>
#include <ksmedia.h>
#include "util/logging.h"
#include "util.h"
namespace hooks::audio {
namespace {
constexpr float ATT_3DB = 0.70710678f;
// speakers routed to the left/right output; anything else (center) feeds both sides
constexpr DWORD LEFT_SPEAKERS = SPEAKER_FRONT_LEFT | SPEAKER_BACK_LEFT | SPEAKER_SIDE_LEFT
| SPEAKER_FRONT_LEFT_OF_CENTER | SPEAKER_TOP_FRONT_LEFT | SPEAKER_TOP_BACK_LEFT;
constexpr DWORD RIGHT_SPEAKERS = SPEAKER_FRONT_RIGHT | SPEAKER_BACK_RIGHT | SPEAKER_SIDE_RIGHT
| SPEAKER_FRONT_RIGHT_OF_CENTER | SPEAKER_TOP_FRONT_RIGHT | SPEAKER_TOP_BACK_RIGHT;
// the speaker mask is only present on WAVE_FORMAT_EXTENSIBLE formats
DWORD read_channel_mask(const WAVEFORMATEX *fmt) {
if (fmt->wFormatTag == WAVE_FORMAT_EXTENSIBLE
&& fmt->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
return reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(fmt)->dwChannelMask;
}
return 0;
}
// call visit(channel_index, speaker_bit) for each present speaker, in channel order
template <typename F>
void for_each_speaker(DWORD mask, int channels, F &&visit) {
int channel = 0;
for (int bit = 0; bit < 18 && channel < channels; bit++) {
const DWORD speaker = 1u << bit;
if (mask & speaker) {
visit(channel++, speaker);
}
}
}
}
void Downmix::setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *stereo_out,
DownmixAlgorithm algorithm) {
this->enabled = true;
this->algorithm = algorithm;
this->bytes_per_sample = game_format->wBitsPerSample / 8;
this->game_frame_size = game_format->nChannels * this->bytes_per_sample;
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
? this->bytes_per_sample == 4
: (this->bytes_per_sample >= 2 && this->bytes_per_sample <= 4);
if (!supported) {
log_fatal(
"audio::downmix",
"unsupported sample format ({}-bit {}), downmix will output silence",
game_format->wBitsPerSample, this->is_float ? "float" : "int");
}
this->left_mix.clear();
this->right_mix.clear();
this->build_layout_mix(game_format);
make_stereo_format(game_format, stereo_out);
}
void Downmix::make_stereo_format(const WAVEFORMATEX *game_format,
WAVEFORMATEXTENSIBLE *stereo_out) {
const int bytes_per_sample = game_format->wBitsPerSample / 8;
memcpy(stereo_out, game_format, sizeof(WAVEFORMATEXTENSIBLE));
stereo_out->Format.nChannels = 2;
stereo_out->Format.nBlockAlign = 2 * bytes_per_sample;
stereo_out->Format.nAvgBytesPerSec =
game_format->nSamplesPerSec * stereo_out->Format.nBlockAlign;
stereo_out->dwChannelMask = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT;
}
HRESULT Downmix::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 smaller stereo buffer can end up unaligned for the device when the game sized the
// 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) {
if (speaker & LEFT_SPEAKERS) {
this->left_mix.push_back({ channel, gain });
} else if (speaker & RIGHT_SPEAKERS) {
this->right_mix.push_back({ channel, gain });
} else { // center: feed both sides
this->left_mix.push_back({ channel, gain });
this->right_mix.push_back({ channel, gain });
}
}
// AC-4 stereo downmix (ETSI TS 103 190-1): front pair at unity, everything else -3 dB, LFE dropped
void Downmix::build_ac4_mix(DWORD mask, int channels) {
for_each_speaker(mask, channels, [&](int ch, DWORD speaker) {
if (speaker == SPEAKER_LOW_FREQUENCY) {
return;
}
const bool front_pair = speaker & (SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT);
this->add_channel(ch, speaker, front_pair ? 1.0f : ATT_3DB);
});
}
// keep only the channels in `keep` (front/rear/side), each at unity gain
void Downmix::build_extract_mix(DWORD mask, int channels, DWORD keep) {
for_each_speaker(mask, channels, [&](int ch, DWORD speaker) {
if (speaker & keep) {
this->add_channel(ch, speaker, 1.0f);
}
});
}
// keep every channel (LFE dropped), then average each side so its gains sum to unity
void Downmix::build_normalize_mix(DWORD mask, int channels) {
for_each_speaker(mask, channels, [&](int ch, DWORD speaker) {
if (speaker != SPEAKER_LOW_FREQUENCY) {
this->add_channel(ch, speaker, 1.0f);
}
});
for (auto *mix : { &this->left_mix, &this->right_mix }) {
if (!mix->empty()) {
const float gain = 1.0f / mix->size();
for (auto &c : *mix) {
c.gain = gain;
}
}
}
}
// fallback when no speaker mask is present: fold interleaved L/R pairs (even->left, odd->right)
void Downmix::build_pairs_mix(int channels, float gain) {
for (int ch = 0; ch < channels; ch++) {
(((ch & 1) == 0) ? this->left_mix : this->right_mix).push_back({ ch, gain });
}
}
void Downmix::build_layout_mix(const WAVEFORMATEX *game_format) {
const int channels = game_format->nChannels;
const DWORD mask = read_channel_mask(game_format);
// without a mask the layout is unknown: extract/normalize have nothing to act on, so all
// algorithms fall back to folding L/R pairs (AC-4 still attenuates by -3 dB)
if (mask == 0) {
this->build_pairs_mix(channels,
this->algorithm == DownmixAlgorithm::AC4 ? ATT_3DB : 1.0f);
return;
}
switch (this->algorithm) {
case DownmixAlgorithm::FrontOnly:
this->build_extract_mix(mask, channels,
SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT);
break;
case DownmixAlgorithm::RearOnly:
this->build_extract_mix(mask, channels,
SPEAKER_BACK_LEFT | SPEAKER_BACK_RIGHT | SPEAKER_BACK_CENTER);
break;
case DownmixAlgorithm::SideOnly:
this->build_extract_mix(mask, channels,
SPEAKER_SIDE_LEFT | SPEAKER_SIDE_RIGHT);
break;
case DownmixAlgorithm::Normalize:
this->build_normalize_mix(mask, channels);
break;
case DownmixAlgorithm::AC4:
this->build_ac4_mix(mask, channels);
break;
}
}
void Downmix::process(BYTE *dst, const BYTE *src, UINT32 frames) const {
const int bps = this->bytes_per_sample;
const int src_stride = this->game_frame_size;
const int dst_stride = 2 * bps;
if (dst == nullptr || src == nullptr || bps <= 0) {
return;
}
// sum each speaker's source channels into the matching stereo output
for (UINT32 i = 0; i < frames; i++) {
const BYTE *in = src + (size_t) i * src_stride;
BYTE *out = dst + (size_t) i * dst_stride;
float left = 0.0f;
float right = 0.0f;
for (const auto &c : this->left_mix) {
left += read_sample(in + c.channel * bps, bps, this->is_float) * c.gain;
}
for (const auto &c : this->right_mix) {
right += read_sample(in + c.channel * bps, bps, this->is_float) * c.gain;
}
write_sample(out, bps, this->is_float, left);
write_sample(out + bps, bps, this->is_float, right);
}
}
HRESULT Downmix::get_buffer(IAudioRenderClient *real, UINT32 frames, BYTE **ppData) {
const size_t needed = (size_t) frames * this->game_frame_size;
if (this->scratch.size() < needed) {
this->scratch.resize(needed);
}
HRESULT ret = real->GetBuffer(frames, &this->device_buffer);
if (FAILED(ret)) {
this->device_buffer = nullptr;
return ret;
}
*ppData = this->scratch.data();
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;
if (this->device_buffer == nullptr || frames == 0 || bps <= 0) {
return;
}
// mute the first few buffers to avoid a pop on stream start
if (this->buffers_to_mute > 0) {
memset(this->device_buffer, 0, (size_t) frames * dst_stride);
this->buffers_to_mute--;
} else if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) {
this->process(this->device_buffer, this->scratch.data(), frames);
}
}
}
+150 -150
View File
@@ -1,150 +1,150 @@
#pragma once
#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 {
// Generic WASAPI surround-to-stereo downmix. The real device is opened in stereo while the
// game keeps writing its native multi-channel audio into a scratch buffer; on release that
// buffer is mixed down into the two front channels.
//
// The mix is derived from the source format's speaker mask according to the selected
// DownmixAlgorithm:
// FrontOnly / RearOnly / SideOnly - keep only that group of channels, routed to their side
// AC4 - AC-4 stereo downmix coefficients (ETSI TS 103 190-1 §6.2.17): front left/right
// pass at 0 dB, center and surrounds fold in at -3 dB, LFE dropped
// Normalize - every channel folded in (center to both sides) with each output side averaged
// so its channels are equally loud, LFE dropped
struct Downmix {
// a source channel routed into one output speaker at the given gain
struct Contribution {
int channel;
float gain;
};
// map an option value (front/rear/side/ac4/normalize) to its algorithm.
static std::optional<DownmixAlgorithm> name_to_algorithm(const char *value) {
if (_stricmp(value, "front") == 0) {
return DownmixAlgorithm::FrontOnly;
} else if (_stricmp(value, "rear") == 0) {
return DownmixAlgorithm::RearOnly;
} else if (_stricmp(value, "side") == 0) {
return DownmixAlgorithm::SideOnly;
} else if (_stricmp(value, "ac4") == 0) {
return DownmixAlgorithm::AC4;
} else if (_stricmp(value, "normalize") == 0) {
return DownmixAlgorithm::Normalize;
}
return std::nullopt;
}
// human-readable name of an algorithm, for logging.
static const char *algorithm_name(DownmixAlgorithm algorithm) {
switch (algorithm) {
case DownmixAlgorithm::FrontOnly: return "front";
case DownmixAlgorithm::RearOnly: return "rear";
case DownmixAlgorithm::SideOnly: return "side";
case DownmixAlgorithm::AC4: return "ac4";
case DownmixAlgorithm::Normalize: return "normalize";
default: return "unknown";
}
}
// whether the downmix is active for the current stream
bool enabled = false;
// algorithm used to fold the multi-channel audio into stereo
DownmixAlgorithm algorithm = DownmixAlgorithm::AC4;
// size in bytes of one frame of the game's multi-channel format
int game_frame_size = 0;
// size in bytes of a single sample (per channel)
int bytes_per_sample = 0;
// whether samples are IEEE floating point rather than integer PCM
bool is_float = false;
// enable the downmix for the given game format and fill stereo_out with the equivalent
// stereo format to open the real device with.
void setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *stereo_out,
DownmixAlgorithm algorithm);
// build the stereo format equivalent to game_format (same sample rate and bit depth).
static void make_stereo_format(const WAVEFORMATEX *game_format,
WAVEFORMATEXTENSIBLE *stereo_out);
// initialize the real device with the stereo format. downmixing reduces the channel count,
// shrinking the buffer's byte size, so the duration the game sized for its multi-channel
// format can leave the smaller stereo buffer unaligned. on AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED
// this performs the standard WASAPI realignment and retries.
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);
// mix `frames` frames of multi-channel `src` down into stereo `dst`.
void process(BYTE *dst, const BYTE *src, UINT32 frames) const;
// 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);
// the real device buffer currently held, or null.
BYTE *current_buffer() const { return this->device_buffer; }
// forget the held device buffer once the caller has released it.
void buffer_released() { this->device_buffer = nullptr; }
private:
// build the mix from the source speaker layout for the selected algorithm
void build_layout_mix(const WAVEFORMATEX *game_format);
// per-algorithm builders, each filling left_mix / right_mix from the speaker mask
void build_ac4_mix(DWORD mask, int channels);
void build_extract_mix(DWORD mask, int channels, DWORD keep);
void build_normalize_mix(DWORD mask, int channels);
// fallback for streams without a speaker mask: fold interleaved L/R pairs at `gain`
void build_pairs_mix(int channels, float gain);
// append one source channel to the output side(s) matching its speaker, at `gain`
void add_channel(int channel, DWORD speaker, float gain);
// source channels summed into each output speaker
std::vector<Contribution> left_mix;
std::vector<Contribution> right_mix;
// buffer the game writes its multi-channel audio into between get/release
std::vector<BYTE> scratch;
// the real stereo device buffer currently held, or null
BYTE *device_buffer = nullptr;
// leading buffers to silence to avoid a pop on stream start
int buffers_to_mute = 16;
};
}
#pragma once
#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 {
// Generic WASAPI surround-to-stereo downmix. The real device is opened in stereo while the
// game keeps writing its native multi-channel audio into a scratch buffer; on release that
// buffer is mixed down into the two front channels.
//
// The mix is derived from the source format's speaker mask according to the selected
// DownmixAlgorithm:
// FrontOnly / RearOnly / SideOnly - keep only that group of channels, routed to their side
// AC4 - AC-4 stereo downmix coefficients (ETSI TS 103 190-1 §6.2.17): front left/right
// pass at 0 dB, center and surrounds fold in at -3 dB, LFE dropped
// Normalize - every channel folded in (center to both sides) with each output side averaged
// so its channels are equally loud, LFE dropped
struct Downmix {
// a source channel routed into one output speaker at the given gain
struct Contribution {
int channel;
float gain;
};
// map an option value (front/rear/side/ac4/normalize) to its algorithm.
static std::optional<DownmixAlgorithm> name_to_algorithm(const char *value) {
if (_stricmp(value, "front") == 0) {
return DownmixAlgorithm::FrontOnly;
} else if (_stricmp(value, "rear") == 0) {
return DownmixAlgorithm::RearOnly;
} else if (_stricmp(value, "side") == 0) {
return DownmixAlgorithm::SideOnly;
} else if (_stricmp(value, "ac4") == 0) {
return DownmixAlgorithm::AC4;
} else if (_stricmp(value, "normalize") == 0) {
return DownmixAlgorithm::Normalize;
}
return std::nullopt;
}
// human-readable name of an algorithm, for logging.
static const char *algorithm_name(DownmixAlgorithm algorithm) {
switch (algorithm) {
case DownmixAlgorithm::FrontOnly: return "front";
case DownmixAlgorithm::RearOnly: return "rear";
case DownmixAlgorithm::SideOnly: return "side";
case DownmixAlgorithm::AC4: return "ac4";
case DownmixAlgorithm::Normalize: return "normalize";
default: return "unknown";
}
}
// whether the downmix is active for the current stream
bool enabled = false;
// algorithm used to fold the multi-channel audio into stereo
DownmixAlgorithm algorithm = DownmixAlgorithm::AC4;
// size in bytes of one frame of the game's multi-channel format
int game_frame_size = 0;
// size in bytes of a single sample (per channel)
int bytes_per_sample = 0;
// whether samples are IEEE floating point rather than integer PCM
bool is_float = false;
// enable the downmix for the given game format and fill stereo_out with the equivalent
// stereo format to open the real device with.
void setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *stereo_out,
DownmixAlgorithm algorithm);
// build the stereo format equivalent to game_format (same sample rate and bit depth).
static void make_stereo_format(const WAVEFORMATEX *game_format,
WAVEFORMATEXTENSIBLE *stereo_out);
// initialize the real device with the stereo format. downmixing reduces the channel count,
// shrinking the buffer's byte size, so the duration the game sized for its multi-channel
// format can leave the smaller stereo buffer unaligned. on AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED
// this performs the standard WASAPI realignment and retries.
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);
// mix `frames` frames of multi-channel `src` down into stereo `dst`.
void process(BYTE *dst, const BYTE *src, UINT32 frames) const;
// 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);
// the real device buffer currently held, or null.
BYTE *current_buffer() const { return this->device_buffer; }
// forget the held device buffer once the caller has released it.
void buffer_released() { this->device_buffer = nullptr; }
private:
// build the mix from the source speaker layout for the selected algorithm
void build_layout_mix(const WAVEFORMATEX *game_format);
// per-algorithm builders, each filling left_mix / right_mix from the speaker mask
void build_ac4_mix(DWORD mask, int channels);
void build_extract_mix(DWORD mask, int channels, DWORD keep);
void build_normalize_mix(DWORD mask, int channels);
// fallback for streams without a speaker mask: fold interleaved L/R pairs at `gain`
void build_pairs_mix(int channels, float gain);
// append one source channel to the output side(s) matching its speaker, at `gain`
void add_channel(int channel, DWORD speaker, float gain);
// source channels summed into each output speaker
std::vector<Contribution> left_mix;
std::vector<Contribution> right_mix;
// buffer the game writes its multi-channel audio into between get/release
std::vector<BYTE> scratch;
// the real stereo device buffer currently held, or null
BYTE *device_buffer = nullptr;
// leading buffers to silence to avoid a pop on stream start
int buffers_to_mute = 16;
};
}
@@ -1,437 +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;
}
}
#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;
}
}
+149 -149
View File
@@ -1,149 +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;
};
}
#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;
};
}
+187 -187
View File
@@ -1,187 +1,187 @@
#include "shared.h"
#include <algorithm>
#include <audioclient.h>
#include "hooks/audio/audio.h"
#include "util/logging.h"
#include "util.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) {
if (!this->redirected_from_exclusive || real == nullptr || sample_rate == 0 || device_frames == 0) {
this->reported_frames = device_frames;
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) {
this->reported_frames = period_frames;
return period_frames;
}
}
this->reported_frames = device_frames;
return device_frames;
}
void SharedRedirect::enable_bridge(int frame_bytes) {
if (!this->redirected_from_exclusive || frame_bytes <= 0) {
return;
}
this->frame_bytes = frame_bytes;
this->device_buffer_frames = 0;
this->fifo.clear();
log_info("audio::wasapi", "shared-mode buffer bridge enabled (frame size {} bytes)",
frame_bytes);
}
BYTE *SharedRedirect::begin_write(UINT32 frames) {
// reserve space at the FIFO tail and let the game write straight into it - no scratch copy.
this->pending_write_offset = this->fifo.size();
this->fifo.resize(this->pending_write_offset + (size_t) frames * this->frame_bytes);
return this->fifo.data() + this->pending_write_offset;
}
void SharedRedirect::commit_write(UINT32 frames, bool silent) {
// trim the tail reservation to the frames actually written; zero it in place if silent.
const size_t end = this->pending_write_offset + (size_t) frames * this->frame_bytes;
if (silent) {
std::fill(this->fifo.begin() + this->pending_write_offset,
this->fifo.begin() + end, (BYTE) 0);
}
this->fifo.resize(end);
}
UINT32 SharedRedirect::pending_frames() const {
if (this->frame_bytes <= 0) {
return 0;
}
return (UINT32) (this->fifo.size() / this->frame_bytes);
}
UINT32 SharedRedirect::virtual_padding() const {
const UINT32 pending = this->pending_frames();
return this->reported_frames > 0 ? std::min(pending, this->reported_frames) : pending;
}
HRESULT SharedRedirect::drain(IAudioRenderClient *real, IAudioClient *client,
const WAVEFORMATEXTENSIBLE &device_format, float boost) {
if (!this->bridge_enabled()) {
return S_OK;
}
// cache the real device buffer size once; it is fixed for the life of the stream.
if (this->device_buffer_frames == 0) {
if (FAILED(client->GetBufferSize(&this->device_buffer_frames))
|| this->device_buffer_frames == 0) {
return S_OK;
}
}
const UINT32 pending = this->pending_frames();
if (pending == 0) {
return S_OK;
}
// push only as many frames as the device currently has free, keeping the rest queued. this
// self-paces to the engine's real consumption so a full-buffer write never overflows.
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;
}
const size_t bytes = (size_t) to_write * this->frame_bytes;
std::copy(this->fifo.begin(), this->fifo.begin() + bytes, dev);
// mute the first few buffers to avoid a startup pop, then apply the volume boost.
if (this->buffers_to_mute > 0) {
std::fill(dev, dev + bytes, (BYTE) 0);
this->buffers_to_mute--;
} else if (boost != 1.0f) {
apply_gain(dev, to_write, device_format, boost);
}
ret = real->ReleaseBuffer(to_write, 0);
// drop the frames just handed to the device from the front of the FIFO.
this->fifo.erase(this->fifo.begin(), this->fifo.begin() + bytes);
return ret;
}
}
#include "shared.h"
#include <algorithm>
#include <audioclient.h>
#include "hooks/audio/audio.h"
#include "util/logging.h"
#include "util.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) {
if (!this->redirected_from_exclusive || real == nullptr || sample_rate == 0 || device_frames == 0) {
this->reported_frames = device_frames;
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) {
this->reported_frames = period_frames;
return period_frames;
}
}
this->reported_frames = device_frames;
return device_frames;
}
void SharedRedirect::enable_bridge(int frame_bytes) {
if (!this->redirected_from_exclusive || frame_bytes <= 0) {
return;
}
this->frame_bytes = frame_bytes;
this->device_buffer_frames = 0;
this->fifo.clear();
log_info("audio::wasapi", "shared-mode buffer bridge enabled (frame size {} bytes)",
frame_bytes);
}
BYTE *SharedRedirect::begin_write(UINT32 frames) {
// reserve space at the FIFO tail and let the game write straight into it - no scratch copy.
this->pending_write_offset = this->fifo.size();
this->fifo.resize(this->pending_write_offset + (size_t) frames * this->frame_bytes);
return this->fifo.data() + this->pending_write_offset;
}
void SharedRedirect::commit_write(UINT32 frames, bool silent) {
// trim the tail reservation to the frames actually written; zero it in place if silent.
const size_t end = this->pending_write_offset + (size_t) frames * this->frame_bytes;
if (silent) {
std::fill(this->fifo.begin() + this->pending_write_offset,
this->fifo.begin() + end, (BYTE) 0);
}
this->fifo.resize(end);
}
UINT32 SharedRedirect::pending_frames() const {
if (this->frame_bytes <= 0) {
return 0;
}
return (UINT32) (this->fifo.size() / this->frame_bytes);
}
UINT32 SharedRedirect::virtual_padding() const {
const UINT32 pending = this->pending_frames();
return this->reported_frames > 0 ? std::min(pending, this->reported_frames) : pending;
}
HRESULT SharedRedirect::drain(IAudioRenderClient *real, IAudioClient *client,
const WAVEFORMATEXTENSIBLE &device_format, float boost) {
if (!this->bridge_enabled()) {
return S_OK;
}
// cache the real device buffer size once; it is fixed for the life of the stream.
if (this->device_buffer_frames == 0) {
if (FAILED(client->GetBufferSize(&this->device_buffer_frames))
|| this->device_buffer_frames == 0) {
return S_OK;
}
}
const UINT32 pending = this->pending_frames();
if (pending == 0) {
return S_OK;
}
// push only as many frames as the device currently has free, keeping the rest queued. this
// self-paces to the engine's real consumption so a full-buffer write never overflows.
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;
}
const size_t bytes = (size_t) to_write * this->frame_bytes;
std::copy(this->fifo.begin(), this->fifo.begin() + bytes, dev);
// mute the first few buffers to avoid a startup pop, then apply the volume boost.
if (this->buffers_to_mute > 0) {
std::fill(dev, dev + bytes, (BYTE) 0);
this->buffers_to_mute--;
} else if (boost != 1.0f) {
apply_gain(dev, to_write, device_format, boost);
}
ret = real->ReleaseBuffer(to_write, 0);
// drop the frames just handed to the device from the front of the FIFO.
this->fifo.erase(this->fifo.begin(), this->fifo.begin() + bytes);
return ret;
}
}
@@ -1,83 +1,83 @@
#pragma once
#include <cstdint>
#include <vector>
#include <windows.h>
#include <mmreg.h>
#include <audioclient.h>
struct IAudioRenderClient;
namespace hooks::audio {
// The -wasapishared option redirects an exclusive WASAPI stream to shared mode, so other apps
// can play sound and devices that can't open the exclusive format still work, 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. the FIFO bridge below is what prevents
// the overflow; this just keeps the game's per-event writes small so the bridge adds minimal
// latency. caches the chosen value for virtual_padding. a no-op unless redirected.
UINT32 clamp_buffer_size(IAudioClient *real, uint32_t sample_rate, UINT32 device_frames);
// FIFO bridge: the redirected game writes a whole reported buffer per event paced by its own
// callback, not the shared engine clock, so a full-buffer write can intermittently exceed the
// double-buffered shared free space (AUDCLNT_E_BUFFER_TOO_LARGE). The game instead writes
// directly into a FIFO that is drained to the device only as fast as it frees space - the
// same free-space-clamped approach the timer-driven resampler uses.
// arm the bridge once the redirected stream is initialized. frame_bytes is one frame's size
// in the game's (== device, via AUTOCONVERTPCM) format.
void enable_bridge(int frame_bytes);
// whether the FIFO bridge is active (a redirect was applied and armed).
bool bridge_enabled() const { return this->frame_bytes > 0; }
// reserve `frames` at the FIFO tail and hand the game a pointer into it to write in place.
// must be paired with commit_write, which trims the reservation to the frames written.
BYTE *begin_write(UINT32 frames);
// trim the reservation from begin_write to the `frames` actually written (zeroing if silent).
void commit_write(UINT32 frames, bool silent);
// padding to report to a game that polls GetCurrentPadding while the bridge is active: the
// FIFO fill level, capped to the reported buffer size so the game's free-space calculation
// (reported_buffer - padding) reflects room in the virtual buffer rather than the device's.
UINT32 virtual_padding() const;
// push as many queued frames as the real device has free, applying `boost`, keeping the rest
// for the next call. `real` is the wrapped render client's underlying interface; `client` is
// the underlying audio client used to query the device's free space.
HRESULT drain(IAudioRenderClient *real, IAudioClient *client,
const WAVEFORMATEXTENSIBLE &device_format, float boost);
private:
// frames currently queued in the FIFO and not yet handed to the device.
UINT32 pending_frames() const;
// FIFO bridge state (see enable_bridge). fifo holds audio queued for the device in the
// game's interleaved frame format; the game writes new frames directly into its tail between
// begin_write and commit_write. frame_bytes > 0 doubles as the "bridge armed" flag (see
// bridge_enabled). pending_write_offset marks the tail reservation handed to begin_write.
int frame_bytes = 0;
UINT32 device_buffer_frames = 0;
UINT32 reported_frames = 0;
int buffers_to_mute = 4;
size_t pending_write_offset = 0;
std::vector<BYTE> fifo;
};
}
#pragma once
#include <cstdint>
#include <vector>
#include <windows.h>
#include <mmreg.h>
#include <audioclient.h>
struct IAudioRenderClient;
namespace hooks::audio {
// The -wasapishared option redirects an exclusive WASAPI stream to shared mode, so other apps
// can play sound and devices that can't open the exclusive format still work, 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. the FIFO bridge below is what prevents
// the overflow; this just keeps the game's per-event writes small so the bridge adds minimal
// latency. caches the chosen value for virtual_padding. a no-op unless redirected.
UINT32 clamp_buffer_size(IAudioClient *real, uint32_t sample_rate, UINT32 device_frames);
// FIFO bridge: the redirected game writes a whole reported buffer per event paced by its own
// callback, not the shared engine clock, so a full-buffer write can intermittently exceed the
// double-buffered shared free space (AUDCLNT_E_BUFFER_TOO_LARGE). The game instead writes
// directly into a FIFO that is drained to the device only as fast as it frees space - the
// same free-space-clamped approach the timer-driven resampler uses.
// arm the bridge once the redirected stream is initialized. frame_bytes is one frame's size
// in the game's (== device, via AUTOCONVERTPCM) format.
void enable_bridge(int frame_bytes);
// whether the FIFO bridge is active (a redirect was applied and armed).
bool bridge_enabled() const { return this->frame_bytes > 0; }
// reserve `frames` at the FIFO tail and hand the game a pointer into it to write in place.
// must be paired with commit_write, which trims the reservation to the frames written.
BYTE *begin_write(UINT32 frames);
// trim the reservation from begin_write to the `frames` actually written (zeroing if silent).
void commit_write(UINT32 frames, bool silent);
// padding to report to a game that polls GetCurrentPadding while the bridge is active: the
// FIFO fill level, capped to the reported buffer size so the game's free-space calculation
// (reported_buffer - padding) reflects room in the virtual buffer rather than the device's.
UINT32 virtual_padding() const;
// push as many queued frames as the real device has free, applying `boost`, keeping the rest
// for the next call. `real` is the wrapped render client's underlying interface; `client` is
// the underlying audio client used to query the device's free space.
HRESULT drain(IAudioRenderClient *real, IAudioClient *client,
const WAVEFORMATEXTENSIBLE &device_format, float boost);
private:
// frames currently queued in the FIFO and not yet handed to the device.
UINT32 pending_frames() const;
// FIFO bridge state (see enable_bridge). fifo holds audio queued for the device in the
// game's interleaved frame format; the game writes new frames directly into its tail between
// begin_write and commit_write. frame_bytes > 0 doubles as the "bridge armed" flag (see
// bridge_enabled). pending_write_offset marks the tail reservation handed to begin_write.
int frame_bytes = 0;
UINT32 device_buffer_frames = 0;
UINT32 reported_frames = 0;
int buffers_to_mute = 4;
size_t pending_write_offset = 0;
std::vector<BYTE> fifo;
};
}
+392 -392
View File
@@ -1,393 +1,393 @@
#include "xact.h"
#include <atomic>
#include <string>
#include <windows.h>
#include <initguid.h>
#include <mmreg.h>
#include <objbase.h>
#include "util/deferlog.h"
#include "util/detour.h"
#include "util/logging.h"
#include "util/utils.h"
namespace hooks::audio::xact {
// XAudio 2.7 is a COM API. Newer Windows SDKs expose a different IXAudio2
// layout, so keep this proxy pinned to the legacy ABI used by libxact.
struct XAudio2DeviceDetails {
WCHAR device_id[256];
WCHAR display_name[256];
DWORD role;
WAVEFORMATEXTENSIBLE output_format;
};
struct XAudio2EffectChain {
UINT32 effect_count;
const void *effect_descriptors;
};
struct IXAudio2_27 {
virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **object) = 0;
virtual ULONG STDMETHODCALLTYPE AddRef() = 0;
virtual ULONG STDMETHODCALLTYPE Release() = 0;
virtual HRESULT STDMETHODCALLTYPE GetDeviceCount(UINT32 *device_count) = 0;
virtual HRESULT STDMETHODCALLTYPE GetDeviceDetails(
UINT32 device_index,
XAudio2DeviceDetails *device_details) = 0;
virtual HRESULT STDMETHODCALLTYPE Initialize(UINT32 flags, UINT32 processor) = 0;
virtual HRESULT STDMETHODCALLTYPE RegisterForCallbacks(void *callback) = 0;
virtual void STDMETHODCALLTYPE UnregisterForCallbacks(void *callback) = 0;
virtual HRESULT STDMETHODCALLTYPE CreateSourceVoice(
void **source_voice,
const WAVEFORMATEX *source_format,
UINT32 flags,
float max_frequency_ratio,
void *callback,
const void *send_list,
const XAudio2EffectChain *effect_chain) = 0;
virtual HRESULT STDMETHODCALLTYPE CreateSubmixVoice(
void **submix_voice,
UINT32 input_channels,
UINT32 input_sample_rate,
UINT32 flags,
UINT32 processing_stage,
const void *send_list,
const XAudio2EffectChain *effect_chain) = 0;
virtual HRESULT STDMETHODCALLTYPE CreateMasteringVoice(
void **mastering_voice,
UINT32 input_channels,
UINT32 input_sample_rate,
UINT32 flags,
UINT32 device_index,
const XAudio2EffectChain *effect_chain) = 0;
virtual HRESULT STDMETHODCALLTYPE StartEngine() = 0;
virtual void STDMETHODCALLTYPE StopEngine() = 0;
virtual HRESULT STDMETHODCALLTYPE CommitChanges(UINT32 operation_set) = 0;
virtual void STDMETHODCALLTYPE GetPerformanceData(void *performance_data) = 0;
virtual void STDMETHODCALLTYPE SetDebugConfiguration(
const void *debug_configuration,
void *reserved) = 0;
};
// XAudio2 2.7 COM class and interface.
DEFINE_GUID(CLSID_XAudio2_7_LEGACY,
0x5a508685, 0xa254, 0x4fba,
0x9b, 0x82, 0x9a, 0x24, 0xb0, 0x03, 0x06, 0xaf);
DEFINE_GUID(IID_IXAudio2_7_LEGACY,
0x8bcf1f58, 0x9fe7, 0x4583,
0x8a, 0xc6, 0xe2, 0xad, 0xc4, 0x65, 0xc8, 0xbb);
static decltype(CoCreateInstance) *CoCreateInstance_orig = nullptr;
using CreateFX_t = HRESULT (WINAPI *)(REFCLSID, IUnknown **, const void *, UINT32);
static CreateFX_t CreateFX_orig = nullptr;
static std::string describe_wave_format(const WAVEFORMATEX *format) {
if (format == nullptr) {
return "null";
}
DWORD channel_mask = 0;
if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
format->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
channel_mask = reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(format)->dwChannelMask;
}
return fmt::format(
"tag=0x{:04x}, channels={}, rate={} Hz, bits={}, valid_block={} B, avg={} B/s, mask=0x{:08x}",
format->wFormatTag,
format->nChannels,
format->nSamplesPerSec,
format->wBitsPerSample,
format->nBlockAlign,
format->nAvgBytesPerSec,
channel_mask);
}
template <size_t Size>
static std::string narrow_fixed(const WCHAR (&value)[Size]) {
size_t length = 0;
while (length < Size && value[length] != L'\0') {
length++;
}
return ws2s(std::wstring(value, length));
}
class WrappedXAudio2 final : public IXAudio2_27 {
public:
explicit WrappedXAudio2(IXAudio2_27 *real) : real(real) {
log_info("audio::xaudio2", "wrapping IXAudio2 2.7 engine {}", static_cast<void *>(real));
}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **object) override {
if (object == nullptr) {
return E_POINTER;
}
if (IsEqualIID(riid, IID_IUnknown) || IsEqualIID(riid, IID_IXAudio2_7_LEGACY)) {
*object = this;
AddRef();
log_info("audio::xaudio2", "IXAudio2::QueryInterface({}) -> proxy", guid2s(riid));
return S_OK;
}
const auto result = real->QueryInterface(riid, object);
log_info(
"audio::xaudio2",
"IXAudio2::QueryInterface({}) -> {}, object={}",
guid2s(riid),
FMT_HRESULT(result),
object != nullptr ? *object : nullptr);
return result;
}
ULONG STDMETHODCALLTYPE AddRef() override {
return ++ref_count;
}
ULONG STDMETHODCALLTYPE Release() override {
const auto remaining = --ref_count;
if (remaining == 0) {
log_info("audio::xaudio2", "destroying IXAudio2 2.7 proxy");
real->Release();
delete this;
}
return remaining;
}
HRESULT STDMETHODCALLTYPE GetDeviceCount(UINT32 *device_count) override {
const auto result = real->GetDeviceCount(device_count);
log_info(
"audio::xaudio2",
"IXAudio2::GetDeviceCount -> {}, count={}",
FMT_HRESULT(result),
SUCCEEDED(result) && device_count != nullptr ? *device_count : 0);
return result;
}
HRESULT STDMETHODCALLTYPE GetDeviceDetails(
UINT32 device_index,
XAudio2DeviceDetails *device_details) override {
const auto result = real->GetDeviceDetails(device_index, device_details);
if (SUCCEEDED(result) && device_details != nullptr) {
const auto device_name = narrow_fixed(device_details->display_name);
if (!device_details_logged.exchange(true, std::memory_order_relaxed)) {
log_info(
"audio::xaudio2",
"IXAudio2::GetDeviceDetails({}) -> {}, id='{}', name='{}', role=0x{:08x}, {}",
device_index,
FMT_HRESULT(result),
narrow_fixed(device_details->device_id),
device_name,
device_details->role,
describe_wave_format(&device_details->output_format.Format));
}
const auto channels = device_details->output_format.Format.nChannels;
if (channels != 2 && channels != 6 &&
!channel_warning_logged.exchange(true, std::memory_order_relaxed)) {
log_warning(
"audio::xaudio2",
"output device '{}' has {} channels; Nostalgia requires stereo or 5.1 output",
device_name,
channels);
deferredlogs::defer_error_messages({
"unsupported audio output channel count detected!",
fmt::format(" device: {}", device_name),
fmt::format(" detected {} channels; Nostalgia requires 2 (stereo) or 6 (5.1)", channels),
" * configure the default Windows playback device for stereo or 5.1 output",
" * disable 7.1 surround sound or spatial audio for this device",
});
}
} else {
log_warning(
"audio::xaudio2",
"IXAudio2::GetDeviceDetails({}) -> {}",
device_index,
FMT_HRESULT(result));
}
return result;
}
HRESULT STDMETHODCALLTYPE Initialize(UINT32 flags, UINT32 processor) override {
const auto result = real->Initialize(flags, processor);
log_info(
"audio::xaudio2",
"IXAudio2::Initialize(flags=0x{:08x}, processor=0x{:08x}) -> {}",
flags,
processor,
FMT_HRESULT(result));
return result;
}
HRESULT STDMETHODCALLTYPE RegisterForCallbacks(void *callback) override {
const auto result = real->RegisterForCallbacks(callback);
log_info(
"audio::xaudio2",
"IXAudio2::RegisterForCallbacks({}) -> {}",
callback,
FMT_HRESULT(result));
return result;
}
void STDMETHODCALLTYPE UnregisterForCallbacks(void *callback) override {
log_info("audio::xaudio2", "IXAudio2::UnregisterForCallbacks({})", callback);
real->UnregisterForCallbacks(callback);
}
HRESULT STDMETHODCALLTYPE CreateSourceVoice(
void **source_voice,
const WAVEFORMATEX *source_format,
UINT32 flags,
float max_frequency_ratio,
void *callback,
const void *send_list,
const XAudio2EffectChain *effect_chain) override {
return real->CreateSourceVoice(
source_voice,
source_format,
flags,
max_frequency_ratio,
callback,
send_list,
effect_chain);
}
HRESULT STDMETHODCALLTYPE CreateSubmixVoice(
void **submix_voice,
UINT32 input_channels,
UINT32 input_sample_rate,
UINT32 flags,
UINT32 processing_stage,
const void *send_list,
const XAudio2EffectChain *effect_chain) override {
return real->CreateSubmixVoice(
submix_voice,
input_channels,
input_sample_rate,
flags,
processing_stage,
send_list,
effect_chain);
}
HRESULT STDMETHODCALLTYPE CreateMasteringVoice(
void **mastering_voice,
UINT32 input_channels,
UINT32 input_sample_rate,
UINT32 flags,
UINT32 device_index,
const XAudio2EffectChain *effect_chain) override {
const auto result = real->CreateMasteringVoice(
mastering_voice,
input_channels,
input_sample_rate,
flags,
device_index,
effect_chain);
log_info(
"audio::xaudio2",
"IXAudio2::CreateMasteringVoice(channels={}, rate={} Hz, flags=0x{:08x}, device={}, effects={}) -> {}, voice={}",
input_channels,
input_sample_rate,
flags,
device_index,
effect_chain != nullptr ? effect_chain->effect_count : 0,
FMT_HRESULT(result),
mastering_voice != nullptr ? *mastering_voice : nullptr);
return result;
}
HRESULT STDMETHODCALLTYPE StartEngine() override {
const auto result = real->StartEngine();
log_info("audio::xaudio2", "IXAudio2::StartEngine -> {}", FMT_HRESULT(result));
return result;
}
void STDMETHODCALLTYPE StopEngine() override {
log_info("audio::xaudio2", "IXAudio2::StopEngine");
real->StopEngine();
}
HRESULT STDMETHODCALLTYPE CommitChanges(UINT32 operation_set) override {
return real->CommitChanges(operation_set);
}
void STDMETHODCALLTYPE GetPerformanceData(void *performance_data) override {
real->GetPerformanceData(performance_data);
}
void STDMETHODCALLTYPE SetDebugConfiguration(
const void *debug_configuration,
void *reserved) override {
log_info("audio::xaudio2", "IXAudio2::SetDebugConfiguration({})", debug_configuration);
real->SetDebugConfiguration(debug_configuration, reserved);
}
private:
std::atomic<ULONG> ref_count = 1;
std::atomic_bool device_details_logged = false;
std::atomic_bool channel_warning_logged = false;
IXAudio2_27 *real;
};
static HRESULT STDAPICALLTYPE CoCreateInstance_hook(
REFCLSID clsid,
LPUNKNOWN outer,
DWORD class_context,
REFIID iid,
LPVOID *object) {
const auto result = CoCreateInstance_orig(clsid, outer, class_context, iid, object);
log_info(
"audio::xact",
"CoCreateInstance(clsid={}, iid={}, context=0x{:08x}) -> {}, object={}",
guid2s(clsid),
guid2s(iid),
class_context,
FMT_HRESULT(result),
object != nullptr ? *object : nullptr);
if (SUCCEEDED(result) && object != nullptr && *object != nullptr &&
IsEqualCLSID(clsid, CLSID_XAudio2_7_LEGACY) &&
IsEqualIID(iid, IID_IXAudio2_7_LEGACY)) {
*object = static_cast<IXAudio2_27 *>(
new WrappedXAudio2(static_cast<IXAudio2_27 *>(*object)));
}
return result;
}
static HRESULT WINAPI CreateFX_hook(
REFCLSID clsid,
IUnknown **effect,
const void *init_data,
UINT32 init_data_size) {
const auto result = CreateFX_orig(clsid, effect, init_data, init_data_size);
log_info(
"audio::xapofx",
"CreateFX(clsid={}, init_data={}, size={}) -> {}, effect={}",
guid2s(clsid),
init_data,
init_data_size,
FMT_HRESULT(result),
effect != nullptr ? static_cast<void *>(*effect) : nullptr);
return result;
}
void init() {
const auto libxact = GetModuleHandleW(L"libxact.dll");
if (libxact == nullptr) {
return;
}
CoCreateInstance_orig = detour::iat_try(
"CoCreateInstance", CoCreateInstance_hook, libxact);
CreateFX_orig = detour::iat_try("CreateFX", CreateFX_hook, libxact);
log_info(
"audio::xact",
"libxact hooks installed: CoCreateInstance={}, CreateFX={}",
CoCreateInstance_orig != nullptr,
CreateFX_orig != nullptr);
}
#include "xact.h"
#include <atomic>
#include <string>
#include <windows.h>
#include <initguid.h>
#include <mmreg.h>
#include <objbase.h>
#include "util/deferlog.h"
#include "util/detour.h"
#include "util/logging.h"
#include "util/utils.h"
namespace hooks::audio::xact {
// XAudio 2.7 is a COM API. Newer Windows SDKs expose a different IXAudio2
// layout, so keep this proxy pinned to the legacy ABI used by libxact.
struct XAudio2DeviceDetails {
WCHAR device_id[256];
WCHAR display_name[256];
DWORD role;
WAVEFORMATEXTENSIBLE output_format;
};
struct XAudio2EffectChain {
UINT32 effect_count;
const void *effect_descriptors;
};
struct IXAudio2_27 {
virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **object) = 0;
virtual ULONG STDMETHODCALLTYPE AddRef() = 0;
virtual ULONG STDMETHODCALLTYPE Release() = 0;
virtual HRESULT STDMETHODCALLTYPE GetDeviceCount(UINT32 *device_count) = 0;
virtual HRESULT STDMETHODCALLTYPE GetDeviceDetails(
UINT32 device_index,
XAudio2DeviceDetails *device_details) = 0;
virtual HRESULT STDMETHODCALLTYPE Initialize(UINT32 flags, UINT32 processor) = 0;
virtual HRESULT STDMETHODCALLTYPE RegisterForCallbacks(void *callback) = 0;
virtual void STDMETHODCALLTYPE UnregisterForCallbacks(void *callback) = 0;
virtual HRESULT STDMETHODCALLTYPE CreateSourceVoice(
void **source_voice,
const WAVEFORMATEX *source_format,
UINT32 flags,
float max_frequency_ratio,
void *callback,
const void *send_list,
const XAudio2EffectChain *effect_chain) = 0;
virtual HRESULT STDMETHODCALLTYPE CreateSubmixVoice(
void **submix_voice,
UINT32 input_channels,
UINT32 input_sample_rate,
UINT32 flags,
UINT32 processing_stage,
const void *send_list,
const XAudio2EffectChain *effect_chain) = 0;
virtual HRESULT STDMETHODCALLTYPE CreateMasteringVoice(
void **mastering_voice,
UINT32 input_channels,
UINT32 input_sample_rate,
UINT32 flags,
UINT32 device_index,
const XAudio2EffectChain *effect_chain) = 0;
virtual HRESULT STDMETHODCALLTYPE StartEngine() = 0;
virtual void STDMETHODCALLTYPE StopEngine() = 0;
virtual HRESULT STDMETHODCALLTYPE CommitChanges(UINT32 operation_set) = 0;
virtual void STDMETHODCALLTYPE GetPerformanceData(void *performance_data) = 0;
virtual void STDMETHODCALLTYPE SetDebugConfiguration(
const void *debug_configuration,
void *reserved) = 0;
};
// XAudio2 2.7 COM class and interface.
DEFINE_GUID(CLSID_XAudio2_7_LEGACY,
0x5a508685, 0xa254, 0x4fba,
0x9b, 0x82, 0x9a, 0x24, 0xb0, 0x03, 0x06, 0xaf);
DEFINE_GUID(IID_IXAudio2_7_LEGACY,
0x8bcf1f58, 0x9fe7, 0x4583,
0x8a, 0xc6, 0xe2, 0xad, 0xc4, 0x65, 0xc8, 0xbb);
static decltype(CoCreateInstance) *CoCreateInstance_orig = nullptr;
using CreateFX_t = HRESULT (WINAPI *)(REFCLSID, IUnknown **, const void *, UINT32);
static CreateFX_t CreateFX_orig = nullptr;
static std::string describe_wave_format(const WAVEFORMATEX *format) {
if (format == nullptr) {
return "null";
}
DWORD channel_mask = 0;
if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
format->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
channel_mask = reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(format)->dwChannelMask;
}
return fmt::format(
"tag=0x{:04x}, channels={}, rate={} Hz, bits={}, valid_block={} B, avg={} B/s, mask=0x{:08x}",
format->wFormatTag,
format->nChannels,
format->nSamplesPerSec,
format->wBitsPerSample,
format->nBlockAlign,
format->nAvgBytesPerSec,
channel_mask);
}
template <size_t Size>
static std::string narrow_fixed(const WCHAR (&value)[Size]) {
size_t length = 0;
while (length < Size && value[length] != L'\0') {
length++;
}
return ws2s(std::wstring(value, length));
}
class WrappedXAudio2 final : public IXAudio2_27 {
public:
explicit WrappedXAudio2(IXAudio2_27 *real) : real(real) {
log_info("audio::xaudio2", "wrapping IXAudio2 2.7 engine {}", static_cast<void *>(real));
}
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **object) override {
if (object == nullptr) {
return E_POINTER;
}
if (IsEqualIID(riid, IID_IUnknown) || IsEqualIID(riid, IID_IXAudio2_7_LEGACY)) {
*object = this;
AddRef();
log_info("audio::xaudio2", "IXAudio2::QueryInterface({}) -> proxy", guid2s(riid));
return S_OK;
}
const auto result = real->QueryInterface(riid, object);
log_info(
"audio::xaudio2",
"IXAudio2::QueryInterface({}) -> {}, object={}",
guid2s(riid),
FMT_HRESULT(result),
object != nullptr ? *object : nullptr);
return result;
}
ULONG STDMETHODCALLTYPE AddRef() override {
return ++ref_count;
}
ULONG STDMETHODCALLTYPE Release() override {
const auto remaining = --ref_count;
if (remaining == 0) {
log_info("audio::xaudio2", "destroying IXAudio2 2.7 proxy");
real->Release();
delete this;
}
return remaining;
}
HRESULT STDMETHODCALLTYPE GetDeviceCount(UINT32 *device_count) override {
const auto result = real->GetDeviceCount(device_count);
log_info(
"audio::xaudio2",
"IXAudio2::GetDeviceCount -> {}, count={}",
FMT_HRESULT(result),
SUCCEEDED(result) && device_count != nullptr ? *device_count : 0);
return result;
}
HRESULT STDMETHODCALLTYPE GetDeviceDetails(
UINT32 device_index,
XAudio2DeviceDetails *device_details) override {
const auto result = real->GetDeviceDetails(device_index, device_details);
if (SUCCEEDED(result) && device_details != nullptr) {
const auto device_name = narrow_fixed(device_details->display_name);
if (!device_details_logged.exchange(true, std::memory_order_relaxed)) {
log_info(
"audio::xaudio2",
"IXAudio2::GetDeviceDetails({}) -> {}, id='{}', name='{}', role=0x{:08x}, {}",
device_index,
FMT_HRESULT(result),
narrow_fixed(device_details->device_id),
device_name,
device_details->role,
describe_wave_format(&device_details->output_format.Format));
}
const auto channels = device_details->output_format.Format.nChannels;
if (channels != 2 && channels != 6 &&
!channel_warning_logged.exchange(true, std::memory_order_relaxed)) {
log_warning(
"audio::xaudio2",
"output device '{}' has {} channels; Nostalgia requires stereo or 5.1 output",
device_name,
channels);
deferredlogs::defer_error_messages({
"unsupported audio output channel count detected!",
fmt::format(" device: {}", device_name),
fmt::format(" detected {} channels; Nostalgia requires 2 (stereo) or 6 (5.1)", channels),
" * configure the default Windows playback device for stereo or 5.1 output",
" * disable 7.1 surround sound or spatial audio for this device",
});
}
} else {
log_warning(
"audio::xaudio2",
"IXAudio2::GetDeviceDetails({}) -> {}",
device_index,
FMT_HRESULT(result));
}
return result;
}
HRESULT STDMETHODCALLTYPE Initialize(UINT32 flags, UINT32 processor) override {
const auto result = real->Initialize(flags, processor);
log_info(
"audio::xaudio2",
"IXAudio2::Initialize(flags=0x{:08x}, processor=0x{:08x}) -> {}",
flags,
processor,
FMT_HRESULT(result));
return result;
}
HRESULT STDMETHODCALLTYPE RegisterForCallbacks(void *callback) override {
const auto result = real->RegisterForCallbacks(callback);
log_info(
"audio::xaudio2",
"IXAudio2::RegisterForCallbacks({}) -> {}",
callback,
FMT_HRESULT(result));
return result;
}
void STDMETHODCALLTYPE UnregisterForCallbacks(void *callback) override {
log_info("audio::xaudio2", "IXAudio2::UnregisterForCallbacks({})", callback);
real->UnregisterForCallbacks(callback);
}
HRESULT STDMETHODCALLTYPE CreateSourceVoice(
void **source_voice,
const WAVEFORMATEX *source_format,
UINT32 flags,
float max_frequency_ratio,
void *callback,
const void *send_list,
const XAudio2EffectChain *effect_chain) override {
return real->CreateSourceVoice(
source_voice,
source_format,
flags,
max_frequency_ratio,
callback,
send_list,
effect_chain);
}
HRESULT STDMETHODCALLTYPE CreateSubmixVoice(
void **submix_voice,
UINT32 input_channels,
UINT32 input_sample_rate,
UINT32 flags,
UINT32 processing_stage,
const void *send_list,
const XAudio2EffectChain *effect_chain) override {
return real->CreateSubmixVoice(
submix_voice,
input_channels,
input_sample_rate,
flags,
processing_stage,
send_list,
effect_chain);
}
HRESULT STDMETHODCALLTYPE CreateMasteringVoice(
void **mastering_voice,
UINT32 input_channels,
UINT32 input_sample_rate,
UINT32 flags,
UINT32 device_index,
const XAudio2EffectChain *effect_chain) override {
const auto result = real->CreateMasteringVoice(
mastering_voice,
input_channels,
input_sample_rate,
flags,
device_index,
effect_chain);
log_info(
"audio::xaudio2",
"IXAudio2::CreateMasteringVoice(channels={}, rate={} Hz, flags=0x{:08x}, device={}, effects={}) -> {}, voice={}",
input_channels,
input_sample_rate,
flags,
device_index,
effect_chain != nullptr ? effect_chain->effect_count : 0,
FMT_HRESULT(result),
mastering_voice != nullptr ? *mastering_voice : nullptr);
return result;
}
HRESULT STDMETHODCALLTYPE StartEngine() override {
const auto result = real->StartEngine();
log_info("audio::xaudio2", "IXAudio2::StartEngine -> {}", FMT_HRESULT(result));
return result;
}
void STDMETHODCALLTYPE StopEngine() override {
log_info("audio::xaudio2", "IXAudio2::StopEngine");
real->StopEngine();
}
HRESULT STDMETHODCALLTYPE CommitChanges(UINT32 operation_set) override {
return real->CommitChanges(operation_set);
}
void STDMETHODCALLTYPE GetPerformanceData(void *performance_data) override {
real->GetPerformanceData(performance_data);
}
void STDMETHODCALLTYPE SetDebugConfiguration(
const void *debug_configuration,
void *reserved) override {
log_info("audio::xaudio2", "IXAudio2::SetDebugConfiguration({})", debug_configuration);
real->SetDebugConfiguration(debug_configuration, reserved);
}
private:
std::atomic<ULONG> ref_count = 1;
std::atomic_bool device_details_logged = false;
std::atomic_bool channel_warning_logged = false;
IXAudio2_27 *real;
};
static HRESULT STDAPICALLTYPE CoCreateInstance_hook(
REFCLSID clsid,
LPUNKNOWN outer,
DWORD class_context,
REFIID iid,
LPVOID *object) {
const auto result = CoCreateInstance_orig(clsid, outer, class_context, iid, object);
log_info(
"audio::xact",
"CoCreateInstance(clsid={}, iid={}, context=0x{:08x}) -> {}, object={}",
guid2s(clsid),
guid2s(iid),
class_context,
FMT_HRESULT(result),
object != nullptr ? *object : nullptr);
if (SUCCEEDED(result) && object != nullptr && *object != nullptr &&
IsEqualCLSID(clsid, CLSID_XAudio2_7_LEGACY) &&
IsEqualIID(iid, IID_IXAudio2_7_LEGACY)) {
*object = static_cast<IXAudio2_27 *>(
new WrappedXAudio2(static_cast<IXAudio2_27 *>(*object)));
}
return result;
}
static HRESULT WINAPI CreateFX_hook(
REFCLSID clsid,
IUnknown **effect,
const void *init_data,
UINT32 init_data_size) {
const auto result = CreateFX_orig(clsid, effect, init_data, init_data_size);
log_info(
"audio::xapofx",
"CreateFX(clsid={}, init_data={}, size={}) -> {}, effect={}",
guid2s(clsid),
init_data,
init_data_size,
FMT_HRESULT(result),
effect != nullptr ? static_cast<void *>(*effect) : nullptr);
return result;
}
void init() {
const auto libxact = GetModuleHandleW(L"libxact.dll");
if (libxact == nullptr) {
return;
}
CoCreateInstance_orig = detour::iat_try(
"CoCreateInstance", CoCreateInstance_hook, libxact);
CreateFX_orig = detour::iat_try("CreateFX", CreateFX_hook, libxact);
log_info(
"audio::xact",
"libxact hooks installed: CoCreateInstance={}, CreateFX={}",
CoCreateInstance_orig != nullptr,
CreateFX_orig != nullptr);
}
}
+4 -4
View File
@@ -1,5 +1,5 @@
#pragma once
namespace hooks::audio::xact {
void init();
#pragma once
namespace hooks::audio::xact {
void init();
}
+1 -1
View File
@@ -574,7 +574,7 @@ void devicehook_init(HMODULE module) {
STORE(EscapeCommFunction_orig, detour::iat_try("EscapeCommFunction", EscapeCommFunction_hook, module));
STORE(GetCommState_orig, detour::iat_try("GetCommState", GetCommState_hook, module));
STORE(GetFileSize_orig, detour::iat_try("GetFileSize", GetFileSize_hook, module));
STORE(GetFileSizeEx_orig, detour::iat_try("GetFileSize", GetFileSizeEx_hook, module));
STORE(GetFileSizeEx_orig, detour::iat_try("GetFileSizeEx", GetFileSizeEx_hook, module));
STORE(GetFileInformationByHandle_orig, detour::iat_try(
"GetFileInformationByHandle", GetFileInformationByHandle_hook, module));
STORE(PurgeComm_orig, detour::iat_try("PurgeComm", PurgeComm_hook, module));
@@ -1,304 +1,304 @@
// dx11 / dxgi hook entrypoint. trampolines d3d11.dll / dxgi.dll exports
// the moment those DLLs appear (LDR notification + poll-thread fallback),
// then drives proactive vtable capture so we don't lose the race against
// the execexe loader. per-vtable hook implementations live in the sibling
// files (d3d11_swapchain / d3d11_factory / d3d11_vtable_capture /
// d3d11_screenshot).
//
// note: never LoadLibrary d3d11/dxgi -- execexe pre-loads them itself and
// fails (error 0xa) if they're already in the loader's module list.
//
// 64-bit only.
#include "d3d11_backend.h"
#ifndef SPICE_D3D11
void graphics_d3d11_init() {}
void graphics_d3d11_shutdown() {}
#else
#include <atomic>
#include <thread>
#include <chrono>
#include <cwchar>
#include <mutex>
#include <windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include <dxgi1_2.h>
#include "d3d11_internal.h"
#include "util/nt_loader.h"
namespace {
using D3D11CreateDeviceAndSwapChain_t = HRESULT(WINAPI *)(
IDXGIAdapter *, D3D_DRIVER_TYPE, HMODULE, UINT,
const D3D_FEATURE_LEVEL *, UINT, UINT,
const DXGI_SWAP_CHAIN_DESC *, IDXGISwapChain **,
ID3D11Device **, D3D_FEATURE_LEVEL *, ID3D11DeviceContext **);
using CreateDXGIFactory_t = HRESULT(WINAPI *)(REFIID, void **);
using CreateDXGIFactory1_t = HRESULT(WINAPI *)(REFIID, void **);
using CreateDXGIFactory2_t = HRESULT(WINAPI *)(UINT, REFIID, void **);
D3D11CreateDeviceAndSwapChain_t D3D11CreateDeviceAndSwapChain_orig = nullptr;
CreateDXGIFactory_t CreateDXGIFactory_orig = nullptr;
CreateDXGIFactory1_t CreateDXGIFactory1_orig = nullptr;
CreateDXGIFactory2_t CreateDXGIFactory2_orig = nullptr;
std::atomic<bool> g_d3d11_exports_hooked { false };
std::atomic<bool> g_dxgi_exports_hooked { false };
// ----------------------------------------------------------------------
// top-level export hooks
HRESULT WINAPI D3D11CreateDeviceAndSwapChain_hook(
IDXGIAdapter *pAdapter, D3D_DRIVER_TYPE DriverType, HMODULE Software, UINT Flags,
const D3D_FEATURE_LEVEL *pFeatureLevels, UINT FeatureLevels, UINT SDKVersion,
const DXGI_SWAP_CHAIN_DESC *pSwapChainDesc, IDXGISwapChain **ppSwapChain,
ID3D11Device **ppDevice, D3D_FEATURE_LEVEL *pFeatureLevel,
ID3D11DeviceContext **ppImmediateContext)
{
HRESULT res = D3D11CreateDeviceAndSwapChain_orig(
pAdapter, DriverType, Software, Flags,
pFeatureLevels, FeatureLevels, SDKVersion,
pSwapChainDesc, ppSwapChain, ppDevice, pFeatureLevel, ppImmediateContext);
if (SUCCEEDED(res) && ppSwapChain && *ppSwapChain) {
if (pSwapChainDesc) {
d3d11_hooks::note_main_hwnd(pSwapChainDesc->OutputWindow);
}
d3d11_hooks::install_swapchain_hooks(*ppSwapChain);
}
return res;
}
#define DEFINE_FACTORY_HOOK(NAME, SIG_PARAMS, ORIG_ARGS) \
HRESULT WINAPI NAME##_hook SIG_PARAMS { \
HRESULT res = NAME##_orig ORIG_ARGS; \
if (SUCCEEDED(res) && ppFactory && *ppFactory) { \
d3d11_hooks::install_factory_hooks( \
reinterpret_cast<IUnknown *>(*ppFactory)); \
} \
return res; \
}
DEFINE_FACTORY_HOOK(CreateDXGIFactory,
(REFIID riid, void **ppFactory),
(riid, ppFactory))
DEFINE_FACTORY_HOOK(CreateDXGIFactory1,
(REFIID riid, void **ppFactory),
(riid, ppFactory))
DEFINE_FACTORY_HOOK(CreateDXGIFactory2,
(UINT Flags, REFIID riid, void **ppFactory),
(Flags, riid, ppFactory))
#undef DEFINE_FACTORY_HOOK
// ----------------------------------------------------------------------
// export trampoline plumbing
// serializes trampoline_export() so the LDR notification callback and the
// poll thread don't race each other into MinHook against the same target.
std::mutex g_export_mutex;
bool trampoline_export(const char *dll, const char *name, void *hook, void **orig) {
std::lock_guard<std::mutex> lock(g_export_mutex);
if (*orig) {
return true;
}
HMODULE mod = GetModuleHandleA(dll);
if (!mod) {
return false;
}
void *addr = reinterpret_cast<void *>(GetProcAddress(mod, name));
if (!addr) {
return false;
}
*orig = addr; // trampoline_try reads *orig before overwriting it.
if (!detour::trampoline_try(addr, hook, orig)) {
*orig = nullptr;
return false;
}
log_info("graphics::d3d11", "trampolined {}!{}", dll, name);
return true;
}
void try_install_d3d11_exports() {
if (g_d3d11_exports_hooked) {
return;
}
if (trampoline_export("d3d11.dll", "D3D11CreateDeviceAndSwapChain",
(void *) D3D11CreateDeviceAndSwapChain_hook,
(void **) &D3D11CreateDeviceAndSwapChain_orig)) {
g_d3d11_exports_hooked = true;
}
}
void try_install_dxgi_exports() {
if (g_dxgi_exports_hooked) {
return;
}
struct entry { const char *name; void *hook; void **orig; };
const entry entries[] = {
{ "CreateDXGIFactory", (void *) CreateDXGIFactory_hook,
(void **) &CreateDXGIFactory_orig },
{ "CreateDXGIFactory1", (void *) CreateDXGIFactory1_hook,
(void **) &CreateDXGIFactory1_orig },
{ "CreateDXGIFactory2", (void *) CreateDXGIFactory2_hook,
(void **) &CreateDXGIFactory2_orig },
};
bool any = false;
for (auto &e : entries) {
any |= trampoline_export("dxgi.dll", e.name, e.hook, e.orig);
}
if (any) {
g_dxgi_exports_hooked = true;
}
}
void try_capture_if_ready() {
if (g_d3d11_exports_hooked && g_dxgi_exports_hooked) {
d3d11_hooks::try_capture_vtables();
}
}
// ----------------------------------------------------------------------
// LDR notification + polling fallback
bool dll_name_ends_with(PCUNICODE_STRING name, const wchar_t *suffix) {
if (!name || !name->Buffer) {
return false;
}
const size_t n = name->Length / sizeof(WCHAR);
const size_t s = wcslen(suffix);
return n >= s && _wcsnicmp(name->Buffer + n - s, suffix, s) == 0;
}
VOID CALLBACK ldr_dll_notification(
ULONG reason, PCLDR_DLL_NOTIFICATION_DATA data, PVOID /*context*/)
{
if (reason != LDR_DLL_NOTIFICATION_REASON_LOADED || !data) {
return;
}
if (dll_name_ends_with(data->Loaded.BaseDllName, L"d3d11.dll")) {
try_install_d3d11_exports();
} else if (dll_name_ends_with(data->Loaded.BaseDllName, L"dxgi.dll")) {
try_install_dxgi_exports();
}
}
// execexe maps d3d11/dxgi via a path that bypasses LdrLoadDll, so the
// notification above never fires for those DLLs and we have to poll.
std::atomic<bool> g_stop { false };
std::thread g_poll_thread;
std::mutex g_init_mutex;
PVOID g_ldr_cookie = nullptr;
void poll_thread() {
using namespace std::chrono_literals;
for (int32_t i = 0; i < 120 && !g_stop.load(); ++i) {
try_install_d3d11_exports();
try_install_dxgi_exports();
if (g_d3d11_exports_hooked && g_dxgi_exports_hooked) {
d3d11_hooks::try_capture_vtables();
return;
}
// sliced so shutdown doesn't have to wait a full second.
for (int32_t s = 0; s < 10 && !g_stop.load(); ++s) {
std::this_thread::sleep_for(100ms);
}
}
}
// 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() {
// dx11 titles always run under execexe. skipping on pure-dx9 games keeps
// their startup path completely untouched (no exports patched, no poll
// thread, no LDR callback).
if (!GetModuleHandleW(L"execexe.dll")) {
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
}
log_info("graphics::d3d11", "initializing");
// trampoline now if either DLL is already in the PEB.
try_install_d3d11_exports();
try_install_dxgi_exports();
try_capture_if_ready();
// catches standard LdrLoadDll loads.
auto reg = reinterpret_cast<decltype(&LdrRegisterDllNotification)>(
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "LdrRegisterDllNotification"));
if (reg) {
NTSTATUS st = reg(0, ldr_dll_notification, nullptr, &g_ldr_cookie);
if (NT_SUCCESS(st)) {
log_info("graphics::d3d11", "registered LDR DLL notification");
} else {
g_ldr_cookie = nullptr;
log_warning("graphics::d3d11",
"LdrRegisterDllNotification failed: {:#x}", (unsigned long)st);
}
}
// catches the execexe loader path that bypasses LdrLoadDll.
g_poll_thread = std::thread(poll_thread);
}
void graphics_d3d11_shutdown() {
std::lock_guard<std::mutex> lock(g_init_mutex);
// unregister first so the callback can't fire mid-teardown.
if (g_ldr_cookie) {
auto unreg = reinterpret_cast<decltype(&LdrUnregisterDllNotification)>(
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "LdrUnregisterDllNotification"));
if (unreg) {
unreg(g_ldr_cookie);
}
g_ldr_cookie = nullptr;
}
g_stop.store(true);
if (g_poll_thread.joinable()) {
g_poll_thread.join();
}
}
#endif // SPICE_D3D11
// dx11 / dxgi hook entrypoint. trampolines d3d11.dll / dxgi.dll exports
// the moment those DLLs appear (LDR notification + poll-thread fallback),
// then drives proactive vtable capture so we don't lose the race against
// the execexe loader. per-vtable hook implementations live in the sibling
// files (d3d11_swapchain / d3d11_factory / d3d11_vtable_capture /
// d3d11_screenshot).
//
// note: never LoadLibrary d3d11/dxgi -- execexe pre-loads them itself and
// fails (error 0xa) if they're already in the loader's module list.
//
// 64-bit only.
#include "d3d11_backend.h"
#ifndef SPICE_D3D11
void graphics_d3d11_init() {}
void graphics_d3d11_shutdown() {}
#else
#include <atomic>
#include <thread>
#include <chrono>
#include <cwchar>
#include <mutex>
#include <windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include <dxgi1_2.h>
#include "d3d11_internal.h"
#include "util/nt_loader.h"
namespace {
using D3D11CreateDeviceAndSwapChain_t = HRESULT(WINAPI *)(
IDXGIAdapter *, D3D_DRIVER_TYPE, HMODULE, UINT,
const D3D_FEATURE_LEVEL *, UINT, UINT,
const DXGI_SWAP_CHAIN_DESC *, IDXGISwapChain **,
ID3D11Device **, D3D_FEATURE_LEVEL *, ID3D11DeviceContext **);
using CreateDXGIFactory_t = HRESULT(WINAPI *)(REFIID, void **);
using CreateDXGIFactory1_t = HRESULT(WINAPI *)(REFIID, void **);
using CreateDXGIFactory2_t = HRESULT(WINAPI *)(UINT, REFIID, void **);
D3D11CreateDeviceAndSwapChain_t D3D11CreateDeviceAndSwapChain_orig = nullptr;
CreateDXGIFactory_t CreateDXGIFactory_orig = nullptr;
CreateDXGIFactory1_t CreateDXGIFactory1_orig = nullptr;
CreateDXGIFactory2_t CreateDXGIFactory2_orig = nullptr;
std::atomic<bool> g_d3d11_exports_hooked { false };
std::atomic<bool> g_dxgi_exports_hooked { false };
// ----------------------------------------------------------------------
// top-level export hooks
HRESULT WINAPI D3D11CreateDeviceAndSwapChain_hook(
IDXGIAdapter *pAdapter, D3D_DRIVER_TYPE DriverType, HMODULE Software, UINT Flags,
const D3D_FEATURE_LEVEL *pFeatureLevels, UINT FeatureLevels, UINT SDKVersion,
const DXGI_SWAP_CHAIN_DESC *pSwapChainDesc, IDXGISwapChain **ppSwapChain,
ID3D11Device **ppDevice, D3D_FEATURE_LEVEL *pFeatureLevel,
ID3D11DeviceContext **ppImmediateContext)
{
HRESULT res = D3D11CreateDeviceAndSwapChain_orig(
pAdapter, DriverType, Software, Flags,
pFeatureLevels, FeatureLevels, SDKVersion,
pSwapChainDesc, ppSwapChain, ppDevice, pFeatureLevel, ppImmediateContext);
if (SUCCEEDED(res) && ppSwapChain && *ppSwapChain) {
if (pSwapChainDesc) {
d3d11_hooks::note_main_hwnd(pSwapChainDesc->OutputWindow);
}
d3d11_hooks::install_swapchain_hooks(*ppSwapChain);
}
return res;
}
#define DEFINE_FACTORY_HOOK(NAME, SIG_PARAMS, ORIG_ARGS) \
HRESULT WINAPI NAME##_hook SIG_PARAMS { \
HRESULT res = NAME##_orig ORIG_ARGS; \
if (SUCCEEDED(res) && ppFactory && *ppFactory) { \
d3d11_hooks::install_factory_hooks( \
reinterpret_cast<IUnknown *>(*ppFactory)); \
} \
return res; \
}
DEFINE_FACTORY_HOOK(CreateDXGIFactory,
(REFIID riid, void **ppFactory),
(riid, ppFactory))
DEFINE_FACTORY_HOOK(CreateDXGIFactory1,
(REFIID riid, void **ppFactory),
(riid, ppFactory))
DEFINE_FACTORY_HOOK(CreateDXGIFactory2,
(UINT Flags, REFIID riid, void **ppFactory),
(Flags, riid, ppFactory))
#undef DEFINE_FACTORY_HOOK
// ----------------------------------------------------------------------
// export trampoline plumbing
// serializes trampoline_export() so the LDR notification callback and the
// poll thread don't race each other into MinHook against the same target.
std::mutex g_export_mutex;
bool trampoline_export(const char *dll, const char *name, void *hook, void **orig) {
std::lock_guard<std::mutex> lock(g_export_mutex);
if (*orig) {
return true;
}
HMODULE mod = GetModuleHandleA(dll);
if (!mod) {
return false;
}
void *addr = reinterpret_cast<void *>(GetProcAddress(mod, name));
if (!addr) {
return false;
}
*orig = addr; // trampoline_try reads *orig before overwriting it.
if (!detour::trampoline_try(addr, hook, orig)) {
*orig = nullptr;
return false;
}
log_info("graphics::d3d11", "trampolined {}!{}", dll, name);
return true;
}
void try_install_d3d11_exports() {
if (g_d3d11_exports_hooked) {
return;
}
if (trampoline_export("d3d11.dll", "D3D11CreateDeviceAndSwapChain",
(void *) D3D11CreateDeviceAndSwapChain_hook,
(void **) &D3D11CreateDeviceAndSwapChain_orig)) {
g_d3d11_exports_hooked = true;
}
}
void try_install_dxgi_exports() {
if (g_dxgi_exports_hooked) {
return;
}
struct entry { const char *name; void *hook; void **orig; };
const entry entries[] = {
{ "CreateDXGIFactory", (void *) CreateDXGIFactory_hook,
(void **) &CreateDXGIFactory_orig },
{ "CreateDXGIFactory1", (void *) CreateDXGIFactory1_hook,
(void **) &CreateDXGIFactory1_orig },
{ "CreateDXGIFactory2", (void *) CreateDXGIFactory2_hook,
(void **) &CreateDXGIFactory2_orig },
};
bool any = false;
for (auto &e : entries) {
any |= trampoline_export("dxgi.dll", e.name, e.hook, e.orig);
}
if (any) {
g_dxgi_exports_hooked = true;
}
}
void try_capture_if_ready() {
if (g_d3d11_exports_hooked && g_dxgi_exports_hooked) {
d3d11_hooks::try_capture_vtables();
}
}
// ----------------------------------------------------------------------
// LDR notification + polling fallback
bool dll_name_ends_with(PCUNICODE_STRING name, const wchar_t *suffix) {
if (!name || !name->Buffer) {
return false;
}
const size_t n = name->Length / sizeof(WCHAR);
const size_t s = wcslen(suffix);
return n >= s && _wcsnicmp(name->Buffer + n - s, suffix, s) == 0;
}
VOID CALLBACK ldr_dll_notification(
ULONG reason, PCLDR_DLL_NOTIFICATION_DATA data, PVOID /*context*/)
{
if (reason != LDR_DLL_NOTIFICATION_REASON_LOADED || !data) {
return;
}
if (dll_name_ends_with(data->Loaded.BaseDllName, L"d3d11.dll")) {
try_install_d3d11_exports();
} else if (dll_name_ends_with(data->Loaded.BaseDllName, L"dxgi.dll")) {
try_install_dxgi_exports();
}
}
// execexe maps d3d11/dxgi via a path that bypasses LdrLoadDll, so the
// notification above never fires for those DLLs and we have to poll.
std::atomic<bool> g_stop { false };
std::thread g_poll_thread;
std::mutex g_init_mutex;
PVOID g_ldr_cookie = nullptr;
void poll_thread() {
using namespace std::chrono_literals;
for (int32_t i = 0; i < 120 && !g_stop.load(); ++i) {
try_install_d3d11_exports();
try_install_dxgi_exports();
if (g_d3d11_exports_hooked && g_dxgi_exports_hooked) {
d3d11_hooks::try_capture_vtables();
return;
}
// sliced so shutdown doesn't have to wait a full second.
for (int32_t s = 0; s < 10 && !g_stop.load(); ++s) {
std::this_thread::sleep_for(100ms);
}
}
}
// 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() {
// dx11 titles always run under execexe. skipping on pure-dx9 games keeps
// their startup path completely untouched (no exports patched, no poll
// thread, no LDR callback).
if (!GetModuleHandleW(L"execexe.dll")) {
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
}
log_info("graphics::d3d11", "initializing");
// trampoline now if either DLL is already in the PEB.
try_install_d3d11_exports();
try_install_dxgi_exports();
try_capture_if_ready();
// catches standard LdrLoadDll loads.
auto reg = reinterpret_cast<decltype(&LdrRegisterDllNotification)>(
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "LdrRegisterDllNotification"));
if (reg) {
NTSTATUS st = reg(0, ldr_dll_notification, nullptr, &g_ldr_cookie);
if (NT_SUCCESS(st)) {
log_info("graphics::d3d11", "registered LDR DLL notification");
} else {
g_ldr_cookie = nullptr;
log_warning("graphics::d3d11",
"LdrRegisterDllNotification failed: {:#x}", (unsigned long)st);
}
}
// catches the execexe loader path that bypasses LdrLoadDll.
g_poll_thread = std::thread(poll_thread);
}
void graphics_d3d11_shutdown() {
std::lock_guard<std::mutex> lock(g_init_mutex);
// unregister first so the callback can't fire mid-teardown.
if (g_ldr_cookie) {
auto unreg = reinterpret_cast<decltype(&LdrUnregisterDllNotification)>(
GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "LdrUnregisterDllNotification"));
if (unreg) {
unreg(g_ldr_cookie);
}
g_ldr_cookie = nullptr;
}
g_stop.store(true);
if (g_poll_thread.joinable()) {
g_poll_thread.join();
}
}
#endif // SPICE_D3D11
@@ -1,24 +1,24 @@
#pragma once
#include "overlay/overlay.h"
void graphics_d3d11_init();
void graphics_d3d11_shutdown();
#ifdef SPICE_D3D11
struct ID3D11Device;
struct ID3D11DeviceContext;
struct ID3D11RenderTargetView;
struct IDXGISwapChain;
namespace overlay::d3d11 {
void render(ID3D11Device *device,
ID3D11DeviceContext *context,
IDXGISwapChain *swapchain,
ID3D11RenderTargetView **rtv);
}
#endif
#pragma once
#include "overlay/overlay.h"
void graphics_d3d11_init();
void graphics_d3d11_shutdown();
#ifdef SPICE_D3D11
struct ID3D11Device;
struct ID3D11DeviceContext;
struct ID3D11RenderTargetView;
struct IDXGISwapChain;
namespace overlay::d3d11 {
void render(ID3D11Device *device,
ID3D11DeviceContext *context,
IDXGISwapChain *swapchain,
ID3D11RenderTargetView **rtv);
}
#endif
@@ -1,102 +1,102 @@
// dx11 factory vtable hooks. patches CreateSwapChain / CreateSwapChainForHwnd
// so we can install_swapchain_hooks against every newly-created swapchain.
#include "d3d11_backend.h"
#ifdef SPICE_D3D11
#include <mutex>
#include <windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include <dxgi1_2.h>
#include "d3d11_internal.h"
namespace {
using CreateSwapChain_t = HRESULT(STDMETHODCALLTYPE *)(
IDXGIFactory *, IUnknown *, DXGI_SWAP_CHAIN_DESC *, IDXGISwapChain **);
using CreateSwapChainForHwnd_t = HRESULT(STDMETHODCALLTYPE *)(
IDXGIFactory2 *, IUnknown *, HWND,
const DXGI_SWAP_CHAIN_DESC1 *,
const DXGI_SWAP_CHAIN_FULLSCREEN_DESC *,
IDXGIOutput *, IDXGISwapChain1 **);
CreateSwapChain_t CreateSwapChain_orig = nullptr;
CreateSwapChainForHwnd_t CreateSwapChainForHwnd_orig = nullptr;
bool g_factory_hooked = false;
bool g_factory2_hooked = false;
std::mutex g_hook_mutex;
HRESULT STDMETHODCALLTYPE CreateSwapChain_hook(
IDXGIFactory *factory, IUnknown *pDevice,
DXGI_SWAP_CHAIN_DESC *pDesc, IDXGISwapChain **ppSwapChain)
{
HRESULT res = CreateSwapChain_orig(factory, pDevice, pDesc, ppSwapChain);
if (SUCCEEDED(res) && ppSwapChain && *ppSwapChain) {
if (pDesc) {
d3d11_hooks::note_main_hwnd(pDesc->OutputWindow);
}
d3d11_hooks::install_swapchain_hooks(*ppSwapChain);
}
return res;
}
HRESULT STDMETHODCALLTYPE CreateSwapChainForHwnd_hook(
IDXGIFactory2 *factory, IUnknown *pDevice, HWND hWnd,
const DXGI_SWAP_CHAIN_DESC1 *pDesc,
const DXGI_SWAP_CHAIN_FULLSCREEN_DESC *pFullscreenDesc,
IDXGIOutput *pRestrictToOutput, IDXGISwapChain1 **ppSwapChain)
{
HRESULT res = CreateSwapChainForHwnd_orig(
factory, pDevice, hWnd, pDesc, pFullscreenDesc, pRestrictToOutput, ppSwapChain);
if (SUCCEEDED(res) && ppSwapChain && *ppSwapChain) {
d3d11_hooks::note_main_hwnd(hWnd);
d3d11_hooks::install_swapchain_hooks(*ppSwapChain);
}
return res;
}
// QI-and-hook helper: dedupes the IDXGIFactory / IDXGIFactory2 install paths.
template<typename Iface>
void install_on(IUnknown *factory, bool &flag,
size_t vtbl_index, void *hook, void **orig, const char *name)
{
if (flag) {
return;
}
Iface *f = nullptr;
if (FAILED(factory->QueryInterface(IID_PPV_ARGS(&f))) || !f) {
return;
}
if (d3d11_hooks::hook_vtbl(f, vtbl_index, hook, orig, name)) {
flag = true;
}
f->Release();
}
} // namespace
namespace d3d11_hooks {
void install_factory_hooks(IUnknown *factory) {
if (!factory) {
return;
}
std::lock_guard<std::mutex> lock(g_hook_mutex);
install_on<IDXGIFactory>(factory, g_factory_hooked, 10,
(void *) CreateSwapChain_hook, (void **) &CreateSwapChain_orig,
"IDXGIFactory::CreateSwapChain");
install_on<IDXGIFactory2>(factory, g_factory2_hooked, 15,
(void *) CreateSwapChainForHwnd_hook, (void **) &CreateSwapChainForHwnd_orig,
"IDXGIFactory2::CreateSwapChainForHwnd");
}
}
#endif // SPICE_D3D11
// dx11 factory vtable hooks. patches CreateSwapChain / CreateSwapChainForHwnd
// so we can install_swapchain_hooks against every newly-created swapchain.
#include "d3d11_backend.h"
#ifdef SPICE_D3D11
#include <mutex>
#include <windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include <dxgi1_2.h>
#include "d3d11_internal.h"
namespace {
using CreateSwapChain_t = HRESULT(STDMETHODCALLTYPE *)(
IDXGIFactory *, IUnknown *, DXGI_SWAP_CHAIN_DESC *, IDXGISwapChain **);
using CreateSwapChainForHwnd_t = HRESULT(STDMETHODCALLTYPE *)(
IDXGIFactory2 *, IUnknown *, HWND,
const DXGI_SWAP_CHAIN_DESC1 *,
const DXGI_SWAP_CHAIN_FULLSCREEN_DESC *,
IDXGIOutput *, IDXGISwapChain1 **);
CreateSwapChain_t CreateSwapChain_orig = nullptr;
CreateSwapChainForHwnd_t CreateSwapChainForHwnd_orig = nullptr;
bool g_factory_hooked = false;
bool g_factory2_hooked = false;
std::mutex g_hook_mutex;
HRESULT STDMETHODCALLTYPE CreateSwapChain_hook(
IDXGIFactory *factory, IUnknown *pDevice,
DXGI_SWAP_CHAIN_DESC *pDesc, IDXGISwapChain **ppSwapChain)
{
HRESULT res = CreateSwapChain_orig(factory, pDevice, pDesc, ppSwapChain);
if (SUCCEEDED(res) && ppSwapChain && *ppSwapChain) {
if (pDesc) {
d3d11_hooks::note_main_hwnd(pDesc->OutputWindow);
}
d3d11_hooks::install_swapchain_hooks(*ppSwapChain);
}
return res;
}
HRESULT STDMETHODCALLTYPE CreateSwapChainForHwnd_hook(
IDXGIFactory2 *factory, IUnknown *pDevice, HWND hWnd,
const DXGI_SWAP_CHAIN_DESC1 *pDesc,
const DXGI_SWAP_CHAIN_FULLSCREEN_DESC *pFullscreenDesc,
IDXGIOutput *pRestrictToOutput, IDXGISwapChain1 **ppSwapChain)
{
HRESULT res = CreateSwapChainForHwnd_orig(
factory, pDevice, hWnd, pDesc, pFullscreenDesc, pRestrictToOutput, ppSwapChain);
if (SUCCEEDED(res) && ppSwapChain && *ppSwapChain) {
d3d11_hooks::note_main_hwnd(hWnd);
d3d11_hooks::install_swapchain_hooks(*ppSwapChain);
}
return res;
}
// QI-and-hook helper: dedupes the IDXGIFactory / IDXGIFactory2 install paths.
template<typename Iface>
void install_on(IUnknown *factory, bool &flag,
size_t vtbl_index, void *hook, void **orig, const char *name)
{
if (flag) {
return;
}
Iface *f = nullptr;
if (FAILED(factory->QueryInterface(IID_PPV_ARGS(&f))) || !f) {
return;
}
if (d3d11_hooks::hook_vtbl(f, vtbl_index, hook, orig, name)) {
flag = true;
}
f->Release();
}
} // namespace
namespace d3d11_hooks {
void install_factory_hooks(IUnknown *factory) {
if (!factory) {
return;
}
std::lock_guard<std::mutex> lock(g_hook_mutex);
install_on<IDXGIFactory>(factory, g_factory_hooked, 10,
(void *) CreateSwapChain_hook, (void **) &CreateSwapChain_orig,
"IDXGIFactory::CreateSwapChain");
install_on<IDXGIFactory2>(factory, g_factory2_hooked, 15,
(void *) CreateSwapChainForHwnd_hook, (void **) &CreateSwapChainForHwnd_orig,
"IDXGIFactory2::CreateSwapChainForHwnd");
}
}
#endif // SPICE_D3D11
@@ -1,59 +1,59 @@
#pragma once
// internal glue for the dx11 backend. all symbols gated on SPICE_D3D11.
#include "overlay/overlay.h"
#ifdef SPICE_D3D11
#include <memory>
#include "util/detour.h"
#include "util/logging.h"
struct HWND__; typedef HWND__ *HWND;
struct IUnknown;
struct IDXGISwapChain;
namespace d3d11_hooks {
void install_swapchain_hooks(IDXGISwapChain *swapchain);
void install_factory_hooks(IUnknown *factory);
void try_capture_vtables();
// first non-null swapchain HWND wins; later ones (sub-screens, IME
// helpers) are ignored. the dummy capture window is exempted via
// ignore_hwnd.
void note_main_hwnd(HWND hwnd);
HWND main_hwnd();
void ignore_hwnd(HWND hwnd);
// capture backbuffer to PNG if a screenshot was requested.
void try_screenshot(IDXGISwapChain *swapchain);
// trampoline a virtual method by vtable index. on failure *orig is null.
inline bool hook_vtbl(void *iface, size_t index,
void *hook, void **orig, const char *name)
{
void **vtbl = *reinterpret_cast<void ***>(iface);
void *target = vtbl[index];
// trampoline_try reads *orig before overwriting it.
*orig = target;
if (!detour::trampoline_try(target, hook, orig)) {
*orig = nullptr;
log_warning("graphics::d3d11", "failed to hook {}", name);
return false;
}
log_info("graphics::d3d11", "hooked {}", name);
return true;
}
// minimal COM RAII used by capture / screenshot paths.
struct com_release {
void operator()(IUnknown *p) const { if (p) p->Release(); }
};
template<typename T> using com_ptr = std::unique_ptr<T, com_release>;
}
#endif
#pragma once
// internal glue for the dx11 backend. all symbols gated on SPICE_D3D11.
#include "overlay/overlay.h"
#ifdef SPICE_D3D11
#include <memory>
#include "util/detour.h"
#include "util/logging.h"
struct HWND__; typedef HWND__ *HWND;
struct IUnknown;
struct IDXGISwapChain;
namespace d3d11_hooks {
void install_swapchain_hooks(IDXGISwapChain *swapchain);
void install_factory_hooks(IUnknown *factory);
void try_capture_vtables();
// first non-null swapchain HWND wins; later ones (sub-screens, IME
// helpers) are ignored. the dummy capture window is exempted via
// ignore_hwnd.
void note_main_hwnd(HWND hwnd);
HWND main_hwnd();
void ignore_hwnd(HWND hwnd);
// capture backbuffer to PNG if a screenshot was requested.
void try_screenshot(IDXGISwapChain *swapchain);
// trampoline a virtual method by vtable index. on failure *orig is null.
inline bool hook_vtbl(void *iface, size_t index,
void *hook, void **orig, const char *name)
{
void **vtbl = *reinterpret_cast<void ***>(iface);
void *target = vtbl[index];
// trampoline_try reads *orig before overwriting it.
*orig = target;
if (!detour::trampoline_try(target, hook, orig)) {
*orig = nullptr;
log_warning("graphics::d3d11", "failed to hook {}", name);
return false;
}
log_info("graphics::d3d11", "hooked {}", name);
return true;
}
// minimal COM RAII used by capture / screenshot paths.
struct com_release {
void operator()(IUnknown *p) const { if (p) p->Release(); }
};
template<typename T> using com_ptr = std::unique_ptr<T, com_release>;
}
#endif
@@ -1,165 +1,165 @@
// dx11 screenshot capture. mirrors the d3d9 backend: copy the current
// backbuffer into a staging texture, force alpha=255, write PNG via
// stb_image_write, push to clipboard and notify.
#include "d3d11_backend.h"
#ifdef SPICE_D3D11
#include <vector>
#include <windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include "d3d11_internal.h"
#include "external/stb_image_write.h"
#include "hooks/graphics/graphics.h"
#include "misc/clipboard.h"
#include "overlay/notifications.h"
#include "util/fileutils.h"
using d3d11_hooks::com_ptr;
namespace {
// copy the swapchain backbuffer into a CPU-readable staging texture and
// flatten it into an RGBA8 buffer (BGRA backbuffers are swizzled,
// alpha is forced to 255).
bool copy_backbuffer_to_rgba(IDXGISwapChain *swapchain,
ID3D11Device *device,
ID3D11DeviceContext *context,
std::vector<uint8_t> &out,
uint32_t &out_w, uint32_t &out_h)
{
ID3D11Texture2D *raw_bb = nullptr;
if (FAILED(swapchain->GetBuffer(0, IID_PPV_ARGS(&raw_bb))) || !raw_bb) {
return false;
}
com_ptr<ID3D11Texture2D> backbuffer(raw_bb);
D3D11_TEXTURE2D_DESC desc {};
backbuffer->GetDesc(&desc);
// MSAA backbuffers can't be CopyResource'd into a non-MS staging target.
com_ptr<ID3D11Texture2D> resolved;
ID3D11Texture2D *source = backbuffer.get();
if (desc.SampleDesc.Count > 1) {
D3D11_TEXTURE2D_DESC rd = desc;
rd.SampleDesc.Count = 1;
rd.SampleDesc.Quality = 0;
rd.Usage = D3D11_USAGE_DEFAULT;
rd.BindFlags = D3D11_BIND_RENDER_TARGET;
rd.CPUAccessFlags = 0;
rd.MiscFlags = 0;
ID3D11Texture2D *r = nullptr;
if (FAILED(device->CreateTexture2D(&rd, nullptr, &r)) || !r) {
return false;
}
resolved.reset(r);
context->ResolveSubresource(resolved.get(), 0, backbuffer.get(), 0, desc.Format);
source = resolved.get();
}
D3D11_TEXTURE2D_DESC sd {};
sd.Width = desc.Width;
sd.Height = desc.Height;
sd.MipLevels = 1;
sd.ArraySize = 1;
sd.Format = desc.Format;
sd.SampleDesc.Count = 1;
sd.Usage = D3D11_USAGE_STAGING;
sd.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
ID3D11Texture2D *raw_staging = nullptr;
if (FAILED(device->CreateTexture2D(&sd, nullptr, &raw_staging)) || !raw_staging) {
return false;
}
com_ptr<ID3D11Texture2D> staging(raw_staging);
context->CopyResource(staging.get(), source);
D3D11_MAPPED_SUBRESOURCE mapped {};
if (FAILED(context->Map(staging.get(), 0, D3D11_MAP_READ, 0, &mapped))) {
return false;
}
// backbuffers from GetDesc are always fully-typed (never _TYPELESS).
const bool is_bgra = desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM
|| desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB;
out.resize(static_cast<size_t>(desc.Width) * desc.Height * 4);
const uint8_t *src_base = reinterpret_cast<const uint8_t *>(mapped.pData);
for (uint32_t y = 0; y < desc.Height; ++y) {
const uint8_t *row = src_base + static_cast<size_t>(y) * mapped.RowPitch;
uint8_t *dst = out.data() + static_cast<size_t>(y) * desc.Width * 4;
for (uint32_t x = 0; x < desc.Width; ++x) {
dst[x * 4 + 0] = row[x * 4 + (is_bgra ? 2 : 0)];
dst[x * 4 + 1] = row[x * 4 + 1];
dst[x * 4 + 2] = row[x * 4 + (is_bgra ? 0 : 2)];
dst[x * 4 + 3] = 255;
}
}
context->Unmap(staging.get(), 0);
out_w = desc.Width;
out_h = desc.Height;
return true;
}
} // namespace
namespace d3d11_hooks {
void try_screenshot(IDXGISwapChain *swapchain) {
if (!swapchain || !graphics_screenshot_consume()) {
return;
}
auto file_path = graphics_screenshot_genpath();
if (file_path.empty()) {
return;
}
ID3D11Device *raw_device = nullptr;
if (FAILED(swapchain->GetDevice(IID_PPV_ARGS(&raw_device))) || !raw_device) {
return;
}
com_ptr<ID3D11Device> device(raw_device);
ID3D11DeviceContext *raw_ctx = nullptr;
device->GetImmediateContext(&raw_ctx);
if (!raw_ctx) {
return;
}
com_ptr<ID3D11DeviceContext> context(raw_ctx);
std::vector<uint8_t> pixels;
uint32_t w = 0, h = 0;
if (!copy_backbuffer_to_rgba(swapchain, device.get(), context.get(), pixels, w, h)) {
log_warning("graphics::d3d11", "screenshot: failed to capture backbuffer");
overlay::notifications::add(
overlay::notifications::Severity::Error,
"Screenshot failed to capture");
return;
}
log_info("graphics::d3d11", "saving screenshot to {}", file_path);
if (stbi_write_png(file_path.c_str(), (int) w, (int) h, 4,
pixels.data(), (int) w * 4))
{
clipboard::copy_image(file_path);
overlay::notifications::add(
overlay::notifications::Severity::Success,
fmt::format("Screenshot saved: {}", fileutils::basename(file_path)));
} else {
log_warning("graphics::d3d11", "screenshot: stbi_write_png failed");
overlay::notifications::add(
overlay::notifications::Severity::Error,
"Screenshot failed to save");
}
}
}
#endif // SPICE_D3D11
// dx11 screenshot capture. mirrors the d3d9 backend: copy the current
// backbuffer into a staging texture, force alpha=255, write PNG via
// stb_image_write, push to clipboard and notify.
#include "d3d11_backend.h"
#ifdef SPICE_D3D11
#include <vector>
#include <windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include "d3d11_internal.h"
#include "external/stb_image_write.h"
#include "hooks/graphics/graphics.h"
#include "misc/clipboard.h"
#include "overlay/notifications.h"
#include "util/fileutils.h"
using d3d11_hooks::com_ptr;
namespace {
// copy the swapchain backbuffer into a CPU-readable staging texture and
// flatten it into an RGBA8 buffer (BGRA backbuffers are swizzled,
// alpha is forced to 255).
bool copy_backbuffer_to_rgba(IDXGISwapChain *swapchain,
ID3D11Device *device,
ID3D11DeviceContext *context,
std::vector<uint8_t> &out,
uint32_t &out_w, uint32_t &out_h)
{
ID3D11Texture2D *raw_bb = nullptr;
if (FAILED(swapchain->GetBuffer(0, IID_PPV_ARGS(&raw_bb))) || !raw_bb) {
return false;
}
com_ptr<ID3D11Texture2D> backbuffer(raw_bb);
D3D11_TEXTURE2D_DESC desc {};
backbuffer->GetDesc(&desc);
// MSAA backbuffers can't be CopyResource'd into a non-MS staging target.
com_ptr<ID3D11Texture2D> resolved;
ID3D11Texture2D *source = backbuffer.get();
if (desc.SampleDesc.Count > 1) {
D3D11_TEXTURE2D_DESC rd = desc;
rd.SampleDesc.Count = 1;
rd.SampleDesc.Quality = 0;
rd.Usage = D3D11_USAGE_DEFAULT;
rd.BindFlags = D3D11_BIND_RENDER_TARGET;
rd.CPUAccessFlags = 0;
rd.MiscFlags = 0;
ID3D11Texture2D *r = nullptr;
if (FAILED(device->CreateTexture2D(&rd, nullptr, &r)) || !r) {
return false;
}
resolved.reset(r);
context->ResolveSubresource(resolved.get(), 0, backbuffer.get(), 0, desc.Format);
source = resolved.get();
}
D3D11_TEXTURE2D_DESC sd {};
sd.Width = desc.Width;
sd.Height = desc.Height;
sd.MipLevels = 1;
sd.ArraySize = 1;
sd.Format = desc.Format;
sd.SampleDesc.Count = 1;
sd.Usage = D3D11_USAGE_STAGING;
sd.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
ID3D11Texture2D *raw_staging = nullptr;
if (FAILED(device->CreateTexture2D(&sd, nullptr, &raw_staging)) || !raw_staging) {
return false;
}
com_ptr<ID3D11Texture2D> staging(raw_staging);
context->CopyResource(staging.get(), source);
D3D11_MAPPED_SUBRESOURCE mapped {};
if (FAILED(context->Map(staging.get(), 0, D3D11_MAP_READ, 0, &mapped))) {
return false;
}
// backbuffers from GetDesc are always fully-typed (never _TYPELESS).
const bool is_bgra = desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM
|| desc.Format == DXGI_FORMAT_B8G8R8A8_UNORM_SRGB;
out.resize(static_cast<size_t>(desc.Width) * desc.Height * 4);
const uint8_t *src_base = reinterpret_cast<const uint8_t *>(mapped.pData);
for (uint32_t y = 0; y < desc.Height; ++y) {
const uint8_t *row = src_base + static_cast<size_t>(y) * mapped.RowPitch;
uint8_t *dst = out.data() + static_cast<size_t>(y) * desc.Width * 4;
for (uint32_t x = 0; x < desc.Width; ++x) {
dst[x * 4 + 0] = row[x * 4 + (is_bgra ? 2 : 0)];
dst[x * 4 + 1] = row[x * 4 + 1];
dst[x * 4 + 2] = row[x * 4 + (is_bgra ? 0 : 2)];
dst[x * 4 + 3] = 255;
}
}
context->Unmap(staging.get(), 0);
out_w = desc.Width;
out_h = desc.Height;
return true;
}
} // namespace
namespace d3d11_hooks {
void try_screenshot(IDXGISwapChain *swapchain) {
if (!swapchain || !graphics_screenshot_consume()) {
return;
}
auto file_path = graphics_screenshot_genpath();
if (file_path.empty()) {
return;
}
ID3D11Device *raw_device = nullptr;
if (FAILED(swapchain->GetDevice(IID_PPV_ARGS(&raw_device))) || !raw_device) {
return;
}
com_ptr<ID3D11Device> device(raw_device);
ID3D11DeviceContext *raw_ctx = nullptr;
device->GetImmediateContext(&raw_ctx);
if (!raw_ctx) {
return;
}
com_ptr<ID3D11DeviceContext> context(raw_ctx);
std::vector<uint8_t> pixels;
uint32_t w = 0, h = 0;
if (!copy_backbuffer_to_rgba(swapchain, device.get(), context.get(), pixels, w, h)) {
log_warning("graphics::d3d11", "screenshot: failed to capture backbuffer");
overlay::notifications::add(
overlay::notifications::Severity::Error,
"Screenshot failed to capture");
return;
}
log_info("graphics::d3d11", "saving screenshot to {}", file_path);
if (stbi_write_png(file_path.c_str(), (int) w, (int) h, 4,
pixels.data(), (int) w * 4))
{
clipboard::copy_image(file_path);
overlay::notifications::add(
overlay::notifications::Severity::Success,
fmt::format("Screenshot saved: {}", fileutils::basename(file_path)));
} else {
log_warning("graphics::d3d11", "screenshot: stbi_write_png failed");
overlay::notifications::add(
overlay::notifications::Severity::Error,
"Screenshot failed to save");
}
}
}
#endif // SPICE_D3D11
@@ -1,294 +1,343 @@
// dx11 swapchain vtable hooks + per-frame overlay pump.
//
// dxgi shares vtables across swapchain instances, so we only need to patch
// Present / Present1 / ResizeBuffers once on the first instance we see.
// each frame we lazily attach the overlay to whichever swapchain is
// presenting, then drive its imgui update / new_frame / render cycle.
#include "d3d11_backend.h"
#ifdef SPICE_D3D11
#include <atomic>
#include <mutex>
#include <windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include <dxgi1_2.h>
#include "d3d11_internal.h"
#include "external/imgui/imgui.h"
#include "external/imgui/backends/imgui_impl_dx11.h"
#include "overlay/imgui/impl_spice.h"
#include "games/io.h"
#include "hooks/graphics/graphics.h"
#include "launcher/launcher.h"
#include "misc/eamuse.h"
#include "util/utils.h"
// --------------------------------------------------------------------------
// overlay render bridge
namespace overlay::d3d11 {
// sRGB backbuffers need a UNORM view: ImGui vertex colors are already
// sRGB-encoded, so an extra linear->sRGB conversion would wash the
// overlay out white.
static DXGI_FORMAT to_unorm_view(DXGI_FORMAT fmt) {
switch (fmt) {
case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: return DXGI_FORMAT_R8G8B8A8_UNORM;
case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: return DXGI_FORMAT_B8G8R8A8_UNORM;
default: return fmt;
}
}
static void ensure_rtv(ID3D11Device *device,
IDXGISwapChain *swapchain,
ID3D11RenderTargetView **rtv)
{
if (*rtv || !device || !swapchain) {
return;
}
ID3D11Texture2D *backbuffer = nullptr;
if (FAILED(swapchain->GetBuffer(0, IID_PPV_ARGS(&backbuffer))) || !backbuffer) {
return;
}
D3D11_TEXTURE2D_DESC td {};
backbuffer->GetDesc(&td);
const DXGI_FORMAT view_fmt = to_unorm_view(td.Format);
if (view_fmt != td.Format) {
D3D11_RENDER_TARGET_VIEW_DESC rtvd {};
rtvd.Format = view_fmt;
rtvd.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D;
device->CreateRenderTargetView(backbuffer, &rtvd, rtv);
} else {
device->CreateRenderTargetView(backbuffer, nullptr, rtv);
}
backbuffer->Release();
}
// bind the backbuffer (lazily creating the RTV) and draw the imgui
// frame on top. reset_invalidate releases *rtv on ResizeBuffers.
void render(ID3D11Device *device,
ID3D11DeviceContext *context,
IDXGISwapChain *swapchain,
ID3D11RenderTargetView **rtv)
{
ensure_rtv(device, swapchain, rtv);
if (!*rtv || !context) {
return;
}
// present happens immediately after, so no need to save the previous
// RT binding (flip-model resets it anyway).
context->OMSetRenderTargets(1, rtv, nullptr);
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
}
}
// --------------------------------------------------------------------------
// file-local state + per-frame helpers
namespace {
using Present_t = HRESULT(STDMETHODCALLTYPE *)(
IDXGISwapChain *, UINT, UINT);
using ResizeBuffers_t = HRESULT(STDMETHODCALLTYPE *)(
IDXGISwapChain *, UINT, UINT, UINT, DXGI_FORMAT, UINT);
using Present1_t = HRESULT(STDMETHODCALLTYPE *)(
IDXGISwapChain1 *, UINT, UINT, const DXGI_PRESENT_PARAMETERS *);
Present_t Present_orig = nullptr;
ResizeBuffers_t ResizeBuffers_orig = nullptr;
Present1_t Present1_orig = nullptr;
bool g_swapchain_hooked = false;
bool g_swapchain1_hooked = false;
void try_create_overlay(IDXGISwapChain *swapchain) {
if (!swapchain || overlay::OVERLAY) {
return;
}
DXGI_SWAP_CHAIN_DESC desc {};
if (FAILED(swapchain->GetDesc(&desc)) || !desc.OutputWindow) {
return;
}
// only attach to the main game window; ignore sub-screens / IME helpers.
HWND main = d3d11_hooks::main_hwnd();
if (main && desc.OutputWindow != main) {
return;
}
// theme the native title bar; first present is the only reliable point for
// windows whose swapchain bypasses our factory hooks (e.g. UnityPlayer.dll)
set_window_dark_titlebar(desc.OutputWindow);
ID3D11Device *device = nullptr;
if (FAILED(swapchain->GetDevice(IID_PPV_ARGS(&device))) || !device) {
return;
}
ID3D11DeviceContext *context = nullptr;
device->GetImmediateContext(&context);
if (context) {
overlay::create_d3d11(desc.OutputWindow, device, context, swapchain);
RECT cr {};
::GetClientRect(desc.OutputWindow, &cr);
log_info("graphics::d3d11",
"attached overlay to swapchain hwnd=0x{:x} backbuffer={}x{} client={}x{}",
(uintptr_t) desc.OutputWindow,
desc.BufferDesc.Width, desc.BufferDesc.Height,
cr.right - cr.left, cr.bottom - cr.top);
context->Release();
}
device->Release();
}
// rising-edge screenshot hotkey poll (mirrors d3d9 backend behaviour).
void poll_screenshot_hotkey() {
static bool s_down = false;
auto buttons = games::get_buttons_overlay(eamuse_get_game());
const bool pressed = buttons
&& (!overlay::OVERLAY || overlay::OVERLAY->hotkeys_triggered())
&& GameAPI::Buttons::getState(RI_MGR,
buttons->at(games::OverlayButtons::Screenshot));
if (pressed && !s_down) {
graphics_screenshot_trigger();
}
s_down = pressed;
}
void pump_overlay(IDXGISwapChain *swapchain) {
if (!overlay::OVERLAY || !overlay::OVERLAY->uses_swapchain(swapchain)) {
return;
}
poll_screenshot_hotkey();
// size imgui to the backbuffer (not window client). dxgi may upscale
// a small backbuffer into a larger client rect; without this override
// imgui would draw past the RTV and the mouse mapping would be off.
DXGI_SWAP_CHAIN_DESC desc {};
if (SUCCEEDED(swapchain->GetDesc(&desc))) {
ImGui_ImplSpice_SetDisplaySizeOverride(
(float) desc.BufferDesc.Width,
(float) desc.BufferDesc.Height);
}
overlay::OVERLAY->update();
overlay::OVERLAY->new_frame();
overlay::OVERLAY->render();
// after overlay render so toasts/menus end up in the saved image.
d3d11_hooks::try_screenshot(swapchain);
}
// ----------------------------------------------------------------------
// swapchain method hooks
HRESULT STDMETHODCALLTYPE Present_hook(
IDXGISwapChain *swapchain, UINT SyncInterval, UINT Flags)
{
try_create_overlay(swapchain);
pump_overlay(swapchain);
return Present_orig(swapchain, SyncInterval, Flags);
}
HRESULT STDMETHODCALLTYPE Present1_hook(
IDXGISwapChain1 *swapchain, UINT SyncInterval, UINT Flags,
const DXGI_PRESENT_PARAMETERS *pParams)
{
try_create_overlay(swapchain);
pump_overlay(swapchain);
return Present1_orig(swapchain, SyncInterval, Flags, pParams);
}
HRESULT STDMETHODCALLTYPE ResizeBuffers_hook(
IDXGISwapChain *swapchain, UINT BufferCount, UINT Width, UINT Height,
DXGI_FORMAT NewFormat, UINT SwapChainFlags)
{
const bool ours = overlay::OVERLAY && overlay::OVERLAY->uses_swapchain(swapchain);
if (ours) {
log_info("graphics::d3d11", "ResizeBuffers {}x{} fmt={}",
Width, Height, (int32_t) NewFormat);
overlay::OVERLAY->reset_invalidate();
}
HRESULT res = ResizeBuffers_orig(
swapchain, BufferCount, Width, Height, NewFormat, SwapChainFlags);
if (ours && SUCCEEDED(res)) {
overlay::OVERLAY->reset_recreate();
}
return res;
}
} // namespace
// --------------------------------------------------------------------------
// d3d11_hooks public surface: main-window tracking + vtable install.
namespace d3d11_hooks {
namespace {
std::atomic<HWND> g_main_hwnd { nullptr };
std::atomic<HWND> g_ignored_hwnd { nullptr };
}
void note_main_hwnd(HWND hwnd) {
if (!hwnd || hwnd == g_ignored_hwnd.load()) {
return;
}
HWND expected = nullptr;
if (g_main_hwnd.compare_exchange_strong(expected, hwnd)) {
log_info("graphics::d3d11", "main hwnd recorded: 0x{:x}",
(uintptr_t) hwnd);
}
}
HWND main_hwnd() {
return g_main_hwnd.load();
}
void ignore_hwnd(HWND hwnd) {
g_ignored_hwnd.store(hwnd);
}
// patch IDXGISwapChain::Present + ResizeBuffers and (if implemented)
// IDXGISwapChain1::Present1. idempotent; flag is set only after success
// so failed attempts can be retried on the next swapchain.
void install_swapchain_hooks(IDXGISwapChain *swapchain) {
if (!swapchain) {
return;
}
static std::mutex s_hook_mutex;
std::lock_guard<std::mutex> lock(s_hook_mutex);
if (!g_swapchain_hooked) {
const bool a = hook_vtbl(swapchain, 8, (void *) Present_hook,
(void **) &Present_orig, "IDXGISwapChain::Present");
const bool b = hook_vtbl(swapchain, 13, (void *) ResizeBuffers_hook,
(void **) &ResizeBuffers_orig, "IDXGISwapChain::ResizeBuffers");
if (a && b) {
g_swapchain_hooked = true;
}
}
if (!g_swapchain1_hooked) {
IDXGISwapChain1 *sc1 = nullptr;
if (SUCCEEDED(swapchain->QueryInterface(IID_PPV_ARGS(&sc1))) && sc1) {
if (hook_vtbl(sc1, 22, (void *) Present1_hook,
(void **) &Present1_orig, "IDXGISwapChain1::Present1")) {
g_swapchain1_hooked = true;
}
sc1->Release();
}
}
}
}
#endif // SPICE_D3D11
// dx11 swapchain vtable hooks + per-frame overlay pump.
//
// dxgi shares vtables across swapchain instances, so we only need to patch
// Present / Present1 / ResizeBuffers once on the first instance we see.
// each frame we lazily attach the overlay to whichever swapchain is
// presenting, then drive its imgui update / new_frame / render cycle.
#include "d3d11_backend.h"
#ifdef SPICE_D3D11
#include <atomic>
#include <mutex>
#include <windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include <dxgi1_2.h>
#include "d3d11_internal.h"
#include "external/imgui/imgui.h"
#include "external/imgui/backends/imgui_impl_dx11.h"
#include "overlay/imgui/impl_spice.h"
#include "hooks/graphics/graphics.h"
#include "util/utils.h"
// --------------------------------------------------------------------------
// overlay render bridge
namespace overlay::d3d11 {
// sRGB backbuffers need a UNORM view: ImGui vertex colors are already
// sRGB-encoded, so an extra linear->sRGB conversion would wash the
// overlay out white.
static DXGI_FORMAT to_unorm_view(DXGI_FORMAT fmt) {
switch (fmt) {
case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: return DXGI_FORMAT_R8G8B8A8_UNORM;
case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: return DXGI_FORMAT_B8G8R8A8_UNORM;
default: return fmt;
}
}
static void ensure_rtv(ID3D11Device *device,
IDXGISwapChain *swapchain,
ID3D11RenderTargetView **rtv)
{
if (*rtv || !device || !swapchain) {
return;
}
ID3D11Texture2D *backbuffer = nullptr;
if (FAILED(swapchain->GetBuffer(0, IID_PPV_ARGS(&backbuffer))) || !backbuffer) {
return;
}
D3D11_TEXTURE2D_DESC td {};
backbuffer->GetDesc(&td);
const DXGI_FORMAT view_fmt = to_unorm_view(td.Format);
if (view_fmt != td.Format) {
D3D11_RENDER_TARGET_VIEW_DESC rtvd {};
rtvd.Format = view_fmt;
rtvd.ViewDimension = D3D11_RTV_DIMENSION_TEXTURE2D;
device->CreateRenderTargetView(backbuffer, &rtvd, rtv);
} else {
device->CreateRenderTargetView(backbuffer, nullptr, rtv);
}
backbuffer->Release();
}
// bind the backbuffer (lazily creating the RTV) and draw the imgui
// frame on top. reset_invalidate releases *rtv on ResizeBuffers.
void render(ID3D11Device *device,
ID3D11DeviceContext *context,
IDXGISwapChain *swapchain,
ID3D11RenderTargetView **rtv)
{
ensure_rtv(device, swapchain, rtv);
if (!*rtv || !context) {
return;
}
// present happens immediately after, so no need to save the previous
// RT binding (flip-model resets it anyway).
context->OMSetRenderTargets(1, rtv, nullptr);
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
}
}
// --------------------------------------------------------------------------
// file-local state + per-frame helpers
namespace {
using Present_t = HRESULT(STDMETHODCALLTYPE *)(
IDXGISwapChain *, UINT, UINT);
using ResizeBuffers_t = HRESULT(STDMETHODCALLTYPE *)(
IDXGISwapChain *, UINT, UINT, UINT, DXGI_FORMAT, UINT);
using Present1_t = HRESULT(STDMETHODCALLTYPE *)(
IDXGISwapChain1 *, UINT, UINT, const DXGI_PRESENT_PARAMETERS *);
Present_t Present_orig = nullptr;
ResizeBuffers_t ResizeBuffers_orig = nullptr;
Present1_t Present1_orig = nullptr;
bool g_swapchain_hooked = false;
bool g_swapchain1_hooked = false;
// sub-screens / IME helpers are usually child or zero-sized windows.
// visibility isn't checked - the game may present before showing the window.
bool looks_like_game_window(HWND hwnd) {
RECT client {};
return GetAncestor(hwnd, GA_ROOT) == hwnd
&& GetClientRect(hwnd, &client)
&& client.right > client.left
&& client.bottom > client.top;
}
// only the main game window; ignore sub-screens / IME helpers.
bool is_main_game_swapchain(IDXGISwapChain *swapchain) {
DXGI_SWAP_CHAIN_DESC desc {};
if (!swapchain || FAILED(swapchain->GetDesc(&desc)) || !desc.OutputWindow) {
return false;
}
HWND main = d3d11_hooks::main_hwnd();
if (!main) {
// no creation hook recorded a window, so fall back to the presenting one;
// the choice is permanent, so require a plausible game window
if (!looks_like_game_window(desc.OutputWindow)) {
return false;
}
log_misc(
"graphics::d3d11",
"try to notemain hwnd from swapchain present: 0x{:x}",
(uintptr_t)desc.OutputWindow);
d3d11_hooks::note_main_hwnd(desc.OutputWindow);
// it may have been ignored, or another thread may have won the slot
main = d3d11_hooks::main_hwnd();
}
return desc.OutputWindow == main;
}
// checks are ordered cheapest first, since this runs on every present
void try_create_overlay(IDXGISwapChain *swapchain) {
if (!swapchain) {
return;
}
// overlay is disabled by user
if (!overlay::ENABLED) {
return;
}
// overlay is already enabled and attached
if (overlay::OVERLAY) {
return;
}
// ignore sub windows
if (!is_main_game_swapchain(swapchain)) {
return;
}
DXGI_SWAP_CHAIN_DESC desc {};
if (FAILED(swapchain->GetDesc(&desc)) || !desc.OutputWindow) {
return;
}
// theme the native title bar; first present is the only reliable point for
// windows whose swapchain bypasses our factory hooks (e.g. UnityPlayer.dll)
set_window_dark_titlebar(desc.OutputWindow);
ID3D11Device *device = nullptr;
if (FAILED(swapchain->GetDevice(IID_PPV_ARGS(&device))) || !device) {
return;
}
ID3D11DeviceContext *context = nullptr;
device->GetImmediateContext(&context);
if (context) {
overlay::create_d3d11(desc.OutputWindow, device, context, swapchain);
RECT cr {};
::GetClientRect(desc.OutputWindow, &cr);
log_info("graphics::d3d11",
"attached overlay to swapchain hwnd=0x{:x} backbuffer={}x{} client={}x{}",
(uintptr_t) desc.OutputWindow,
desc.BufferDesc.Width, desc.BufferDesc.Height,
cr.right - cr.left, cr.bottom - cr.top);
context->Release();
}
device->Release();
}
// screenshots have to keep working with the overlay disabled, so they are not gated on it
void pump_frame(IDXGISwapChain *swapchain) {
const bool has_overlay =
overlay::OVERLAY && overlay::OVERLAY->uses_swapchain(swapchain);
if (!has_overlay && !is_main_game_swapchain(swapchain)) {
return;
}
graphics_poll_screenshot_hotkey();
// before the overlay render so the screenshot excludes it
if (!GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY) {
d3d11_hooks::try_screenshot(swapchain);
}
if (has_overlay) {
// size imgui to the backbuffer (not window client). dxgi may upscale
// a small backbuffer into a larger client rect; without this override
// imgui would draw past the RTV and the mouse mapping would be off.
DXGI_SWAP_CHAIN_DESC desc {};
if (SUCCEEDED(swapchain->GetDesc(&desc))) {
ImGui_ImplSpice_SetDisplaySizeOverride(
(float) desc.BufferDesc.Width,
(float) desc.BufferDesc.Height);
}
overlay::OVERLAY->update();
overlay::OVERLAY->new_frame();
overlay::OVERLAY->render();
}
// after the overlay render so the screenshot includes toasts / menus
if (GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY) {
d3d11_hooks::try_screenshot(swapchain);
}
}
// ----------------------------------------------------------------------
// swapchain method hooks
HRESULT STDMETHODCALLTYPE Present_hook(
IDXGISwapChain *swapchain, UINT SyncInterval, UINT Flags)
{
// a test present doesn't display anything; don't pick a window or take a screenshot off it
if (!(Flags & DXGI_PRESENT_TEST)) {
try_create_overlay(swapchain);
pump_frame(swapchain);
}
return Present_orig(swapchain, SyncInterval, Flags);
}
HRESULT STDMETHODCALLTYPE Present1_hook(
IDXGISwapChain1 *swapchain, UINT SyncInterval, UINT Flags,
const DXGI_PRESENT_PARAMETERS *pParams)
{
if (!(Flags & DXGI_PRESENT_TEST)) {
try_create_overlay(swapchain);
pump_frame(swapchain);
}
return Present1_orig(swapchain, SyncInterval, Flags, pParams);
}
HRESULT STDMETHODCALLTYPE ResizeBuffers_hook(
IDXGISwapChain *swapchain, UINT BufferCount, UINT Width, UINT Height,
DXGI_FORMAT NewFormat, UINT SwapChainFlags)
{
const bool ours = overlay::OVERLAY && overlay::OVERLAY->uses_swapchain(swapchain);
if (ours) {
log_info("graphics::d3d11", "ResizeBuffers {}x{} fmt={}",
Width, Height, (int32_t) NewFormat);
overlay::OVERLAY->reset_invalidate();
}
HRESULT res = ResizeBuffers_orig(
swapchain, BufferCount, Width, Height, NewFormat, SwapChainFlags);
if (ours && SUCCEEDED(res)) {
overlay::OVERLAY->reset_recreate();
}
return res;
}
} // namespace
// --------------------------------------------------------------------------
// d3d11_hooks public surface: main-window tracking + vtable install.
namespace d3d11_hooks {
namespace {
std::atomic<HWND> g_main_hwnd { nullptr };
std::atomic<HWND> g_ignored_hwnd { nullptr };
}
void note_main_hwnd(HWND hwnd) {
if (!hwnd || hwnd == g_ignored_hwnd.load()) {
return;
}
HWND expected = nullptr;
if (g_main_hwnd.compare_exchange_strong(expected, hwnd)) {
log_info("graphics::d3d11", "main hwnd recorded: 0x{:x}",
(uintptr_t) hwnd);
}
}
HWND main_hwnd() {
return g_main_hwnd.load();
}
void ignore_hwnd(HWND hwnd) {
g_ignored_hwnd.store(hwnd);
}
// patch IDXGISwapChain::Present + ResizeBuffers and (if implemented)
// IDXGISwapChain1::Present1. idempotent; flag is set only after success
// so failed attempts can be retried on the next swapchain.
void install_swapchain_hooks(IDXGISwapChain *swapchain) {
if (!swapchain) {
return;
}
static std::mutex s_hook_mutex;
std::lock_guard<std::mutex> lock(s_hook_mutex);
if (!g_swapchain_hooked) {
const bool a = hook_vtbl(swapchain, 8, (void *) Present_hook,
(void **) &Present_orig, "IDXGISwapChain::Present");
const bool b = hook_vtbl(swapchain, 13, (void *) ResizeBuffers_hook,
(void **) &ResizeBuffers_orig, "IDXGISwapChain::ResizeBuffers");
if (a && b) {
g_swapchain_hooked = true;
}
}
if (!g_swapchain1_hooked) {
IDXGISwapChain1 *sc1 = nullptr;
if (SUCCEEDED(swapchain->QueryInterface(IID_PPV_ARGS(&sc1))) && sc1) {
if (hook_vtbl(sc1, 22, (void *) Present1_hook,
(void **) &Present1_orig, "IDXGISwapChain1::Present1")) {
g_swapchain1_hooked = true;
}
sc1->Release();
}
}
}
}
#endif // SPICE_D3D11
@@ -1,175 +1,175 @@
// proactive vtable capture for the dx11 backend.
//
// titles under the execexe loader routinely race past our export-level
// trampolines, so the game's first real swapchain never goes through us.
// we sidestep that by creating a throwaway device + swapchain ourselves
// the moment d3d11.dll + dxgi.dll appear, which patches the shared
// IDXGISwapChain[1] / IDXGIFactory[2] vtables ahead of the game.
#include "d3d11_backend.h"
#ifdef SPICE_D3D11
#include <atomic>
#include <memory>
#include <windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include <dxgi1_2.h>
#include "d3d11_internal.h"
using d3d11_hooks::com_ptr;
namespace {
using D3D11CreateDevice_t = HRESULT(WINAPI *)(
IDXGIAdapter *, D3D_DRIVER_TYPE, HMODULE, UINT,
const D3D_FEATURE_LEVEL *, UINT, UINT,
ID3D11Device **, D3D_FEATURE_LEVEL *, ID3D11DeviceContext **);
using CreateDXGIFactory1_t = HRESULT(WINAPI *)(REFIID, void **);
using CreateDXGIFactory2_t = HRESULT(WINAPI *)(UINT, REFIID, void **);
std::atomic<bool> g_vtables_captured { false };
template<typename Fn>
Fn resolve(HMODULE mod, const char *name) {
return reinterpret_cast<Fn>(GetProcAddress(mod, name));
}
com_ptr<IDXGIFactory2> create_factory2(CreateDXGIFactory2_t f2,
CreateDXGIFactory1_t f1)
{
IDXGIFactory2 *raw = nullptr;
if (f2 && SUCCEEDED(f2(0, IID_PPV_ARGS(&raw))) && raw) {
return com_ptr<IDXGIFactory2>(raw);
}
IDXGIFactory1 *factory1 = nullptr;
if (f1 && SUCCEEDED(f1(IID_PPV_ARGS(&factory1))) && factory1) {
factory1->QueryInterface(IID_PPV_ARGS(&raw));
factory1->Release();
}
return com_ptr<IDXGIFactory2>(raw);
}
bool create_dummy_device(D3D11CreateDevice_t create,
com_ptr<ID3D11Device> &device,
com_ptr<ID3D11DeviceContext> &context)
{
static constexpr D3D_FEATURE_LEVEL levels[] = {
D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0,
D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0,
};
// hardware first, then WARP so headless / unusual configs still work.
for (auto type : { D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP }) {
ID3D11Device *d = nullptr;
ID3D11DeviceContext *c = nullptr;
D3D_FEATURE_LEVEL got;
if (SUCCEEDED(create(nullptr, type, nullptr, 0,
levels, ARRAYSIZE(levels), D3D11_SDK_VERSION,
&d, &got, &c)) && d) {
device.reset(d);
context.reset(c);
return true;
}
}
return false;
}
} // namespace
namespace d3d11_hooks {
// create a throwaway device + swapchain to patch the shared vtables before
// the game's loader races past our export trampolines. safe to call
// repeatedly; runs at most once.
void try_capture_vtables() {
if (g_vtables_captured.load()) {
return;
}
HMODULE d3d11 = GetModuleHandleW(L"d3d11.dll");
HMODULE dxgi = GetModuleHandleW(L"dxgi.dll");
if (!d3d11 || !dxgi) {
return;
}
auto create_device = resolve<D3D11CreateDevice_t>(d3d11, "D3D11CreateDevice");
auto f2 = resolve<CreateDXGIFactory2_t>(dxgi, "CreateDXGIFactory2");
auto f1 = resolve<CreateDXGIFactory1_t>(dxgi, "CreateDXGIFactory1");
if (!create_device || (!f1 && !f2)) {
return;
}
// serialize concurrent calls (poll thread + LDR notification). only
// flip g_vtables_captured after success so failed attempts remain
// retriable on the next tick.
static std::atomic<bool> in_progress { false };
if (in_progress.exchange(true)) {
return;
}
struct scope_clear {
std::atomic<bool> &flag;
~scope_clear() { flag.store(false); }
} clear { in_progress };
// hidden message-only window; STATIC is always registered by user32.
HWND dummy_hwnd = CreateWindowExW(
0, L"STATIC", L"", 0, 0, 0, 1, 1,
HWND_MESSAGE, nullptr, GetModuleHandleW(nullptr), nullptr);
if (!dummy_hwnd) {
log_warning("graphics::d3d11",
"vtable capture: CreateWindowExW failed (gle={})", (unsigned long)GetLastError());
return;
}
auto destroy_hwnd = std::unique_ptr<HWND__, decltype(&DestroyWindow)>(
dummy_hwnd, &DestroyWindow);
// if the game's CreateDXGIFactory_hook already raced us, our
// CreateSwapChainForHwnd call below would trip the hook and try to
// record dummy_hwnd as the main window. block that.
ignore_hwnd(dummy_hwnd);
auto factory2 = create_factory2(f2, f1);
if (!factory2) {
log_warning("graphics::d3d11", "vtable capture: CreateDXGIFactory* failed");
return;
}
com_ptr<ID3D11Device> device;
com_ptr<ID3D11DeviceContext> context;
if (!create_dummy_device(create_device, device, context)) {
log_warning("graphics::d3d11", "vtable capture: D3D11CreateDevice failed");
return;
}
DXGI_SWAP_CHAIN_DESC1 desc {};
desc.Width = 1;
desc.Height = 1;
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
desc.SampleDesc.Count = 1;
desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
desc.BufferCount = 2;
desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
IDXGISwapChain1 *raw_sc = nullptr;
HRESULT hr = factory2->CreateSwapChainForHwnd(
device.get(), dummy_hwnd, &desc, nullptr, nullptr, &raw_sc);
if (FAILED(hr) || !raw_sc) {
log_warning("graphics::d3d11",
"vtable capture: CreateSwapChainForHwnd failed (hr={:#x})", (unsigned long)hr);
return;
}
com_ptr<IDXGISwapChain1> swapchain(raw_sc);
install_swapchain_hooks(swapchain.get());
install_factory_hooks(factory2.get());
g_vtables_captured.store(true);
log_info("graphics::d3d11", "vtable capture complete (via dummy swapchain)");
}
}
#endif // SPICE_D3D11
// proactive vtable capture for the dx11 backend.
//
// titles under the execexe loader routinely race past our export-level
// trampolines, so the game's first real swapchain never goes through us.
// we sidestep that by creating a throwaway device + swapchain ourselves
// the moment d3d11.dll + dxgi.dll appear, which patches the shared
// IDXGISwapChain[1] / IDXGIFactory[2] vtables ahead of the game.
#include "d3d11_backend.h"
#ifdef SPICE_D3D11
#include <atomic>
#include <memory>
#include <windows.h>
#include <d3d11.h>
#include <dxgi.h>
#include <dxgi1_2.h>
#include "d3d11_internal.h"
using d3d11_hooks::com_ptr;
namespace {
using D3D11CreateDevice_t = HRESULT(WINAPI *)(
IDXGIAdapter *, D3D_DRIVER_TYPE, HMODULE, UINT,
const D3D_FEATURE_LEVEL *, UINT, UINT,
ID3D11Device **, D3D_FEATURE_LEVEL *, ID3D11DeviceContext **);
using CreateDXGIFactory1_t = HRESULT(WINAPI *)(REFIID, void **);
using CreateDXGIFactory2_t = HRESULT(WINAPI *)(UINT, REFIID, void **);
std::atomic<bool> g_vtables_captured { false };
template<typename Fn>
Fn resolve(HMODULE mod, const char *name) {
return reinterpret_cast<Fn>(GetProcAddress(mod, name));
}
com_ptr<IDXGIFactory2> create_factory2(CreateDXGIFactory2_t f2,
CreateDXGIFactory1_t f1)
{
IDXGIFactory2 *raw = nullptr;
if (f2 && SUCCEEDED(f2(0, IID_PPV_ARGS(&raw))) && raw) {
return com_ptr<IDXGIFactory2>(raw);
}
IDXGIFactory1 *factory1 = nullptr;
if (f1 && SUCCEEDED(f1(IID_PPV_ARGS(&factory1))) && factory1) {
factory1->QueryInterface(IID_PPV_ARGS(&raw));
factory1->Release();
}
return com_ptr<IDXGIFactory2>(raw);
}
bool create_dummy_device(D3D11CreateDevice_t create,
com_ptr<ID3D11Device> &device,
com_ptr<ID3D11DeviceContext> &context)
{
static constexpr D3D_FEATURE_LEVEL levels[] = {
D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0,
D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0,
};
// hardware first, then WARP so headless / unusual configs still work.
for (auto type : { D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_WARP }) {
ID3D11Device *d = nullptr;
ID3D11DeviceContext *c = nullptr;
D3D_FEATURE_LEVEL got;
if (SUCCEEDED(create(nullptr, type, nullptr, 0,
levels, ARRAYSIZE(levels), D3D11_SDK_VERSION,
&d, &got, &c)) && d) {
device.reset(d);
context.reset(c);
return true;
}
}
return false;
}
} // namespace
namespace d3d11_hooks {
// create a throwaway device + swapchain to patch the shared vtables before
// the game's loader races past our export trampolines. safe to call
// repeatedly; runs at most once.
void try_capture_vtables() {
if (g_vtables_captured.load()) {
return;
}
HMODULE d3d11 = GetModuleHandleW(L"d3d11.dll");
HMODULE dxgi = GetModuleHandleW(L"dxgi.dll");
if (!d3d11 || !dxgi) {
return;
}
auto create_device = resolve<D3D11CreateDevice_t>(d3d11, "D3D11CreateDevice");
auto f2 = resolve<CreateDXGIFactory2_t>(dxgi, "CreateDXGIFactory2");
auto f1 = resolve<CreateDXGIFactory1_t>(dxgi, "CreateDXGIFactory1");
if (!create_device || (!f1 && !f2)) {
return;
}
// serialize concurrent calls (poll thread + LDR notification). only
// flip g_vtables_captured after success so failed attempts remain
// retriable on the next tick.
static std::atomic<bool> in_progress { false };
if (in_progress.exchange(true)) {
return;
}
struct scope_clear {
std::atomic<bool> &flag;
~scope_clear() { flag.store(false); }
} clear { in_progress };
// hidden message-only window; STATIC is always registered by user32.
HWND dummy_hwnd = CreateWindowExW(
0, L"STATIC", L"", 0, 0, 0, 1, 1,
HWND_MESSAGE, nullptr, GetModuleHandleW(nullptr), nullptr);
if (!dummy_hwnd) {
log_warning("graphics::d3d11",
"vtable capture: CreateWindowExW failed (gle={})", (unsigned long)GetLastError());
return;
}
auto destroy_hwnd = std::unique_ptr<HWND__, decltype(&DestroyWindow)>(
dummy_hwnd, &DestroyWindow);
// if the game's CreateDXGIFactory_hook already raced us, our
// CreateSwapChainForHwnd call below would trip the hook and try to
// record dummy_hwnd as the main window. block that.
ignore_hwnd(dummy_hwnd);
auto factory2 = create_factory2(f2, f1);
if (!factory2) {
log_warning("graphics::d3d11", "vtable capture: CreateDXGIFactory* failed");
return;
}
com_ptr<ID3D11Device> device;
com_ptr<ID3D11DeviceContext> context;
if (!create_dummy_device(create_device, device, context)) {
log_warning("graphics::d3d11", "vtable capture: D3D11CreateDevice failed");
return;
}
DXGI_SWAP_CHAIN_DESC1 desc {};
desc.Width = 1;
desc.Height = 1;
desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM;
desc.SampleDesc.Count = 1;
desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
desc.BufferCount = 2;
desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD;
IDXGISwapChain1 *raw_sc = nullptr;
HRESULT hr = factory2->CreateSwapChainForHwnd(
device.get(), dummy_hwnd, &desc, nullptr, nullptr, &raw_sc);
if (FAILED(hr) || !raw_sc) {
log_warning("graphics::d3d11",
"vtable capture: CreateSwapChainForHwnd failed (hr={:#x})", (unsigned long)hr);
return;
}
com_ptr<IDXGISwapChain1> swapchain(raw_sc);
install_swapchain_hooks(swapchain.get());
install_factory_hooks(factory2.get());
g_vtables_captured.store(true);
log_info("graphics::d3d11", "vtable capture complete (via dummy swapchain)");
}
}
#endif // SPICE_D3D11
@@ -1478,7 +1478,7 @@ static void graphics_d3d9_ldj_on_present(IDirect3DDevice9 *wrapped_device) {
void graphics_d3d9_on_present(
HWND hFocusWindow,
IDirect3DDevice9 *device,
IDirect3DDevice9 *wrapped_device) {
WrappedIDirect3DDevice9 *wrapped_device) {
// image resize / orientation swap. run here (the present path) rather than from `EndScene`,
// which may fire several times per frame on multi-pass / render-to-texture games. this is the
@@ -1489,6 +1489,13 @@ void graphics_d3d9_on_present(
SurfaceHook(device);
}
graphics_poll_screenshot_hotkey();
// before the overlay render so the screenshot excludes it
if (!GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY) {
graphics_d3d9_process_screenshot(device, wrapped_device);
}
// Do overlay init as many d3d9 hooks create a dummy instance to get vtable offsets and never
// call `Present`. This avoids race conditions on `IDirect3D9::CreateDevice` like with
// `dx9osd.dll` for pfreepanic.
@@ -1508,6 +1515,15 @@ void graphics_d3d9_on_present(
device->EndScene();
}
// after the overlay render so the screenshot includes toasts / menus
if (GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY) {
graphics_d3d9_process_screenshot(device, wrapped_device);
}
// API capture always includes the overlay; it must run before the subscreen present
// below, which leaves the arena SMALL back buffer black
graphics_d3d9_process_capture(device, wrapped_device);
// for IIDX TDJ / SDVX UFC, handle subscreen
const bool is_vm = games::sdvx::is_valkyrie_model();
const bool is_tdj = avs::game::is_model("LDJ") && games::iidx::TDJ_MODE;
@@ -1521,9 +1537,6 @@ void graphics_d3d9_on_present(
if (is_mfc) {
wintouchemu::update();
}
graphics_d3d9_poll_screenshot_hotkey();
graphics_d3d9_process_screenshot_and_capture(device, SUB_SWAP_CHAIN);
}
void update_backbuffer_dimensions(D3DPRESENT_PARAMETERS *params) {
@@ -4,6 +4,8 @@
#include "d3d9_gfdm.h"
struct WrappedIDirect3DDevice9;
// {EEE9CCF6-53D6-4326-9AE5-60921B3DB394}
static const GUID IID_WrappedIDirect3D9 = {
0xeee9ccf6, 0x53d6, 0x4326, { 0x9a, 0xe5, 0x60, 0x92, 0x1b, 0x3d, 0xb3, 0x94 }
@@ -13,7 +15,7 @@ void graphics_d3d9_init();
void graphics_d3d9_on_present(
HWND hFocusWindow,
IDirect3DDevice9 *device,
IDirect3DDevice9 *wrapped_device);
WrappedIDirect3DDevice9 *wrapped_device);
void graphics_d3d9_notify_subscreen_present();
@@ -17,12 +17,18 @@
#include "d3d9_backend.h"
#include "d3d9_live2d.h"
#include "d3d9_readback.h"
#include "d3d9_texture.h"
#ifndef SPICE64
#include "shaders/vertex_shader.h"
#endif
// maps arena's cached additional swap chains (SMALL, LEFT, RIGHT) to screen numbers.
// MAIN is the implicit swap chain, is not in those slots, and is always screen 0.
// screen 1 is the subscreen for every other game, so SMALL takes that number here too.
static constexpr int GFDM_ARENA_SLOT_SCREENS[] { 1, 2, 3 };
#define CHECK_RESULT_FMT(x, fmt, ...) \
HRESULT __ret = (x); \
if (GRAPHICS_LOG_HRESULT && FAILED(__ret)) [[unlikely]] { \
@@ -151,6 +157,8 @@ ULONG STDMETHODCALLTYPE WrappedIDirect3DDevice9::Release() {
}
}
d3d9_readback::release_device_resources(this->pReal);
if (overlay::ENABLED) {
const std::lock_guard<std::mutex> lock(overlay::OVERLAY_MUTEX);
@@ -336,20 +344,25 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::CreateAdditionalSwapChain(
int index = 0;
bool create_swap_chain = false;
bool create_fake_swap_chain = false;
bool arena_slot = false;
if (avs::game::is_model({"LDJ", "KFC", "M39"})) {
create_swap_chain = true;
} else if (games::gitadora::is_arena_model() &&
(GRAPHICS_PREVENT_SECONDARY_WINDOWS || GRAPHICS_GITADORA_HIDE_SIDE_WINDOWS)) {
(GRAPHICS_SCREENSHOT_SUBSCREENS ||
GRAPHICS_PREVENT_SECONDARY_WINDOWS ||
GRAPHICS_GITADORA_HIDE_SIDE_WINDOWS)) {
if (pPresentationParameters->BackBufferWidth == 800) {
// SMALL (subscreen)
create_swap_chain = true;
arena_slot = true;
index = 0;
} else if (pPresentationParameters->BackBufferWidth == 1080) {
// LEFT/RIGHT
create_swap_chain = true;
arena_slot = true;
index = 1;
if (sub_swapchain[index] || fake_sub_swapchain[index]) {
index = 2;
@@ -361,6 +374,11 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::CreateAdditionalSwapChain(
}
}
// the api lists screens from this registry, so arena heads need their logical numbers in it
if (arena_slot) {
graphics_screens_register(GFDM_ARENA_SLOT_SCREENS[index]);
}
if (create_fake_swap_chain) {
if (!fake_sub_swapchain[index]) {
log_info(
@@ -539,6 +557,64 @@ UINT STDMETHODCALLTYPE WrappedIDirect3DDevice9::GetNumberOfSwapChains() {
return n;
}
void WrappedIDirect3DDevice9::get_screenshot_screens(std::vector<int> &screens) const {
if (games::gitadora::is_arena_model()) {
screens.push_back(0);
// every head the game renders into, whether or not it reaches a display
for (int slot = 0; slot < 3; slot++) {
if (sub_swapchain[slot] != nullptr || fake_sub_swapchain[slot] != nullptr) {
screens.push_back(GFDM_ARENA_SLOT_SCREENS[slot]);
}
}
return;
}
graphics_screens_get(screens);
// the sub screen is only registered once the game asks for it by index
if (sub_swapchain[0] != nullptr &&
avs::game::is_model({"LDJ", "KFC", "M39"}) &&
std::find(screens.begin(), screens.end(), 1) == screens.end())
{
screens.push_back(1);
}
}
HRESULT WrappedIDirect3DDevice9::get_screenshot_swap_chain(
UINT iSwapChain,
IDirect3DSwapChain9 **ppSwapChain)
{
if (ppSwapChain == nullptr) {
return D3DERR_INVALIDCALL;
}
// the game numbers the two-head SMALL head itself; keep screen 1 meaning SMALL
if (games::gitadora::is_arena_model() && is_gfdm_two_head_exclusive() && iSwapChain == 1) {
return GetSwapChain(gfdm_logical_small_swapchain, ppSwapChain);
}
if (games::gitadora::is_arena_model()) {
for (int slot = 0; slot < 3; slot++) {
if (GFDM_ARENA_SLOT_SCREENS[slot] != (int) iSwapChain) {
continue;
}
if (sub_swapchain[slot] != nullptr) {
sub_swapchain[slot]->AddRef();
*ppSwapChain = sub_swapchain[slot];
return D3D_OK;
}
if (fake_sub_swapchain[slot] != nullptr) {
fake_sub_swapchain[slot]->AddRef();
*ppSwapChain = fake_sub_swapchain[slot];
return D3D_OK;
}
}
}
return GetSwapChain(iSwapChain, ppSwapChain);
}
HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::Reset(
D3DPRESENT_PARAMETERS *pPresentationParameters)
{
@@ -3,6 +3,7 @@
#include <array>
#include <atomic>
#include <mutex>
#include <vector>
#include <initguid.h>
#include <d3d9.h>
@@ -234,6 +235,10 @@ struct WrappedIDirect3DDevice9 : IDirect3DDevice9Ex {
virtual HRESULT STDMETHODCALLTYPE GetDisplayModeEx(UINT iSwapChain, D3DDISPLAYMODEEX *pMode, D3DDISPLAYROTATION *pRotation) override;
#pragma endregion
// logical screens the game draws, and the swap chain each one lives on
void get_screenshot_screens(std::vector<int> &screens) const;
HRESULT get_screenshot_swap_chain(UINT iSwapChain, IDirect3DSwapChain9 **ppSwapChain);
bool is_gfdm_two_head_exclusive() const;
bool is_gfdm_logical_small_swapchain(UINT swapchain) const;
bool is_gfdm_logical_side_swapchain(UINT swapchain) const;
@@ -4,10 +4,12 @@
#include <d3d9.h>
#include "games/gitadora/gitadora.h"
inline constexpr UINT GFDM_SIDE_WIDTH = 1080;
inline constexpr UINT GFDM_SIDE_HEIGHT = 1920;
inline constexpr UINT GFDM_SMALL_WIDTH = 800;
inline constexpr UINT GFDM_SMALL_HEIGHT = 1280;
inline constexpr UINT GFDM_SMALL_WIDTH = games::gitadora::ARENA_SUBSCREEN_WIDTH;
inline constexpr UINT GFDM_SMALL_HEIGHT = games::gitadora::ARENA_SUBSCREEN_HEIGHT;
inline constexpr UINT GFDM_LOGICAL_HEAD_COUNT = 4;
inline constexpr UINT GFDM_NATIVE_SMALL_SWAPCHAIN = 1;
@@ -1,144 +1,144 @@
#include "d3d9_live2d.h"
// only the Live2D-capable SDVX versions are 64-bit, so the entire implementation
// is compiled out of 32-bit builds (the header supplies inline no-op stubs there).
#ifdef SPICE64
#include <cstdint>
#include <unordered_set>
#include "hooks/graphics/graphics.h"
// how the Live2D draw filtering works
// ------------------------------------
// SDVX draws its Live2D characters with a small, fixed set of pixel and
// vertex shaders. to skip those draws (and save GPU) we have to recognise them at
// the exact moment the game issues a draw call. the d3d9 device hooks feed three
// kinds of events into this module:
//
// 1. shader creation (on_create_pixel_shader / on_create_vertex_shader)
// the game compiles its shaders once at load. we can't trust the shader
// *object pointer* to identify a shader (it's just a heap address that
// varies per run and can be recycled), so instead we hash the shader's
// D3D9 *bytecode* - that fingerprint is stable across runs because the
// game ships the same shaders. if the hash matches a known Live2D shader
// we remember that object pointer in g_live2d_shaders.
//
// 2. shader binding (on_set_pixel_shader / on_set_vertex_shader)
// whenever the game binds a shader we look it up in that set once and cache
// the yes/no answer in g_cur_ps_is_live2d / g_cur_vs_is_live2d. binds happen
// far less often than draws, so this is where the lookup cost lives.
//
// 3. draw call (should_skip_draw, called from every Draw* hook)
// the per-draw question "is this a Live2D draw?" is then just reading those
// two cached bools - no hashing, no map lookups. if the skip is currently
// active (see graphics_sdvx_live2d_should_skip) and either bound shader is
// Live2D, the Draw* hook drops the call instead of forwarding it.
//
// everything is gated on the feature being enabled (mode != Off); when it's Off
// every entry point is a single predicted-not-taken branch. d3d9 rendering for a
// device is single-threaded, so none of this state needs locking.
namespace {
// shader state is tracked whenever the feature might act (mode != Off) so the
// known-shader set is populated before a song starts. when Off, every entry
// point is a single cheap branch.
bool tracking_enabled() {
return GRAPHICS_SDVX_LIVE2D_MODE != SdvxLive2dMode::Off;
}
// the set of shader objects (pixel or vertex) whose bytecode matched a known
// Live2D fingerprint. only matching shaders are stored, so this stays tiny.
std::unordered_set<void *> g_live2d_shaders;
// whether the currently-bound shaders are known Live2D shaders. cached at set
// time so the per-draw check is just two bool reads.
bool g_cur_ps_is_live2d = false;
bool g_cur_vs_is_live2d = false;
// FNV-1a 64 over a D3D9 shader token stream (ends with D3DSIO_END = 0x0000FFFF)
uint64_t bytecode_hash(const DWORD *func) {
if (func == nullptr) {
return 0;
}
const DWORD *p = func;
const DWORD *cap = func + 65536; // safety bound
while (p < cap && *p != 0x0000FFFF) {
p++;
}
const size_t n_bytes = ((size_t)(p - func) + 1) * sizeof(DWORD);
uint64_t h = 1469598103934665603ULL;
const auto *bytes = reinterpret_cast<const uint8_t *>(func);
for (size_t i = 0; i < n_bytes; i++) {
h ^= bytes[i];
h *= 1099511628211ULL;
}
return h;
}
// known SDVX Live2D shader bytecode hashes (4 pixel + 3 vertex). stable
// across runs because the game ships fixed shaders. the two sets are disjoint so
// a single shader can be classified by its own hash alone.
bool hash_is_live2d(uint64_t hash) {
switch (hash) {
case 0x75c89951817421a4ULL: // pixel: dominant model draw (~4.9M prims/120f in-song)
case 0x2d7ce428c6b4775dULL: // pixel: masked model draw
case 0x3ce00cc6111c10e7ULL: // pixel: mask generation
case 0x8bb3a2f37150ac34ULL: // pixel: mask generation (variant)
case 0xe9cf898c331e2a51ULL: // vertex
case 0x94dc84e7b7c0f437ULL: // vertex
case 0xc872937c5cc04309ULL: // vertex
return true;
}
return false;
}
// classify a shader at creation time and record it if it is Live2D. erasing on a
// miss keeps the set correct if the runtime reuses a freed shader pointer.
void classify_shader(void *shader, const DWORD *func) {
if (hash_is_live2d(bytecode_hash(func))) {
g_live2d_shaders.insert(shader);
} else {
g_live2d_shaders.erase(shader);
}
}
} // namespace
namespace d3d9_live2d {
// stage 1: fingerprint each shader as the game creates it
void on_create_vertex_shader(IDirect3DVertexShader9 *shader, const DWORD *func) {
if (tracking_enabled() && shader != nullptr) [[unlikely]] {
classify_shader(shader, func);
}
}
void on_create_pixel_shader(IDirect3DPixelShader9 *shader, const DWORD *func) {
if (tracking_enabled() && shader != nullptr) [[unlikely]] {
classify_shader(shader, func);
}
}
// stage 2: remember whether the just-bound shader is a Live2D one
void on_set_vertex_shader(IDirect3DVertexShader9 *shader) {
if (tracking_enabled()) [[unlikely]] {
g_cur_vs_is_live2d = g_live2d_shaders.count(shader) != 0;
}
}
void on_set_pixel_shader(IDirect3DPixelShader9 *shader) {
if (tracking_enabled()) [[unlikely]] {
g_cur_ps_is_live2d = g_live2d_shaders.count(shader) != 0;
}
}
// stage 3: drop the draw if the skip is active and a Live2D shader is bound
bool should_skip_draw() {
return graphics_sdvx_live2d_should_skip() && (g_cur_ps_is_live2d || g_cur_vs_is_live2d);
}
} // namespace d3d9_live2d
#endif // SPICE64
#include "d3d9_live2d.h"
// only the Live2D-capable SDVX versions are 64-bit, so the entire implementation
// is compiled out of 32-bit builds (the header supplies inline no-op stubs there).
#ifdef SPICE64
#include <cstdint>
#include <unordered_set>
#include "hooks/graphics/graphics.h"
// how the Live2D draw filtering works
// ------------------------------------
// SDVX draws its Live2D characters with a small, fixed set of pixel and
// vertex shaders. to skip those draws (and save GPU) we have to recognise them at
// the exact moment the game issues a draw call. the d3d9 device hooks feed three
// kinds of events into this module:
//
// 1. shader creation (on_create_pixel_shader / on_create_vertex_shader)
// the game compiles its shaders once at load. we can't trust the shader
// *object pointer* to identify a shader (it's just a heap address that
// varies per run and can be recycled), so instead we hash the shader's
// D3D9 *bytecode* - that fingerprint is stable across runs because the
// game ships the same shaders. if the hash matches a known Live2D shader
// we remember that object pointer in g_live2d_shaders.
//
// 2. shader binding (on_set_pixel_shader / on_set_vertex_shader)
// whenever the game binds a shader we look it up in that set once and cache
// the yes/no answer in g_cur_ps_is_live2d / g_cur_vs_is_live2d. binds happen
// far less often than draws, so this is where the lookup cost lives.
//
// 3. draw call (should_skip_draw, called from every Draw* hook)
// the per-draw question "is this a Live2D draw?" is then just reading those
// two cached bools - no hashing, no map lookups. if the skip is currently
// active (see graphics_sdvx_live2d_should_skip) and either bound shader is
// Live2D, the Draw* hook drops the call instead of forwarding it.
//
// everything is gated on the feature being enabled (mode != Off); when it's Off
// every entry point is a single predicted-not-taken branch. d3d9 rendering for a
// device is single-threaded, so none of this state needs locking.
namespace {
// shader state is tracked whenever the feature might act (mode != Off) so the
// known-shader set is populated before a song starts. when Off, every entry
// point is a single cheap branch.
bool tracking_enabled() {
return GRAPHICS_SDVX_LIVE2D_MODE != SdvxLive2dMode::Off;
}
// the set of shader objects (pixel or vertex) whose bytecode matched a known
// Live2D fingerprint. only matching shaders are stored, so this stays tiny.
std::unordered_set<void *> g_live2d_shaders;
// whether the currently-bound shaders are known Live2D shaders. cached at set
// time so the per-draw check is just two bool reads.
bool g_cur_ps_is_live2d = false;
bool g_cur_vs_is_live2d = false;
// FNV-1a 64 over a D3D9 shader token stream (ends with D3DSIO_END = 0x0000FFFF)
uint64_t bytecode_hash(const DWORD *func) {
if (func == nullptr) {
return 0;
}
const DWORD *p = func;
const DWORD *cap = func + 65536; // safety bound
while (p < cap && *p != 0x0000FFFF) {
p++;
}
const size_t n_bytes = ((size_t)(p - func) + 1) * sizeof(DWORD);
uint64_t h = 1469598103934665603ULL;
const auto *bytes = reinterpret_cast<const uint8_t *>(func);
for (size_t i = 0; i < n_bytes; i++) {
h ^= bytes[i];
h *= 1099511628211ULL;
}
return h;
}
// known SDVX Live2D shader bytecode hashes (4 pixel + 3 vertex). stable
// across runs because the game ships fixed shaders. the two sets are disjoint so
// a single shader can be classified by its own hash alone.
bool hash_is_live2d(uint64_t hash) {
switch (hash) {
case 0x75c89951817421a4ULL: // pixel: dominant model draw (~4.9M prims/120f in-song)
case 0x2d7ce428c6b4775dULL: // pixel: masked model draw
case 0x3ce00cc6111c10e7ULL: // pixel: mask generation
case 0x8bb3a2f37150ac34ULL: // pixel: mask generation (variant)
case 0xe9cf898c331e2a51ULL: // vertex
case 0x94dc84e7b7c0f437ULL: // vertex
case 0xc872937c5cc04309ULL: // vertex
return true;
}
return false;
}
// classify a shader at creation time and record it if it is Live2D. erasing on a
// miss keeps the set correct if the runtime reuses a freed shader pointer.
void classify_shader(void *shader, const DWORD *func) {
if (hash_is_live2d(bytecode_hash(func))) {
g_live2d_shaders.insert(shader);
} else {
g_live2d_shaders.erase(shader);
}
}
} // namespace
namespace d3d9_live2d {
// stage 1: fingerprint each shader as the game creates it
void on_create_vertex_shader(IDirect3DVertexShader9 *shader, const DWORD *func) {
if (tracking_enabled() && shader != nullptr) [[unlikely]] {
classify_shader(shader, func);
}
}
void on_create_pixel_shader(IDirect3DPixelShader9 *shader, const DWORD *func) {
if (tracking_enabled() && shader != nullptr) [[unlikely]] {
classify_shader(shader, func);
}
}
// stage 2: remember whether the just-bound shader is a Live2D one
void on_set_vertex_shader(IDirect3DVertexShader9 *shader) {
if (tracking_enabled()) [[unlikely]] {
g_cur_vs_is_live2d = g_live2d_shaders.count(shader) != 0;
}
}
void on_set_pixel_shader(IDirect3DPixelShader9 *shader) {
if (tracking_enabled()) [[unlikely]] {
g_cur_ps_is_live2d = g_live2d_shaders.count(shader) != 0;
}
}
// stage 3: drop the draw if the skip is active and a Live2D shader is bound
bool should_skip_draw() {
return graphics_sdvx_live2d_should_skip() && (g_cur_ps_is_live2d || g_cur_vs_is_live2d);
}
} // namespace d3d9_live2d
#endif // SPICE64
@@ -1,44 +1,44 @@
#pragma once
#include <windows.h>
#include <d3d9.h>
// SDVX Live2D draw-skip support for the D3D9 backend.
//
// SDVX renders its Live2D navigator / in-song character through a fixed set of
// shaders. when the skip is active (see graphics_sdvx_live2d_should_skip)
// the matching draw calls are dropped to save GPU. shaders are identified by a
// stable hash of their D3D9 bytecode (object pointers vary per run, the bytecode
// does not). the hashes were captured with the draw-call fingerprinting tool.
//
// every entry point is a no-op unless the feature is enabled (mode != Off), and
// d3d9 rendering for a device is single-threaded, so none of this needs locking.
namespace d3d9_live2d {
#ifdef SPICE64
// record a shader's bytecode fingerprint at creation time
void on_create_vertex_shader(IDirect3DVertexShader9 *shader, const DWORD *func);
void on_create_pixel_shader(IDirect3DPixelShader9 *shader, const DWORD *func);
// remember the currently-bound shaders
void on_set_vertex_shader(IDirect3DVertexShader9 *shader);
void on_set_pixel_shader(IDirect3DPixelShader9 *shader);
// true if the current draw call should be dropped (skip active AND the bound
// shaders identify it as SDVX Live2D)
bool should_skip_draw();
#else // !SPICE64
// only the Live2D-capable SDVX versions are 64-bit; on 32-bit every entry point
// compiles away to nothing, so the d3d9 device hooks need no #ifdefs at their
// call sites.
inline void on_create_vertex_shader(IDirect3DVertexShader9 *, const DWORD *) {}
inline void on_create_pixel_shader(IDirect3DPixelShader9 *, const DWORD *) {}
inline void on_set_vertex_shader(IDirect3DVertexShader9 *) {}
inline void on_set_pixel_shader(IDirect3DPixelShader9 *) {}
inline bool should_skip_draw() { return false; }
#endif // SPICE64
}
#pragma once
#include <windows.h>
#include <d3d9.h>
// SDVX Live2D draw-skip support for the D3D9 backend.
//
// SDVX renders its Live2D navigator / in-song character through a fixed set of
// shaders. when the skip is active (see graphics_sdvx_live2d_should_skip)
// the matching draw calls are dropped to save GPU. shaders are identified by a
// stable hash of their D3D9 bytecode (object pointers vary per run, the bytecode
// does not). the hashes were captured with the draw-call fingerprinting tool.
//
// every entry point is a no-op unless the feature is enabled (mode != Off), and
// d3d9 rendering for a device is single-threaded, so none of this needs locking.
namespace d3d9_live2d {
#ifdef SPICE64
// record a shader's bytecode fingerprint at creation time
void on_create_vertex_shader(IDirect3DVertexShader9 *shader, const DWORD *func);
void on_create_pixel_shader(IDirect3DPixelShader9 *shader, const DWORD *func);
// remember the currently-bound shaders
void on_set_vertex_shader(IDirect3DVertexShader9 *shader);
void on_set_pixel_shader(IDirect3DPixelShader9 *shader);
// true if the current draw call should be dropped (skip active AND the bound
// shaders identify it as SDVX Live2D)
bool should_skip_draw();
#else // !SPICE64
// only the Live2D-capable SDVX versions are 64-bit; on 32-bit every entry point
// compiles away to nothing, so the d3d9 device hooks need no #ifdefs at their
// call sites.
inline void on_create_vertex_shader(IDirect3DVertexShader9 *, const DWORD *) {}
inline void on_create_pixel_shader(IDirect3DPixelShader9 *, const DWORD *) {}
inline void on_set_vertex_shader(IDirect3DVertexShader9 *) {}
inline void on_set_pixel_shader(IDirect3DPixelShader9 *) {}
inline bool should_skip_draw() { return false; }
#endif // SPICE64
}
@@ -0,0 +1,241 @@
#include "d3d9_readback.h"
#include <mutex>
#include <vector>
#include "hooks/graphics/graphics.h"
#include "util/logging.h"
namespace d3d9_readback {
namespace {
SurfacePtr create_readback_surface(IDirect3DDevice9 *device, const D3DSURFACE_DESC &desc) {
IDirect3DSurface9 *surface = nullptr;
const HRESULT hr = device->CreateOffscreenPlainSurface(
desc.Width, desc.Height, desc.Format, D3DPOOL_SYSTEMMEM, &surface, nullptr);
if (FAILED(hr) || surface == nullptr) {
log_warning("graphics::d3d9",
"failed to create readback surface, hr={}",
FMT_HRESULT(hr));
return nullptr;
}
return SurfacePtr(surface);
}
size_t surface_bytes(const D3DSURFACE_DESC &desc) {
size_t bytes_per_pixel = 4;
switch (desc.Format) {
case D3DFMT_R5G6B5:
case D3DFMT_X1R5G5B5:
case D3DFMT_A1R5G5B5:
bytes_per_pixel = 2;
break;
default:
break;
}
return static_cast<size_t>(desc.Width) * desc.Height * bytes_per_pixel;
}
// idle surfaces are kept between captures, bucketed by layout so that screens of
// differing resolution do not evict each other. a new device drops everything,
// since system memory surfaces outlive Reset but not the device itself
class ReadbackPool {
public:
SurfacePtr acquire(IDirect3DDevice9 *device, const D3DSURFACE_DESC &desc) {
{
std::lock_guard<std::mutex> lock(this->mutex);
if (this->device != device) {
this->drop();
this->device = device;
}
auto *bucket = this->find(desc);
if (bucket && !bucket->idle.empty()) {
auto surface = std::move(bucket->idle.back());
bucket->idle.pop_back();
const size_t bytes = surface_bytes(desc);
this->idle_bytes = this->idle_bytes > bytes ? this->idle_bytes - bytes : 0;
return surface;
}
}
return create_readback_surface(device, desc);
}
void release(IDirect3DDevice9 *device, SurfacePtr surface) {
if (!surface) {
return;
}
D3DSURFACE_DESC desc {};
if (FAILED(surface->GetDesc(&desc))) {
return;
}
const size_t bytes = surface_bytes(desc);
std::lock_guard<std::mutex> lock(this->mutex);
if (this->device != device || this->idle_bytes + bytes > MAX_IDLE_BYTES) {
return;
}
auto *bucket = this->find(desc);
if (bucket == nullptr) {
if (this->buckets.size() >= MAX_BUCKETS) {
return;
}
this->buckets.push_back(Bucket { desc.Width, desc.Height, desc.Format, {} });
bucket = &this->buckets.back();
}
if (bucket->idle.size() < MAX_IDLE_PER_BUCKET) {
bucket->idle.push_back(std::move(surface));
this->idle_bytes += bytes;
}
}
// every cached surface holds a reference on the device, so they have to go
// before it does or the device never reaches a zero reference count
void clear_device(IDirect3DDevice9 *device) {
std::lock_guard<std::mutex> lock(this->mutex);
if (this->device != device) {
return;
}
this->drop();
this->device = nullptr;
}
private:
struct Bucket {
UINT width;
UINT height;
D3DFORMAT format;
std::vector<SurfacePtr> idle;
};
static constexpr size_t MAX_BUCKETS = GRAPHICS_CAPTURE_SCREEN_NO;
// one returning surface plus one for the next capture; a full screen surface
// is several megabytes, so the cap matters
static constexpr size_t MAX_IDLE_PER_BUCKET = 2;
// a 4K surface is 33MB, so the per bucket count alone does not bound this
static constexpr size_t MAX_IDLE_BYTES = 64u * 1024 * 1024;
void drop() {
this->buckets.clear();
this->idle_bytes = 0;
}
Bucket *find(const D3DSURFACE_DESC &desc) {
for (auto &bucket : this->buckets) {
if (bucket.width == desc.Width
&& bucket.height == desc.Height
&& bucket.format == desc.Format) {
return &bucket;
}
}
return nullptr;
}
std::mutex mutex;
std::vector<Bucket> buckets;
IDirect3DDevice9 *device = nullptr;
size_t idle_bytes = 0;
};
// deliberately never destroyed: releasing D3D surfaces during static destruction
// would run after d3d9 may already be unloaded
ReadbackPool &pool() {
static ReadbackPool *instance = new ReadbackPool();
return *instance;
}
} // namespace
void release_device_resources(IDirect3DDevice9 *device) {
pool().clear_device(device);
}
BackbufferCopy::~BackbufferCopy() {
if (this->pooled && this->surface) {
pool().release(this->device, std::move(this->surface));
}
}
std::optional<BackbufferCopy> acquire_backbuffer_copy(
IDirect3DDevice9 *device, IDirect3DSwapChain9 *swap_chain, int screen, bool pooled) {
IDirect3DSurface9 *buffer = nullptr;
HRESULT hr = swap_chain->GetBackBuffer(0, D3DBACKBUFFER_TYPE_MONO, &buffer);
if (FAILED(hr) || buffer == nullptr) {
log_warning("graphics::d3d9",
"failed to get back buffer for screen {}, hr={}",
screen,
FMT_HRESULT(hr));
return std::nullopt;
}
D3DSURFACE_DESC desc {};
hr = buffer->GetDesc(&desc);
if (FAILED(hr)) {
log_warning("graphics::d3d9",
"failed to acquire back buffer descriptor, hr={}",
FMT_HRESULT(hr));
buffer->Release();
return std::nullopt;
}
// GetRenderTargetData rejects multisampled sources. no supported game has been
// seen presenting one, so resolving is left unimplemented rather than untested
if (desc.MultiSampleType != D3DMULTISAMPLE_NONE) {
static std::once_flag warned;
std::call_once(warned, [&desc] {
log_warning("graphics::d3d9",
"back buffer is multisampled ({}), screenshots and capture are unsupported",
static_cast<uint32_t>(desc.MultiSampleType));
});
buffer->Release();
return std::nullopt;
}
auto destination = pooled
? pool().acquire(device, desc)
: create_readback_surface(device, desc);
if (!destination) {
buffer->Release();
return std::nullopt;
}
hr = device->GetRenderTargetData(buffer, destination.get());
buffer->Release();
if (FAILED(hr)) {
log_warning("graphics::d3d9",
"failed to copy back buffer contents, hr={}",
FMT_HRESULT(hr));
if (pooled) {
pool().release(device, std::move(destination));
}
return std::nullopt;
}
BackbufferCopy copy;
copy.screen = screen;
copy.desc = desc;
copy.device = device;
copy.surface = std::move(destination);
copy.pooled = pooled;
return copy;
}
}
@@ -0,0 +1,44 @@
#pragma once
#include <memory>
#include <optional>
#include <d3d9.h>
namespace d3d9_readback {
struct SurfaceReleaser {
void operator()(IDirect3DSurface9 *surface) const {
surface->Release();
}
};
using SurfacePtr = std::unique_ptr<IDirect3DSurface9, SurfaceReleaser>;
// system memory copy of a back buffer; locking it neither stalls the GPU nor reads over PCIe
struct BackbufferCopy {
int screen {};
D3DSURFACE_DESC desc {};
IDirect3DDevice9 *device = nullptr;
SurfacePtr surface;
bool pooled = false;
BackbufferCopy() = default;
BackbufferCopy(BackbufferCopy &&) noexcept = default;
BackbufferCopy &operator=(BackbufferCopy &&) noexcept = default;
BackbufferCopy(const BackbufferCopy &) = delete;
BackbufferCopy &operator=(const BackbufferCopy &) = delete;
~BackbufferCopy();
};
// pooled copies reuse surfaces across calls and return them once the copy is destroyed,
// so the caller must keep it alive for as long as the pixels are being read
std::optional<BackbufferCopy> acquire_backbuffer_copy(
IDirect3DDevice9 *device,
IDirect3DSwapChain9 *swap_chain,
int screen,
bool pooled);
// pooled surfaces hold references on the device; call this before releasing it
void release_device_resources(IDirect3DDevice9 *device);
}
@@ -1,44 +1,34 @@
#include "d3d9_screenshot.h"
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <future>
#include <limits>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <utility>
#include <vector>
#include <external/robin_hood.h>
#ifdef __GNUC__
#include <d3dx9tex.h>
#endif
#include <external/fpng/fpng.h>
#include "avs/game.h"
#include "games/io.h"
#include "hooks/graphics/graphics.h"
#include "launcher/launcher.h"
#include "misc/clipboard.h"
#include "misc/eamuse.h"
#include "overlay/notifications.h"
#include "overlay/overlay.h"
#include "util/fileutils.h"
#include "util/libutils.h"
#include "util/logging.h"
#include "util/threadpool.h"
#ifdef __GNUC__
typedef decltype(D3DXSaveSurfaceToFileA) *D3DXSaveSurfaceToFileA_t;
#else
#define D3DXIFF_PNG ((DWORD) 3)
#include "d3d9_device.h"
#include "d3d9_readback.h"
typedef HRESULT (WINAPI *D3DXSaveSurfaceToFileA_t)(
LPCSTR pDestFile,
DWORD DestFormat,
LPDIRECT3DSURFACE9 pSrcSurface,
CONST PALETTEENTRY *pSrcPalette,
CONST RECT *pSrcRect);
#endif
static bool ATTEMPTED_D3DX9_LOAD_LIBRARY = false;
// genpath picks filenames by probing the disk, so the whole save has to be serialised:
// a name is only taken once its file exists, not when genpath hands it out
static std::mutex SCREENSHOT_SAVE_M;
namespace {
@@ -52,55 +42,146 @@ struct ImageRequest {
int screen;
};
struct SurfaceReleaser {
void operator()(IDirect3DSurface9 *surface) const {
surface->Release();
// a screen already read out of its surface, so nothing here touches D3D. the bytes
// are still in the surface's format; converting them is left to the encode
struct PendingWrite {
int screen {};
D3DFORMAT format {};
UINT width {};
UINT height {};
size_t pitch {};
std::vector<uint8_t> data;
std::string path;
bool saved = false;
};
struct PendingCapture {
int screen {};
D3DFORMAT format {};
UINT width {};
UINT height {};
size_t pitch {};
std::vector<uint8_t> data;
};
// packed 24bpp RGB, what both the png encoder and the api capture consume
constexpr size_t RGB_PIXEL_SIZE = 3;
// the formats surface_to_rgb knows how to convert; the two must stay in sync
static std::optional<size_t> surface_pixel_size(D3DFORMAT format) {
switch (format) {
// what back buffers are actually created as in practice
case D3DFMT_X8R8G8B8:
case D3DFMT_A8R8G8B8:
// a valid display format, but no supported game has been seen presenting one
case D3DFMT_A2R10G10B10:
return 4;
// valid display formats, but no supported game has been seen presenting one
case D3DFMT_R5G6B5:
case D3DFMT_X1R5G5B5:
case D3DFMT_A1R5G5B5:
return 2;
default:
return std::nullopt;
}
}
struct ImageSize {
size_t row_size {};
size_t total_size {};
};
using SurfacePtr = std::unique_ptr<IDirect3DSurface9, SurfaceReleaser>;
static std::optional<ImageSize> compute_image_size(
UINT width,
UINT height,
size_t bytes_per_pixel) {
struct BackbufferCopy {
D3DSURFACE_DESC desc {};
SurfacePtr surface;
if (width == 0 || height == 0
|| width > std::numeric_limits<size_t>::max() / bytes_per_pixel) {
return std::nullopt;
}
const size_t row_size = static_cast<size_t>(width) * bytes_per_pixel;
if (height > std::numeric_limits<size_t>::max() / row_size) {
return std::nullopt;
}
return ImageSize { row_size, static_cast<size_t>(height) * row_size };
}
static bool resize_pixels(std::vector<uint8_t> &pixels, size_t size) {
try {
pixels.resize(size);
return true;
} catch (const std::exception &error) {
log_warning("graphics::d3d9", "failed to allocate image buffer: {}", error.what());
return false;
}
}
// the api capture stages a whole back buffer every frame, so the staging buffer
// is recycled rather than reallocated. returned buffers keep their size, which
// leaves the reuse free of a zero fill
class CaptureBuffers {
public:
std::vector<uint8_t> take() {
std::lock_guard<std::mutex> lock(this->mutex);
if (this->idle.empty()) {
return {};
}
auto buffer = std::move(this->idle.back());
this->idle.pop_back();
return buffer;
}
void give(std::vector<uint8_t> buffer) {
std::lock_guard<std::mutex> lock(this->mutex);
if (this->idle.size() < MAX_IDLE) {
this->idle.push_back(std::move(buffer));
}
}
private:
// one per save in flight plus one for the next capture; a full screen is
// several megabytes, so the cap matters
static constexpr size_t MAX_IDLE = 2;
std::mutex mutex;
std::vector<std::vector<uint8_t>> idle;
};
} // namespace
// deliberately never destroyed, so a save still running at process exit cannot
// hand a buffer back to a dead free list
CaptureBuffers &capture_buffers() {
static CaptureBuffers *instance = new CaptureBuffers();
return *instance;
}
static void save_capture(
int screen,
// encodes get their own pool: the dispatch below already occupies a worker on its
// pool, so queueing onto that one and waiting could starve itself. never destroyed
// for the same reason as the buffers above
ThreadPool &encode_pool() {
static auto *instance = new ThreadPool(2);
return *instance;
}
// normalize the supported D3D formats to packed 24bpp RGB. callers screen the
// format through surface_pixel_size first, so the black fill below is a fallback
void surface_to_rgb(
D3DFORMAT format,
UINT width,
UINT height,
IDirect3DSurface9 *surface) {
HRESULT hr;
const uint8_t *data,
size_t pitch,
uint8_t *pixels) {
// lock surface to be able to access the data
D3DLOCKED_RECT finished_copy {};
hr = surface->LockRect(&finished_copy, nullptr, 0);
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to lock screenshot surface, hr={}", FMT_HRESULT(hr));
graphics_capture_skip(screen);
return;
}
// normalize supported D3D formats to packed RGB for API capture
size_t pitch = finished_copy.Pitch;
auto data = reinterpret_cast<uint8_t *>(finished_copy.pBits);
auto pixels = std::unique_ptr<uint8_t[]>(new uint8_t[width * height * 3]);
for (size_t row = 0; row < height; row++) {
size_t offset_row = row * width * 3;
switch (format) {
case D3DFMT_R8G8B8: {
for (size_t column = 0; column < width; column++) {
auto cell = data + row * pitch + column * 3;
auto pixel = &pixels[offset_row + column * 3];
pixel[0] = cell[0];
pixel[1] = cell[1];
pixel[2] = cell[2];
}
break;
}
case D3DFMT_X8R8G8B8:
case D3DFMT_A8R8G8B8: {
for (size_t column = 0; column < width; column++) {
@@ -112,14 +193,45 @@ static void save_capture(
}
break;
}
case D3DFMT_X8B8G8R8:
case D3DFMT_A8B8G8R8: {
// the 5 and 6 bit channels are widened by bit replication so that
// full scale stays full scale
case D3DFMT_R5G6B5: {
auto cells = reinterpret_cast<const uint16_t *>(data + row * pitch);
for (size_t column = 0; column < width; column++) {
auto cell = data + row * pitch + column * 4;
const uint16_t cell = cells[column];
const uint8_t red = (cell >> 11) & 0x1F;
const uint8_t green = (cell >> 5) & 0x3F;
const uint8_t blue = cell & 0x1F;
auto pixel = &pixels[offset_row + column * 3];
pixel[0] = cell[0];
pixel[1] = cell[1];
pixel[2] = cell[2];
pixel[0] = (red << 3) | (red >> 2);
pixel[1] = (green << 2) | (green >> 4);
pixel[2] = (blue << 3) | (blue >> 2);
}
break;
}
case D3DFMT_X1R5G5B5:
case D3DFMT_A1R5G5B5: {
auto cells = reinterpret_cast<const uint16_t *>(data + row * pitch);
for (size_t column = 0; column < width; column++) {
const uint16_t cell = cells[column];
const uint8_t red = (cell >> 10) & 0x1F;
const uint8_t green = (cell >> 5) & 0x1F;
const uint8_t blue = cell & 0x1F;
auto pixel = &pixels[offset_row + column * 3];
pixel[0] = (red << 3) | (red >> 2);
pixel[1] = (green << 3) | (green >> 2);
pixel[2] = (blue << 3) | (blue >> 2);
}
break;
}
case D3DFMT_A2R10G10B10: {
auto cells = reinterpret_cast<const uint32_t *>(data + row * pitch);
for (size_t column = 0; column < width; column++) {
const uint32_t cell = cells[column];
auto pixel = &pixels[offset_row + column * 3];
pixel[0] = static_cast<uint8_t>((cell >> 22) & 0xFF);
pixel[1] = static_cast<uint8_t>((cell >> 12) & 0xFF);
pixel[2] = static_cast<uint8_t>((cell >> 2) & 0xFF);
}
break;
}
@@ -133,220 +245,169 @@ static void save_capture(
}
}
}
}
// unlock surface
hr = surface->UnlockRect();
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to unlock screenshot surface, hr={}", FMT_HRESULT(hr));
graphics_capture_skip(screen);
} // namespace
using d3d9_readback::BackbufferCopy;
static void save_capture(PendingCapture capture) {
const auto size = compute_image_size(capture.width, capture.height, RGB_PIXEL_SIZE);
if (!size.has_value()) {
capture_buffers().give(std::move(capture.data));
graphics_capture_skip(capture.screen);
return;
}
// enqueue
graphics_capture_enqueue(screen, pixels.release(), width, height);
auto pixels = std::unique_ptr<uint8_t[]>(new (std::nothrow) uint8_t[size->total_size]);
if (!pixels) {
log_warning("graphics::d3d9", "failed to allocate capture image buffer");
capture_buffers().give(std::move(capture.data));
graphics_capture_skip(capture.screen);
return;
}
// a format we cannot read still has to produce a frame, or api clients stall
if (capture.data.empty()) {
std::memset(pixels.get(), 0, size->total_size);
} else {
surface_to_rgb(
capture.format,
capture.width,
capture.height,
capture.data.data(),
capture.pitch,
pixels.get());
capture_buffers().give(std::move(capture.data));
}
graphics_capture_enqueue(capture.screen, pixels.release(), capture.width, capture.height);
}
static void save_screenshot(
enum class SurfaceRead {
Ok,
Unsupported,
Failed,
};
// copying the surface touches D3D, so it stays on the caller's thread. the bytes come
// out in the surface's own format; converting them is plain memory work for later
static SurfaceRead read_surface_raw(
const BackbufferCopy &copy,
size_t &row_size,
std::vector<uint8_t> &out) {
const auto bytes_per_pixel = surface_pixel_size(copy.desc.Format);
if (!bytes_per_pixel.has_value()) {
static std::once_flag warned;
std::call_once(warned, [&copy] {
log_warning("graphics::d3d9",
"unsupported surface format {}",
static_cast<uint32_t>(copy.desc.Format));
});
return SurfaceRead::Unsupported;
}
const auto size = compute_image_size(copy.desc.Width, copy.desc.Height, *bytes_per_pixel);
if (!size.has_value() || !resize_pixels(out, size->total_size)) {
return SurfaceRead::Failed;
}
D3DLOCKED_RECT locked {};
HRESULT hr = copy.surface->LockRect(&locked, nullptr, D3DLOCK_READONLY);
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to lock capture surface, hr={}", FMT_HRESULT(hr));
return SurfaceRead::Failed;
}
if (locked.Pitch < 0 || static_cast<size_t>(locked.Pitch) < size->row_size) {
log_warning("graphics::d3d9", "capture surface has invalid pitch {}", locked.Pitch);
copy.surface->UnlockRect();
return SurfaceRead::Failed;
}
auto data = reinterpret_cast<const uint8_t *>(locked.pBits);
for (size_t row = 0; row < copy.desc.Height; row++) {
std::memcpy(
out.data() + row * size->row_size,
data + row * locked.Pitch,
size->row_size);
}
hr = copy.surface->UnlockRect();
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to unlock capture surface, hr={}", FMT_HRESULT(hr));
return SurfaceRead::Failed;
}
row_size = size->row_size;
return SurfaceRead::Ok;
}
static bool read_capture_surface(
const BackbufferCopy &copy,
PendingCapture &capture) {
capture.screen = copy.screen;
capture.format = copy.desc.Format;
capture.width = copy.desc.Width;
capture.height = copy.desc.Height;
capture.data = capture_buffers().take();
const auto result = read_surface_raw(copy, capture.pitch, capture.data);
if (result == SurfaceRead::Ok) {
return true;
}
capture_buffers().give(std::move(capture.data));
capture.data.clear();
// a format we cannot read is reported as a black frame rather than nothing,
// so a client polling the api keeps getting responses
return result == SurfaceRead::Unsupported;
}
static bool write_screenshot_png(
const std::string &file_path,
D3DFORMAT format,
UINT width,
UINT height,
IDirect3DSurface9 *surface) {
// 32-bit XRGB and ARGB surfaces use byte 3 as alpha; force opaque PNG output
if (format == D3DFMT_X8R8G8B8 || format == D3DFMT_A8R8G8B8 ||
format == D3DFMT_X8B8G8R8 || format == D3DFMT_A8B8G8R8) {
const std::vector<uint8_t> &pixels) {
D3DLOCKED_RECT finished_copy {};
HRESULT hr = surface->LockRect(&finished_copy, nullptr, 0);
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to lock screenshot surface, hr={}", FMT_HRESULT(hr));
return;
}
// a no-op while FPNG_NO_SSE is set, but fpng requires it before any encode
static std::once_flag fpng_ready;
std::call_once(fpng_ready, [] { fpng::fpng_init(); });
const size_t pitch = finished_copy.Pitch;
auto data = reinterpret_cast<uint8_t *>(finished_copy.pBits);
for (size_t row = 0; row < height; row++) {
for (size_t column = 0; column < width; column++) {
data[row * pitch + column * 4 + 3] = 255;
}
}
log_info("graphics::d3d9", "saving screenshot to {}", file_path);
hr = surface->UnlockRect();
if (FAILED(hr)) {
log_warning("graphics::d3d9", "failed to unlock screenshot surface, hr={}", FMT_HRESULT(hr));
return;
}
if (!fpng::fpng_encode_image_to_file(
file_path.c_str(),
pixels.data(),
static_cast<uint32_t>(width),
static_cast<uint32_t>(height),
3)) {
log_warning("graphics::d3d9", "failed to write screenshot png");
return false;
}
// lazy load function
static D3DXSaveSurfaceToFileA_t D3DXSaveSurfaceToFileA_ptr = nullptr;
if (D3DXSaveSurfaceToFileA_ptr == nullptr) {
D3DXSaveSurfaceToFileA_ptr = libutils::try_proc<D3DXSaveSurfaceToFileA_t>("D3DXSaveSurfaceToFileA");
// check if function was not found, likely because d3dx9 is not loaded
if (!ATTEMPTED_D3DX9_LOAD_LIBRARY && D3DXSaveSurfaceToFileA_ptr == nullptr) {
ATTEMPTED_D3DX9_LOAD_LIBRARY = true;
// prefer the newest installed helper while supporting older D3DX9 runtimes
for (size_t i = 43; i >= 24; i--) {
auto lib_name = fmt::format("d3dx9_{}.dll", i);
auto d3dx9 = libutils::try_library(lib_name);
// Check if library was not found
if (d3dx9 == nullptr) {
continue;
}
D3DXSaveSurfaceToFileA_ptr = libutils::try_proc<D3DXSaveSurfaceToFileA_t>(
d3dx9, "D3DXSaveSurfaceToFileA");
// Check if function was not found
if (D3DXSaveSurfaceToFileA_ptr == nullptr) {
FreeLibrary(d3dx9);
d3dx9 = nullptr;
continue;
}
log_info("graphics::d3d9", "found surface save function in '{}'", lib_name);
break;
}
}
}
if (D3DXSaveSurfaceToFileA_ptr != nullptr) {
// save to file
log_info("graphics::d3d9", "saving screenshot to {}", file_path);
const HRESULT save_result = D3DXSaveSurfaceToFileA_ptr(
file_path.c_str(), D3DXIFF_PNG, surface, nullptr, nullptr);
if (FAILED(save_result)) {
log_warning("graphics::d3d9", "Failed to save screenshot");
overlay::notifications::add(
overlay::notifications::Severity::Error,
"Screenshot failed to save");
return;
}
// save to clipboard
clipboard::copy_image(file_path);
overlay::notifications::add(
overlay::notifications::Severity::Success,
fmt::format("Screenshot saved: {}", fileutils::basename(file_path)));
} else {
log_warning("graphics::d3d9", "Direct3D save helper function not available");
}
return true;
}
void graphics_d3d9_poll_screenshot_hotkey() {
static bool trigger_last = false;
auto buttons = games::get_buttons_overlay(eamuse_get_game());
if (buttons && (!overlay::OVERLAY || overlay::OVERLAY->hotkeys_triggered()) &&
GameAPI::Buttons::getState(RI_MGR, buttons->at(games::OverlayButtons::Screenshot)))
{
if (!trigger_last) {
graphics_screenshot_trigger();
}
trigger_last = true;
} else {
trigger_last = false;
// screen 0 keeps the plain name so existing tooling and the clipboard copy are unaffected
static std::string screenshot_path_for_screen(const std::string &primary_path, int screen) {
if (screen == 0) {
return primary_path;
}
const std::filesystem::path path(primary_path);
return (path.parent_path() /
fmt::format("{}_{}{}", path.stem().string(), screen, path.extension().string()))
.string();
}
static std::optional<BackbufferCopy> acquire_backbuffer_copy(
IDirect3DDevice9 *device, IDirect3DSwapChain9 *sub_swap_chain, int screen) {
HRESULT hr = S_OK;
// TODO: verify screen is a valid swapchain
IDirect3DSurface9 *buffer = nullptr;
if (sub_swap_chain != nullptr && screen & 1) {
hr = sub_swap_chain->GetBackBuffer(0, D3DBACKBUFFER_TYPE_MONO, &buffer);
} else {
hr = device->GetBackBuffer(screen, 0, D3DBACKBUFFER_TYPE_MONO, &buffer);
}
if (FAILED(hr) || buffer == nullptr) {
log_warning("graphics::d3d9",
"failed to get back buffer, hr={}",
FMT_HRESULT(hr));
return std::nullopt;
}
D3DSURFACE_DESC desc {};
hr = buffer->GetDesc(&desc);
if (FAILED(hr)) {
log_warning("graphics::d3d9",
"failed to acquire back buffer descriptor, hr={}",
FMT_HRESULT(hr));
buffer->Release();
return std::nullopt;
}
// TODO: cache render targets
IDirect3DSurface9 *temp_surface = nullptr;
hr = device->CreateRenderTarget(
desc.Width, desc.Height, desc.Format, desc.MultiSampleType,
desc.MultiSampleQuality, TRUE, &temp_surface, nullptr);
if (FAILED(hr) || temp_surface == nullptr) {
log_warning("graphics::d3d9",
"failed to acquire temporary surface, hr={}",
FMT_HRESULT(hr));
buffer->Release();
return std::nullopt;
}
hr = device->StretchRect(buffer, nullptr, temp_surface, nullptr, D3DTEXF_NONE);
if (FAILED(hr)) {
log_warning("graphics::d3d9",
"failed to copy back buffer contents, hr={}",
FMT_HRESULT(hr));
temp_surface->Release();
buffer->Release();
return std::nullopt;
}
// release original back buffer reference
buffer->Release();
return BackbufferCopy {
.desc = desc,
.surface = SurfacePtr(temp_surface),
};
}
static void dispatch_surface_save(
const ImageRequest &request,
BackbufferCopy copy) {
auto surface_process = [request, copy = std::move(copy)]() {
switch (request.kind) {
case ImageRequestKind::Capture:
save_capture(
request.screen,
copy.desc.Format,
copy.desc.Width,
copy.desc.Height,
copy.surface.get());
break;
case ImageRequestKind::Screenshot: {
auto file_path = graphics_screenshot_genpath();
if (!file_path.empty()) {
save_screenshot(
file_path,
copy.desc.Format,
copy.desc.Width,
copy.desc.Height,
copy.surface.get());
}
break;
}
}
};
// list of games that crash when running the screenshot processor on another thread
// games that crash or hang when the screenshot processor runs on another thread.
// D3DCREATE_MULTITHREADED is not a predictor of this; MDX omits it and threads fine
static bool image_processing_must_be_inline() {
static const robin_hood::unordered_set<std::string> THREAD_BAN {
"JMA",
#ifndef SPICE64
@@ -358,52 +419,263 @@ static void dispatch_surface_save(
"LMA",
};
// run the save operation on another thread for supported games
if (THREAD_BAN.contains(avs::game::MODEL)) {
surface_process();
return THREAD_BAN.contains(avs::game::MODEL);
}
static void dispatch_capture_save(PendingCapture capture) {
auto capture_process = [capture = std::move(capture)]() mutable {
// an escape from here would cross a thread boundary and terminate
try {
save_capture(std::move(capture));
} catch (const std::exception &error) {
log_warning("graphics::d3d9", "capture save failed: {}", error.what());
} catch (...) {
log_warning("graphics::d3d9", "capture save failed");
}
};
if (image_processing_must_be_inline()) {
capture_process();
} else {
static auto pool = ThreadPool(2);
pool.add(std::move(surface_process));
pool.add(std::move(capture_process));
}
}
static std::optional<ImageRequest> consume_image_request() {
// by this point the pixels are plain memory, so none of this needs the device
static void dispatch_screenshot_save(std::vector<PendingWrite> writes, size_t screen_count) {
auto screenshot_process = [writes = std::move(writes), screen_count]() mutable {
std::lock_guard<std::mutex> lock(SCREENSHOT_SAVE_M);
std::vector<int> screens;
screens.reserve(writes.size());
for (const auto &write : writes) {
screens.push_back(write.screen);
}
const auto base_path = graphics_screenshot_genpath(screens);
if (base_path.empty()) {
return;
}
for (auto &write : writes) {
write.path = screenshot_path_for_screen(base_path, write.screen);
}
// screens missing from writes either failed to be acquired or failed to read
size_t failed = screen_count - writes.size();
// a throw here would otherwise reach a thread boundary and terminate
auto encode_one = [](PendingWrite &write) {
try {
const auto rgb = compute_image_size(write.width, write.height, RGB_PIXEL_SIZE);
std::vector<uint8_t> pixels;
if (!rgb.has_value() || !resize_pixels(pixels, rgb->total_size)) {
write.saved = false;
return;
}
surface_to_rgb(
write.format,
write.width,
write.height,
write.data.data(),
write.pitch,
pixels.data());
// the encode below is the long part; the raw copy is dead by now
write.data.clear();
write.data.shrink_to_fit();
write.saved = write_screenshot_png(
write.path, write.width, write.height, pixels);
} catch (const std::exception &error) {
log_warning("graphics::d3d9",
"screenshot encode failed for {}: {}", write.path, error.what());
write.saved = false;
} catch (...) {
log_warning("graphics::d3d9",
"screenshot encode failed for {}", write.path);
write.saved = false;
}
};
{
// sized up front and assigned by index: storing a future must not be able
// to throw once its task is queued, or the screen would encode twice
std::vector<std::future<void>> pending(writes.empty() ? 0 : writes.size() - 1);
for (size_t i = 1; i < writes.size(); i++) {
try {
pending[i - 1] = encode_pool().add([&writes, &encode_one, i] {
encode_one(writes[i]);
});
} catch (const std::exception &) {
// nothing to queue onto; encoding it here still makes progress
encode_one(writes[i]);
}
}
if (!writes.empty()) {
encode_one(writes.front());
}
for (auto &task : pending) {
if (task.valid()) {
task.wait();
}
}
}
std::string primary_path;
std::string notify_path;
for (const auto &write : writes) {
if (!write.saved) {
failed++;
continue;
}
if (notify_path.empty()) {
notify_path = write.path;
}
if (write.screen == 0) {
primary_path = write.path;
}
}
// only the primary screen goes to the clipboard, but any saved file is a success
if (!primary_path.empty()) {
clipboard::copy_image(primary_path);
}
if (!notify_path.empty()) {
overlay::notifications::add(
overlay::notifications::Severity::Success,
fmt::format("Screenshot saved: {}", fileutils::basename(notify_path)));
} else {
overlay::notifications::add(
overlay::notifications::Severity::Error,
"Screenshot failed to save");
}
if (failed > 0) {
log_warning("graphics::d3d9", "{} screenshot screen(s) missing", failed);
}
};
// genpath and the path building below allocate, so an escape from here would
// cross a thread boundary and terminate
auto guarded = [process = std::move(screenshot_process)]() mutable {
try {
process();
} catch (const std::exception &error) {
log_warning("graphics::d3d9", "screenshot save failed: {}", error.what());
} catch (...) {
log_warning("graphics::d3d9", "screenshot save failed");
}
};
if (image_processing_must_be_inline()) {
guarded();
} else {
static auto pool = ThreadPool(2);
pool.add(std::move(guarded));
}
}
static void process_image_request(
IDirect3DDevice9 *device,
WrappedIDirect3DDevice9 *wrapped_device,
const ImageRequest &request) {
const bool screenshot = request.kind == ImageRequestKind::Screenshot;
std::vector<int> screens { request.screen };
if (screenshot && GRAPHICS_SCREENSHOT_SUBSCREENS) {
screens.clear();
wrapped_device->get_screenshot_screens(screens);
}
std::vector<BackbufferCopy> copies;
copies.reserve(screens.size());
for (const int screen : screens) {
std::optional<BackbufferCopy> copy;
IDirect3DSwapChain9 *swap_chain = nullptr;
HRESULT hr = wrapped_device->get_screenshot_swap_chain(screen, &swap_chain);
if (FAILED(hr) || swap_chain == nullptr) {
log_warning("graphics::d3d9",
"failed to get swap chain for screen {}, hr={}",
screen,
FMT_HRESULT(hr));
} else {
// only the API capture path runs often enough to benefit from pooling
copy = d3d9_readback::acquire_backbuffer_copy(device, swap_chain, screen, !screenshot);
swap_chain->Release();
}
if (copy.has_value()) {
copies.emplace_back(std::move(*copy));
} else if (!screenshot) {
graphics_capture_skip(request.screen);
return;
}
}
if (copies.empty()) {
return;
}
if (!screenshot) {
PendingCapture capture;
if (!read_capture_surface(copies.front(), capture)) {
graphics_capture_skip(request.screen);
return;
}
copies.clear();
dispatch_capture_save(std::move(capture));
return;
}
// reading a surface touches the device, and doing that off the present thread
// has been seen to deadlock games whose device has no internal locking
std::vector<PendingWrite> writes;
writes.reserve(copies.size());
for (const auto &copy : copies) {
PendingWrite write;
write.screen = copy.screen;
write.format = copy.desc.Format;
write.width = copy.desc.Width;
write.height = copy.desc.Height;
if (read_surface_raw(copy, write.pitch, write.data) != SurfaceRead::Ok) {
continue;
}
writes.push_back(std::move(write));
}
copies.clear();
dispatch_screenshot_save(std::move(writes), screens.size());
}
void graphics_d3d9_process_screenshot(
IDirect3DDevice9 *device,
WrappedIDirect3DDevice9 *wrapped_device) {
if (graphics_screenshot_consume()) {
return ImageRequest {
process_image_request(device, wrapped_device, ImageRequest {
.kind = ImageRequestKind::Screenshot,
.screen = 0,
};
});
}
int capture_screen = 0;
if (graphics_capture_consume(&capture_screen)) {
return ImageRequest {
.kind = ImageRequestKind::Capture,
.screen = capture_screen,
};
}
return std::nullopt;
}
void graphics_d3d9_process_screenshot_and_capture(
void graphics_d3d9_process_capture(
IDirect3DDevice9 *device,
IDirect3DSwapChain9 *sub_swap_chain) {
const auto request = consume_image_request();
if (!request.has_value()) {
return;
WrappedIDirect3DDevice9 *wrapped_device) {
int screen = 0;
if (graphics_capture_consume(&screen)) {
process_image_request(device, wrapped_device, ImageRequest {
.kind = ImageRequestKind::Capture,
.screen = screen,
});
}
auto copy = acquire_backbuffer_copy(
device,
sub_swap_chain,
request->screen);
if (!copy.has_value()) {
if (request->kind == ImageRequestKind::Capture) {
graphics_capture_skip(request->screen);
}
return;
}
dispatch_surface_save(*request, std::move(*copy));
}
@@ -2,8 +2,12 @@
#include <d3d9.h>
void graphics_d3d9_poll_screenshot_hotkey();
struct WrappedIDirect3DDevice9;
void graphics_d3d9_process_screenshot_and_capture(
void graphics_d3d9_process_screenshot(
IDirect3DDevice9 *device,
IDirect3DSwapChain9 *sub_swap_chain);
WrappedIDirect3DDevice9 *wrapped_device);
void graphics_d3d9_process_capture(
IDirect3DDevice9 *device,
WrappedIDirect3DDevice9 *wrapped_device);
+110 -13
View File
@@ -17,11 +17,14 @@
#include "games/ddr/ddr.h"
#include "games/gitadora/gitadora.h"
#include "games/iidx/iidx.h"
#include "games/io.h"
#include "games/sdvx/sdvx.h"
#include "games/popn/popn.h"
#include "hooks/graphics/jpeg_encoder.h"
#include "hooks/graphics/backends/d3d9/d3d9_backend.h"
#include "hooks/graphics/backends/d3d11/d3d11_backend.h"
#include "launcher/shutdown.h"
#include "misc/hotkeys.h"
#include "overlay/overlay.h"
#include "touch/touch.h"
#include "touch/touch_gestures.h"
@@ -31,6 +34,7 @@
#include "util/utils.h"
#include "misc/wintouchemu.h"
#include "touch/native/inject.h"
#include "touch/native/nativetouchhook.h"
#include "util/time.h"
#include "rawinput/rawinput.h"
@@ -61,7 +65,6 @@ static bool GRAPHICS_SCREENSHOT_TRIGGER = false;
static std::set<int> GRAPHICS_SCREENS { 0 };
static std::mutex GRAPHICS_SCREENS_M {};
static std::vector<int> GRAPHICS_CAPTURE_SCREENS;
static const size_t GRAPHICS_CAPTURE_SCREEN_NO = 4;
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] {};
@@ -116,6 +119,8 @@ uint32_t GRAPHICS_FS_ORIGINAL_HEIGHT = 0;
// settings
std::string GRAPHICS_DEVICEID = "PCI\\VEN_1002&DEV_7146";
std::string GRAPHICS_SCREENSHOT_DIR = ".\\screenshots";
bool GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY = false;
bool GRAPHICS_SCREENSHOT_SUBSCREENS = false;
static decltype(ChangeDisplaySettingsA) *ChangeDisplaySettingsA_orig = nullptr;
static decltype(ChangeDisplaySettingsExA) *ChangeDisplaySettingsExA_orig = nullptr;
@@ -212,6 +217,18 @@ static void gitadora_remember_window(HWND hWnd, const std::string &window_name)
} else if (window_name == "SMALL") {
GFDM_SUBSCREEN_WINDOW = hWnd;
}
// touch belongs to the SMALL panel when it exists, otherwise to the main window
// that draws the subscreen overlay
const bool hosts_touch = window_name == "SMALL" ||
(window_name == "GITADORA" && !graphics_gitadora_has_dedicated_subscreen());
if (nativetouch::is_hooked() && hWnd != nullptr && hosts_touch) {
nativetouch::inject::set_preferred_injection_window(hWnd);
}
}
bool graphics_gitadora_has_dedicated_subscreen() {
return GFDM_SUBSCREEN_WINDOW != nullptr;
}
bool graphics_gitadora_prepare_two_head_device_window(
@@ -612,8 +629,10 @@ static HWND WINAPI CreateWindowExA_hook(DWORD dwExStyle, LPCSTR lpClassName, LPC
}
}
const bool is_sdvx = avs::game::is_model("KFC");
bool is_tdj_sub_window = avs::game::is_model("LDJ") && window_name.ends_with(" sub");
bool is_sdvx_sub_window = avs::game::is_model("KFC") && window_name.ends_with(" Sub Screen");
bool is_sdvx_sub_window = is_sdvx && window_name.ends_with(" Sub Screen");
bool is_sdvx_main_window = is_sdvx && window_name.ends_with(" Main Screen");
bool is_popn_sub_window = avs::game::is_model("M39") && window_name.ends_with("Sub Screen");
const std::string gfdm_window_name = games::gitadora::is_arena_model()
? gitadora_canonical_window_name(effective_window_name)
@@ -716,6 +735,20 @@ static HWND WINAPI CreateWindowExA_hook(DWORD dwExStyle, LPCSTR lpClassName, LPC
graphics_hook_subscreen_window(SDVX_SUBSCREEN_WINDOW);
}
// SDVX registers touch on both windows, so name the one synthetic touches must land on
// instead of letting window creation order decide: the sub screen window when windowed,
// the main window in fullscreen since the game reads it in primary-display coordinates
if (nativetouch::is_hooked() &&
result != nullptr &&
(GRAPHICS_WINDOWED ? is_sdvx_sub_window : is_sdvx_main_window)) {
log_misc(
"graphics",
"SDVX touch surface is {}, {}",
fmt::ptr(result),
window_name);
nativetouch::inject::set_preferred_injection_window(result);
}
// only hook touch window if multiple windows are allowed
if (gfdm_window_name == "LEFT" || gfdm_window_name == "RIGHT") {
gitadora_remember_window(result, gfdm_window_name);
@@ -727,6 +760,11 @@ static HWND WINAPI CreateWindowExA_hook(DWORD dwExStyle, LPCSTR lpClassName, LPC
if (GRAPHICS_WINDOWED && !GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
graphics_hook_subscreen_window(GFDM_SUBSCREEN_WINDOW);
}
// the dedicated SMALL window is the touch panel; mouse and API touch target it
if (nativetouch::is_hooked() && result != nullptr) {
nativetouch::inject::register_and_attach_window(result);
}
}
if (is_gfdm_window && GRAPHICS_WINDOWED && !GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
gitadora_force_window_style(result);
@@ -1100,6 +1138,15 @@ static BOOL WINAPI ShowWindow_hook(HWND hWnd, int nCmdShow) {
return true;
}
// fullscreen SDVX keeps two adapters so the subscreen overlay can draw, so the game still
// creates the sub window even when the user asked for it to be gone
if (avs::game::is_model("KFC") &&
GRAPHICS_PREVENT_SECONDARY_WINDOWS &&
hWnd == SDVX_SUBSCREEN_WINDOW) {
log_info("graphics", "ShowWindow_hook - hiding sub window {}", fmt::ptr(hWnd));
return true;
}
// call original
return ShowWindow_orig(hWnd, nCmdShow);
}
@@ -1316,7 +1363,9 @@ void graphics_hook_window(HWND hWnd, D3DPRESENT_PARAMETERS *pPresentationParamet
const bool native_touch_overlay =
(games::iidx::NATIVE_TOUCH && games::iidx::TDJ_MODE && !GRAPHICS_IIDX_WSUB) ||
(games::popn::NATIVE_TOUCH &&
games::popn::is_pikapika_model() && GRAPHICS_PREVENT_SECONDARY_WINDOWS);
games::popn::is_pikapika_model() && GRAPHICS_PREVENT_SECONDARY_WINDOWS) ||
(games::gitadora::NATIVE_TOUCH &&
games::gitadora::is_arena_model() && GRAPHICS_PREVENT_SECONDARY_WINDOWS);
if (native_touch_overlay) {
nativetouch::inject::register_and_attach_window(hWnd);
}
@@ -1374,6 +1423,12 @@ void graphics_screens_get(std::vector<int> &screens) {
screens.insert(screens.end(), GRAPHICS_SCREENS.begin(), GRAPHICS_SCREENS.end());
}
void graphics_poll_screenshot_hotkey() {
if (hotkeys::consume_screenshot()) {
graphics_screenshot_trigger();
}
}
void graphics_screenshot_trigger() {
GRAPHICS_SCREENSHOT_TRIGGER = true;
}
@@ -1426,10 +1481,12 @@ void graphics_capture_skip(int screen) {
GRAPHICS_CAPTURE_CV[screen].notify_one();
}
bool graphics_capture_receive_jpeg(int screen, TooJpeg::WRITE_ONE_BYTE receiver,
bool rgb, int quality, bool downsample, int divide, uint64_t *timestamp,
bool graphics_capture_receive_raw(int screen, std::shared_ptr<uint8_t[]> &out,
int divide, uint64_t *timestamp,
int *width, int *height) {
out = nullptr;
if (screen < 0 || screen >= static_cast<int>(GRAPHICS_CAPTURE_SCREEN_NO)) {
return false;
}
@@ -1501,11 +1558,7 @@ bool graphics_capture_receive_jpeg(int screen, TooJpeg::WRITE_ONE_BYTE receiver,
capture_height = height_new;
}
// compress
auto success = TooJpeg::writeJpeg(
receiver, capture_data.get(),
capture_width, capture_height,
rgb, quality, downsample);
out = std::move(capture_data);
// status
if (timestamp) {
@@ -1518,11 +1571,45 @@ bool graphics_capture_receive_jpeg(int screen, TooJpeg::WRITE_ONE_BYTE receiver,
*height = capture_height;
}
return true;
}
bool graphics_capture_receive_jpeg(int screen, std::vector<uint8_t> &out,
int quality, int divide, uint64_t *timestamp,
int *width, int *height) {
out.clear();
std::shared_ptr<uint8_t[]> pixels;
int capture_width = 0;
int capture_height = 0;
if (!graphics_capture_receive_raw(
screen, pixels, divide, timestamp, &capture_width, &capture_height)) {
return false;
}
// compress
const bool success = jpeg_encoder::encode(
out, pixels.get(),
capture_width, capture_height, quality);
if (!success) {
out.clear();
}
// status
if (width) {
*width = capture_width;
}
if (height) {
*height = capture_height;
}
// clean up
return success;
}
std::string graphics_screenshot_genpath() {
std::string graphics_screenshot_genpath(const std::vector<int> &screens) {
// verify dir path
if (GRAPHICS_SCREENSHOT_DIR.empty()) {
@@ -1547,11 +1634,21 @@ std::string graphics_screenshot_genpath() {
auto tm_now = *std::gmtime(&t_now);
auto prefix = to_string(std::put_time(&tm_now, "%Y%m%d"));
// find next filename
// find next filename; the whole set has to be free so one shot stays numbered together
size_t id = 0;
while (true) {
auto filepath = fmt::format("{}\\{}_{}.png", GRAPHICS_SCREENSHOT_DIR, prefix, id);
if (!fileutils::file_exists(filepath)) {
bool available = !fileutils::file_exists(filepath);
for (const auto screen : screens) {
if (!available) {
break;
}
if (screen != 0) {
available = !fileutils::file_exists(fmt::format(
"{}\\{}_{}_{}.png", GRAPHICS_SCREENSHOT_DIR, prefix, id, screen));
}
}
if (available) {
return filepath;
}
+18 -5
View File
@@ -1,6 +1,7 @@
#pragma once
#include <atomic>
#include <memory>
#include <string>
#include <vector>
#include <optional>
@@ -12,8 +13,6 @@
#include <dwmapi.h>
#endif
#include "external/toojpeg/toojpeg.h"
// order must match spice2x_AutoOrientation UI enum order
enum graphics_orientation {
ORIENTATION_CW = 0,
@@ -111,6 +110,8 @@ extern bool FAKE_SUBSCREEN_ADAPTER;
// settings
extern std::string GRAPHICS_DEVICEID;
extern std::string GRAPHICS_SCREENSHOT_DIR;
extern bool GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY;
extern bool GRAPHICS_SCREENSHOT_SUBSCREENS;
// Direct3D 9 settings
extern std::optional<UINT> D3D9_ADAPTER;
@@ -119,6 +120,7 @@ extern bool D3D9_DEVICE_HOOK_DISABLE;
void graphics_init();
void graphics_hook_window(HWND hWnd, D3DPRESENT_PARAMETERS *pPresentationParameters);
bool graphics_gitadora_has_dedicated_subscreen();
// The native GITADORA two-head D3D9 group uses the game's named SMALL
// device window for the native physical SMALL head. The game requests
// D3DCREATE_NOWINDOWCHANGES, so this host must be made borderless and sized
@@ -134,17 +136,28 @@ void graphics_hook_subscreen_window(HWND hWnd);
void graphics_screens_register(int screen);
void graphics_screens_unregister(int screen);
void graphics_screens_get(std::vector<int> &screens);
void graphics_poll_screenshot_hotkey();
void graphics_screenshot_trigger();
bool graphics_screenshot_consume();
inline constexpr size_t GRAPHICS_CAPTURE_SCREEN_NO = 4;
void graphics_capture_trigger(int screen);
bool graphics_capture_consume(int *screen);
void graphics_capture_enqueue(int screen, uint8_t *data, size_t width, size_t height);
void graphics_capture_skip(int screen);
bool graphics_capture_receive_jpeg(int screen, TooJpeg::WRITE_ONE_BYTE receiver,
bool rgb = true, int quality = 80, bool downsample = true, int divide = 0,
// on success `out` owns packed 24bpp RGB pixels, width * height * 3 bytes
bool graphics_capture_receive_raw(int screen, std::shared_ptr<uint8_t[]> &out,
int divide = 0,
uint64_t *timestamp = nullptr,
int *width = nullptr, int *height = nullptr);
std::string graphics_screenshot_genpath();
// on success `out` holds the encoded JPEG; its storage is reused across calls
bool graphics_capture_receive_jpeg(int screen, std::vector<uint8_t> &out,
int quality = 80, int divide = 0,
uint64_t *timestamp = nullptr,
int *width = nullptr, int *height = nullptr);
// the returned path is for screen 0; any extra screens only reserve their suffixed names
std::string graphics_screenshot_genpath(const std::vector<int> &screens = {});
// graphics_windowed.cpp
void graphics_windowed_wndproc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
+156
View File
@@ -0,0 +1,156 @@
#include "jpeg_encoder.h"
#ifdef SPICE_JPEG
#include <csetjmp>
#include <cstdio>
#include <jpeglib.h>
namespace jpeg_encoder {
namespace {
constexpr size_t CHUNK_SIZE = 16 * 1024;
// libjpeg writes through a destination manager; this one appends straight into
// the caller's vector so the encoded frame is never copied
struct VectorDestination {
jpeg_destination_mgr mgr {};
std::vector<uint8_t> *out = nullptr;
uint8_t chunk[CHUNK_SIZE] {};
};
void dest_init(j_compress_ptr cinfo) {
auto dest = reinterpret_cast<VectorDestination *>(cinfo->dest);
dest->mgr.next_output_byte = dest->chunk;
dest->mgr.free_in_buffer = CHUNK_SIZE;
}
// libjpeg cannot unwind a C++ exception out of its own frames, so growing the
// output has to fail by value and be turned into an error_exit by the caller
bool dest_append(VectorDestination *dest, size_t size) {
try {
dest->out->insert(dest->out->end(), dest->chunk, dest->chunk + size);
return true;
} catch (...) {
return false;
}
}
boolean dest_empty(j_compress_ptr cinfo) {
auto dest = reinterpret_cast<VectorDestination *>(cinfo->dest);
// returning FALSE would mean suspension to libjpeg, not failure
if (!dest_append(dest, CHUNK_SIZE)) {
(*cinfo->err->error_exit)(reinterpret_cast<j_common_ptr>(cinfo));
}
dest->mgr.next_output_byte = dest->chunk;
dest->mgr.free_in_buffer = CHUNK_SIZE;
return TRUE;
}
void dest_term(j_compress_ptr cinfo) {
auto dest = reinterpret_cast<VectorDestination *>(cinfo->dest);
if (!dest_append(dest, CHUNK_SIZE - dest->mgr.free_in_buffer)) {
(*cinfo->err->error_exit)(reinterpret_cast<j_common_ptr>(cinfo));
}
}
// the default handler calls exit(), which is not an option inside a game process
struct ErrorManager {
jpeg_error_mgr mgr {};
jmp_buf escape {};
};
void on_error(j_common_ptr cinfo) {
longjmp(reinterpret_cast<ErrorManager *>(cinfo->err)->escape, 1);
}
void on_message(j_common_ptr) {
}
} // namespace
bool encode(
std::vector<uint8_t> &out,
const uint8_t *pixels,
int width,
int height,
int quality) {
if (!pixels || width <= 0 || height <= 0) {
return false;
}
if (quality < 1) {
quality = 1;
} else if (quality > 100) {
quality = 100;
}
// zero init is load bearing: the trap below is armed before the struct is
// created, and jpeg_destroy_compress only tolerates that on a zeroed struct
jpeg_compress_struct cinfo {};
ErrorManager err;
VectorDestination dest;
cinfo.err = jpeg_std_error(&err.mgr);
err.mgr.error_exit = on_error;
err.mgr.output_message = on_message;
if (setjmp(err.escape)) {
jpeg_destroy_compress(&cinfo);
return false;
}
jpeg_create_compress(&cinfo);
dest.out = &out;
dest.mgr.init_destination = dest_init;
dest.mgr.empty_output_buffer = dest_empty;
dest.mgr.term_destination = dest_term;
cinfo.dest = &dest.mgr;
cinfo.image_width = static_cast<JDIMENSION>(width);
cinfo.image_height = static_cast<JDIMENSION>(height);
cinfo.in_color_space = JCS_RGB;
cinfo.input_components = 3;
jpeg_set_defaults(&cinfo);
jpeg_set_quality(&cinfo, quality, TRUE);
// 4:2:0, matching what the capture path asked the previous encoder for
cinfo.comp_info[0].h_samp_factor = 2;
cinfo.comp_info[0].v_samp_factor = 2;
jpeg_start_compress(&cinfo, TRUE);
const size_t pitch = static_cast<size_t>(width) * 3;
while (cinfo.next_scanline < cinfo.image_height) {
auto row = const_cast<uint8_t *>(pixels + cinfo.next_scanline * pitch);
JSAMPROW rows[1] = { row };
jpeg_write_scanlines(&cinfo, rows, 1);
}
jpeg_finish_compress(&cinfo);
jpeg_destroy_compress(&cinfo);
return true;
}
}
#else // SPICE_JPEG
namespace jpeg_encoder {
// builds without libjpeg-turbo (the WinXP toolchains) simply cannot encode;
// callers already treat a false return as "no frame available"
bool encode(std::vector<uint8_t> &out, const uint8_t *, int, int, int) {
out.clear();
return false;
}
}
#endif // SPICE_JPEG
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <cstdint>
#include <vector>
namespace jpeg_encoder {
// appends a baseline 4:2:0 JPEG of packed 24bpp RGB pixels to `out`
bool encode(
std::vector<uint8_t> &out,
const uint8_t *pixels,
int width,
int height,
int quality);
}
+480 -480
View File
@@ -1,480 +1,480 @@
#include "nvapi_impl.h"
#ifdef SPICE64
#include <algorithm>
#include <vector>
#include <windows.h>
#include "external/nvapi/nvapi.h"
#include "hooks/libraryhook.h"
#include "util/logging.h"
#include "util/sysutils.h"
namespace nvapi_impl {
namespace {
constexpr unsigned int NVAPI_INITIALIZE_ID = 0x0150E828;
constexpr unsigned int NVAPI_INITIALIZE_EX_ID = 0xAD298D3F;
constexpr unsigned int NVAPI_UNLOAD_ID = 0xD22BDD7E;
constexpr unsigned int NVAPI_ENUM_PHYSICAL_GPUS_ID = 0xE5AC921F;
constexpr unsigned int NVAPI_GPU_GET_CONNECTED_DISPLAY_IDS_ID = 0x0078DBA2;
constexpr unsigned int NVAPI_DISP_GET_GDI_PRIMARY_DISPLAY_ID = 0x1E9D8A31;
constexpr unsigned int NVAPI_DISP_GET_DISPLAY_CONFIG_ID = 0x11ABCCF8;
constexpr unsigned int NVAPI_DISP_SET_DISPLAY_CONFIG_ID = 0x5D8CF8DE;
constexpr char NVAPI_DLL_NAME_A[] = "nvapi64.dll";
struct SyntheticDisplay {
NvU32 display_id;
NvU32 width;
NvU32 height;
NvU32 color_depth;
NvS32 x;
NvS32 y;
NvU32 refresh_rate_1k;
NV_ROTATE rotation;
bool primary;
};
static bool provider_initialized = false;
static bool nvapi_initialized = false;
static int gpu_handle_storage = 0;
// snapshot of the Win32 display state exposed through synthetic NVAPI
static std::vector<SyntheticDisplay> displays;
static NvPhysicalGpuHandle get_gpu_handle() {
return reinterpret_cast<NvPhysicalGpuHandle>(&gpu_handle_storage);
}
static NV_ROTATE get_rotation(DWORD orientation) {
switch (orientation) {
case DMDO_90:
return NV_ROTATE_90;
case DMDO_180:
return NV_ROTATE_180;
case DMDO_270:
return NV_ROTATE_270;
default:
return NV_ROTATE_0;
}
}
static std::vector<SyntheticDisplay> enumerate_displays(
uint32_t main_refresh_hz,
uint32_t sub_refresh_hz) {
std::vector<SyntheticDisplay> result;
// reuse the active monitor list, then read live modes after -mainmonitor changes
for (const auto &monitor : sysutils::enumerate_monitors()) {
DEVMODEA mode {};
mode.dmSize = sizeof(mode);
if (!EnumDisplaySettingsExA(
monitor.display_name.c_str(),
ENUM_CURRENT_SETTINGS,
&mode,
0)) {
continue;
}
const bool primary = mode.dmPosition.x == 0 && mode.dmPosition.y == 0;
result.push_back({
.display_id = 0,
.width = mode.dmPelsWidth,
.height = mode.dmPelsHeight,
.color_depth = mode.dmBitsPerPel > 0 ? mode.dmBitsPerPel : 32,
.x = mode.dmPosition.x,
.y = mode.dmPosition.y,
.refresh_rate_1k = 0,
.rotation = get_rotation(mode.dmDisplayOrientation),
.primary = primary,
});
}
std::stable_sort(result.begin(), result.end(), [](const auto &left, const auto &right) {
return left.primary && !right.primary;
});
if (result.size() > 2) {
result.resize(2);
}
if (result.empty()) {
result.push_back({
.display_id = 0,
.width = 1920,
.height = 1080,
.color_depth = 32,
.x = 0,
.y = 0,
.refresh_rate_1k = 0,
.rotation = NV_ROTATE_0,
.primary = true,
});
}
for (size_t index = 0; index < result.size(); index++) {
auto &display = result[index];
display.primary = index == 0;
display.display_id = 0x80000000u | static_cast<NvU32>(index + 1);
const uint32_t refresh_hz = index == 0 ? main_refresh_hz : sub_refresh_hz;
display.refresh_rate_1k = refresh_hz * 1000;
}
return result;
}
// initializes NVAPI for the calling process.
// marks the synthetic provider initialized without contacting a driver.
static NvAPI_Status __cdecl NvAPI_Initialize_impl() {
log_misc("nvapi_impl", "NvAPI_Initialize");
nvapi_initialized = true;
return NVAPI_OK;
}
// initializes NVAPI with additional client flags.
// accepts the flags and marks the synthetic provider initialized.
static NvAPI_Status __cdecl NvAPI_InitializeEx_impl(NvU32 flags) {
log_misc("nvapi_impl", "NvAPI_InitializeEx(flags={:#x})", flags);
nvapi_initialized = true;
return NVAPI_OK;
}
// releases NVAPI state held for the calling process.
// clears the synthetic initialization state while leaving the provider installed.
static NvAPI_Status __cdecl NvAPI_Unload_impl() {
log_misc("nvapi_impl", "NvAPI_Unload");
nvapi_initialized = false;
return NVAPI_OK;
}
// enumerates physical GPU handles managed by the NVIDIA driver.
// returns one stable synthetic GPU containing all exposed displays.
static NvAPI_Status __cdecl NvAPI_EnumPhysicalGPUs_impl(
NvPhysicalGpuHandle gpu_handles[NVAPI_MAX_PHYSICAL_GPUS],
NvU32 *gpu_count) {
log_misc(
"nvapi_impl",
"NvAPI_EnumPhysicalGPUs(handles={}, count={})",
fmt::ptr(gpu_handles),
fmt::ptr(gpu_count));
if (!nvapi_initialized) {
return NVAPI_API_NOT_INITIALIZED;
}
if (gpu_handles == nullptr || gpu_count == nullptr) {
return NVAPI_INVALID_ARGUMENT;
}
gpu_handles[0] = get_gpu_handle();
*gpu_count = 1;
log_misc(
"nvapi_impl",
"NvAPI_EnumPhysicalGPUs - gpu={}, count={}",
fmt::ptr(gpu_handles[0]),
*gpu_count);
return NVAPI_OK;
}
// returns connected display descriptors for a physical GPU.
// exposes the monitor snapshot as DP primary and HDMI secondary displays.
static NvAPI_Status __cdecl NvAPI_GPU_GetConnectedDisplayIds_impl(
NvPhysicalGpuHandle gpu_handle,
NV_GPU_DISPLAYIDS *display_ids,
NvU32 *display_id_count,
NvU32 flags) {
const NvU32 input_count = display_id_count != nullptr ? *display_id_count : 0;
log_misc(
"nvapi_impl",
"NvAPI_GPU_GetConnectedDisplayIds(gpu={}, ids={}, count={}, flags={:#x})",
fmt::ptr(gpu_handle),
fmt::ptr(display_ids),
input_count,
flags);
if (!nvapi_initialized) {
return NVAPI_API_NOT_INITIALIZED;
}
if (gpu_handle != get_gpu_handle()) {
return NVAPI_EXPECTED_PHYSICAL_GPU_HANDLE;
}
if (display_id_count == nullptr) {
return NVAPI_INVALID_ARGUMENT;
}
const NvU32 required_count = static_cast<NvU32>(displays.size());
if (display_ids == nullptr) {
*display_id_count = required_count;
log_misc(
"nvapi_impl",
"NvAPI_GPU_GetConnectedDisplayIds - required_count={}",
required_count);
return NVAPI_OK;
}
const NvU32 capacity = *display_id_count;
*display_id_count = required_count;
if (capacity < required_count) {
return NVAPI_INSUFFICIENT_BUFFER;
}
for (NvU32 index = 0; index < required_count; index++) {
const auto &source = displays[index];
auto &destination = display_ids[index];
destination = {};
destination.version = NV_GPU_DISPLAYIDS_VER;
destination.connectorType = source.primary ?
NV_MONITOR_CONN_TYPE_DP : NV_MONITOR_CONN_TYPE_HDMI;
destination.displayId = source.display_id;
destination.isActive = 1;
destination.isOSVisible = 1;
destination.isConnected = 1;
destination.isPhysicallyConnected = 1;
}
log_misc(
"nvapi_impl",
"NvAPI_GPU_GetConnectedDisplayIds - returned_count={}",
required_count);
return NVAPI_OK;
}
// returns the NVAPI display ID associated with the Windows GDI primary.
// returns the first synthetic display, ordered from the live desktop origin.
static NvAPI_Status __cdecl NvAPI_DISP_GetGDIPrimaryDisplayId_impl(NvU32 *display_id) {
log_misc(
"nvapi_impl",
"NvAPI_DISP_GetGDIPrimaryDisplayId(display_id={})",
fmt::ptr(display_id));
if (!nvapi_initialized) {
return NVAPI_API_NOT_INITIALIZED;
}
if (display_id == nullptr || displays.empty()) {
return NVAPI_INVALID_ARGUMENT;
}
*display_id = displays.front().display_id;
log_misc(
"nvapi_impl",
"NvAPI_DISP_GetGDIPrimaryDisplayId - display_id={:#x}",
*display_id);
return NVAPI_OK;
}
static void fill_source_mode(
NV_DISPLAYCONFIG_SOURCE_MODE_INFO *destination,
const SyntheticDisplay &source) {
if (destination == nullptr) {
return;
}
*destination = {};
destination->resolution.width = source.width;
destination->resolution.height = source.height;
destination->resolution.colorDepth = source.color_depth;
destination->colorFormat = NV_FORMAT_A8R8G8B8;
destination->position.x = source.x;
destination->position.y = source.y;
destination->spanningOrientation = NV_DISPLAYCONFIG_SPAN_NONE;
destination->bGDIPrimary = source.primary ? 1 : 0;
}
static NvAPI_Status fill_target(
NV_DISPLAYCONFIG_PATH_TARGET_INFO *destination,
const SyntheticDisplay &source,
NvU32 target_id) {
if (destination == nullptr) {
return NVAPI_OK;
}
auto *details = destination->details;
destination->displayId = source.display_id;
destination->targetId = target_id;
if (details == nullptr) {
return NVAPI_OK;
}
if (details->version != NV_DISPLAYCONFIG_PATH_ADVANCED_TARGET_INFO_VER) {
return NVAPI_INCOMPATIBLE_STRUCT_VERSION;
}
*details = {};
details->version = NV_DISPLAYCONFIG_PATH_ADVANCED_TARGET_INFO_VER;
details->rotation = source.rotation;
details->scaling = NV_SCALING_DEFAULT;
details->refreshRate1K = source.refresh_rate_1k;
details->timingOverride = NV_TIMING_OVERRIDE_CURRENT;
return NVAPI_OK;
}
// retrieves the current global display topology through NVAPI's three-pass contract.
// fills caller-owned buffers from the synthetic monitor snapshot and configured rates.
static NvAPI_Status __cdecl NvAPI_DISP_GetDisplayConfig_impl(
NvU32 *path_info_count,
NV_DISPLAYCONFIG_PATH_INFO *path_info) {
const NvU32 input_count = path_info_count != nullptr ? *path_info_count : 0;
log_misc(
"nvapi_impl",
"NvAPI_DISP_GetDisplayConfig(count={}, paths={})",
input_count,
fmt::ptr(path_info));
if (!nvapi_initialized) {
return NVAPI_API_NOT_INITIALIZED;
}
if (path_info_count == nullptr) {
return NVAPI_INVALID_ARGUMENT;
}
const NvU32 required_count = static_cast<NvU32>(displays.size());
if (path_info == nullptr) {
*path_info_count = required_count;
log_misc(
"nvapi_impl",
"NvAPI_DISP_GetDisplayConfig - required_count={}",
required_count);
return NVAPI_OK;
}
const NvU32 capacity = *path_info_count;
*path_info_count = required_count;
if (capacity < required_count) {
return NVAPI_INSUFFICIENT_BUFFER;
}
for (NvU32 index = 0; index < required_count; index++) {
auto &path = path_info[index];
if (path.version != NV_DISPLAYCONFIG_PATH_INFO_VER2) {
return NVAPI_INCOMPATIBLE_STRUCT_VERSION;
}
if (path.targetInfo != nullptr && path.targetInfoCount < 1) {
return NVAPI_INSUFFICIENT_BUFFER;
}
const auto &display = displays[index];
path.sourceId = index;
path.targetInfoCount = 1;
path.IsNonNVIDIAAdapter = 0;
path.pOSAdapterID = nullptr;
fill_source_mode(path.sourceModeInfo, display);
const NvAPI_Status status = fill_target(path.targetInfo, display, index);
if (status != NVAPI_OK) {
return status;
}
}
log_misc(
"nvapi_impl",
"NvAPI_DISP_GetDisplayConfig - returned_count={}",
required_count);
return NVAPI_OK;
}
// applies a supplied global display topology through the NVIDIA driver.
// accepts the cabinet topology without making any changes to Windows.
static NvAPI_Status __cdecl NvAPI_DISP_SetDisplayConfig_impl(
NvU32 path_info_count,
NV_DISPLAYCONFIG_PATH_INFO *path_info,
NvU32 flags) {
log_misc(
"nvapi_impl",
"NvAPI_DISP_SetDisplayConfig(count={}, paths={}, flags={:#x})",
path_info_count,
fmt::ptr(path_info),
flags);
if (!nvapi_initialized) {
return NVAPI_API_NOT_INITIALIZED;
}
log_misc("nvapi_impl", "NvAPI_DISP_SetDisplayConfig - return synthetic success");
return NVAPI_OK;
}
template<typename T>
static uintptr_t *query_result(T function) {
return reinterpret_cast<uintptr_t *>(function);
}
// resolves an NVAPI function ID to its implementation address.
// exposes only the synthetic entry points used by KFC and rejects all others.
static uintptr_t *__cdecl NvAPI_QueryInterface_impl(unsigned int function_id) {
uintptr_t *result = nullptr;
switch (function_id) {
case NVAPI_INITIALIZE_ID:
result = query_result(NvAPI_Initialize_impl);
break;
case NVAPI_INITIALIZE_EX_ID:
result = query_result(NvAPI_InitializeEx_impl);
break;
case NVAPI_UNLOAD_ID:
result = query_result(NvAPI_Unload_impl);
break;
case NVAPI_ENUM_PHYSICAL_GPUS_ID:
result = query_result(NvAPI_EnumPhysicalGPUs_impl);
break;
case NVAPI_GPU_GET_CONNECTED_DISPLAY_IDS_ID:
result = query_result(NvAPI_GPU_GetConnectedDisplayIds_impl);
break;
case NVAPI_DISP_GET_GDI_PRIMARY_DISPLAY_ID:
result = query_result(NvAPI_DISP_GetGDIPrimaryDisplayId_impl);
break;
case NVAPI_DISP_GET_DISPLAY_CONFIG_ID:
result = query_result(NvAPI_DISP_GetDisplayConfig_impl);
break;
case NVAPI_DISP_SET_DISPLAY_CONFIG_ID:
result = query_result(NvAPI_DISP_SetDisplayConfig_impl);
break;
default:
break;
}
log_misc(
"nvapi_impl",
"NvAPI_QueryInterface(0x{:x}) - {}",
function_id,
result != nullptr ? "implemented" : "unsupported");
return result;
}
}
bool initialize(HINSTANCE dll, uint32_t main_refresh_hz, uint32_t sub_refresh_hz) {
if (provider_initialized) {
return true;
}
if (dll == nullptr) {
log_warning("nvapi_impl", "invalid synthetic module handle");
return false;
}
displays = enumerate_displays(main_refresh_hz, sub_refresh_hz);
libraryhook_hook_library(NVAPI_DLL_NAME_A, dll);
libraryhook_hook_proc("nvapi_QueryInterface", NvAPI_QueryInterface_impl);
libraryhook_enable();
provider_initialized = true;
log_info(
"nvapi_impl",
"synthetic {} enabled with {} display(s), main={} Hz, sub={} Hz",
NVAPI_DLL_NAME_A,
displays.size(),
main_refresh_hz,
sub_refresh_hz);
return true;
}
}
#endif
#include "nvapi_impl.h"
#ifdef SPICE64
#include <algorithm>
#include <vector>
#include <windows.h>
#include "external/nvapi/nvapi.h"
#include "hooks/libraryhook.h"
#include "util/logging.h"
#include "util/sysutils.h"
namespace nvapi_impl {
namespace {
constexpr unsigned int NVAPI_INITIALIZE_ID = 0x0150E828;
constexpr unsigned int NVAPI_INITIALIZE_EX_ID = 0xAD298D3F;
constexpr unsigned int NVAPI_UNLOAD_ID = 0xD22BDD7E;
constexpr unsigned int NVAPI_ENUM_PHYSICAL_GPUS_ID = 0xE5AC921F;
constexpr unsigned int NVAPI_GPU_GET_CONNECTED_DISPLAY_IDS_ID = 0x0078DBA2;
constexpr unsigned int NVAPI_DISP_GET_GDI_PRIMARY_DISPLAY_ID = 0x1E9D8A31;
constexpr unsigned int NVAPI_DISP_GET_DISPLAY_CONFIG_ID = 0x11ABCCF8;
constexpr unsigned int NVAPI_DISP_SET_DISPLAY_CONFIG_ID = 0x5D8CF8DE;
constexpr char NVAPI_DLL_NAME_A[] = "nvapi64.dll";
struct SyntheticDisplay {
NvU32 display_id;
NvU32 width;
NvU32 height;
NvU32 color_depth;
NvS32 x;
NvS32 y;
NvU32 refresh_rate_1k;
NV_ROTATE rotation;
bool primary;
};
static bool provider_initialized = false;
static bool nvapi_initialized = false;
static int gpu_handle_storage = 0;
// snapshot of the Win32 display state exposed through synthetic NVAPI
static std::vector<SyntheticDisplay> displays;
static NvPhysicalGpuHandle get_gpu_handle() {
return reinterpret_cast<NvPhysicalGpuHandle>(&gpu_handle_storage);
}
static NV_ROTATE get_rotation(DWORD orientation) {
switch (orientation) {
case DMDO_90:
return NV_ROTATE_90;
case DMDO_180:
return NV_ROTATE_180;
case DMDO_270:
return NV_ROTATE_270;
default:
return NV_ROTATE_0;
}
}
static std::vector<SyntheticDisplay> enumerate_displays(
uint32_t main_refresh_hz,
uint32_t sub_refresh_hz) {
std::vector<SyntheticDisplay> result;
// reuse the active monitor list, then read live modes after -mainmonitor changes
for (const auto &monitor : sysutils::enumerate_monitors()) {
DEVMODEA mode {};
mode.dmSize = sizeof(mode);
if (!EnumDisplaySettingsExA(
monitor.display_name.c_str(),
ENUM_CURRENT_SETTINGS,
&mode,
0)) {
continue;
}
const bool primary = mode.dmPosition.x == 0 && mode.dmPosition.y == 0;
result.push_back({
.display_id = 0,
.width = mode.dmPelsWidth,
.height = mode.dmPelsHeight,
.color_depth = mode.dmBitsPerPel > 0 ? mode.dmBitsPerPel : 32,
.x = mode.dmPosition.x,
.y = mode.dmPosition.y,
.refresh_rate_1k = 0,
.rotation = get_rotation(mode.dmDisplayOrientation),
.primary = primary,
});
}
std::stable_sort(result.begin(), result.end(), [](const auto &left, const auto &right) {
return left.primary && !right.primary;
});
if (result.size() > 2) {
result.resize(2);
}
if (result.empty()) {
result.push_back({
.display_id = 0,
.width = 1920,
.height = 1080,
.color_depth = 32,
.x = 0,
.y = 0,
.refresh_rate_1k = 0,
.rotation = NV_ROTATE_0,
.primary = true,
});
}
for (size_t index = 0; index < result.size(); index++) {
auto &display = result[index];
display.primary = index == 0;
display.display_id = 0x80000000u | static_cast<NvU32>(index + 1);
const uint32_t refresh_hz = index == 0 ? main_refresh_hz : sub_refresh_hz;
display.refresh_rate_1k = refresh_hz * 1000;
}
return result;
}
// initializes NVAPI for the calling process.
// marks the synthetic provider initialized without contacting a driver.
static NvAPI_Status __cdecl NvAPI_Initialize_impl() {
log_misc("nvapi_impl", "NvAPI_Initialize");
nvapi_initialized = true;
return NVAPI_OK;
}
// initializes NVAPI with additional client flags.
// accepts the flags and marks the synthetic provider initialized.
static NvAPI_Status __cdecl NvAPI_InitializeEx_impl(NvU32 flags) {
log_misc("nvapi_impl", "NvAPI_InitializeEx(flags={:#x})", flags);
nvapi_initialized = true;
return NVAPI_OK;
}
// releases NVAPI state held for the calling process.
// clears the synthetic initialization state while leaving the provider installed.
static NvAPI_Status __cdecl NvAPI_Unload_impl() {
log_misc("nvapi_impl", "NvAPI_Unload");
nvapi_initialized = false;
return NVAPI_OK;
}
// enumerates physical GPU handles managed by the NVIDIA driver.
// returns one stable synthetic GPU containing all exposed displays.
static NvAPI_Status __cdecl NvAPI_EnumPhysicalGPUs_impl(
NvPhysicalGpuHandle gpu_handles[NVAPI_MAX_PHYSICAL_GPUS],
NvU32 *gpu_count) {
log_misc(
"nvapi_impl",
"NvAPI_EnumPhysicalGPUs(handles={}, count={})",
fmt::ptr(gpu_handles),
fmt::ptr(gpu_count));
if (!nvapi_initialized) {
return NVAPI_API_NOT_INITIALIZED;
}
if (gpu_handles == nullptr || gpu_count == nullptr) {
return NVAPI_INVALID_ARGUMENT;
}
gpu_handles[0] = get_gpu_handle();
*gpu_count = 1;
log_misc(
"nvapi_impl",
"NvAPI_EnumPhysicalGPUs - gpu={}, count={}",
fmt::ptr(gpu_handles[0]),
*gpu_count);
return NVAPI_OK;
}
// returns connected display descriptors for a physical GPU.
// exposes the monitor snapshot as DP primary and HDMI secondary displays.
static NvAPI_Status __cdecl NvAPI_GPU_GetConnectedDisplayIds_impl(
NvPhysicalGpuHandle gpu_handle,
NV_GPU_DISPLAYIDS *display_ids,
NvU32 *display_id_count,
NvU32 flags) {
const NvU32 input_count = display_id_count != nullptr ? *display_id_count : 0;
log_misc(
"nvapi_impl",
"NvAPI_GPU_GetConnectedDisplayIds(gpu={}, ids={}, count={}, flags={:#x})",
fmt::ptr(gpu_handle),
fmt::ptr(display_ids),
input_count,
flags);
if (!nvapi_initialized) {
return NVAPI_API_NOT_INITIALIZED;
}
if (gpu_handle != get_gpu_handle()) {
return NVAPI_EXPECTED_PHYSICAL_GPU_HANDLE;
}
if (display_id_count == nullptr) {
return NVAPI_INVALID_ARGUMENT;
}
const NvU32 required_count = static_cast<NvU32>(displays.size());
if (display_ids == nullptr) {
*display_id_count = required_count;
log_misc(
"nvapi_impl",
"NvAPI_GPU_GetConnectedDisplayIds - required_count={}",
required_count);
return NVAPI_OK;
}
const NvU32 capacity = *display_id_count;
*display_id_count = required_count;
if (capacity < required_count) {
return NVAPI_INSUFFICIENT_BUFFER;
}
for (NvU32 index = 0; index < required_count; index++) {
const auto &source = displays[index];
auto &destination = display_ids[index];
destination = {};
destination.version = NV_GPU_DISPLAYIDS_VER;
destination.connectorType = source.primary ?
NV_MONITOR_CONN_TYPE_DP : NV_MONITOR_CONN_TYPE_HDMI;
destination.displayId = source.display_id;
destination.isActive = 1;
destination.isOSVisible = 1;
destination.isConnected = 1;
destination.isPhysicallyConnected = 1;
}
log_misc(
"nvapi_impl",
"NvAPI_GPU_GetConnectedDisplayIds - returned_count={}",
required_count);
return NVAPI_OK;
}
// returns the NVAPI display ID associated with the Windows GDI primary.
// returns the first synthetic display, ordered from the live desktop origin.
static NvAPI_Status __cdecl NvAPI_DISP_GetGDIPrimaryDisplayId_impl(NvU32 *display_id) {
log_misc(
"nvapi_impl",
"NvAPI_DISP_GetGDIPrimaryDisplayId(display_id={})",
fmt::ptr(display_id));
if (!nvapi_initialized) {
return NVAPI_API_NOT_INITIALIZED;
}
if (display_id == nullptr || displays.empty()) {
return NVAPI_INVALID_ARGUMENT;
}
*display_id = displays.front().display_id;
log_misc(
"nvapi_impl",
"NvAPI_DISP_GetGDIPrimaryDisplayId - display_id={:#x}",
*display_id);
return NVAPI_OK;
}
static void fill_source_mode(
NV_DISPLAYCONFIG_SOURCE_MODE_INFO *destination,
const SyntheticDisplay &source) {
if (destination == nullptr) {
return;
}
*destination = {};
destination->resolution.width = source.width;
destination->resolution.height = source.height;
destination->resolution.colorDepth = source.color_depth;
destination->colorFormat = NV_FORMAT_A8R8G8B8;
destination->position.x = source.x;
destination->position.y = source.y;
destination->spanningOrientation = NV_DISPLAYCONFIG_SPAN_NONE;
destination->bGDIPrimary = source.primary ? 1 : 0;
}
static NvAPI_Status fill_target(
NV_DISPLAYCONFIG_PATH_TARGET_INFO *destination,
const SyntheticDisplay &source,
NvU32 target_id) {
if (destination == nullptr) {
return NVAPI_OK;
}
auto *details = destination->details;
destination->displayId = source.display_id;
destination->targetId = target_id;
if (details == nullptr) {
return NVAPI_OK;
}
if (details->version != NV_DISPLAYCONFIG_PATH_ADVANCED_TARGET_INFO_VER) {
return NVAPI_INCOMPATIBLE_STRUCT_VERSION;
}
*details = {};
details->version = NV_DISPLAYCONFIG_PATH_ADVANCED_TARGET_INFO_VER;
details->rotation = source.rotation;
details->scaling = NV_SCALING_DEFAULT;
details->refreshRate1K = source.refresh_rate_1k;
details->timingOverride = NV_TIMING_OVERRIDE_CURRENT;
return NVAPI_OK;
}
// retrieves the current global display topology through NVAPI's three-pass contract.
// fills caller-owned buffers from the synthetic monitor snapshot and configured rates.
static NvAPI_Status __cdecl NvAPI_DISP_GetDisplayConfig_impl(
NvU32 *path_info_count,
NV_DISPLAYCONFIG_PATH_INFO *path_info) {
const NvU32 input_count = path_info_count != nullptr ? *path_info_count : 0;
log_misc(
"nvapi_impl",
"NvAPI_DISP_GetDisplayConfig(count={}, paths={})",
input_count,
fmt::ptr(path_info));
if (!nvapi_initialized) {
return NVAPI_API_NOT_INITIALIZED;
}
if (path_info_count == nullptr) {
return NVAPI_INVALID_ARGUMENT;
}
const NvU32 required_count = static_cast<NvU32>(displays.size());
if (path_info == nullptr) {
*path_info_count = required_count;
log_misc(
"nvapi_impl",
"NvAPI_DISP_GetDisplayConfig - required_count={}",
required_count);
return NVAPI_OK;
}
const NvU32 capacity = *path_info_count;
*path_info_count = required_count;
if (capacity < required_count) {
return NVAPI_INSUFFICIENT_BUFFER;
}
for (NvU32 index = 0; index < required_count; index++) {
auto &path = path_info[index];
if (path.version != NV_DISPLAYCONFIG_PATH_INFO_VER2) {
return NVAPI_INCOMPATIBLE_STRUCT_VERSION;
}
if (path.targetInfo != nullptr && path.targetInfoCount < 1) {
return NVAPI_INSUFFICIENT_BUFFER;
}
const auto &display = displays[index];
path.sourceId = index;
path.targetInfoCount = 1;
path.IsNonNVIDIAAdapter = 0;
path.pOSAdapterID = nullptr;
fill_source_mode(path.sourceModeInfo, display);
const NvAPI_Status status = fill_target(path.targetInfo, display, index);
if (status != NVAPI_OK) {
return status;
}
}
log_misc(
"nvapi_impl",
"NvAPI_DISP_GetDisplayConfig - returned_count={}",
required_count);
return NVAPI_OK;
}
// applies a supplied global display topology through the NVIDIA driver.
// accepts the cabinet topology without making any changes to Windows.
static NvAPI_Status __cdecl NvAPI_DISP_SetDisplayConfig_impl(
NvU32 path_info_count,
NV_DISPLAYCONFIG_PATH_INFO *path_info,
NvU32 flags) {
log_misc(
"nvapi_impl",
"NvAPI_DISP_SetDisplayConfig(count={}, paths={}, flags={:#x})",
path_info_count,
fmt::ptr(path_info),
flags);
if (!nvapi_initialized) {
return NVAPI_API_NOT_INITIALIZED;
}
log_misc("nvapi_impl", "NvAPI_DISP_SetDisplayConfig - return synthetic success");
return NVAPI_OK;
}
template<typename T>
static uintptr_t *query_result(T function) {
return reinterpret_cast<uintptr_t *>(function);
}
// resolves an NVAPI function ID to its implementation address.
// exposes only the synthetic entry points used by KFC and rejects all others.
static uintptr_t *__cdecl NvAPI_QueryInterface_impl(unsigned int function_id) {
uintptr_t *result = nullptr;
switch (function_id) {
case NVAPI_INITIALIZE_ID:
result = query_result(NvAPI_Initialize_impl);
break;
case NVAPI_INITIALIZE_EX_ID:
result = query_result(NvAPI_InitializeEx_impl);
break;
case NVAPI_UNLOAD_ID:
result = query_result(NvAPI_Unload_impl);
break;
case NVAPI_ENUM_PHYSICAL_GPUS_ID:
result = query_result(NvAPI_EnumPhysicalGPUs_impl);
break;
case NVAPI_GPU_GET_CONNECTED_DISPLAY_IDS_ID:
result = query_result(NvAPI_GPU_GetConnectedDisplayIds_impl);
break;
case NVAPI_DISP_GET_GDI_PRIMARY_DISPLAY_ID:
result = query_result(NvAPI_DISP_GetGDIPrimaryDisplayId_impl);
break;
case NVAPI_DISP_GET_DISPLAY_CONFIG_ID:
result = query_result(NvAPI_DISP_GetDisplayConfig_impl);
break;
case NVAPI_DISP_SET_DISPLAY_CONFIG_ID:
result = query_result(NvAPI_DISP_SetDisplayConfig_impl);
break;
default:
break;
}
log_misc(
"nvapi_impl",
"NvAPI_QueryInterface(0x{:x}) - {}",
function_id,
result != nullptr ? "implemented" : "unsupported");
return result;
}
}
bool initialize(HINSTANCE dll, uint32_t main_refresh_hz, uint32_t sub_refresh_hz) {
if (provider_initialized) {
return true;
}
if (dll == nullptr) {
log_warning("nvapi_impl", "invalid synthetic module handle");
return false;
}
displays = enumerate_displays(main_refresh_hz, sub_refresh_hz);
libraryhook_hook_library(NVAPI_DLL_NAME_A, dll);
libraryhook_hook_proc("nvapi_QueryInterface", NvAPI_QueryInterface_impl);
libraryhook_enable();
provider_initialized = true;
log_info(
"nvapi_impl",
"synthetic {} enabled with {} display(s), main={} Hz, sub={} Hz",
NVAPI_DLL_NAME_A,
displays.size(),
main_refresh_hz,
sub_refresh_hz);
return true;
}
}
#endif
+14 -14
View File
@@ -1,14 +1,14 @@
#pragma once
#ifdef SPICE64
#include <cstdint>
#include <windows.h>
namespace nvapi_impl {
bool initialize(HINSTANCE dll, uint32_t main_refresh_hz, uint32_t sub_refresh_hz);
}
#endif
#pragma once
#ifdef SPICE64
#include <cstdint>
#include <windows.h>
namespace nvapi_impl {
bool initialize(HINSTANCE dll, uint32_t main_refresh_hz, uint32_t sub_refresh_hz);
}
#endif
+22 -10
View File
@@ -26,13 +26,13 @@ constexpr UINT CODEPAGE_SHIFT_JIS = 932;
static decltype(GetACP) *GetACP_orig = nullptr;
static decltype(GetOEMCP) *GetOEMCP_orig = nullptr;
static decltype(MultiByteToWideChar) *MultiByteToWideChar_orig = nullptr;
static decltype(WideCharToMultiByte) *WideCharToMultiByte_orig = nullptr;
static decltype(GetLocaleInfoEx) *GetLocaleInfoEx_orig = nullptr;
#ifdef SPICE64
static decltype(GetSystemDefaultLCID) *GetSystemDefaultLCID_orig = nullptr;
static decltype(IsDBCSLeadByte) *IsDBCSLeadByte_orig = nullptr;
static decltype(IsDBCSLeadByteEx) *IsDBCSLeadByteEx_orig = nullptr;
static decltype(WideCharToMultiByte) *WideCharToMultiByte_orig = nullptr;
static decltype(GetLocaleInfoA) *GetLocaleInfoA_orig = nullptr;
static decltype(GetThreadLocale) *GetThreadLocale_orig = nullptr;
#endif
@@ -209,6 +209,8 @@ static BOOL WINAPI IsDBCSLeadByteEx_hook(
return IsDBCSLeadByteEx_orig(CodePage, TestChar);
}
#endif
static
int
WINAPI
@@ -244,6 +246,8 @@ WideCharToMultiByte_hook(
lpUsedDefaultChar);
}
#ifdef SPICE64
int
WINAPI
GetLocaleInfoA_hook(
@@ -343,15 +347,6 @@ void hooks::lang::early_init() {
&IsDBCSLeadByte_orig);
}
if (games::gitadora::is_arena_model() || avs::game::is_model("T44")) {
log_info("hooks::lang", "hooking WideCharToMultiByte");
detour::trampoline_try(
"kernel32.dll",
"WideCharToMultiByte",
WideCharToMultiByte_hook,
&WideCharToMultiByte_orig);
}
if (games::popn::is_pikapika_model() && native_code_page == CP_UTF8) {
detour::trampoline_try(
"kernel32.dll",
@@ -362,6 +357,23 @@ void hooks::lang::early_init() {
#endif
#ifdef SPICE64
const auto hook_wide_char_to_multi_byte =
games::gitadora::is_arena_model() || avs::game::is_model("T44");
#else
// XG2 converts UTF-8 property strings through CP_ACP before rendering.
const auto hook_wide_char_to_multi_byte = avs::game::is_model({ "K32", "K33" });
#endif
if (hook_wide_char_to_multi_byte) {
log_info("hooks::lang", "hooking WideCharToMultiByte");
detour::trampoline_try(
"kernel32.dll",
"WideCharToMultiByte",
WideCharToMultiByte_hook,
&WideCharToMultiByte_orig);
}
}
void hooks::lang::init() {
+60 -12
View File
@@ -14,6 +14,7 @@
#include "acio/icca/icca.h"
#include "acio/mdxf/mdxf.h"
#include "api/controller.h"
#include "api/stream_server.h"
#include "avs/automap.h"
#include "avs/core.h"
#include "avs/ea3.h"
@@ -90,13 +91,13 @@
#include "launcher/launcher.h"
#include "launcher/logger.h"
#include "launcher/signal.h"
#include "launcher/superexit.h"
#include "launcher/richpresence.h"
#include "launcher/shutdown.h"
#include "launcher/options.h"
#include "misc/bt5api.h"
#include "misc/device.h"
#include "misc/eamuse.h"
#include "misc/hotkeys.h"
#include "misc/extdev.h"
#include "misc/ami2000.h"
#include "misc/sciunit.h"
@@ -153,6 +154,7 @@ std::string CARD_OVERRIDES[2];
// sub-systems
std::unique_ptr<api::Controller> API_CONTROLLER;
std::unique_ptr<api::StreamServer> API_STREAM_SERVER;
std::unique_ptr<rawinput::RawInputManager> RI_MGR;
// trigger NVIDIA Optimus & AMD Enduro High Performance Graphics
@@ -201,6 +203,7 @@ int main_implementation(int argc, char *argv[]) {
bool api_pretty = false;
bool api_debug = false;
unsigned short api_port = 1337;
bool api_stream_enable = false;
std::string api_pass = "";
std::vector<std::string> api_serial_port;
std::vector<DWORD> api_serial_baud;
@@ -1035,6 +1038,9 @@ int main_implementation(int argc, char *argv[]) {
if (options[launcher::Options::APIScreenMirrorDivide].is_active()) {
api::modules::CAPTURE_DIVIDE = options[launcher::Options::APIScreenMirrorDivide].value_uint32();
}
if (options[launcher::Options::APIStreamEnable].value_bool() && !cfg::CONFIGURATOR_STANDALONE) {
api_stream_enable = true;
}
if (options[launcher::Options::DisableDebugHooks].value_bool()) {
debughook::DEBUGHOOK_LOGGING = false;
@@ -1106,6 +1112,12 @@ int main_implementation(int argc, char *argv[]) {
if (options[launcher::Options::ScreenshotFolder].is_active()) {
GRAPHICS_SCREENSHOT_DIR = options[launcher::Options::ScreenshotFolder].value_text();
}
if (options[launcher::Options::ScreenshotIncludeOverlay].value_bool()) {
GRAPHICS_SCREENSHOT_INCLUDE_OVERLAY = true;
}
if (options[launcher::Options::ScreenshotSubscreens].value_bool()) {
GRAPHICS_SCREENSHOT_SUBSCREENS = true;
}
if (options[launcher::Options::DisableColoredOutput].value_bool()) {
logger::COLOR = false;
}
@@ -1680,6 +1692,26 @@ int main_implementation(int argc, char *argv[]) {
});
}
if (options[launcher::Options::PathToModules].is_active() && !cfg::CONFIGURATOR_STANDALONE) {
log_warning(
"launcher",
"WARNING - user specified -modules option\n\n\n"
"!!! !!!\n"
"!!! Using -modules changes which game DLLs get loaded! !!!\n"
"!!! Unless you know exactly what you are doing, clear -modules !!!\n"
"!!! and try again; usually this is accidentally set by users !!!\n"
"!!! without understanding the implications. !!!\n"
"!!! !!!\n"
);
deferredlogs::defer_error_messages({
"-modules option specified by user",
" game DLLs and patches are loaded from that folder instead of the spice folder,",
" and it is also prepended to the DLL search path, so dependencies may resolve to",
" unexpected copies; instead, clear -modules option and place spice binaries in",
" the intended game directory",
});
}
if (launcher::signal::DISABLE && !cfg::CONFIGURATOR_STANDALONE) {
log_warning(
"launcher",
@@ -1780,9 +1812,8 @@ int main_implementation(int argc, char *argv[]) {
nvapi::initialize();
// add application profile to nvcp
nvapi::set_profile_settings();
// enable super exit
superexit::enable();
// keep ALT+F4 available during lengthy non-standalone boot
hotkeys::start();
// enable subscreen touch emulation
if (options[launcher::Options::spice2x_IIDXEmulateSubscreenKeypadTouch].is_active()) {
games::iidx::ENABLE_POKE = true;
@@ -2456,6 +2487,7 @@ int main_implementation(int argc, char *argv[]) {
// initialize raw input
RI_MGR = std::make_unique<rawinput::RawInputManager>();
hotkeys::enable_raw_input();
for (const auto &device : sextet_devices) {
RI_MGR->sextet_register(device);
}
@@ -2478,6 +2510,9 @@ int main_implementation(int argc, char *argv[]) {
log_misc("rawinput", "Analog mappings:");
dump_analog_bindings();
// mappings are ready; begin screenshot and coin polling during late startup
hotkeys::enable_input();
// for certain games, show cursor if no touch is available (must be called after RI_MGR is available)
if (show_cursor_if_no_touch && !is_touch_available("launcher::main_implementation")) {
GRAPHICS_SHOW_CURSOR = true;
@@ -2713,9 +2748,20 @@ int main_implementation(int argc, char *argv[]) {
for (size_t i = 0; i < std::min(api_serial_port.size(), api_serial_baud.size()); i++) {
API_CONTROLLER->listen_serial(api_serial_port[i], api_serial_baud[i]);
}
// start coin input thread
eamuse_coin_start_thread();
// the websocket already sits on the API port plus one, so the stream takes plus two
if (api_stream_enable) {
if (!api_enable) {
log_fatal("launcher", "video stream requires API port to be set (-api)");
} else if (api_port + 2 > 65535) {
log_fatal(
"launcher",
"ignoring the video stream, API port {} leaves no room for port plus two",
api_port);
} else {
API_STREAM_SERVER = std::make_unique<api::StreamServer>(
static_cast<unsigned short>(api_port + 2));
}
}
// pin macro
if (!cfg::CONFIGURATOR_STANDALONE && PIN_MACRO_ENABLED) {
@@ -2765,6 +2811,9 @@ int main_implementation(int argc, char *argv[]) {
// cleanup procedure
launcher::on_shutdown = [&]() {
// stop screenshot and coin polling; mapped SuperExit and ALT+F4 remain active
hotkeys::disable_input();
// clear presence
richpresence::shutdown();
@@ -2803,11 +2852,9 @@ int main_implementation(int argc, char *argv[]) {
}
// free api controller
API_STREAM_SERVER.reset();
API_CONTROLLER.reset();
// stop coin input thread
eamuse_coin_stop_thread();
eamuse_pin_macro_stop_thread();
// BT5API
@@ -2818,6 +2865,7 @@ int main_implementation(int argc, char *argv[]) {
sdk::fini_sdk_modules();
// stop raw input
hotkeys::disable_raw_input();
RI_MGR.reset();
// debug hook
@@ -2850,8 +2898,8 @@ int main_implementation(int argc, char *argv[]) {
// dispose crypt
crypt::dispose();
// disable super exit
superexit::disable();
// end early/late ALT+F4 monitoring at the same teardown point as legacy SuperExit
hotkeys::stop();
// disable poke
games::iidx::poke::disable();
+64 -14
View File
@@ -1,6 +1,7 @@
#include "logger.h"
#include <algorithm>
#include <atomic>
#include <condition_variable>
#include <mutex>
#include <thread>
@@ -25,13 +26,16 @@ namespace logger {
bool COLOR = true;
// state
static bool RUNNING = false;
static std::atomic<bool> RUNNING = false;
static WORD DEFAULT_ATTRIBUTES = 0;
static std::mutex EVENT_MUTEX;
static std::condition_variable EVENT_CV;
static std::thread *THREAD = nullptr;
static HANDLE THREAD_FINISHED = nullptr;
static std::atomic<bool> THREAD_ABANDONED = false;
static std::mutex OUTPUT_MUTEX;
static bool OUTPUT_BUFFER_HOT = false;
static std::mutex FLUSH_MUTEX;
static std::atomic<bool> OUTPUT_BUFFER_HOT = false;
static std::vector<std::pair<std::string, Style>> OUTPUT_BUFFER1;
static std::vector<std::pair<std::string, Style>> OUTPUT_BUFFER2;
static std::vector<std::pair<std::string, Style>> *OUTPUT_BUFFER = &OUTPUT_BUFFER1;
@@ -71,7 +75,9 @@ namespace logger {
SetConsoleTextAttribute(hTerminal, info.wAttributes);
}
static void output_buffer_flush() {
// the buffer is swapped under OUTPUT_MUTEX but drained outside of it, so two concurrent
// drains would leave one of them iterating a buffer that push() has started appending to
static void output_buffer_flush_locked() {
// get buffer and swap
auto buffer = output_buffer_swap();
@@ -142,6 +148,23 @@ namespace logger {
}
}
static void output_buffer_flush() {
// a detached logging thread can hold FLUSH_MUTEX forever, so never wait on it
if (THREAD_ABANDONED) {
std::unique_lock<std::mutex> guard(FLUSH_MUTEX, std::try_to_lock);
if (guard.owns_lock()) {
output_buffer_flush_locked();
}
return;
}
std::lock_guard<std::mutex> guard(FLUSH_MUTEX);
output_buffer_flush_locked();
}
void start() {
// don't start if blocking
@@ -151,6 +174,7 @@ namespace logger {
// start logging thread
RUNNING = true;
THREAD_FINISHED = CreateEvent(nullptr, TRUE, FALSE, nullptr);
THREAD = new std::thread([] {
std::unique_lock<std::mutex> lock(EVENT_MUTEX);
@@ -160,7 +184,7 @@ namespace logger {
while (RUNNING) {
// wait for hot buffer
EVENT_CV.wait(lock, [] { return OUTPUT_BUFFER_HOT; });
EVENT_CV.wait(lock, [] { return OUTPUT_BUFFER_HOT.load(); });
OUTPUT_BUFFER_HOT = false;
// flush buffer
@@ -180,22 +204,51 @@ namespace logger {
HANDLE hTerminal = GetStdHandle(STD_OUTPUT_HANDLE);
SetConsoleTextAttribute(hTerminal, DEFAULT_ATTRIBUTES);
}
if (THREAD_FINISHED) {
SetEvent(THREAD_FINISHED);
}
});
}
void stop() {
log_info("logger", "stop");
// NOTE: don't log to the logger here!
RUNNING = false;
// clean up thread if required
RUNNING = false;
if (THREAD) {
// fake notify to exit wait loop
OUTPUT_BUFFER_HOT = true;
EVENT_CV.notify_all();
// join and clean up
THREAD->join();
// never block forever - this also runs on the fatal/crash path, where the logging
// thread may be suspended or wedged and would take the whole process down with it
const bool finished = THREAD_FINISHED != nullptr &&
WaitForSingleObject(THREAD_FINISHED, 1000) == WAIT_OBJECT_0;
if (finished) {
THREAD->join();
CloseHandle(THREAD_FINISHED);
THREAD_FINISHED = nullptr;
} else {
THREAD->detach();
THREAD_ABANDONED = true;
// THREAD_FINISHED is leaked on purpose: the detached thread can still wake up and
// signal it, and closing it here risks signaling an unrelated recycled handle
// write out whatever the logging thread never got to
output_buffer_flush();
if (LOG_FILE && LOG_FILE != INVALID_HANDLE_VALUE) {
FlushFileBuffers(LOG_FILE);
}
}
delete THREAD;
THREAD = nullptr;
}
@@ -229,17 +282,14 @@ namespace logger {
// check if blocking or the logging thread is not running
if (BLOCKING || !RUNNING) {
// blocking guard
static std::mutex blocking_lock;
std::lock_guard<std::mutex> blocking_guard(blocking_lock);
// immediately process logs
output_buffer_flush();
} else {
// mark buffer as hot
std::unique_lock<std::mutex> lock(EVENT_MUTEX);
// never block here - the logging thread can be suspended while holding EVENT_MUTEX,
// and it re-checks OUTPUT_BUFFER_HOT before waiting again
std::unique_lock<std::mutex> lock(EVENT_MUTEX, std::try_to_lock);
OUTPUT_BUFFER_HOT = true;
EVENT_CV.notify_one();
}
+31
View File
@@ -1743,6 +1743,21 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.setting_name = "1",
.category = "Companion & API",
},
{
// APIStreamEnable
.title = "API Video Stream Server Enable",
.name = "apistream",
.desc = "Serves the mirrored screen as a video stream, on the API port plus two; "
"alternative to API screen capture. Requires -api.\n\n"
"http://host:apiport+2/stream.mjpg - MJPEG\n\n"
"http://host:apiport+2/stream.h264 - H.264\n\n"
"Parameters: screen (0-3), fps (1-60, default 30), q (1-100, default 70).\n\n"
"Example with -api 1337: http://host:1339/stream.h264?fps=30&q=70\n\n"
"VIEW ONLY - touch input still requires -api. "
"No password protection or encryption of any kind; video sent in the clear!",
.type = OptionType::Bool,
.category = "Companion & API",
},
{
.title = "Enable All IO Modules",
.name = "io",
@@ -3399,6 +3414,22 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.type = OptionType::Bool,
.category = "OBS Control",
},
{
// ScreenshotIncludeOverlay
.title = "Include Overlay in Screenshots",
.name = "screenshotoverlay",
.desc = "Includes Spice overlay in screenshots.",
.type = OptionType::Bool,
.category = "General Overlay",
},
{
// ScreenshotSubscreens
.title = "Include Subscreens in Screenshots",
.name = "screenshotsub",
.desc = "Saves each subscreen as a separate PNG alongside the primary screenshot.",
.type = OptionType::Bool,
.category = "General Overlay",
},
};
const std::vector<std::string> &launcher::get_categories(Options::OptionsCategory category) {
+4 -1
View File
@@ -170,6 +170,7 @@ namespace launcher {
APIDebugMode,
APIScreenMirrorQuality,
APIScreenMirrorDivide,
APIStreamEnable,
EnableAllIOModules,
EnableACIOModule,
EnableICCAModule,
@@ -320,7 +321,9 @@ namespace launcher {
OBSWebSocketHost,
OBSWebSocketPort,
OBSWebSocketPassword,
OBSWebSocketDebug
OBSWebSocketDebug,
ScreenshotIncludeOverlay,
ScreenshotSubscreens
};
enum class OptionsCategory {
+18 -95
View File
@@ -1,21 +1,14 @@
#include "superexit.h"
#include <thread>
#include "windows.h"
#include "cfg/configurator.h"
#include "launcher/shutdown.h"
#include "rawinput/rawinput.h"
#include "util/logging.h"
#include "touch/touch.h"
#include "misc/eamuse.h"
#include "games/io.h"
#include "overlay/overlay.h"
#include "util/logging.h"
namespace superexit {
static std::thread *THREAD = nullptr;
static bool THREAD_RUNNING = false;
bool has_focus() {
HWND fg_wnd = GetForegroundWindow();
if (fg_wnd == NULL) {
@@ -29,92 +22,22 @@ namespace superexit {
return fg_pid == GetCurrentProcessId();
}
void enable() {
// check if already running
if (THREAD)
return;
// create new thread
THREAD_RUNNING = true;
THREAD = new std::thread([] {
// log
log_info("superexit", "enabled");
// set variable to false to stop
while (THREAD_RUNNING) {
// check rawinput for ALT+F4
bool rawinput_exit = false;
if (RI_MGR != nullptr) {
auto devices = RI_MGR->devices_get();
for (auto &device : devices) {
switch (device.type) {
case rawinput::KEYBOARD: {
auto &key_states = device.keyboardInfo->key_states;
for (int page_index = 0; page_index < 1024; page_index += 256) {
if (key_states[page_index + VK_MENU]
&& key_states[page_index + VK_F4]) {
rawinput_exit = true;
}
}
break;
}
default:
break;
}
}
}
bool async_key_exit =
(GetAsyncKeyState(VK_MENU) & 0x8000) != 0 &&
(GetAsyncKeyState(VK_F4) & 0x8000) != 0;
bool overlay_exit = false;
auto buttons = games::get_buttons_overlay(eamuse_get_game());
if (buttons &&
(!overlay::OVERLAY || overlay::OVERLAY->hotkeys_triggered()) &&
GameAPI::Buttons::getState(RI_MGR, buttons->at(games::OverlayButtons::SuperExit))) {
overlay_exit = true;
}
// check for exit
if (rawinput_exit || async_key_exit) {
if (has_focus()) {
log_info("superexit", "detected ALT+F4, exiting...");
launcher::shutdown();
}
}
if (overlay_exit) {
if (has_focus()) {
log_info("superexit", "detected Force Exit Game overlay shortcut, exiting...");
launcher::shutdown();
}
}
// slow down
Sleep(100);
}
return nullptr;
});
}
void disable() {
if (!THREAD) {
void handle_hotkeys(bool alt_f4, bool mapped_exit) {
if (!alt_f4 && !mapped_exit) {
return;
}
// stop old thread
THREAD_RUNNING = false;
THREAD->join();
// delete thread
delete THREAD;
THREAD = nullptr;
// log
log_info("superexit", "disabled");
if (cfg::CONFIGURATOR_STANDALONE) {
return;
}
if (!has_focus()) {
return;
}
if (alt_f4) {
log_info("superexit", "detected ALT+F4, exiting...");
launcher::shutdown();
return;
}
log_info("superexit", "detected Force Exit Game overlay shortcut, exiting...");
launcher::shutdown();
}
}
+1 -2
View File
@@ -2,6 +2,5 @@
namespace superexit {
bool has_focus();
void enable();
void disable();
void handle_hotkeys(bool alt_f4, bool mapped_exit);
}
+89 -16
View File
@@ -527,25 +527,78 @@ HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABI
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
toojpeg (zlib)
fpng (Unlicense)
-------------------------------------------
zlib License
Copyright (c) 2021 Richard Geldreich, Jr.
Incorporates public domain code by Alex Evans, the original miniz by
Richard Geldreich, Jr., and Huffman code size work by Alistair Moffat and
Jyrki Katajainen.
Copyright (c) 2011-2016 Stephan Brumme
This is free and unencumbered software released into the public domain.
This software is provided 'as-is', without any express or implied warranty. In
no event will the authors be held liable for any damages arising from the use
of this software.
Permission is granted to anyone to use this software for any purpose, including
commercial applications, and to alter it and redistribute it freely, subject to
the following restrictions:
1. The origin of this software must not be misrepresented; you must not claim
that you wrote the original software. If you use this software in a product,
an acknowledgment in the product documentation would be appreciated but is
not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute
this software, either in source code form or as a compiled binary, for any
purpose, commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this
software dedicate any and all copyright interest in the software to the public
domain. We make this dedication for the benefit of the public at large and to
the detriment of our heirs and successors. We intend this dedication to be an
overt act of relinquishment in perpetuity of all present and future rights to
this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
libjpeg-turbo (IJG)
-------------------------------------------
This software is based in part on the work of the Independent JPEG Group.
Only the libjpeg API library is used here. Per the libjpeg-turbo licensing
terms that portion is covered by the IJG License, reproduced below; the SIMD
sources bear the zlib License, whose terms are subsumed by the IJG License in
the context of the overall libjpeg API library. The Modified (3-clause) BSD
License covers the TurboJPEG API library and build system, neither of which is
distributed here.
The authors make NO WARRANTY or representation, either express or implied,
with respect to this software, its quality, accuracy, merchantability, or
fitness for a particular purpose. This software is provided "AS IS", and you,
its user, assume the entire risk as to its quality and accuracy.
This software is copyright (C) 1991-2020, Thomas G. Lane, Guido Vollbeding.
All Rights Reserved except as specified below.
Permission is hereby granted to use, copy, modify, and distribute this
software (or portions thereof) for any purpose, without fee, subject to these
conditions:
(1) If any part of the source code for this software is distributed, then this
README file must be included, with this copyright and no-warranty notice
unaltered; and any additions, deletions, or changes to the original files
must be clearly indicated in accompanying documentation.
(2) If only executable code is distributed, then the accompanying
documentation must state that "this software is based in part on the work of
the Independent JPEG Group".
(3) Permission for use of this software is granted only if the user accepts
full responsibility for any undesirable consequences; the authors accept
NO LIABILITY for damages of any kind.
These conditions apply to any software derived from or based on the IJG code,
not just to the unmodified library. If you use our work, you ought to
acknowledge us.
Permission is NOT granted for the use of any IJG author's name or company name
in advertising or publicity relating to this software or products derived from
it. This software may be referred to only as "the Independent JPEG Group's
software".
We specifically permit and encourage the use of this software as the basis of
commercial products, provided that all warranty or liability claims are
assumed by the product vendor.
robin_hood.h (MIT)
-------------------------------------------
@@ -1353,3 +1406,23 @@ Contributions
-------------------------------------------
cardio - Felix - MIT License
scard - nolm - MIT License
x264 (GPL-2.0-or-later)
-------------------------------------------
https://www.videolan.org/developers/x264.html
Statically linked for the API video stream H.264 encoder.
Copyright (C) 2003-2024 x264 project
This program is free software; you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation; either version 2 of the License, or (at your option) any later
version.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with
this program; if not, write to the Free Software Foundation, Inc., 51 Franklin
Street, Fifth Floor, Boston, MA 02110-1301, USA.
+5 -4
View File
@@ -58,17 +58,18 @@ namespace clipboard {
return;
}
// If the screenshot button is print screen, the OpenClipboard call seems to fail often if we only
// call it once, probably due to a race condition. So, we can try calling a lot until we can open it.
// print screen key leaves the OS briefly holding the clipboard, so retry
// spinning without yielding starves the thread we are waiting on and looks like a hang
bool clipboard_open = false;
for (int i = 0; i < 1000000; i++) {
for (int i = 0; i < 100; i++) {
if (OpenClipboard(nullptr)) {
clipboard_open = true;
break;
}
Sleep(5);
}
if (!clipboard_open) {
log_warning("clipboard", "Failed to open clipboard");
log_warning("clipboard", "failed to open clipboard");
return;
}
+24 -55
View File
@@ -25,10 +25,8 @@ static double CARD_INSERT_TIME[2] = {0, 0};
static double CARD_INSERT_TIMEOUT = 2.0;
static char CARD_INSERT_UID[2][8] = {{0}, {0}};
static char CARD_INSERT_UID_ENABLE[2] = {false, false};
static int COIN_STOCK = 0;
static bool COIN_BLOCK = false;
static std::thread *COIN_INPUT_THREAD;
static bool COIN_INPUT_THREAD_ACTIVE = false;
static std::atomic_int COIN_STOCK {0};
static std::atomic_bool COIN_BLOCK {false};
static uint16_t KEYPAD_STATE[] = {0, 0};
static uint16_t KEYPAD_STATE_OVERRIDES[] = {0, 0};
static uint16_t KEYPAD_STATE_OVERRIDES_BT5[] = {0, 0};
@@ -292,78 +290,49 @@ bool eamuse_card_insert_consume(int active_count, int unit_id) {
}
bool eamuse_coin_get_block() {
return COIN_BLOCK;
return COIN_BLOCK.load(std::memory_order_relaxed);
}
void eamuse_coin_set_block(bool block) {
COIN_BLOCK = block;
COIN_BLOCK.store(block, std::memory_order_relaxed);
}
int eamuse_coin_get_stock() {
return COIN_STOCK;
return COIN_STOCK.load(std::memory_order_relaxed);
}
void eamuse_coin_set_stock(int amount) {
COIN_STOCK = amount;
COIN_STOCK.store(amount, std::memory_order_relaxed);
}
bool eamuse_coin_consume(int amount) {
if (COIN_STOCK < amount) {
return false;
} else {
COIN_STOCK -= amount;
return true;
auto stock = COIN_STOCK.load(std::memory_order_relaxed);
while (stock >= amount) {
if (COIN_STOCK.compare_exchange_weak(
stock,
stock - amount,
std::memory_order_relaxed)) {
return true;
}
}
return false;
}
int eamuse_coin_consume_stock() {
int stock = COIN_STOCK;
COIN_STOCK = 0;
return stock;
return COIN_STOCK.exchange(0, std::memory_order_relaxed);
}
int eamuse_coin_add() {
return ++COIN_STOCK;
return COIN_STOCK.fetch_add(1, std::memory_order_relaxed) + 1;
}
void eamuse_coin_start_thread() {
// set active
COIN_INPUT_THREAD_ACTIVE = true;
// create thread
COIN_INPUT_THREAD = new std::thread([]() {
auto overlay_buttons = games::get_buttons_overlay(eamuse_get_game());
static bool COIN_INPUT_KEY_STATE = false;
while (COIN_INPUT_THREAD_ACTIVE) {
// check input key
if (overlay_buttons && GameAPI::Buttons::getState(RI_MGR, overlay_buttons->at(
games::OverlayButtons::InsertCoin))) {
if (!COIN_INPUT_KEY_STATE) {
if (COIN_BLOCK)
log_info("eamuse", "coin inserted while blocked");
else {
log_info("eamuse", "coin insert");
COIN_STOCK++;
}
}
COIN_INPUT_KEY_STATE = true;
} else {
COIN_INPUT_KEY_STATE = false;
}
// once every two frames
std::this_thread::sleep_for(std::chrono::milliseconds(1000 / 30));
}
});
}
void eamuse_coin_stop_thread() {
COIN_INPUT_THREAD_ACTIVE = false;
COIN_INPUT_THREAD->join();
delete COIN_INPUT_THREAD;
COIN_INPUT_THREAD = nullptr;
void eamuse_coin_insert() {
if (COIN_BLOCK.load(std::memory_order_relaxed)) {
log_info("eamuse", "coin inserted while blocked");
} else {
log_info("eamuse", "coin insert");
COIN_STOCK.fetch_add(1, std::memory_order_relaxed);
}
}
void eamuse_pin_macro_start_thread() {
+1 -3
View File
@@ -58,9 +58,7 @@ bool eamuse_coin_consume(int amount);
int eamuse_coin_consume_stock();
int eamuse_coin_add();
void eamuse_coin_start_thread();
void eamuse_coin_stop_thread();
void eamuse_coin_insert();
void eamuse_pin_macro_start_thread();
void eamuse_pin_macro_stop_thread();
+154
View File
@@ -0,0 +1,154 @@
#include "hotkeys.h"
#include <atomic>
#include <chrono>
#include <mutex>
#include <stop_token>
#include <thread>
#include <vector>
#include <windows.h>
#include "games/io.h"
#include "launcher/superexit.h"
#include "misc/eamuse.h"
#include "overlay/overlay.h"
#include "rawinput/rawinput.h"
#include "util/logging.h"
namespace hotkeys {
namespace {
// 8 ms targets short screenshot pulses; sleep_for may use a coarser scheduler
// interval during early boot or when process timer adjustments are disabled
constexpr auto MIN_SAMPLE_INTERVAL = std::chrono::milliseconds(8);
std::atomic_bool SCREENSHOT_PENDING {false};
std::mutex INPUT_MUTEX;
bool INPUT_ENABLED = false;
bool RAW_INPUT_ENABLED = false;
std::jthread WORKER;
bool read_button(std::vector<Button> *buttons, size_t index) {
// getState retains each binding's focus, modifier, inversion, and debounce policy
return RI_MGR && buttons && index < buttons->size() &&
GameAPI::Buttons::getState(RI_MGR, buttons->at(index));
}
bool read_alt_f4() {
// preserve both legacy detection paths whenever raw input is available
bool pressed = (GetAsyncKeyState(VK_MENU) & 0x8000) != 0 &&
(GetAsyncKeyState(VK_F4) & 0x8000) != 0;
if (!RAW_INPUT_ENABLED || !RI_MGR) {
return pressed;
}
return pressed || RI_MGR->keyboard_combo_pressed(VK_MENU, VK_F4);
}
bool rising_edge(bool current, bool &previous) {
const bool edge = current && !previous;
previous = current;
return edge;
}
void run(std::stop_token stop_token) {
bool screenshot_previous = false;
bool coin_previous = false;
while (!stop_token.stop_requested()) {
bool coin_edge = false;
bool super_exit_current = false;
bool alt_f4_current = false;
{
// lifecycle functions hold this mutex until raw-input polling is complete
std::lock_guard<std::mutex> lock(INPUT_MUTEX);
if (INPUT_ENABLED || RAW_INPUT_ENABLED) {
auto *buttons = games::get_buttons_overlay(eamuse_get_game());
const bool screenshot_down = INPUT_ENABLED && read_button(
buttons, games::OverlayButtons::Screenshot);
const bool coin_current = INPUT_ENABLED && read_button(
buttons, games::OverlayButtons::InsertCoin);
const bool super_exit_down = RAW_INPUT_ENABLED && read_button(
buttons, games::OverlayButtons::SuperExit);
// global_hotkeys_triggered takes OVERLAY_MUTEX, then the overlay's
// hotkeys_mutex; its button reads may then take device mutexes. polling
// every mapped-input tick is intentional so HotkeyToggle releases cannot
// be missed when the render thread stalls.
const bool gate_active = overlay::global_hotkeys_triggered();
const bool screenshot_current = screenshot_down && gate_active;
super_exit_current = super_exit_down && gate_active;
if (rising_edge(screenshot_current, screenshot_previous)) {
SCREENSHOT_PENDING.store(true, std::memory_order_relaxed);
}
coin_edge = rising_edge(coin_current, coin_previous);
} else {
screenshot_previous = false;
coin_previous = false;
}
alt_f4_current = read_alt_f4();
}
if (coin_edge) {
eamuse_coin_insert();
}
// pass held state so returning focus can exit without another key press
superexit::handle_hotkeys(alt_f4_current, super_exit_current);
std::this_thread::sleep_for(MIN_SAMPLE_INTERVAL);
}
}
}
void start() {
if (WORKER.joinable()) {
return;
}
SCREENSHOT_PENDING.store(false, std::memory_order_relaxed);
WORKER = std::jthread(run);
log_info("hotkeys", "sampler started");
}
void enable_raw_input() {
std::lock_guard<std::mutex> lock(INPUT_MUTEX);
RAW_INPUT_ENABLED = true;
}
void enable_input() {
std::lock_guard<std::mutex> lock(INPUT_MUTEX);
SCREENSHOT_PENDING.store(false, std::memory_order_relaxed);
INPUT_ENABLED = true;
log_info("hotkeys", "configured input enabled");
}
void disable_input() {
std::lock_guard<std::mutex> lock(INPUT_MUTEX);
INPUT_ENABLED = false;
SCREENSHOT_PENDING.store(false, std::memory_order_relaxed);
log_info("hotkeys", "configured input disabled");
}
void disable_raw_input() {
std::lock_guard<std::mutex> lock(INPUT_MUTEX);
RAW_INPUT_ENABLED = false;
}
void stop() {
if (!WORKER.joinable()) {
return;
}
WORKER.request_stop();
WORKER.join();
log_info("hotkeys", "sampler stopped");
}
bool consume_screenshot() {
return SCREENSHOT_PENDING.exchange(false, std::memory_order_relaxed);
}
}
+13
View File
@@ -0,0 +1,13 @@
#pragma once
namespace hotkeys {
// ALT+F4 monitoring spans boot and teardown; configured actions are enabled separately
void start();
void enable_raw_input();
void enable_input();
void disable_input();
void disable_raw_input();
void stop();
bool consume_screenshot();
}
+265 -265
View File
@@ -1,265 +1,265 @@
#include "notifications.h"
#include <atomic>
#include <deque>
#include <mutex>
#include <unordered_map>
#include "external/imgui/imgui.h"
#include "external/imgui/imgui_internal.h"
#include "external/fmt/include/fmt/format.h"
#include "overlay/overlay.h"
#include "util/time.h"
namespace overlay::notifications {
bool ENABLED = true;
Position POSITION = Position::BottomRight;
struct Notification {
uint64_t id;
std::string text;
Severity severity;
double created_ms;
float duration_s;
};
static std::mutex g_mutex;
static std::deque<Notification> g_items;
static std::atomic<uint64_t> g_next_id { 1 };
static std::atomic<size_t> g_count { 0 };
// duration in seconds each notification stays visible
static constexpr float DURATION_S = 3.0f;
// maximum number of notifications kept in the queue (oldest dropped beyond this)
static constexpr size_t MAX_NOTIFICATIONS = 6;
// time (ms) over which a toast fades out at the end of its lifetime
static constexpr float FADE_OUT_MS = 400.0f;
// fixed width of each toast window, in unscaled pixels
static constexpr float TOAST_WIDTH = 320.0f;
// gap between the toast stack and the screen edges (right + bottom)
static constexpr float TOAST_MARGIN = 20.0f;
// vertical gap between stacked toasts
static constexpr float TOAST_SPACING = 8.0f;
// inner padding inside a toast window (horizontal / vertical)
static constexpr float TOAST_PAD_X = 10.0f;
static constexpr float TOAST_PAD_Y = 8.0f;
// width of the colored severity accent bar drawn on the left edge
static constexpr float TOAST_ACCENT_W = 6.0f;
// base opacity of the toast background (0..1), multiplied by the fade alpha
static constexpr float TOAST_BG_ALPHA = 0.85f;
static constexpr ImGuiWindowFlags TOAST_FLAGS =
ImGuiWindowFlags_NoDecoration
| ImGuiWindowFlags_NoInputs
| ImGuiWindowFlags_NoNav
| ImGuiWindowFlags_NoMove
| ImGuiWindowFlags_NoSavedSettings
| ImGuiWindowFlags_NoFocusOnAppearing
| ImGuiWindowFlags_NoBringToFrontOnFocus
| ImGuiWindowFlags_AlwaysAutoResize;
static ImU32 severity_accent(Severity sev) {
switch (sev) {
case Severity::Success: return IM_COL32(80, 200, 120, 255);
case Severity::Warning: return IM_COL32(230, 180, 60, 255);
case Severity::Error: return IM_COL32(220, 60, 60, 255);
case Severity::Info:
default: return IM_COL32(90, 160, 230, 255);
}
}
static bool is_expired(const Notification &n, double now_ms) {
return (now_ms - n.created_ms) >= (n.duration_s * 1000.0);
}
// returns 0.0 .. 1.0 fade alpha based on time remaining
static float compute_alpha(const Notification &n, double now_ms) {
const double remaining_ms = (n.duration_s * 1000.0) - (now_ms - n.created_ms);
if (remaining_ms >= FADE_OUT_MS) {
return 1.0f;
}
if (remaining_ms <= 0.0) {
return 0.0f;
}
return static_cast<float>(remaining_ms / FADE_OUT_MS);
}
// drop expired items and copy the rest under a single lock acquisition
static std::vector<Notification> snapshot_and_prune(double now_ms) {
std::vector<Notification> snapshot;
std::lock_guard<std::mutex> lock(g_mutex);
for (auto it = g_items.begin(); it != g_items.end();) {
if (is_expired(*it, now_ms)) {
it = g_items.erase(it);
} else {
++it;
}
}
g_count.store(g_items.size(), std::memory_order_release);
snapshot.assign(g_items.begin(), g_items.end());
return snapshot;
}
// is the configured anchor on the right edge of the screen?
static bool position_is_right(Position p) {
return p == Position::BottomRight || p == Position::TopRight;
}
// is the configured anchor on the bottom edge of the screen?
static bool position_is_bottom(Position p) {
return p == Position::BottomRight || p == Position::BottomLeft;
}
// draw a single toast anchored to the configured corner; `cursor_y` is the
// y-coordinate of the toast edge nearest the anchor (top edge for Top* anchors,
// bottom edge for Bottom* anchors). returns its height in pixels.
static float draw_toast(const Notification &n, float cursor_y, float alpha) {
const float toast_width = apply_scaling(TOAST_WIDTH);
const float margin = apply_scaling(TOAST_MARGIN);
const ImVec2 &display = ImGui::GetIO().DisplaySize;
const Position pos = POSITION;
const auto window_id = fmt::format("##spice_notif_{}", n.id);
// anchor x/pivot.x select the screen edge; pivot.y matches cursor_y semantics
const float anchor_x = position_is_right(pos) ? (display.x - margin) : margin;
const float pivot_x = position_is_right(pos) ? 1.0f : 0.0f;
const float pivot_y = position_is_bottom(pos) ? 1.0f : 0.0f;
ImGui::SetNextWindowPos(ImVec2(anchor_x, cursor_y),
ImGuiCond_Always, ImVec2(pivot_x, pivot_y));
ImGui::SetNextWindowSize(ImVec2(toast_width, 0.f), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(TOAST_BG_ALPHA * alpha);
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,
ImVec2(apply_scaling(TOAST_PAD_X), apply_scaling(TOAST_PAD_Y)));
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();
// accent bar on the left edge of the window
const ImU32 accent = severity_accent(n.severity);
const ImU32 accent_faded =
(accent & 0x00FFFFFFu) | (static_cast<ImU32>(alpha * 255.0f) << 24);
ImGui::GetWindowDrawList()->AddRectFilled(
win_pos,
ImVec2(win_pos.x + apply_scaling(TOAST_ACCENT_W), win_pos.y + win_size.y),
accent_faded);
// small gutter past the accent bar, then wrapped text
ImGui::Dummy(ImVec2(apply_scaling(2.0f), 0.f));
ImGui::SameLine();
ImGui::PushTextWrapPos(win_pos.x + win_size.x - apply_scaling(TOAST_PAD_X));
ImGui::TextUnformatted(n.text.c_str());
ImGui::PopTextWrapPos();
height = ImGui::GetWindowSize().y;
}
ImGui::End();
ImGui::PopStyleVar(2);
return height;
}
uint64_t add(Severity severity, std::string text) {
if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) {
return 0;
}
Notification n {
.id = g_next_id.fetch_add(1, std::memory_order_relaxed),
.text = std::move(text),
.severity = severity,
.created_ms = get_performance_milliseconds(),
.duration_s = DURATION_S,
};
{
std::lock_guard<std::mutex> lock(g_mutex);
g_items.push_back(std::move(n));
while (g_items.size() > MAX_NOTIFICATIONS) {
g_items.pop_front();
}
g_count.store(g_items.size(), std::memory_order_release);
}
return n.id;
}
uint64_t add_throttled(Severity severity, const std::string &key,
double cooldown_seconds, std::string text) {
if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) {
return 0;
}
// per-key last-emit timestamps live behind their own lock so we don't
// hold g_mutex across the map lookup.
static std::mutex throttle_mutex;
static std::unordered_map<std::string, double> last_emit_ms;
const double now_ms = get_performance_milliseconds();
{
std::lock_guard<std::mutex> lock(throttle_mutex);
auto it = last_emit_ms.find(key);
if (it != last_emit_ms.end()
&& (now_ms - it->second) < (cooldown_seconds * 1000.0)) {
return 0;
}
last_emit_ms[key] = now_ms;
}
return add(severity, std::move(text));
}
bool has_pending() {
return g_count.load(std::memory_order_acquire) > 0;
}
void draw() {
const double now_ms = get_performance_milliseconds();
const auto snapshot = snapshot_and_prune(now_ms);
if (snapshot.empty()) {
return;
}
// stack from the anchored edge with newest toast at the anchor.
// Bottom* anchors stack upward; Top* anchors stack downward.
const float spacing = apply_scaling(TOAST_SPACING);
const float margin = apply_scaling(TOAST_MARGIN);
const bool bottom = position_is_bottom(POSITION);
float cursor_y = bottom
? (ImGui::GetIO().DisplaySize.y - margin)
: margin;
for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) {
const float alpha = compute_alpha(*it, now_ms);
const float height = draw_toast(*it, cursor_y, alpha);
cursor_y += bottom ? -(height + spacing) : (height + spacing);
}
}
void apply_game_default_position(const std::string &game_name) {
if (game_name == "Reflec Beat") {
POSITION = Position::TopRight;
}
// others keep the default (BottomRight)
}
}
#include "notifications.h"
#include <atomic>
#include <deque>
#include <mutex>
#include <unordered_map>
#include "external/imgui/imgui.h"
#include "external/imgui/imgui_internal.h"
#include "external/fmt/include/fmt/format.h"
#include "overlay/overlay.h"
#include "util/time.h"
namespace overlay::notifications {
bool ENABLED = true;
Position POSITION = Position::BottomRight;
struct Notification {
uint64_t id;
std::string text;
Severity severity;
double created_ms;
float duration_s;
};
static std::mutex g_mutex;
static std::deque<Notification> g_items;
static std::atomic<uint64_t> g_next_id { 1 };
static std::atomic<size_t> g_count { 0 };
// duration in seconds each notification stays visible
static constexpr float DURATION_S = 3.0f;
// maximum number of notifications kept in the queue (oldest dropped beyond this)
static constexpr size_t MAX_NOTIFICATIONS = 6;
// time (ms) over which a toast fades out at the end of its lifetime
static constexpr float FADE_OUT_MS = 400.0f;
// fixed width of each toast window, in unscaled pixels
static constexpr float TOAST_WIDTH = 320.0f;
// gap between the toast stack and the screen edges (right + bottom)
static constexpr float TOAST_MARGIN = 20.0f;
// vertical gap between stacked toasts
static constexpr float TOAST_SPACING = 8.0f;
// inner padding inside a toast window (horizontal / vertical)
static constexpr float TOAST_PAD_X = 10.0f;
static constexpr float TOAST_PAD_Y = 8.0f;
// width of the colored severity accent bar drawn on the left edge
static constexpr float TOAST_ACCENT_W = 6.0f;
// base opacity of the toast background (0..1), multiplied by the fade alpha
static constexpr float TOAST_BG_ALPHA = 0.85f;
static constexpr ImGuiWindowFlags TOAST_FLAGS =
ImGuiWindowFlags_NoDecoration
| ImGuiWindowFlags_NoInputs
| ImGuiWindowFlags_NoNav
| ImGuiWindowFlags_NoMove
| ImGuiWindowFlags_NoSavedSettings
| ImGuiWindowFlags_NoFocusOnAppearing
| ImGuiWindowFlags_NoBringToFrontOnFocus
| ImGuiWindowFlags_AlwaysAutoResize;
static ImU32 severity_accent(Severity sev) {
switch (sev) {
case Severity::Success: return IM_COL32(80, 200, 120, 255);
case Severity::Warning: return IM_COL32(230, 180, 60, 255);
case Severity::Error: return IM_COL32(220, 60, 60, 255);
case Severity::Info:
default: return IM_COL32(90, 160, 230, 255);
}
}
static bool is_expired(const Notification &n, double now_ms) {
return (now_ms - n.created_ms) >= (n.duration_s * 1000.0);
}
// returns 0.0 .. 1.0 fade alpha based on time remaining
static float compute_alpha(const Notification &n, double now_ms) {
const double remaining_ms = (n.duration_s * 1000.0) - (now_ms - n.created_ms);
if (remaining_ms >= FADE_OUT_MS) {
return 1.0f;
}
if (remaining_ms <= 0.0) {
return 0.0f;
}
return static_cast<float>(remaining_ms / FADE_OUT_MS);
}
// drop expired items and copy the rest under a single lock acquisition
static std::vector<Notification> snapshot_and_prune(double now_ms) {
std::vector<Notification> snapshot;
std::lock_guard<std::mutex> lock(g_mutex);
for (auto it = g_items.begin(); it != g_items.end();) {
if (is_expired(*it, now_ms)) {
it = g_items.erase(it);
} else {
++it;
}
}
g_count.store(g_items.size(), std::memory_order_release);
snapshot.assign(g_items.begin(), g_items.end());
return snapshot;
}
// is the configured anchor on the right edge of the screen?
static bool position_is_right(Position p) {
return p == Position::BottomRight || p == Position::TopRight;
}
// is the configured anchor on the bottom edge of the screen?
static bool position_is_bottom(Position p) {
return p == Position::BottomRight || p == Position::BottomLeft;
}
// draw a single toast anchored to the configured corner; `cursor_y` is the
// y-coordinate of the toast edge nearest the anchor (top edge for Top* anchors,
// bottom edge for Bottom* anchors). returns its height in pixels.
static float draw_toast(const Notification &n, float cursor_y, float alpha) {
const float toast_width = apply_scaling(TOAST_WIDTH);
const float margin = apply_scaling(TOAST_MARGIN);
const ImVec2 &display = ImGui::GetIO().DisplaySize;
const Position pos = POSITION;
const auto window_id = fmt::format("##spice_notif_{}", n.id);
// anchor x/pivot.x select the screen edge; pivot.y matches cursor_y semantics
const float anchor_x = position_is_right(pos) ? (display.x - margin) : margin;
const float pivot_x = position_is_right(pos) ? 1.0f : 0.0f;
const float pivot_y = position_is_bottom(pos) ? 1.0f : 0.0f;
ImGui::SetNextWindowPos(ImVec2(anchor_x, cursor_y),
ImGuiCond_Always, ImVec2(pivot_x, pivot_y));
ImGui::SetNextWindowSize(ImVec2(toast_width, 0.f), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(TOAST_BG_ALPHA * alpha);
ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding,
ImVec2(apply_scaling(TOAST_PAD_X), apply_scaling(TOAST_PAD_Y)));
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();
// accent bar on the left edge of the window
const ImU32 accent = severity_accent(n.severity);
const ImU32 accent_faded =
(accent & 0x00FFFFFFu) | (static_cast<ImU32>(alpha * 255.0f) << 24);
ImGui::GetWindowDrawList()->AddRectFilled(
win_pos,
ImVec2(win_pos.x + apply_scaling(TOAST_ACCENT_W), win_pos.y + win_size.y),
accent_faded);
// small gutter past the accent bar, then wrapped text
ImGui::Dummy(ImVec2(apply_scaling(2.0f), 0.f));
ImGui::SameLine();
ImGui::PushTextWrapPos(win_pos.x + win_size.x - apply_scaling(TOAST_PAD_X));
ImGui::TextUnformatted(n.text.c_str());
ImGui::PopTextWrapPos();
height = ImGui::GetWindowSize().y;
}
ImGui::End();
ImGui::PopStyleVar(2);
return height;
}
uint64_t add(Severity severity, std::string text) {
if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) {
return 0;
}
Notification n {
.id = g_next_id.fetch_add(1, std::memory_order_relaxed),
.text = std::move(text),
.severity = severity,
.created_ms = get_performance_milliseconds(),
.duration_s = DURATION_S,
};
{
std::lock_guard<std::mutex> lock(g_mutex);
g_items.push_back(std::move(n));
while (g_items.size() > MAX_NOTIFICATIONS) {
g_items.pop_front();
}
g_count.store(g_items.size(), std::memory_order_release);
}
return n.id;
}
uint64_t add_throttled(Severity severity, const std::string &key,
double cooldown_seconds, std::string text) {
if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) {
return 0;
}
// per-key last-emit timestamps live behind their own lock so we don't
// hold g_mutex across the map lookup.
static std::mutex throttle_mutex;
static std::unordered_map<std::string, double> last_emit_ms;
const double now_ms = get_performance_milliseconds();
{
std::lock_guard<std::mutex> lock(throttle_mutex);
auto it = last_emit_ms.find(key);
if (it != last_emit_ms.end()
&& (now_ms - it->second) < (cooldown_seconds * 1000.0)) {
return 0;
}
last_emit_ms[key] = now_ms;
}
return add(severity, std::move(text));
}
bool has_pending() {
return g_count.load(std::memory_order_acquire) > 0;
}
void draw() {
const double now_ms = get_performance_milliseconds();
const auto snapshot = snapshot_and_prune(now_ms);
if (snapshot.empty()) {
return;
}
// stack from the anchored edge with newest toast at the anchor.
// Bottom* anchors stack upward; Top* anchors stack downward.
const float spacing = apply_scaling(TOAST_SPACING);
const float margin = apply_scaling(TOAST_MARGIN);
const bool bottom = position_is_bottom(POSITION);
float cursor_y = bottom
? (ImGui::GetIO().DisplaySize.y - margin)
: margin;
for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) {
const float alpha = compute_alpha(*it, now_ms);
const float height = draw_toast(*it, cursor_y, alpha);
cursor_y += bottom ? -(height + spacing) : (height + spacing);
}
}
void apply_game_default_position(const std::string &game_name) {
if (game_name == "Reflec Beat") {
POSITION = Position::TopRight;
}
// others keep the default (BottomRight)
}
}
+55 -55
View File
@@ -1,55 +1,55 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
namespace overlay::notifications {
// master switch for the notification system; when false, add() is a no-op.
// controlled by selecting "none" for the -notifypos launcher option.
extern bool ENABLED;
enum class Severity {
Info,
Success,
Warning,
Error,
};
// screen anchor for the toast stack. toasts stack away from the anchored edge.
enum class Position {
BottomRight,
BottomLeft,
TopRight,
TopLeft,
};
// current toast anchor. defaults to BottomRight; may be reassigned by
// apply_game_default_position() or by the user via -notifypos.
extern Position POSITION;
// apply the default toast position appropriate for a game (by display name,
// as returned by eamuse_get_game()). called once after game autodetect, before
// any user -notifypos override is applied.
void apply_game_default_position(const std::string &game_name);
// add a notification (thread-safe). returns the assigned id, or 0 if the
// notification was dropped (overlay disabled or notifications disabled).
uint64_t add(Severity severity, std::string text);
// rate-limited variant of add(). suppresses the toast if another call with
// the same `key` succeeded within the last `cooldown_seconds`. useful for
// events that can fire every frame (e.g. a button held down). returns the
// assigned id, or 0 if the toast was suppressed or dropped. thread-safe.
uint64_t add_throttled(Severity severity, const std::string &key,
double cooldown_seconds, std::string text);
// true if there is at least one notification that still needs to be drawn.
// safe to call from the render thread without locking the underlying store.
bool has_pending();
// draw all active notifications and prune expired ones.
// must be called from the ImGui render thread inside a NewFrame/EndFrame pair.
void draw();
}
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
namespace overlay::notifications {
// master switch for the notification system; when false, add() is a no-op.
// controlled by selecting "none" for the -notifypos launcher option.
extern bool ENABLED;
enum class Severity {
Info,
Success,
Warning,
Error,
};
// screen anchor for the toast stack. toasts stack away from the anchored edge.
enum class Position {
BottomRight,
BottomLeft,
TopRight,
TopLeft,
};
// current toast anchor. defaults to BottomRight; may be reassigned by
// apply_game_default_position() or by the user via -notifypos.
extern Position POSITION;
// apply the default toast position appropriate for a game (by display name,
// as returned by eamuse_get_game()). called once after game autodetect, before
// any user -notifypos override is applied.
void apply_game_default_position(const std::string &game_name);
// add a notification (thread-safe). returns the assigned id, or 0 if the
// notification was dropped (overlay disabled or notifications disabled).
uint64_t add(Severity severity, std::string text);
// rate-limited variant of add(). suppresses the toast if another call with
// the same `key` succeeded within the last `cooldown_seconds`. useful for
// events that can fire every frame (e.g. a button held down). returns the
// assigned id, or 0 if the toast was suppressed or dropped. thread-safe.
uint64_t add_throttled(Severity severity, const std::string &key,
double cooldown_seconds, std::string text);
// true if there is at least one notification that still needs to be drawn.
// safe to call from the render thread without locking the underlying store.
bool has_pending();
// draw all active notifications and prune expired ones.
// must be called from the ImGui render thread inside a NewFrame/EndFrame pair.
void draw();
}

Some files were not shown because too many files have changed in this diff Show More