Compare commits

..

42 Commits

Author SHA1 Message Date
bicarus-dev 2aae8ad9ca move updater scripts 2026-07-15 02:14:19 -07:00
bicarus 1b6d1af951 jb, shogikai: double buffer overlay, run at ~60fps (#805)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
GDI bit blit overlay used exclusively for jubeat & shogikai was running
at 30fps, which caused the Jubeat touch debug UI to not render fast
enough. Run it at 60 fps, and also perform double buffering to avoid
flickering.

This could have been scoped to JB but I made the change for both. XP
builds will keep running at 30fps since those people run on cabs with
ancient hardware.

## Testing
Tested jubeat and shogikai, overlay renders without flickering at 60 fps
2026-07-15 01:31:29 -07:00
bicarus 5c3617c05f rawinput: fix ttl logic in raw touch (#806)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Fixes held touch contacts briefly dropping under rapid multi-touch (e.g.
holding several jubeat buttons and tapping elsewhere many times would
make the holds briefly release and the hold again).

A HID digitizer reports a fixed number of contact slots per report and
cycles the rest through later reports, so the touch layer keeps an
unreported contact alive for a few reports (a per-contact TTL). The TTL
was only set for contacts in the current report and had no slack, so
when the contact count rose (from the rapid taps) the reporting cycle
got longer and stationary holds were expired before the device cycled
back to them.

Now, the cycle length is recomputed every report and every tracked
contact's TTL is extended to it (never shrunk), so a rising contact
count can't starve a currently held contact. The 50 ms last-report
timeout still removes genuinely lifted contacts.

## Testing
Tested on jb, rb.
2026-07-14 23:46:17 -07:00
bicarus a9a943fbfe jb: fix card not detected on title screen (#804)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Fix a long standing issue with card reader behavior in jubeat.

sciunit emulation has a bug - when on the title screen / demo loop,
inserting a card did not generate an interrupt. Users always had to
press the game start button and card in. Fix this by reversing what the
actual sciunit does and mirroring the state machine behavior.

## Testing
Tested L44 and K44.
2026-07-14 17:06:00 -07:00
bicarus-dev b094a9e352 deprecate insert card button 2026-07-14 02:49:46 -07:00
bicarus 4bd18afb12 rb: fix alignment of vertical lines in debug view (#803)
they were off by third of a column
2026-07-14 00:18:55 -07:00
bicarus 0b24e703a1 overlay: re-enable keyboard navigation (#802)
## Link to GitHub Issue or related Pull Request, if one exists
Fixes #800 

## Description of change
Restores keyboard navigation by default in spicecfg, and add an option
to optionally enable it in the overlay.

## Testing
2026-07-14 00:18:21 -07:00
bicarus 1a1be7a393 rb: touch debug overlay, tweak scaling (#801)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Similar to #797, draw an overlay to debug touch issues for reflec beat.

For rb, we draw on the ImGui surface. Draw horizontal and vertical line
for triggered sensors, and also a bounding box if scaling is in use.

Make small tweaks to how scaling works.

## Testing
Verified volzza 2 / reflesia / 1st game (landscape KBR)
2026-07-13 23:30:41 -07:00
bicarus a2d8f60d12 jb: only show debug overlay for jubeat (#799) 2026-07-13 19:42:27 -07:00
bicarus 5d25fdb035 jb: touch debounce (#798)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Debouce option for touch input. Applies to all touch handlers (rawinput,
win8, win7) since it sits at the touch layer, and then consumed by jb
touch logic.

## Testing
2026-07-13 02:06:54 -07:00
bicarus c255e3a1db jb: option to draw debug graphics to show touch interactions (#797)
## Link to GitHub Issue or related Pull Request, if one exists
related to #697 

## Description of change
Add a new option that paints touch targets and touch points for jubeat;
gets enabled if there is a touch screen connected. This is to help
explain / debug issues when users have their touch screen issues, which
is usually due to misalignment.

Fix the touch targets being off by 1 pixel (center gap is 1px wider in
both directions).

Also, simplify the `improved` algorithm to simulate a finger and trigger
nearest-neighbor for the AC dimensions. In terms of touch performance
this is near identical to what we had before, but it allows us to draw a
nice debug overlay instead of just grids.

## Testing
Tested portrait and landscape versions of jb.
2026-07-13 00:31:05 -07:00
bicarus d7c144646f rawinput: deal with high poll rate devices, use 3x3 for default for rb touch emu (#796)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Two changes:

1. Handle all HID events in `WM_INPUT` and not just the first one. This
only really affects 4000Hz / 8000Hz HID devices.
2. Go back to `3x3` as the default for rb IR touch emulation and remove
`1x1` as an option as it does not perform as well as I expected.

## Testing
2026-07-12 10:32:29 -07:00
bicarus-dev 27077aa21f bump action-gh-release version 2026-07-12 01:45:16 -07:00
bicarus-dev 632dcf7b41 clean up option text for consistency 2026-07-12 00:00:31 -07:00
bicarus a25bbdc9c9 rawinput: refactor midi (#795)
pure movement of code only, no functional changes
2026-07-11 23:59:34 -07:00
bicarus 47d886306e rawinput: handle midi scanning asynchronously, fix midi hotplug (#793)
## Link to GitHub Issue or related Pull Request, if one exists
Fixes #603

## Description of change
Scanning for midi devices can take a while on Windows 11 with MIDI 2.0
service. Sometimes it'll take a couple seconds. Sometimes, on a PC with
zero MIDI devices, it takes 10-11 seconds.

This was causing two issues:

1. slow startup time
2. crash on invalid memory access due to a race condition

Address both.

Also fix a bug: when a device change event fires, we do a MIDI scan, and
invalidated all existing MIDI devices in favor of creating new handles.
Stop doing this, and instead check for duplicates by matching the ID and
keep existing device handles alone. Properly clean up devices on unplug.

Also: fix MIDI buttons being stuck on when unplugged while holding a
key.

## Testing
Tested with Nostroller in MIDI mode. rtpMIDI works too.
2026-07-11 21:57:55 -07:00
bicarus-dev edda658fa4 update rawtouchaspect text 2026-07-11 21:16:34 -07:00
bicarus-dev 1ab20f1b56 update wintouch option text 2026-07-11 21:06:29 -07:00
bicarus 0e9093a74b xinput: allow for newer xinput DLL (#794)
Fixes #792
2026-07-11 03:04:49 -07:00
bicarus f52eaea76c touch: fix indicators showing in touch overlay, disable additional gestures (#791) 2026-07-11 00:24:36 -07:00
bicarus c3fcf2f417 touch: rawinput touch to compensate for letterboxing (#790)
## Link to GitHub Issue or related Pull Request, if one exists

Fixes #697

## Description of change

Adds aspect-ratio compensation for raw input touch handler.

This addresses the problem with playing rawinput touch games - namely
jubeat and reflec beat - on a display that is not 16:9, and user chose
to preserve the aspect ratio. This happens a lot on:

* 16:10 touch screens
* touch screen laptops (which range from 3:2, 16:10, and so on).

Root cause is a **Windows Raw Input limitation**. Unlike
`WM_POINTER`/`WM_TOUCH`, where the OS already maps digitizer coordinates
to screen space (applying the display's calibration/scaling), Raw Input
delivers the HID digitizer's *raw* logical coordinates, spanning the
whole physical panel, with **no calibration or display-mapping data
attached**. We have to reconstruct that mapping ourselves.

When a game runs at a display mode whose aspect ratio differs from the
panel's native resolution, Windows scales the image with
letterboxing/pillarboxing (black bars). That is standard OS/GPU
behavior. Because Raw Input gives us no mapping to account for those
bars, touches land off-target. This change detects the mismatch and
remaps each touch from panel-space into the displayed image region;
touches inside the black bars are treated as released.

Adds new launcher option **`-rawtouchaspect`**.

Notes:

- Native resolution is the largest-area mode from `EnumDisplaySettings`
on the **primary** display, consistent with the existing primary-only
touch pipeline.
- Math assumes centered, aspect-preserving scaling; only one axis is
ever inset.
- A small epsilon (0.01) makes near-matched panels (e.g. 1366×768 vs
true 16:9) a no-op.
- If the user forces a stretched image, then the touches will be off
again. In this case, the user needs to use the `force off` setting.

## Testing

On my tiny Surface Go tablet, 
* checked that jubeat squares align correctly
* good enough to full combo level 11 for reflec beat
2026-07-10 23:23:45 -07:00
bicarus 8d04eba21d build: check for win7 compat, fix 32-bit xp check (#789)
## Link to GitHub Issue or related Pull Request, if one exists
#787 

## Description of change
Updates usage of `windows_dll_compat_checker`

## Testing
it builds
2026-07-10 01:51:04 -07:00
bicarus 8d42c0ce77 asio: fix XP build; remove call to RegGetValueA (#788)
## Link to GitHub Issue or related Pull Request, if one exists
Fixes #787 

## Description of change
Remove the call to `RegGetValueA` as it is Vista and up. Replace with
`RegOpenKeyExA` + `RegQueryValueExA`

## Testing
2026-07-10 00:26:03 -07:00
bicarus-dev 829503568e fix UI text for -sdvxnolive2d 2026-07-06 01:27:18 -07:00
bicarus 746fdb5409 build: manual update scripts (#786)
people asked for this often enough
2026-07-02 23:48:52 -07:00
bicarus 67da892fe9 cfg: aliases for -network and -subnet (#785)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
`-network` and `-subnet` are poorly named. Add new names for them that
are more explicit.

Old command line parameters will continue to work, this is an additive
change.

## Testing
2026-07-01 02:41:14 -07:00
bicarus dc191487e0 Update CONTRIBUTING.md 2026-06-30 17:45:23 -07:00
bicarus 5c0c405800 Update CONTRIBUTING.md 2026-06-30 14:32:30 -07:00
bicarus-dev 60166e6ba1 update gh actions 2026-06-30 02:29:44 -07:00
bicarus cb0b59600a build: GitHub action for creating a draft relase (#783) 2026-06-30 00:53:08 -07:00
bicarus b9f3be4df2 asio: cache result from CoCreateInstance to work around buggy asio drivers (#781)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
IIDX likes to call `CoCreateInstance` and `init` multiple times on ASIO
drivers, but some ASIO drivers like `Neva Uno` really don't like that
and ends up crashing. In our wrapper, cache the instances and try to
reuse them for better compatibility.

## Testing
Worked fine for FlexASIO / Xonar AE / Realtek ASIO though none of these
drivers repro the crashing behavior.
2026-06-28 22:37:48 -07:00
bicarus cf86fbd238 graphics: remove static import of d3d9 (#780)
## Link to GitHub Issue or related Pull Request, if one exists
Fixes #779
Regressed by #720 

## Description of change
#720 introduced a static dependency on DX9 which caused `d3d9.dll` to be
loaded at boot from `system32`. Some third party hooks (like
ifs_layeredfs and dxvk) rely on supplying a custom `d3d9.dll` in the DLL
search path (usually in modules) but this change caused Windows to skip
that check.

Remove the hard dependency on d3d9 and add a CMake check to ensure that
compiled binaries do not accidentally introduce new static imports in
the future.

## Testing
Confirmed that dxvk runs again.
2026-06-27 15:23:59 -07:00
bicarus 3fdb369128 sdvx: option to disable Live2D (#777)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Adds an option to disable Live2D. Can be disabled everywhere, or just
during a song.

## Testing
Tested EG final and recent Nabla.
2026-06-27 02:34:19 -07:00
bicarus db7defff5a overlay: fix OS cursor showing up in some games (#776)
## Link to GitHub Issue or related Pull Request, if one exists
regressed by #766, fixes #775 

## Description of change
Some games like DDR leave `ShowCursor` ref count at non-negative number,
so when spice handles `WM_SETCURSOR` to change the cursor shape, it ends
up showing the OS cursor.

## Testing
2026-06-25 15:30:59 -07:00
bicarus 451cbec0b9 overlay: OBS control window via WebSocket API (#773)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Adds an overlay window widget for controlling OBS over WebSocket.

Add new lines to the FPS widget that shows recording / streaming timers.

Show notifications for major state changes (stream started/stopped,
recording started/paused/resumed/stopped)

## Testing
2026-06-24 02:44:36 -07:00
bicarus 5c69e295ab ccj: fix mouse trackball cursor wrap (#774)
I don't have a repro but a user reported that the cursor doesn't wrap
around on one side. Correctly account for windows bleeding over to other
monitors.
2026-06-21 17:03:44 -07:00
bicarus 5065a92d55 launcher: don't warn about hook option conflicts (#771)
DLL hook options (-k and -z) allow multiple flags being specified in
both spicecfg and via command line; they never conflict as they are
additive. Remove the warning about option conflicts.
2026-06-20 15:46:45 -07:00
bicarus 5483e8e2c0 ponp: make Force Sub Redraw an option (#770)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Similar to how it is on SDVX, turning this on can cause graphical
glitches depending on the GPU, so it needs to be an option that users
can toggle.

## Testing
2026-06-19 22:30:08 -07:00
bicarus e8949a2612 overlay: fix highlights, alignment in presets table (#769)
* Fix table row highlight hover detection in Options tab when row has
two lines of text
* Fix text alignment in controller presets table
2026-06-19 00:40:54 -07:00
bicarus db71a0b24d cfg: center window on launch (#768) 2026-06-18 23:02:23 -07:00
bicarus d3d5422768 cfg: enforce minimum window size (#767) 2026-06-18 21:10:42 -07:00
bicarus-dev 5185f753e5 fix truncated text 2026-06-18 17:32:59 -07:00
75 changed files with 5557 additions and 1422 deletions
+3 -3
View File
@@ -12,12 +12,12 @@ jobs:
run: run:
working-directory: ./src/spice2x working-directory: ./src/spice2x
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set ccache environment variables - name: Set ccache environment variables
run: | run: |
echo "CCACHE_DIR=${{ github.workspace }}/src/spice2x/.ccache" >> $GITHUB_ENV echo "CCACHE_DIR=${{ github.workspace }}/src/spice2x/.ccache" >> $GITHUB_ENV
- name: Install ccache - name: Install ccache
uses: hendrikmuhs/ccache-action@v1.2 uses: hendrikmuhs/ccache-action@v1.2.23
- name: Calculate commit SHA - name: Calculate commit SHA
id: vars id: vars
run: | run: |
@@ -25,7 +25,7 @@ jobs:
echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV echo "COMMIT_SHORT_SHA=$calculatedSha" >> $GITHUB_ENV
- name: Compile - name: Compile
run: ./build_docker.sh run: ./build_docker.sh
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v6
with: with:
name: spice2x-ci-${{ env.COMMIT_SHORT_SHA }} name: spice2x-ci-${{ env.COMMIT_SHORT_SHA }}
path: src/spice2x/bin path: src/spice2x/bin
+49
View File
@@ -0,0 +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
+7 -3
View File
@@ -11,13 +11,17 @@ To contribute, fork the repo (just the main branch), make changes in your fork,
* For a new game that was never supported (not a new version of a game, but rather a new series): please wait 1 year after official AC release in Japan. * For a new game that was never supported (not a new version of a game, but rather a new series): please wait 1 year after official AC release in Japan.
* For new version of an already supported game: proceed with caution and use generally-accepted community guidelines. * For new version of an already supported game: proceed with caution and use generally-accepted community guidelines.
### Forbidden "features" that will never be accepted ### Pull requests that will not be accepted
* Performing decryption of encrypted files * Performing decryption of encrypted files.
* Including (bundling), distributing, or linking to game data, or making it easier to find/download game data * Including (bundling), distributing, or linking to game data, or making it easier to find/download game data.
* Any features that [phone home](https://en.wikipedia.org/wiki/Phoning_home) * Any features that [phone home](https://en.wikipedia.org/wiki/Phoning_home)
* This includes automatic updaters. * This includes automatic updaters.
* Exception: we have allowed the "patch import from URL" feature, but this has only been done under very careful considerations and plenty of warnings to the user. * Exception: we have allowed the "patch import from URL" feature, but this has only been done under very careful considerations and plenty of warnings to the user.
* Built-in EA server that saves data. They should live in a separate project.
* Any additions to built-in patches (modifications / removals are OK if we have a good reason).
* Changes to the software license terms.
* Localization / translation for UI text. It becomes too difficult to manage by the maintainers.
### Avoiding regressions ### Avoiding regressions
+52 -1
View File
@@ -408,9 +408,11 @@ set(SOURCE_FILES ${SOURCE_FILES}
games/iidx/mf_wrappers.cpp games/iidx/mf_wrappers.cpp
games/sdvx/bi2x_hook.cpp games/sdvx/bi2x_hook.cpp
games/sdvx/sdvx.cpp games/sdvx/sdvx.cpp
games/sdvx/sdvx_live2d.cpp
games/sdvx/io.cpp games/sdvx/io.cpp
games/sdvx/camera.cpp games/sdvx/camera.cpp
games/jb/jb.cpp games/jb/jb.cpp
games/jb/jb_touch.cpp
games/jb/io.cpp games/jb/io.cpp
games/nost/nost.cpp games/nost/nost.cpp
games/nost/io.cpp games/nost/io.cpp
@@ -430,6 +432,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
games/rb/rb.cpp games/rb/rb.cpp
games/rb/io.cpp games/rb/io.cpp
games/rb/touch.cpp games/rb/touch.cpp
games/rb/touch_debug.cpp
games/bs/bs.cpp games/bs/bs.cpp
games/bs/io.cpp games/bs/io.cpp
games/rf3d/rf3d.cpp games/rf3d/rf3d.cpp
@@ -529,6 +532,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
hooks/graphics/nvenc_hook.cpp hooks/graphics/nvenc_hook.cpp
hooks/graphics/backends/d3d9/d3d9_backend.cpp hooks/graphics/backends/d3d9/d3d9_backend.cpp
hooks/graphics/backends/d3d9/d3d9_device.cpp hooks/graphics/backends/d3d9/d3d9_device.cpp
hooks/graphics/backends/d3d9/d3d9_live2d.cpp
hooks/graphics/backends/d3d9/d3d9_fake_swapchain.cpp hooks/graphics/backends/d3d9/d3d9_fake_swapchain.cpp
hooks/graphics/backends/d3d9/d3d9_swapchain.cpp hooks/graphics/backends/d3d9/d3d9_swapchain.cpp
hooks/graphics/backends/d3d9/d3d9_texture.cpp hooks/graphics/backends/d3d9/d3d9_texture.cpp
@@ -605,12 +609,18 @@ set(SOURCE_FILES ${SOURCE_FILES}
overlay/windows/keypad.cpp overlay/windows/keypad.cpp
overlay/windows/log.cpp overlay/windows/log.cpp
overlay/windows/midi.cpp overlay/windows/midi.cpp
overlay/windows/obs.cpp
overlay/windows/obs_websocket.cpp
overlay/windows/patch_manager.cpp overlay/windows/patch_manager.cpp
overlay/windows/popn_sub.cpp overlay/windows/popn_sub.cpp
overlay/windows/wnd_manager.cpp overlay/windows/wnd_manager.cpp
# external
external/easywsclient/easywsclient.cpp
# rawinput # rawinput
rawinput/rawinput.cpp rawinput/rawinput.cpp
rawinput/midi.cpp
rawinput/sextet.cpp rawinput/sextet.cpp
rawinput/piuio.cpp rawinput/piuio.cpp
rawinput/touch.cpp rawinput/touch.cpp
@@ -637,8 +647,9 @@ set(SOURCE_FILES ${SOURCE_FILES}
stubs/stubs.cpp stubs/stubs.cpp
# touch # touch
touch/gdi_overlay.cpp
touch/touch.cpp touch/touch.cpp
touch/touch_indicators.cpp touch/touch_gestures.cpp
touch/win7.cpp touch/win7.cpp
touch/win8.cpp touch/win8.cpp
@@ -671,6 +682,33 @@ set(SOURCE_FILES ${SOURCE_FILES}
source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} PREFIX "Source Files" FILES ${SOURCE_FILES}) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} PREFIX "Source Files" FILES ${SOURCE_FILES})
# guard against statically importing DLLs that must always be loaded dynamically:
# * DLLs users override via the modules directory (e.g. DXVK's d3d9.dll) - a
# static import loads the system copy at startup and preempts the override
# (issue #779).
# * Media Foundation DLLs (mf/mfplat/mfreadwrite) - a static import breaks
# Unity games.
# the check runs objdump on each produced binary and fails the build if any
# forbidden DLL is imported.
set(SPICE_FORBIDDEN_STATIC_IMPORTS
d3d8.dll d3d9.dll d3d10core.dll d3d11.dll dxgi.dll opengl32.dll
mf.dll mfplat.dll mfreadwrite.dll)
function(spice_guard_dll_imports target)
if(MSVC)
# objdump-based parsing assumes a GNU/LLVM toolchain
return()
endif()
add_custom_command(TARGET ${target} POST_BUILD
COMMAND ${CMAKE_COMMAND}
-DOBJDUMP=${CMAKE_OBJDUMP}
-DTARGET_FILE=$<TARGET_FILE:${target}>
"-DFORBIDDEN=${SPICE_FORBIDDEN_STATIC_IMPORTS}"
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/check_no_static_dll_imports.cmake
VERBATIM
COMMENT "Checking ${target} for forbidden static DLL imports")
endfunction()
# spice.exe / spice_laa.exe shared objects # spice.exe / spice_laa.exe shared objects
########################################### ###########################################
# spice.exe and spice_laa.exe are compiled identically; the only difference is # spice.exe and spice_laa.exe are compiled identically; the only difference is
@@ -955,3 +993,16 @@ set_target_properties(spicetools_spice64 spicetools_spice64_linux spicetools_stu
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/archive64" ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/archive64"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/spicetools/64" LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/spicetools/64"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/spicetools/64") RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/spicetools/64")
# forbidden static DLL import guard
###################################
# apply the check to every executable and DLL produced by this project.
get_property(spice_all_targets DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} PROPERTY BUILDSYSTEM_TARGETS)
foreach(spice_target IN LISTS spice_all_targets)
get_target_property(spice_target_type ${spice_target} TYPE)
if(spice_target_type STREQUAL "EXECUTABLE"
OR spice_target_type STREQUAL "SHARED_LIBRARY"
OR spice_target_type STREQUAL "MODULE_LIBRARY")
spice_guard_dll_imports(${spice_target})
endif()
endforeach()
+18 -1
View File
@@ -214,6 +214,19 @@ time (
echo "===========================" echo "==========================="
) )
echo ""
echo "Checking Win7 compatibility..."
echo "============================="
if ! command -v windows_dll_compat_checker &> /dev/null; then
echo "WARNING: windows_dll_compat_checker not found, skipping Win7 compatibility check"
else
windows_dll_compat_checker -s PREMADE/konami_win7_museca_x86_64.ini \
${BUILDDIR_64}/spicetools/64/spice64.exe
windows_dll_compat_checker -s PREMADE/konami_win7_museca_x86_64_32bit_dlls.ini \
${BUILDDIR_32}/spicetools/spicecfg.exe \
${BUILDDIR_32}/spicetools/32/spice.exe
fi
if ((BUILD_XP_32 > 0)) || ((BUILD_XP_64 > 0)) if ((BUILD_XP_32 > 0)) || ((BUILD_XP_64 > 0))
then then
echo "" echo ""
@@ -229,7 +242,7 @@ then
fi fi
if ((BUILD_XP_32 > 0)) if ((BUILD_XP_32 > 0))
then then
windows_dll_compat_checker -s PREMADE/winxp_x86_64_32bit_dlls.ini \ windows_dll_compat_checker -s PREMADE/konami_winxp_jubeat_i686.ini \
${BUILDDIR_WINXP_32}/spicetools/spicecfg.exe \ ${BUILDDIR_WINXP_32}/spicetools/spicecfg.exe \
${BUILDDIR_WINXP_32}/spicetools/32/spice.exe ${BUILDDIR_WINXP_32}/spicetools/32/spice.exe
fi fi
@@ -294,6 +307,7 @@ mkdir -p ${OUTDIR_EXTRAS}/largeaddressaware
mkdir -p ${OUTDIR_EXTRAS}/linux mkdir -p ${OUTDIR_EXTRAS}/linux
mkdir -p ${OUTDIR_EXTRAS}/sdk/samples/32 mkdir -p ${OUTDIR_EXTRAS}/sdk/samples/32
mkdir -p ${OUTDIR_EXTRAS}/sdk/samples/64 mkdir -p ${OUTDIR_EXTRAS}/sdk/samples/64
mkdir -p ${OUTDIR_EXTRAS}/updater
if ((BUILD_XP_32 > 0)) || ((BUILD_XP_64 > 0)) if ((BUILD_XP_32 > 0)) || ((BUILD_XP_64 > 0))
then then
mkdir -p ${OUTDIR_EXTRAS}/winxp mkdir -p ${OUTDIR_EXTRAS}/winxp
@@ -354,6 +368,9 @@ mkdir ${OUTDIR_EXTRAS}/api
find ./api/resources/python -name "__pycache__" -exec rm -rf {} + find ./api/resources/python -name "__pycache__" -exec rm -rf {} +
cp -r ./api/resources/* ${OUTDIR_EXTRAS}/api cp -r ./api/resources/* ${OUTDIR_EXTRAS}/api
# generate the standalone updater scripts from the shared template and drop them
bash ./build_updaters.sh ${OUTDIR_EXTRAS}/updater
# build distribution archive # build distribution archive
if ((DIST_ENABLE > 0)) if ((DIST_ENABLE > 0))
then then
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# builds the standalone updater scripts from the shared PowerShell logic in
# update_spice.ps1. each output is a self-contained polyglot .bat: a small batch
# header (with the release channel baked in) followed by the shared PowerShell
# body after the marker line.
#
# usage: build_updaters.sh OUT_DIR
# OUT_DIR directory to write the generated .bat files into
set -eu
if [ $# -lt 1 ]; then
echo "usage: build_updaters.sh OUT_DIR" >&2
exit 1
fi
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PS_BODY="${SRC_DIR}/update_spice.ps1"
OUT_DIR="$1"
[ -f "$PS_BODY" ] || { echo "error: $PS_BODY not found" >&2; exit 1; }
MARKER='#___PS___'
gen() {
channel="$1"; label="$2"; out="$3"
{
cat <<HEADER
@echo off
setlocal
REM ============================================================================
REM spice2x updater - updates spice.exe, spice64.exe and spicecfg.exe in
REM this folder to the latest spice2x release from GitHub.
REM
REM release channel: ${label}
REM make sure spice/spicecfg are NOT running before updating.
REM ============================================================================
REM expose the script folder + channel to the embedded PowerShell, then run the
REM PowerShell part: re-read this file and execute everything after the marker.
set "SPICE_DIR=%~dp0"
set "SPICE_CHANNEL=${channel}"
powershell -NoProfile -ExecutionPolicy Bypass -Command "\$m='#___'+'PS'+'___'; \$code=((Get-Content -LiteralPath '%~f0' -Raw) -split \$m)[-1]; Invoke-Expression \$code"
set "RC=%ERRORLEVEL%"
endlocal & exit /b %RC%
${MARKER}
HEADER
cat "$PS_BODY"
} | sed -e 's/\r$//' -e 's/$/\r/' > "${OUT_DIR}/${out}"
echo " generated ${OUT_DIR}/${out}"
}
echo "Generating updater scripts from $(basename "$PS_BODY")..."
gen "" "stable (pre-releases excluded)" "update_spice.bat"
gen "beta" "beta (newest, pre-releases included)" "update_spice_beta.bat"
+6
View File
@@ -411,6 +411,12 @@ GameAPI::Buttons::State GameAPI::Buttons::getState(rawinput::RawInputManager *ma
BUTTON_PRESSED : BUTTON_NOT_PRESSED; BUTTON_PRESSED : BUTTON_NOT_PRESSED;
break; break;
} }
case rawinput::DESTROYED:
// device was unplugged. release the button instead of leaving
// state at getLastState(), which would latch a held button on
// forever (e.g. a MIDI note held while the device is removed)
state = BUTTON_NOT_PRESSED;
break;
default: default:
break; break;
} }
+21 -1
View File
@@ -3,6 +3,7 @@
#include <d3d9.h> #include <d3d9.h>
#include "overlay/overlay.h" #include "overlay/overlay.h"
#include "util/libutils.h"
#include "util/logging.h" #include "util/logging.h"
namespace cfg { namespace cfg {
@@ -35,7 +36,26 @@ namespace cfg {
return false; return false;
} }
IDirect3D9 *d3d = Direct3DCreate9(D3D_SDK_VERSION); // load d3d9.dll dynamically rather than linking against it statically.
// a static import would force the system d3d9.dll to load at process
// startup for the main spice executable too (this file is shared with
// spice.exe), which loads system32\d3d9.dll before the modules directory
// is added to the DLL search path - preventing a user-supplied DXVK
// d3d9.dll in modules from ever loading for the game.
typedef IDirect3D9 *(WINAPI *Direct3DCreate9_t)(UINT);
HMODULE d3d9_module = libutils::try_library("d3d9.dll");
if (d3d9_module == nullptr) {
log_warning("configurator", "could not load d3d9.dll, falling back to software renderer");
return false;
}
auto Direct3DCreate9_fn =
reinterpret_cast<Direct3DCreate9_t>(libutils::try_proc(d3d9_module, "Direct3DCreate9"));
if (Direct3DCreate9_fn == nullptr) {
log_warning("configurator", "could not find Direct3DCreate9, falling back to software renderer");
return false;
}
IDirect3D9 *d3d = Direct3DCreate9_fn(D3D_SDK_VERSION);
if (d3d == nullptr) { if (d3d == nullptr) {
log_warning("configurator", "Direct3DCreate9 returned NULL, falling back to software renderer"); log_warning("configurator", "Direct3DCreate9 returned NULL, falling back to software renderer");
return false; return false;
+38 -1
View File
@@ -21,6 +21,8 @@ static const char *CLASS_NAME = "ConfiguratorWindow";
static std::string WINDOW_TITLE; static std::string WINDOW_TITLE;
static int WINDOW_SIZE_X = 800; static int WINDOW_SIZE_X = 800;
static int WINDOW_SIZE_Y = 600; static int WINDOW_SIZE_Y = 600;
static const int WINDOW_MIN_SIZE_X = 540;
static const int WINDOW_MIN_SIZE_Y = 300;
static HICON WINDOW_ICON = LoadIcon(GetModuleHandle(nullptr), MAKEINTRESOURCE(MAINICON)); static HICON WINDOW_ICON = LoadIcon(GetModuleHandle(nullptr), MAKEINTRESOURCE(MAINICON));
static const UINT_PTR RENDER_TIMER_ID = 1; static const UINT_PTR RENDER_TIMER_ID = 1;
@@ -85,6 +87,23 @@ static cfg::ConfiguratorWindow *get_state(HWND hWnd) {
GetWindowLongPtrW(hWnd, GWLP_USERDATA)); GetWindowLongPtrW(hWnd, GWLP_USERDATA));
} }
// computes the top-left position that centers a window of the given size on
// the primary monitor's work area (the desktop minus the taskbar); falls back
// to (0, 0) if the monitor info can't be queried.
static POINT center_on_primary_monitor(int width, int height) {
POINT pos = { 0, 0 };
HMONITOR mon = MonitorFromPoint(pos, MONITOR_DEFAULTTOPRIMARY);
MONITORINFO mi {};
mi.cbSize = sizeof(mi);
if (GetMonitorInfo(mon, &mi)) {
const int work_w = mi.rcWork.right - mi.rcWork.left;
const int work_h = mi.rcWork.bottom - mi.rcWork.top;
pos.x = mi.rcWork.left + (work_w - width) / 2;
pos.y = mi.rcWork.top + (work_h - height) / 2;
}
return pos;
}
// Returns the refresh rate (Hz) of the monitor the window currently lives on, // Returns the refresh rate (Hz) of the monitor the window currently lives on,
// clamped to a sane range. Falls back to 60 if detection fails or the value // clamped to a sane range. Falls back to 60 if detection fails or the value
// looks invalid (DEVMODE may report 0 or 1 to mean "use hardware default"). // looks invalid (DEVMODE may report 0 or 1 to mean "use hardware default").
@@ -243,8 +262,10 @@ cfg::ConfiguratorWindow::~ConfiguratorWindow() {
void cfg::ConfiguratorWindow::run() { void cfg::ConfiguratorWindow::run() {
const POINT pos = center_on_primary_monitor(WINDOW_SIZE_X, WINDOW_SIZE_Y);
SetWindowPos(this->hWnd, HWND_TOP, pos.x, pos.y, WINDOW_SIZE_X, WINDOW_SIZE_Y, 0);
// show window // show window
SetWindowPos(this->hWnd, HWND_TOP, 0, 0, WINDOW_SIZE_X, WINDOW_SIZE_Y, 0);
ShowWindow(this->hWnd, SW_SHOWNORMAL); ShowWindow(this->hWnd, SW_SHOWNORMAL);
UpdateWindow(this->hWnd); UpdateWindow(this->hWnd);
@@ -290,6 +311,22 @@ LRESULT CALLBACK cfg::ConfiguratorWindow::window_proc(HWND hWnd, UINT uMsg, WPAR
} }
break; break;
} }
case WM_GETMINMAXINFO: {
// enforce a minimum window size so the UI can't be shrunk to a
// point where the tabs/controls become unusable. The minimum is
// expressed in client-area pixels and converted to a full window
// size that accounts for the current frame/title-bar style.
RECT rc = { 0, 0, WINDOW_MIN_SIZE_X, WINDOW_MIN_SIZE_Y };
DWORD style = static_cast<DWORD>(GetWindowLongPtrW(hWnd, GWL_STYLE));
DWORD ex_style = static_cast<DWORD>(GetWindowLongPtrW(hWnd, GWL_EXSTYLE));
if (AdjustWindowRectEx(&rc, style, FALSE, ex_style)) {
auto *mmi = reinterpret_cast<MINMAXINFO *>(lParam);
mmi->ptMinTrackSize.x = rc.right - rc.left;
mmi->ptMinTrackSize.y = rc.bottom - rc.top;
}
break;
}
case WM_CREATE: { case WM_CREATE: {
// set user data of window to class pointer // set user data of window to class pointer
@@ -0,0 +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}")
+552
View File
@@ -0,0 +1,552 @@
// 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 "easywsclient.hpp"
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment( lib, "ws2_32" )
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <vector>
#include <string>
typedef SOCKET socket_t;
#ifndef _SSIZE_T_DEFINED
typedef int ssize_t;
#define _SSIZE_T_DEFINED
#endif
#ifndef _SOCKET_T_DEFINED
typedef SOCKET socket_t;
#define _SOCKET_T_DEFINED
#endif
#if defined(_MSC_VER) && !defined(snprintf)
#define snprintf _snprintf_s
#endif
#include <stdint.h>
#define socketerrno WSAGetLastError()
#define SOCKET_EAGAIN_EINPROGRESS WSAEINPROGRESS
#define SOCKET_EWOULDBLOCK WSAEWOULDBLOCK
#else
#include <fcntl.h>
#include <netdb.h>
#include <netinet/tcp.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#include <string>
#include <vector>
#include <errno.h>
typedef int socket_t;
#ifndef INVALID_SOCKET
#define INVALID_SOCKET (-1)
#endif
#ifndef SOCKET_ERROR
#define SOCKET_ERROR (-1)
#endif
#define closesocket(s) ::close(s)
#include <errno.h>
#define socketerrno errno
#define SOCKET_EAGAIN_EINPROGRESS EAGAIN
#define SOCKET_EWOULDBLOCK EWOULDBLOCK
#endif
#include <vector>
#include <string>
#include <stdarg.h>
#include "util/logging.h"
// When false, easywsclient's diagnostics are suppressed. The host application
// opts in by setting this flag (see obs_websocket.cpp).
bool EASYWSCLIENT_LOGGING_ENABLED = false;
// Route easywsclient's diagnostic output through the project logger instead of
// writing to stderr, without editing the upstream source lines below: these two
// macros redirect fprintf(stderr, ...) / fputs(..., stderr) to log_misc.
// Only emitted when EASYWSCLIENT_LOGGING_ENABLED is set.
static inline void easywsclient_logf(const char *fmt, ...) {
if (!EASYWSCLIENT_LOGGING_ENABLED) {
return;
}
char buf[1024];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
// trim trailing newline(s); the logger appends its own
size_t len = strlen(buf);
while (len > 0 && (buf[len - 1] == '\n' || buf[len - 1] == '\r')) {
buf[--len] = '\0';
}
log_misc("easywsclient", "{}", buf);
}
#define fprintf(stream, ...) easywsclient_logf(__VA_ARGS__)
#define fputs(str, stream) easywsclient_logf("%s", str)
using namespace easywsclient;
namespace { // private module-only namespace
socket_t hostname_connect(const std::string& hostname, int port) {
struct addrinfo hints;
struct addrinfo *result;
struct addrinfo *p;
int ret;
socket_t sockfd = INVALID_SOCKET;
char sport[16];
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;
snprintf(sport, 16, "%d", port);
if ((ret = getaddrinfo(hostname.c_str(), sport, &hints, &result)) != 0)
{
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(ret));
return 1;
}
for(p = result; p != NULL; p = p->ai_next)
{
sockfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
if (sockfd == INVALID_SOCKET) { continue; }
if (connect(sockfd, p->ai_addr, p->ai_addrlen) != SOCKET_ERROR) {
break;
}
closesocket(sockfd);
sockfd = INVALID_SOCKET;
}
freeaddrinfo(result);
return sockfd;
}
class _DummyWebSocket : public easywsclient::WebSocket
{
public:
void poll(int timeout) { }
void send(const std::string& message) { }
void sendBinary(const std::string& message) { }
void sendBinary(const std::vector<uint8_t>& message) { }
void sendPing() { }
void close() { }
readyStateValues getReadyState() const { return CLOSED; }
void _dispatch(Callback_Imp & callable) { }
void _dispatchBinary(BytesCallback_Imp& callable) { }
};
class _RealWebSocket : public easywsclient::WebSocket
{
public:
// http://tools.ietf.org/html/rfc6455#section-5.2 Base Framing Protocol
//
// 0 1 2 3
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
// +-+-+-+-+-------+-+-------------+-------------------------------+
// |F|R|R|R| opcode|M| Payload len | Extended payload length |
// |I|S|S|S| (4) |A| (7) | (16/64) |
// |N|V|V|V| |S| | (if payload len==126/127) |
// | |1|2|3| |K| | |
// +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
// | Extended payload length continued, if payload len == 127 |
// + - - - - - - - - - - - - - - - +-------------------------------+
// | |Masking-key, if MASK set to 1 |
// +-------------------------------+-------------------------------+
// | Masking-key (continued) | Payload Data |
// +-------------------------------- - - - - - - - - - - - - - - - +
// : Payload Data continued ... :
// + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
// | Payload Data continued ... |
// +---------------------------------------------------------------+
struct wsheader_type {
unsigned header_size;
bool fin;
bool mask;
enum opcode_type {
CONTINUATION = 0x0,
TEXT_FRAME = 0x1,
BINARY_FRAME = 0x2,
CLOSE = 8,
PING = 9,
PONG = 0xa,
} opcode;
int N0;
uint64_t N;
uint8_t masking_key[4];
};
std::vector<uint8_t> rxbuf;
std::vector<uint8_t> txbuf;
std::vector<uint8_t> receivedData;
socket_t sockfd;
readyStateValues readyState;
bool useMask;
bool isRxBad;
_RealWebSocket(socket_t sockfd, bool useMask) : sockfd(sockfd), readyState(OPEN), useMask(useMask), isRxBad(false) {
}
readyStateValues getReadyState() const {
return readyState;
}
void poll(int timeout) { // timeout in milliseconds
if (readyState == CLOSED) {
if (timeout > 0) {
timeval tv = { timeout/1000, (timeout%1000) * 1000 };
select(0, NULL, NULL, NULL, &tv);
}
return;
}
if (timeout != 0) {
fd_set rfds;
fd_set wfds;
timeval tv = { timeout/1000, (timeout%1000) * 1000 };
FD_ZERO(&rfds);
FD_ZERO(&wfds);
FD_SET(sockfd, &rfds);
if (txbuf.size()) { FD_SET(sockfd, &wfds); }
select(sockfd + 1, &rfds, &wfds, NULL, timeout > 0 ? &tv : NULL);
}
while (true) {
// FD_ISSET(0, &rfds) will be true
int N = rxbuf.size();
ssize_t ret;
rxbuf.resize(N + 1500);
ret = recv(sockfd, (char*)&rxbuf[0] + N, 1500, 0);
if (false) { }
else if (ret < 0 && (socketerrno == SOCKET_EWOULDBLOCK || socketerrno == SOCKET_EAGAIN_EINPROGRESS)) {
rxbuf.resize(N);
break;
}
else if (ret <= 0) {
rxbuf.resize(N);
closesocket(sockfd);
readyState = CLOSED;
fputs(ret < 0 ? "Connection error!\n" : "Connection closed!\n", stderr);
break;
}
else {
rxbuf.resize(N + ret);
}
}
while (txbuf.size()) {
int ret = ::send(sockfd, (char*)&txbuf[0], txbuf.size(), 0);
if (false) { } // ??
else if (ret < 0 && (socketerrno == SOCKET_EWOULDBLOCK || socketerrno == SOCKET_EAGAIN_EINPROGRESS)) {
break;
}
else if (ret <= 0) {
closesocket(sockfd);
readyState = CLOSED;
fputs(ret < 0 ? "Connection error!\n" : "Connection closed!\n", stderr);
break;
}
else {
txbuf.erase(txbuf.begin(), txbuf.begin() + ret);
}
}
if (!txbuf.size() && readyState == CLOSING) {
closesocket(sockfd);
readyState = CLOSED;
}
}
virtual void _dispatch(Callback_Imp & callable) {
struct CallbackAdapter : public BytesCallback_Imp
// Adapt void(const std::string<uint8_t>&) to void(const std::string&)
{
Callback_Imp& callable;
CallbackAdapter(Callback_Imp& callable) : callable(callable) { }
void operator()(const std::vector<uint8_t>& message) {
std::string stringMessage(message.begin(), message.end());
callable(stringMessage);
}
};
CallbackAdapter bytesCallback(callable);
_dispatchBinary(bytesCallback);
}
virtual void _dispatchBinary(BytesCallback_Imp& callable) {
// TODO: consider acquiring a lock on rxbuf...
while (true) {
wsheader_type ws;
if (rxbuf.size() < 2) { return; /* Need at least 2 */ }
const uint8_t * data = (uint8_t *) &rxbuf[0]; // peek, but don't consume
ws.fin = (data[0] & 0x80) == 0x80;
ws.opcode = (wsheader_type::opcode_type) (data[0] & 0x0f);
ws.mask = (data[1] & 0x80) == 0x80;
ws.N0 = (data[1] & 0x7f);
ws.header_size = 2 + (ws.N0 == 126? 2 : 0) + (ws.N0 == 127? 8 : 0) + (ws.mask? 4 : 0);
if (rxbuf.size() < ws.header_size) { return; /* Need: ws.header_size - rxbuf.size() */ }
int i = 0;
if (ws.N0 < 126) {
ws.N = ws.N0;
i = 2;
}
else if (ws.N0 == 126) {
ws.N = 0;
ws.N |= ((uint64_t) data[2]) << 8;
ws.N |= ((uint64_t) data[3]) << 0;
i = 4;
}
else if (ws.N0 == 127) {
ws.N = 0;
ws.N |= ((uint64_t) data[2]) << 56;
ws.N |= ((uint64_t) data[3]) << 48;
ws.N |= ((uint64_t) data[4]) << 40;
ws.N |= ((uint64_t) data[5]) << 32;
ws.N |= ((uint64_t) data[6]) << 24;
ws.N |= ((uint64_t) data[7]) << 16;
ws.N |= ((uint64_t) data[8]) << 8;
ws.N |= ((uint64_t) data[9]) << 0;
i = 10;
}
if (ws.mask) {
ws.masking_key[0] = ((uint8_t) data[i+0]) << 0;
ws.masking_key[1] = ((uint8_t) data[i+1]) << 0;
ws.masking_key[2] = ((uint8_t) data[i+2]) << 0;
ws.masking_key[3] = ((uint8_t) data[i+3]) << 0;
}
else {
ws.masking_key[0] = 0;
ws.masking_key[1] = 0;
ws.masking_key[2] = 0;
ws.masking_key[3] = 0;
}
// Note: The checks above should hopefully ensure this addition
// cannot overflow:
if (rxbuf.size() < ws.header_size+ws.N) { return; /* Need: ws.header_size+ws.N - rxbuf.size() */ }
// We got a whole message, now do something with it:
if (false) { }
else if (
ws.opcode == wsheader_type::TEXT_FRAME
|| ws.opcode == wsheader_type::BINARY_FRAME
|| ws.opcode == wsheader_type::CONTINUATION
) {
if (ws.mask) { for (size_t i = 0; i != ws.N; ++i) { rxbuf[i+ws.header_size] ^= ws.masking_key[i&0x3]; } }
receivedData.insert(receivedData.end(), rxbuf.begin()+ws.header_size, rxbuf.begin()+ws.header_size+(size_t)ws.N);// just feed
if (ws.fin) {
callable((const std::vector<uint8_t>) receivedData);
receivedData.erase(receivedData.begin(), receivedData.end());
std::vector<uint8_t> ().swap(receivedData);// free memory
}
}
else if (ws.opcode == wsheader_type::PING) {
if (ws.mask) { for (size_t i = 0; i != ws.N; ++i) { rxbuf[i+ws.header_size] ^= ws.masking_key[i&0x3]; } }
std::string data(rxbuf.begin()+ws.header_size, rxbuf.begin()+ws.header_size+(size_t)ws.N);
sendData(wsheader_type::PONG, data.size(), data.begin(), data.end());
}
else if (ws.opcode == wsheader_type::PONG) { }
else if (ws.opcode == wsheader_type::CLOSE) { close(); }
else { fprintf(stderr, "ERROR: Got unexpected WebSocket message.\n"); close(); }
rxbuf.erase(rxbuf.begin(), rxbuf.begin() + ws.header_size+(size_t)ws.N);
}
}
void sendPing() {
std::string empty;
sendData(wsheader_type::PING, empty.size(), empty.begin(), empty.end());
}
void send(const std::string& message) {
sendData(wsheader_type::TEXT_FRAME, message.size(), message.begin(), message.end());
}
void sendBinary(const std::string& message) {
sendData(wsheader_type::BINARY_FRAME, message.size(), message.begin(), message.end());
}
void sendBinary(const std::vector<uint8_t>& message) {
sendData(wsheader_type::BINARY_FRAME, message.size(), message.begin(), message.end());
}
template<class Iterator>
void sendData(wsheader_type::opcode_type type, uint64_t message_size, Iterator message_begin, Iterator message_end) {
// TODO:
// Masking key should (must) be derived from a high quality random
// number generator, to mitigate attacks on non-WebSocket friendly
// middleware:
const uint8_t masking_key[4] = { 0x12, 0x34, 0x56, 0x78 };
// TODO: consider acquiring a lock on txbuf...
if (readyState == CLOSING || readyState == CLOSED) { return; }
std::vector<uint8_t> header;
header.assign(2 + (message_size >= 126 ? 2 : 0) + (message_size >= 65536 ? 6 : 0) + (useMask ? 4 : 0), 0);
header[0] = 0x80 | type;
if (false) { }
else if (message_size < 126) {
header[1] = (message_size & 0xff) | (useMask ? 0x80 : 0);
if (useMask) {
header[2] = masking_key[0];
header[3] = masking_key[1];
header[4] = masking_key[2];
header[5] = masking_key[3];
}
}
else if (message_size < 65536) {
header[1] = 126 | (useMask ? 0x80 : 0);
header[2] = (message_size >> 8) & 0xff;
header[3] = (message_size >> 0) & 0xff;
if (useMask) {
header[4] = masking_key[0];
header[5] = masking_key[1];
header[6] = masking_key[2];
header[7] = masking_key[3];
}
}
else { // TODO: run coverage testing here
header[1] = 127 | (useMask ? 0x80 : 0);
header[2] = (message_size >> 56) & 0xff;
header[3] = (message_size >> 48) & 0xff;
header[4] = (message_size >> 40) & 0xff;
header[5] = (message_size >> 32) & 0xff;
header[6] = (message_size >> 24) & 0xff;
header[7] = (message_size >> 16) & 0xff;
header[8] = (message_size >> 8) & 0xff;
header[9] = (message_size >> 0) & 0xff;
if (useMask) {
header[10] = masking_key[0];
header[11] = masking_key[1];
header[12] = masking_key[2];
header[13] = masking_key[3];
}
}
// N.B. - txbuf will keep growing until it can be transmitted over the socket:
txbuf.insert(txbuf.end(), header.begin(), header.end());
txbuf.insert(txbuf.end(), message_begin, message_end);
if (useMask) {
size_t message_offset = txbuf.size() - message_size;
for (size_t i = 0; i != message_size; ++i) {
txbuf[message_offset + i] ^= masking_key[i&0x3];
}
}
}
void close() {
if(readyState == CLOSING || readyState == CLOSED) { return; }
readyState = CLOSING;
uint8_t closeFrame[6] = {0x88, 0x80, 0x00, 0x00, 0x00, 0x00}; // last 4 bytes are a masking key
std::vector<uint8_t> header(closeFrame, closeFrame+6);
txbuf.insert(txbuf.end(), header.begin(), header.end());
}
};
easywsclient::WebSocket::pointer from_url(const std::string& url, bool useMask, const std::string& origin) {
char host[512];
int port;
char path[512];
if (url.size() >= 512) {
fprintf(stderr, "ERROR: url size limit exceeded: %s\n", url.c_str());
return NULL;
}
if (origin.size() >= 200) {
fprintf(stderr, "ERROR: origin size limit exceeded: %s\n", origin.c_str());
return NULL;
}
if (false) { }
else if (sscanf(url.c_str(), "ws://%[^:/]:%d/%s", host, &port, path) == 3) {
}
else if (sscanf(url.c_str(), "ws://%[^:/]/%s", host, path) == 2) {
port = 80;
}
else if (sscanf(url.c_str(), "ws://%[^:/]:%d", host, &port) == 2) {
path[0] = '\0';
}
else if (sscanf(url.c_str(), "ws://%[^:/]", host) == 1) {
port = 80;
path[0] = '\0';
}
else {
fprintf(stderr, "ERROR: Could not parse WebSocket url: %s\n", url.c_str());
return NULL;
}
//fprintf(stderr, "easywsclient: connecting: host=%s port=%d path=/%s\n", host, port, path);
socket_t sockfd = hostname_connect(host, port);
if (sockfd == INVALID_SOCKET) {
fprintf(stderr, "Unable to connect to %s:%d\n", host, port);
return NULL;
}
{
// XXX: this should be done non-blocking,
char line[1024];
int status;
int i;
snprintf(line, 1024, "GET /%s HTTP/1.1\r\n", path); ::send(sockfd, line, strlen(line), 0);
if (port == 80) {
snprintf(line, 1024, "Host: %s\r\n", host); ::send(sockfd, line, strlen(line), 0);
}
else {
snprintf(line, 1024, "Host: %s:%d\r\n", host, port); ::send(sockfd, line, strlen(line), 0);
}
snprintf(line, 1024, "Upgrade: websocket\r\n"); ::send(sockfd, line, strlen(line), 0);
snprintf(line, 1024, "Connection: Upgrade\r\n"); ::send(sockfd, line, strlen(line), 0);
if (!origin.empty()) {
snprintf(line, 1024, "Origin: %s\r\n", origin.c_str()); ::send(sockfd, line, strlen(line), 0);
}
snprintf(line, 1024, "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==\r\n"); ::send(sockfd, line, strlen(line), 0);
snprintf(line, 1024, "Sec-WebSocket-Version: 13\r\n"); ::send(sockfd, line, strlen(line), 0);
snprintf(line, 1024, "\r\n"); ::send(sockfd, line, strlen(line), 0);
for (i = 0; i < 2 || (i < 1023 && line[i-2] != '\r' && line[i-1] != '\n'); ++i) { if (recv(sockfd, line+i, 1, 0) == 0) { return NULL; } }
line[i] = 0;
if (i == 1023) { fprintf(stderr, "ERROR: Got invalid status line connecting to: %s\n", url.c_str()); return NULL; }
if (sscanf(line, "HTTP/1.1 %d", &status) != 1 || status != 101) { fprintf(stderr, "ERROR: Got bad status connecting to %s: %s", url.c_str(), line); return NULL; }
// TODO: verify response headers,
while (true) {
for (i = 0; i < 2 || (i < 1023 && line[i-2] != '\r' && line[i-1] != '\n'); ++i) { if (recv(sockfd, line+i, 1, 0) == 0) { return NULL; } }
if (line[0] == '\r' && line[1] == '\n') { break; }
}
}
int flag = 1;
setsockopt(sockfd, IPPROTO_TCP, TCP_NODELAY, (char*) &flag, sizeof(flag)); // Disable Nagle's algorithm
#ifdef _WIN32
u_long on = 1;
ioctlsocket(sockfd, FIONBIO, &on);
#else
fcntl(sockfd, F_SETFL, O_NONBLOCK);
#endif
//fprintf(stderr, "Connected to: %s\n", url.c_str());
return easywsclient::WebSocket::pointer(new _RealWebSocket(sockfd, useMask));
}
} // end of module-only namespace
namespace easywsclient {
WebSocket::pointer WebSocket::create_dummy() {
static pointer dummy = pointer(new _DummyWebSocket);
return dummy;
}
WebSocket::pointer WebSocket::from_url(const std::string& url, const std::string& origin) {
return ::from_url(url, true, origin);
}
WebSocket::pointer WebSocket::from_url_no_mask(const std::string& url, const std::string& origin) {
return ::from_url(url, false, origin);
}
} // namespace easywsclient
+73
View File
@@ -0,0 +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 */
+150 -98
View File
@@ -33,13 +33,14 @@ namespace games::ccj {
static UINT WINAPI GetRawInputDeviceList_hook(PRAWINPUTDEVICELIST pRawInputDeviceList, PUINT puiNumDevices, static UINT WINAPI GetRawInputDeviceList_hook(PRAWINPUTDEVICELIST pRawInputDeviceList, PUINT puiNumDevices,
UINT cbSize) { UINT cbSize) {
auto result = GetRawInputDeviceList_orig(pRawInputDeviceList, puiNumDevices, cbSize); auto result = GetRawInputDeviceList_orig(pRawInputDeviceList, puiNumDevices, cbSize);
if (result == 0xFFFFFFFF) if (result == 0xFFFFFFFF) {
return result; return result;
}
if (pRawInputDeviceList == NULL) { if (pRawInputDeviceList == NULL) {
(*puiNumDevices)++; (*puiNumDevices)++;
} else if (result < *puiNumDevices) { } else if (result < *puiNumDevices) {
pRawInputDeviceList[result] = {fakeHandle, 0}; pRawInputDeviceList[result] = { fakeHandle, 0 };
result++; result++;
} }
@@ -47,8 +48,9 @@ namespace games::ccj {
} }
static UINT WINAPI GetRawInputDeviceInfoW_hook(HANDLE hDevice, UINT uiCommand, LPVOID pData, PUINT pcbSize) { static UINT WINAPI GetRawInputDeviceInfoW_hook(HANDLE hDevice, UINT uiCommand, LPVOID pData, PUINT pcbSize) {
if (hDevice != fakeHandle || uiCommand != RIDI_DEVICENAME) if (hDevice != fakeHandle || uiCommand != RIDI_DEVICENAME) {
return GetRawInputDeviceInfoW_orig(hDevice, uiCommand, pData, pcbSize); return GetRawInputDeviceInfoW_orig(hDevice, uiCommand, pData, pcbSize);
}
const auto requiredLen = (wcslen(fakeDeviceName) + 1) * sizeof(wchar_t); const auto requiredLen = (wcslen(fakeDeviceName) + 1) * sizeof(wchar_t);
@@ -68,23 +70,157 @@ namespace games::ccj {
static LONG_PTR WINAPI SetWindowLongPtrW_hook(HWND _hWnd, int nIndex, LONG_PTR dwNewLong) { static LONG_PTR WINAPI SetWindowLongPtrW_hook(HWND _hWnd, int nIndex, LONG_PTR dwNewLong) {
wchar_t buffer[256]; wchar_t buffer[256];
if (nIndex != GWLP_WNDPROC || GetWindowTextW(_hWnd, buffer, 256) == 0 || !wcswcs(buffer, windowName)) if (nIndex != GWLP_WNDPROC || GetWindowTextW(_hWnd, buffer, 256) == 0 || !wcswcs(buffer, windowName)) {
return SetWindowLongPtrW_orig(_hWnd, nIndex, dwNewLong); return SetWindowLongPtrW_orig(_hWnd, nIndex, dwNewLong);
}
hWnd = _hWnd; hWnd = _hWnd;
wndProc = (WNDPROC)dwNewLong; wndProc = (WNDPROC)dwNewLong;
return SetWindowLongPtrW_orig(_hWnd, nIndex, dwNewLong); return SetWindowLongPtrW_orig(_hWnd, nIndex, dwNewLong);
} }
// compute the cursor wrap region in client coordinates. The cursor is confined to the
// monitor, so if the window's client area extends past a screen edge (window larger than /
// offset off the monitor) the cursor can never reach that far client edge. Clamp the region
// to the on-screen portion of the client area so the right/bottom edges wrap as reliably as
// the left/top edges.
static RECT trackball_wrap_bounds(HWND wnd, const RECT &client) {
RECT bounds = client;
POINT origin = { 0, 0 };
ClientToScreen(wnd, &origin);
RECT clientScreen = {
origin.x,
origin.y,
origin.x + client.right,
origin.y + client.bottom
};
MONITORINFO mi = {};
mi.cbSize = sizeof(mi);
RECT usable;
if (GetMonitorInfo(MonitorFromWindow(wnd, MONITOR_DEFAULTTONEAREST), &mi)
&& IntersectRect(&usable, &clientScreen, &mi.rcMonitor)) {
bounds.left = usable.left - origin.x;
bounds.top = usable.top - origin.y;
bounds.right = usable.right - origin.x;
bounds.bottom = usable.bottom - origin.y;
}
return bounds;
}
// drive the trackball from the physical mouse cursor, wrapping it at the window edges so it
// can spin indefinitely. gated by the secondary-mouse button (hold or debounced toggle).
static void trackball_mouse_input(RAWMOUSE &rawMouse) {
static bool active = false;
static bool lastState = false;
static std::chrono::steady_clock::time_point lastModified = std::chrono::steady_clock::now();
static const std::chrono::milliseconds debounceDuration(100);
const auto currentTime = std::chrono::steady_clock::now();
const bool pressed = get_async_secondary_mouse();
const bool focused = GetForegroundWindow() == hWnd;
if (focused && MOUSE_TRACKBALL_USE_TOGGLE && pressed && (currentTime - lastModified > debounceDuration)) {
active = !active;
lastModified = currentTime;
}
const bool engaged = focused
&& ((MOUSE_TRACKBALL_USE_TOGGLE && active) || (!MOUSE_TRACKBALL_USE_TOGGLE && pressed));
if (!engaged) {
if (lastState && !active) {
lastState = false;
}
return;
}
POINT cursor;
RECT client;
GetClientRect(hWnd, &client);
GetCursorPos(&cursor);
ScreenToClient(hWnd, &cursor);
const RECT bounds = trackball_wrap_bounds(hWnd, client);
static int lastX = cursor.x;
static int lastY = cursor.y;
if (!lastState) {
lastX = cursor.x;
lastY = cursor.y;
lastState = true;
}
rawMouse.usFlags = MOUSE_MOVE_RELATIVE;
rawMouse.lLastX = (int)((float)(cursor.x - lastX) * (float)TRACKBALL_SENSITIVITY / 20.0f);
rawMouse.lLastY = (int)((float)(lastY - cursor.y) * (float)TRACKBALL_SENSITIVITY / 20.0f);
// wrap the cursor to the opposite edge once it reaches a boundary, so the trackball
// can keep spinning past the screen edge.
bool updateCursor = false;
auto wrap = [&updateCursor](LONG value, LONG lo, LONG hi) -> LONG {
if (value <= lo) {
updateCursor = true;
return hi - 5;
}
if (value >= hi - 1) {
updateCursor = true;
return lo + 5;
}
return value;
};
cursor.x = wrap(cursor.x, bounds.left, bounds.right);
cursor.y = wrap(cursor.y, bounds.top, bounds.bottom);
lastX = cursor.x;
lastY = cursor.y;
if (updateCursor) {
ClientToScreen(hWnd, &cursor);
SetCursorPos(cursor.x, cursor.y);
}
}
// drive the trackball from the configured analog axes / direction buttons.
static void trackball_mapped_input(RAWMOUSE &rawMouse) {
rawMouse.usFlags = MOUSE_MOVE_RELATIVE;
auto &analogs = get_analogs();
if (analogs[Analogs::Trackball_DX].isSet() || analogs[Analogs::Trackball_DY].isSet()) {
float x = GameAPI::Analogs::getState(RI_MGR, analogs[Analogs::Trackball_DX]) * 2.0f - 1.0f;
float y = GameAPI::Analogs::getState(RI_MGR, analogs[Analogs::Trackball_DY]) * 2.0f - 1.0f;
rawMouse.lLastX = (long) (x * (float) TRACKBALL_SENSITIVITY);
rawMouse.lLastY = (long) (-y * (float) TRACKBALL_SENSITIVITY);
}
auto &buttons = get_buttons();
if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::Trackball_Up])) {
rawMouse.lLastY = TRACKBALL_SENSITIVITY;
}
if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::Trackball_Down])) {
rawMouse.lLastY = -TRACKBALL_SENSITIVITY;
}
if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::Trackball_Left])) {
rawMouse.lLastX = -TRACKBALL_SENSITIVITY;
}
if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::Trackball_Right])) {
rawMouse.lLastX = TRACKBALL_SENSITIVITY;
}
}
static UINT WINAPI GetRawInputData_hook(HRAWINPUT hRawInput, UINT uiCommand, LPVOID pData, PUINT pcbSize, UINT cbSizeHeader) { static UINT WINAPI GetRawInputData_hook(HRAWINPUT hRawInput, UINT uiCommand, LPVOID pData, PUINT pcbSize, UINT cbSizeHeader) {
if (hRawInput != fakeHandle) if (hRawInput != fakeHandle) {
return GetRawInputData_orig(hRawInput, uiCommand, pData, pcbSize, cbSizeHeader); return GetRawInputData_orig(hRawInput, uiCommand, pData, pcbSize, cbSizeHeader);
}
if (pData == NULL) { if (pData == NULL) {
if (uiCommand == RID_HEADER) if (uiCommand == RID_HEADER) {
*pcbSize = sizeof(RAWINPUTHEADER); *pcbSize = sizeof(RAWINPUTHEADER);
else } else {
*pcbSize = sizeof(RAWINPUT); *pcbSize = sizeof(RAWINPUT);
}
return 0; return 0;
} }
@@ -107,92 +243,9 @@ namespace games::ccj {
RAWMOUSE rawMouse {}; RAWMOUSE rawMouse {};
if (MOUSE_TRACKBALL) { if (MOUSE_TRACKBALL) {
static bool active = false; trackball_mouse_input(rawMouse);
static bool lastState = false;
static std::chrono::time_point<std::chrono::steady_clock> lastModified = std::chrono::steady_clock::now();
static std::chrono::milliseconds debounceDuration(100);
auto currentTime = std::chrono::steady_clock::now();
bool pressed = get_async_secondary_mouse();
bool focused = GetForegroundWindow() == hWnd;
if (focused && MOUSE_TRACKBALL_USE_TOGGLE && pressed && (currentTime - lastModified > debounceDuration)) {
active = !active;
lastModified = currentTime;
}
if (focused && ((MOUSE_TRACKBALL_USE_TOGGLE && active) || (!MOUSE_TRACKBALL_USE_TOGGLE && pressed))) {
POINT cursor;
RECT client;
GetClientRect(hWnd, &client);
int sx = client.right - client.left;
int sy = client.bottom - client.top;
GetCursorPos(&cursor);
ScreenToClient(hWnd, &cursor);
static int lastX = cursor.x;
static int lastY = cursor.y;
if (!lastState) {
lastX = cursor.x;
lastY = cursor.y;
lastState = true;
}
rawMouse.usFlags = MOUSE_MOVE_RELATIVE;
rawMouse.lLastX = (int)((float)(cursor.x - lastX) * (float)TRACKBALL_SENSITIVITY / 20.0f);
rawMouse.lLastY = (int)((float)(lastY - cursor.y) * (float)TRACKBALL_SENSITIVITY / 20.0f);
bool updateCursor = false;
if (cursor.x <= 0) {
updateCursor = true;
cursor.x = sx - 5;
} else if (cursor.x >= sx - 1) {
updateCursor = true;
cursor.x = 5;
}
if (cursor.y <= 0) {
updateCursor = true;
cursor.y = sy - 5;
} else if (cursor.y >= sy - 1) {
updateCursor = true;
cursor.y = 5;
}
lastX = cursor.x;
lastY = cursor.y;
if (updateCursor) {
ClientToScreen(hWnd, &cursor);
SetCursorPos(cursor.x, cursor.y);
}
} else if (lastState && !active) {
lastState = false;
}
} else { } else {
rawMouse.usFlags = MOUSE_MOVE_RELATIVE; trackball_mapped_input(rawMouse);
auto &analogs = get_analogs();
if (analogs[Analogs::Trackball_DX].isSet() || analogs[Analogs::Trackball_DY].isSet()) {
float x = GameAPI::Analogs::getState(RI_MGR, analogs[Analogs::Trackball_DX]) * 2.0f - 1.0f;
float y = GameAPI::Analogs::getState(RI_MGR, analogs[Analogs::Trackball_DY]) * 2.0f - 1.0f;
rawMouse.lLastX = (long) (x * (float) TRACKBALL_SENSITIVITY);
rawMouse.lLastY = (long) (-y * (float) TRACKBALL_SENSITIVITY);
}
auto &buttons = get_buttons();
if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::Trackball_Up]))
rawMouse.lLastY = TRACKBALL_SENSITIVITY;
if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::Trackball_Down]))
rawMouse.lLastY = -TRACKBALL_SENSITIVITY;
if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::Trackball_Left]))
rawMouse.lLastX = -TRACKBALL_SENSITIVITY;
if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::Trackball_Right]))
rawMouse.lLastX = TRACKBALL_SENSITIVITY;
} }
*((RAWINPUT*)pData) = { header, { rawMouse } }; *((RAWINPUT*)pData) = { header, { rawMouse } };
@@ -224,9 +277,8 @@ namespace games::ccj {
static bool initialized = false; static bool initialized = false;
if (initialized) { if (initialized) {
return; return;
} else {
initialized = true;
} }
initialized = true;
// announce // announce
log_info("trackball", "init"); log_info("trackball", "init");
@@ -246,8 +298,6 @@ namespace games::ccj {
} }
void trackball_thread_start() { void trackball_thread_start() {
using namespace std::chrono_literals;
tbThreadRunning = true; tbThreadRunning = true;
log_info("trackball", "thread start, use mouse: {}, toggle: {}", MOUSE_TRACKBALL, MOUSE_TRACKBALL_USE_TOGGLE); log_info("trackball", "thread start, use mouse: {}, toggle: {}", MOUSE_TRACKBALL, MOUSE_TRACKBALL_USE_TOGGLE);
@@ -259,8 +309,9 @@ namespace games::ccj {
wndProc(hWnd, WM_INPUT, RIM_INPUT, (LPARAM)fakeHandle); wndProc(hWnd, WM_INPUT, RIM_INPUT, (LPARAM)fakeHandle);
} }
if (!tbThreadRunning) if (!tbThreadRunning) {
break; break;
}
timer.sleep(10); timer.sleep(10);
} }
@@ -269,8 +320,9 @@ namespace games::ccj {
void trackball_thread_stop() { void trackball_thread_stop() {
tbThreadRunning = false; tbThreadRunning = false;
if (tbThread) if (tbThread) {
tbThread->join(); tbThread->join();
}
log_info("trackball", "thread stop"); log_info("trackball", "thread stop");
+2
View File
@@ -474,6 +474,8 @@ namespace games {
vkey_defaults.push_back(VK_F12); vkey_defaults.push_back(VK_F12);
names.emplace_back("Toggle Camera Control"); names.emplace_back("Toggle Camera Control");
vkey_defaults.push_back(0xFF); vkey_defaults.push_back(0xFF);
names.emplace_back("Toggle OBS Control");
vkey_defaults.push_back(0xFF);
names.emplace_back("Player 1 PIN Macro"); names.emplace_back("Player 1 PIN Macro");
vkey_defaults.push_back(0xFF); vkey_defaults.push_back(0xFF);
names.emplace_back("Player 2 PIN Macro"); names.emplace_back("Player 2 PIN Macro");
+1
View File
@@ -23,6 +23,7 @@ namespace games {
ToggleScreenResize, ToggleScreenResize,
ToggleFps, ToggleFps,
ToggleCameraControl, ToggleCameraControl,
ToggleOBSControl,
TriggerPinMacroP1, TriggerPinMacroP1,
TriggerPinMacroP2, TriggerPinMacroP2,
ScreenResize, ScreenResize,
+5 -210
View File
@@ -3,212 +3,24 @@
#include <windows.h> #include <windows.h>
#include <filesystem> #include <filesystem>
#include "avs/game.h"
#include "cfg/configurator.h" #include "cfg/configurator.h"
#include "hooks/graphics/graphics.h"
#include "touch/touch.h"
#include "util/logging.h" #include "util/logging.h"
#include "util/utils.h"
#include "util/detour.h" #include "util/detour.h"
#include "util/libutils.h" #include "util/libutils.h"
#define JB_BUTTON_SIZE 160
#define JB_BUTTON_GAP 37
#define JB_BUTTON_HITBOX (JB_BUTTON_SIZE + JB_BUTTON_GAP)
namespace games::jb { namespace games::jb {
// touch stuff // fixes "IP ADDR CHANGE" errors with unusual network setups (e.g. a VPN)
JubeatTouchAlgorithm TOUCH_ALGORITHM = Improved;
static bool TOUCH_ENABLE = false;
static bool TOUCH_ATTACHED = false;
static bool IS_PORTRAIT = true;
static std::vector<TouchPoint> TOUCH_POINTS;
bool TOUCH_STATE[16];
void touch_update() {
// check if touch enabled
if (!TOUCH_ENABLE) {
return;
}
// attach touch module
if (!TOUCH_ATTACHED) {
/*
* Find the game window.
* We check the foreground window first, then fall back to searching for the window title
* All game versions seem to have their model first in the window title
*/
HWND wnd = GetForegroundWindow();
if (!string_begins_with(GetActiveWindowTitle(), avs::game::MODEL)) {
wnd = FindWindowBeginsWith(avs::game::MODEL);
}
// check if we have a window handle
if (!wnd) {
log_warning("jubeat", "could not find window handle for touch");
TOUCH_ENABLE = false;
return;
}
// attach touch hook
log_info("jubeat", "using window handle for touch: {}", fmt::ptr(wnd));
touch_create_wnd(wnd, true);
// show cursor
if (GRAPHICS_SHOW_CURSOR) {
ShowCursor(TRUE);
}
// earlier games use a different screen orientation
if (!avs::game::is_model("L44")) {
IS_PORTRAIT = false;
}
// set attached
TOUCH_ATTACHED = true;
}
// reset touch state
memset(TOUCH_STATE, 0, sizeof(TOUCH_STATE));
// check touch points
// note that the IO code in device.cpp will correctly compensate for orientation, depending on the model.
TOUCH_POINTS.clear();
touch_get_points(TOUCH_POINTS);
if (TOUCH_ALGORITHM == Legacy) {
auto offset = IS_PORTRAIT ? 580 : 0;
for (auto &tp : TOUCH_POINTS) {
// get grid coordinates
int x = tp.x * 4 / 768;
int y = (tp.y - offset) * 4 / (1360 - 580);
// set the corresponding state
int index = y * 4 + x;
if (index >= 0 && index < 16) {
TOUCH_STATE[index] = true;
}
}
} else {
for (auto &tp : TOUCH_POINTS) {
// check window out of bounds
if (IS_PORTRAIT) {
if (tp.x > 768 || tp.y > 1360) {
continue;
}
} else {
if (tp.x > 1360 || tp.y > 768) {
continue;
}
}
int x_relative = tp.x;
int y_relative = tp.y;
// x_relative and y_relative are relative to the top-left pixel of the first button
if (IS_PORTRAIT) {
// which is at (8, 602) in portrait:
// X: [8...759] (752 pixels wide)
// Y: [602...1353] (752 pixels high)
x_relative -= 8;
y_relative -= 602;
} else {
// and at (8, 8) in landscape
x_relative -= 8;
y_relative -= 8;
}
int x_index = -1;
int x_hitbox = 0;
int y_index = -1;
int y_hitbox = 0;
// x_hitbox and y_hitbox is relative to top-left pixel of each button
if (x_relative >= 0) {
x_index = x_relative / JB_BUTTON_HITBOX;
x_hitbox = x_relative % JB_BUTTON_HITBOX;
}
if (y_relative >= 0) {
y_index = y_relative / JB_BUTTON_HITBOX;
y_hitbox = y_relative % JB_BUTTON_HITBOX;
}
// log_info("jb", "raw={}, idx={}, hitbox={}", tp.x, x_index, x_hitbox);
if (TOUCH_ALGORITHM == Improved) {
if (IS_PORTRAIT) {
// extend to left border
if (x_relative < 0) {
x_index = 0;
}
// right and bottom borders are covered by the hit box
} else {
// extend to top border
if (y_relative < 0) {
y_index = 0;
}
// extend to left border
if (x_relative < 0) {
x_index = 0;
}
// bottom border is covered by the hit box
// rightmost edge - ignore them entirely
if (x_index == 3 && JB_BUTTON_SIZE < x_hitbox) {
continue;
}
}
}
if (x_index < 0 || y_index < 0 || x_index > 3 || y_index > 3) {
continue;
}
// check if the gap was touched
if (TOUCH_ALGORITHM == AcAccurate) {
// in ac-accurate mode, touching the gap is ignored
if (x_hitbox > JB_BUTTON_SIZE || y_hitbox > JB_BUTTON_SIZE) {
continue;
}
} else if (TOUCH_ALGORITHM == Improved) {
// in improved mode, touching the gap triggers the closest button
if (x_index <= 2 && (JB_BUTTON_SIZE + JB_BUTTON_GAP / 2) < x_hitbox) {
x_index++;
}
if (y_index <= 2 && (JB_BUTTON_SIZE + JB_BUTTON_GAP / 2) < y_hitbox) {
y_index++;
}
}
// set the corresponding state
int index = y_index * 4 + x_index;
if (0 <= index && index < 16) {
TOUCH_STATE[index] = true;
}
}
}
}
/*
* to fix "IP ADDR CHANGE" errors on boot and in-game when using weird network setups such as a VPN
*/
static BOOL __stdcall network_addr_is_changed() { static BOOL __stdcall network_addr_is_changed() {
return 0; return 0;
} }
/* // fixes lag spikes from the periodic ping to "eamuse.konami.fun"
* to fix lag spikes when game tries to ping "eamuse.konami.fun" every few minutes
*/
static BOOL __stdcall network_get_network_check_info() { static BOOL __stdcall network_get_network_check_info() {
return 0; return 0;
} }
/* // fixes network errors on non-DHCP interfaces
* to fix network error on non DHCP interfaces
*/
static BOOL __cdecl network_get_dhcp_result() { static BOOL __cdecl network_get_dhcp_result() {
return 1; return 1;
} }
@@ -248,23 +60,7 @@ namespace games::jb {
void JBGame::attach() { void JBGame::attach() {
Game::attach(); Game::attach();
// enable touch touch_attach();
TOUCH_ENABLE = true;
switch (TOUCH_ALGORITHM) {
case Legacy:
log_info("jubeat", "using 'legacy' touch targets");
break;
case Improved:
log_info("jubeat", "using 'improved' touch targets");
break;
case AcAccurate:
log_info("jubeat", "using 'ac accurate' touch targets");
break;
default:
log_fatal("jubeat", "unknown touch algo detected in touch_update, this is a bug");
break;
}
// enable debug logging of gftools // enable debug logging of gftools
HMODULE gftools = libutils::try_module("gftools.dll"); HMODULE gftools = libutils::try_module("gftools.dll");
@@ -284,7 +80,6 @@ namespace games::jb {
void JBGame::detach() { void JBGame::detach() {
Game::detach(); Game::detach();
// disable touch touch_detach();
TOUCH_ENABLE = false;
} }
} }
+1 -11
View File
@@ -1,20 +1,10 @@
#pragma once #pragma once
#include "games/game.h" #include "games/game.h"
#include "games/jb/jb_touch.h"
namespace games::jb { namespace games::jb {
enum JubeatTouchAlgorithm {
Legacy,
Improved,
AcAccurate
};
// touch stuff
extern JubeatTouchAlgorithm TOUCH_ALGORITHM;
extern bool TOUCH_STATE[16];
void touch_update();
class JBGame : public games::Game { class JBGame : public games::Game {
public: public:
JBGame(); JBGame();
+470
View File
@@ -0,0 +1,470 @@
#include "jb_touch.h"
#include <windows.h>
#include <atomic>
#include <cmath>
#include <limits>
#include <vector>
#include "avs/game.h"
#include "hooks/graphics/graphics.h"
#include "launcher/launcher.h"
#include "touch/touch.h"
#include "rawinput/touch.h"
#include "util/logging.h"
#include "util/time.h"
#include "util/utils.h"
// touch layout: a 4x4 grid of 160px buttons separated by 37 / 38 / 37 px gaps
// (752px across). the first button's top-left is at (8, 602) in portrait and
// (6, 8) in landscape.
#define JB_BUTTON_SIZE 160
#define JB_MAX_BUTTON_GAP 38
// improved and plus modes use this reach around each button. must be >= the
// diagonal half of the widest gap (~27px) so the grid centre still reaches a button.
#define JB_TOUCH_RADIUS 38
namespace games::jb {
static_assert(std::atomic_bool::is_always_lock_free);
static_assert(std::atomic_uint16_t::is_always_lock_free);
static_assert(std::atomic<POINT>::is_always_lock_free);
static constexpr int JB_MAX_GAP_DISTANCE = (JB_MAX_BUTTON_GAP + 1) / 2;
static_assert(JB_TOUCH_RADIUS * JB_TOUCH_RADIUS >=
2 * JB_MAX_GAP_DISTANCE * JB_MAX_GAP_DISTANCE);
// touch state
JubeatTouchAlgorithm TOUCH_ALGORITHM = Improved;
JubeatTouchDebugMode TOUCH_DEBUG_OVERLAY = JbTouchDebugAuto;
uint32_t TOUCH_DEBOUNCE_MS = 0;
static std::atomic_bool TOUCH_ENABLE = false;
static bool TOUCH_ATTACHED = false;
static bool IS_PORTRAIT = true;
static std::atomic_uint16_t TOUCH_STATE = 0;
// fixed-size contact view used by the debug overlay
static const size_t JB_MAX_TOUCH_POINTS = 16;
static constexpr LONG JB_INVALID_TOUCH_COORD = std::numeric_limits<LONG>::max();
static constexpr POINT JB_INVALID_TOUCH_POINT {
JB_INVALID_TOUCH_COORD,
JB_INVALID_TOUCH_COORD
};
static std::atomic<POINT> TOUCH_POINTS[JB_MAX_TOUCH_POINTS] {};
static void clear_touch_points() {
for (auto &point : TOUCH_POINTS) {
point.store(JB_INVALID_TOUCH_POINT, std::memory_order_release);
}
}
// false when a contact is younger than the debounce window and should be ignored
static bool touch_matured(const TouchPoint &tp, double now_ms, double threshold_ms) {
return now_ms - tp.down_ms >= threshold_ms;
}
// snapshot for the debug overlay: each slot atomically holds one matured contact or
// the invalid sentinel. immature and absent contacts are published as invalid, so the
// overlay never shows a suppressed tap and a single atomic per slot cannot tear
static void publish_touch_points(const std::vector<TouchPoint> &touch_points,
double now_ms, double threshold_ms) {
size_t count = touch_points.size();
if (count > JB_MAX_TOUCH_POINTS) {
count = JB_MAX_TOUCH_POINTS;
}
for (size_t i = 0; i < JB_MAX_TOUCH_POINTS; i++) {
POINT point = JB_INVALID_TOUCH_POINT;
if (i < count && touch_matured(touch_points[i], now_ms, threshold_ms)) {
point = { touch_points[i].x, touch_points[i].y };
}
TOUCH_POINTS[i].store(point, std::memory_order_release);
}
}
// --- touch geometry ------------------------------------------------------
// gaps between the four buttons along one axis (the middle gap is 1px wider)
static const int JB_BUTTON_GAPS[3] = { 37, JB_MAX_BUTTON_GAP, 37 };
struct AxisGeometry {
int button[4]; // left/top edge of each button
};
// left/top edges of the four buttons along one axis, starting at `first`
static AxisGeometry axis_geometry(int first) {
AxisGeometry g {};
g.button[0] = first;
for (int i = 1; i < 4; i++) {
g.button[i] = g.button[i - 1] + JB_BUTTON_SIZE + JB_BUTTON_GAPS[i - 1];
}
return g;
}
// button edges for the current orientation
static void touch_geometry(AxisGeometry &gx, AxisGeometry &gy) {
if (IS_PORTRAIT) {
gx = axis_geometry(8);
gy = axis_geometry(602);
} else {
gx = axis_geometry(6);
gy = axis_geometry(8);
}
}
// distance from `p` to a button along one axis (0 when inside)
static int axis_distance(int p, int button) {
int end = button + JB_BUTTON_SIZE - 1;
if (p < button) {
return button - p;
}
if (p > end) {
return p - end;
}
return 0;
}
// index (0..15) of the nearest button to (px, py) within `radius`, or -1 if none;
// shared by detection and the debug overlay so both agree which button a touch hits
static int nearest_button(
int px, int py, const AxisGeometry &gx, const AxisGeometry &gy, int radius) {
int best_index = -1;
int best_dist = 0;
for (int r = 0; r < 4; r++) {
for (int c = 0; c < 4; c++) {
int dx = axis_distance(px, gx.button[c]);
int dy = axis_distance(py, gy.button[r]);
int dist = dx * dx + dy * dy;
if (dist <= radius * radius && (best_index < 0 || dist < best_dist)) {
best_dist = dist;
best_index = r * 4 + c;
}
}
}
return best_index;
}
// detection reach for the current algorithm (0 = register only inside a button)
static int touch_radius() {
return TOUCH_ALGORITHM == AcAccurate ? 0 : JB_TOUCH_RADIUS;
}
// mark the buttons a touch at (px, py) hits: only the nearest within `radius`, or
// every button within `radius` when `multi` (edge/gap presses trigger several)
static void mark_buttons(uint16_t &state, int px, int py,
const AxisGeometry &gx, const AxisGeometry &gy,
int radius, bool multi) {
if (!multi) {
int index = nearest_button(px, py, gx, gy, radius);
if (index >= 0) {
state |= uint16_t(1) << index;
}
return;
}
for (int r = 0; r < 4; r++) {
for (int c = 0; c < 4; c++) {
int dx = axis_distance(px, gx.button[c]);
int dy = axis_distance(py, gy.button[r]);
if (dx * dx + dy * dy <= radius * radius) {
state |= uint16_t(1) << (r * 4 + c);
}
}
}
}
std::bitset<16> touch_state() {
return std::bitset<16>(TOUCH_STATE.load(std::memory_order_acquire));
}
void touch_update() {
if (!TOUCH_ENABLE.load(std::memory_order_acquire)) {
return;
}
// one-time touch window attach
if (!TOUCH_ATTACHED) {
// find the game window: prefer the foreground window, else search by
// title (the model name prefixes the window title in every version)
HWND wnd = GetForegroundWindow();
if (!string_begins_with(GetActiveWindowTitle(), avs::game::MODEL)) {
wnd = FindWindowBeginsWith(avs::game::MODEL);
}
if (!wnd) {
log_warning("jubeat", "could not find window handle for touch");
TOUCH_ENABLE.store(false, std::memory_order_release);
TOUCH_STATE.store(0, std::memory_order_release);
return;
}
// only the L44 model runs in portrait; set this before starting the
// touch-window thread so the renderer only observes the final value
IS_PORTRAIT = avs::game::is_model("L44");
log_info("jubeat", "using window handle for touch: {}", fmt::ptr(wnd));
touch_create_wnd(wnd, true);
// let the rawinput stack correct the aspect ratio
::rawinput::touch::ASPECT_COMPENSATION_GAME = true;
if (GRAPHICS_SHOW_CURSOR) {
ShowCursor(TRUE);
}
TOUCH_ATTACHED = true;
}
// calculate the next state locally and publish it after processing every touch
uint16_t next_state = 0;
std::vector<TouchPoint> touch_points;
touch_get_points(touch_points);
// debounce: the landing time is stamped by the touch layer, so this is
// independent of how often the game polls for input (0 = off)
double now_ms = get_performance_milliseconds();
double threshold_ms = TOUCH_DEBOUNCE_MS;
// publish every raw contact (with its maturity) for the debug overlay, then drop
// the ones still inside the debounce window so no algorithm sees them
publish_touch_points(touch_points, now_ms, threshold_ms);
std::erase_if(touch_points, [&](const TouchPoint &tp) {
return !touch_matured(tp, now_ms, threshold_ms);
});
if (TOUCH_ALGORITHM == Legacy) {
// legacy: evenly divide the play area into a 4x4 grid
auto offset = IS_PORTRAIT ? 580 : 0;
for (auto &tp : touch_points) {
int x = tp.x * 4 / 768;
int y = (tp.y - offset) * 4 / (1360 - 580);
int index = y * 4 + x;
if (index >= 0 && index < 16) {
next_state |= uint16_t(1) << index;
}
}
} else {
// accurate registers only a touch inside a button; improved snaps each touch
// to the single nearest button within reach (so a gap or centre touch still
// triggers exactly one button); plus marks every button within the same reach,
// so an edge or gap touch can trigger several at once (like the mobile game)
AxisGeometry gx, gy;
touch_geometry(gx, gy);
int radius = touch_radius();
bool multi = (TOUCH_ALGORITHM == Plus);
for (auto &tp : touch_points) {
mark_buttons(next_state, tp.x, tp.y, gx, gy, radius, multi);
}
}
TOUCH_STATE.store(next_state, std::memory_order_release);
}
// true when any supported touch handler detects a touchscreen (checked once)
static bool touchscreen_detected() {
static const bool detected = is_touch_available("jubeat touch debug overlay");
return detected;
}
// auto draws the boxes only when a touch screen is present
static bool debug_show_boxes() {
switch (TOUCH_DEBUG_OVERLAY) {
case JbTouchDebugBox:
case JbTouchDebugAll:
return true;
case JbTouchDebugAuto:
return touchscreen_detected();
default:
return false;
}
}
static bool debug_show_taps() {
return TOUCH_DEBUG_OVERLAY == JbTouchDebugAll;
}
bool touch_debug_overlay_enabled() {
return TOUCH_ENABLE.load(std::memory_order_acquire) &&
(debug_show_boxes() || debug_show_taps());
}
// the 16 boundary rects: legacy divides the area evenly, others use button squares
static void debug_cells(
bool legacy, const AxisGeometry &gx, const AxisGeometry &gy, RECT cells[16]) {
if (legacy) {
int offset = IS_PORTRAIT ? 580 : 0;
int x_edges[5];
int y_edges[5];
for (int i = 0; i <= 4; i++) {
x_edges[i] = i * 768 / 4;
y_edges[i] = offset + i * (1360 - 580) / 4;
}
for (int r = 0; r < 4; r++) {
for (int c = 0; c < 4; c++) {
cells[r * 4 + c] = { x_edges[c], y_edges[r], x_edges[c + 1], y_edges[r + 1] };
}
}
} else {
for (int r = 0; r < 4; r++) {
for (int c = 0; c < 4; c++) {
cells[r * 4 + c] = {
gx.button[c], gy.button[r],
gx.button[c] + JB_BUTTON_SIZE, gy.button[r] + JB_BUTTON_SIZE
};
}
}
}
}
// hollow box outlines: grey idle, thick green when pressed (PS_INSIDEFRAME stays inside)
static void draw_debug_boxes(
HDC hdc, bool legacy, const AxisGeometry &gx, const AxisGeometry &gy) {
RECT cells[16];
debug_cells(legacy, gx, gy, cells);
HPEN pen_idle = CreatePen(PS_SOLID, 1, RGB(160, 160, 160));
HPEN pen_active = CreatePen(PS_INSIDEFRAME, 4, RGB(0, 200, 0));
auto state = touch_state();
for (int i = 0; i < 16; i++) {
HGDIOBJ old_pen = SelectObject(hdc, state[i] ? pen_active : pen_idle);
Rectangle(hdc, cells[i].left, cells[i].top, cells[i].right, cells[i].bottom);
SelectObject(hdc, old_pen);
}
DeleteObject(pen_idle);
DeleteObject(pen_active);
}
// whether a touch at (px, py) presses at least one button: legacy has no gaps, plus
// reaches across gaps to nearby buttons, and the others only fire inside a button square
static bool touch_presses_button(int px, int py, const AxisGeometry &gx, const AxisGeometry &gy,
bool legacy, int radius) {
if (legacy) {
return true;
}
int reach = (TOUCH_ALGORITHM == Plus) ? radius : 0;
return nearest_button(px, py, gx, gy, reach) >= 0;
}
// 90-degree arc on the circle rim facing the centre of button `index`
static void draw_tap_arc(HDC hdc, int px, int py, int index,
const AxisGeometry &gx, const AxisGeometry &gy, int arc_radius) {
int c = index % 4;
int r = index / 4;
double mid = std::atan2((gy.button[r] + JB_BUTTON_SIZE / 2) - py,
(gx.button[c] + JB_BUTTON_SIZE / 2) - px);
const double quarter = 3.14159265358979323846 / 2.0;
const int segments = 16;
POINT arc[segments + 1];
for (int i = 0; i <= segments; i++) {
double a = mid - quarter / 2.0 + quarter * i / segments;
arc[i].x = px + static_cast<LONG>(std::lround(arc_radius * std::cos(a)));
arc[i].y = py + static_cast<LONG>(std::lround(arc_radius * std::sin(a)));
}
Polyline(hdc, arc, segments + 1);
}
// circle at each touch: white when it presses a button, grey otherwise; in single-button
// modes a gap touch also gets a white arc facing the button it snapped to (skipped in
// plus, where a touch can trigger several buttons at once)
static void draw_debug_taps(
HDC hdc, bool legacy, const AxisGeometry &gx, const AxisGeometry &gy) {
// PS_INSIDEFRAME keeps the stroke inside the circle radius
const int stroke = 4;
HPEN pen_white = CreatePen(PS_INSIDEFRAME, stroke, RGB(255, 255, 255));
HPEN pen_gray = CreatePen(PS_INSIDEFRAME, stroke, RGB(128, 128, 128));
HGDIOBJ old_pen = SelectObject(hdc, pen_white);
// accurate has no reach, so fall back to a visible marker size when drawing the circle
int radius = touch_radius();
int draw_radius = radius > 0 ? radius : JB_TOUCH_RADIUS;
// each slot holds a matured contact or the invalid sentinel; immature and absent
// contacts were already filtered out by publish_touch_points
for (auto &touch_point : TOUCH_POINTS) {
POINT point = touch_point.load(std::memory_order_acquire);
if (point.x == JB_INVALID_TOUCH_COORD) {
continue;
}
bool pressed = touch_presses_button(point.x, point.y, gx, gy, legacy, radius);
SelectObject(hdc, pressed ? pen_white : pen_gray);
Ellipse(hdc, point.x - draw_radius, point.y - draw_radius,
point.x + draw_radius, point.y + draw_radius);
// the arc points at a single snapped-to button; plus can trigger several at
// once, so skip it there
if (pressed || TOUCH_ALGORITHM == Plus) {
continue;
}
int index = nearest_button(point.x, point.y, gx, gy, radius);
if (index >= 0) {
SelectObject(hdc, pen_white);
draw_tap_arc(hdc, point.x, point.y, index, gx, gy, draw_radius - stroke / 2);
}
}
SelectObject(hdc, old_pen);
DeleteObject(pen_white);
DeleteObject(pen_gray);
}
void touch_draw_debug_overlay(HDC hdc) {
// only draw while touch is active
if (!TOUCH_ENABLE.load(std::memory_order_acquire)) {
return;
}
bool show_boxes = debug_show_boxes();
bool show_taps = debug_show_taps();
if (!show_boxes && !show_taps) {
return;
}
// legacy divides the field evenly; the other algorithms use button squares
bool legacy = (TOUCH_ALGORITHM == Legacy);
AxisGeometry gx {}, gy {};
if (!legacy) {
touch_geometry(gx, gy);
}
HGDIOBJ old_brush = SelectObject(hdc, GetStockObject(NULL_BRUSH));
if (show_boxes) {
draw_debug_boxes(hdc, legacy, gx, gy);
}
if (show_taps) {
draw_debug_taps(hdc, legacy, gx, gy);
}
SelectObject(hdc, old_brush);
}
void touch_attach() {
clear_touch_points();
TOUCH_ENABLE.store(true, std::memory_order_release);
switch (TOUCH_ALGORITHM) {
case Legacy:
log_info("jubeat", "using 'legacy' touch targets");
break;
case Improved:
log_info("jubeat", "using 'improved' touch targets");
break;
case Plus:
log_info("jubeat", "using 'plus' touch targets");
break;
case AcAccurate:
log_info("jubeat", "using 'ac accurate' touch targets");
break;
default:
log_fatal("jubeat", "unknown touch algo, this is a bug");
break;
}
}
void touch_detach() {
TOUCH_ENABLE.store(false, std::memory_order_release);
TOUCH_STATE.store(0, std::memory_order_release);
}
}
+49
View File
@@ -0,0 +1,49 @@
#pragma once
#include <bitset>
#include <cstdint>
#include <windows.h>
namespace games::jb {
// touch detection algorithm
enum JubeatTouchAlgorithm {
Legacy,
Improved,
Plus,
AcAccurate
};
// debug touch overlay mode
enum JubeatTouchDebugMode {
JbTouchDebugAuto, // boundary boxes when a touch screen is detected, else nothing
JbTouchDebugNone, // draw nothing
JbTouchDebugBox, // boundary boxes
JbTouchDebugAll, // boundary boxes and touch circles
};
// selected algorithm and debug mode (set from the launcher options)
extern JubeatTouchAlgorithm TOUCH_ALGORITHM;
extern JubeatTouchDebugMode TOUCH_DEBUG_OVERLAY;
// minimum contact age in milliseconds before a touch registers (0 = off);
// filters momentary phantom touches from noisy panels
extern uint32_t TOUCH_DEBOUNCE_MS;
// atomically published panel state, read by the I/O layer
std::bitset<16> touch_state();
// read the current touch points and refresh the panel state
void touch_update();
// enable/disable touch handling on game attach/detach
void touch_attach();
void touch_detach();
// whether the debug overlay should currently draw anything
bool touch_debug_overlay_enabled();
// draw the debug touch overlay (boundary boxes and/or touch circles per the
// selected mode) onto the spice touch overlay window's device context
void touch_draw_debug_overlay(HDC hdc);
}
+2 -2
View File
@@ -41,7 +41,7 @@ void games::rb::RBGame::attach() {
Game::attach(); Game::attach();
if (1000 < games::rb::TOUCH_POLL_RATE) { if (1000 < games::rb::TOUCH_POLL_RATE) {
log_fatal("rb", "-rbtouchsize is set too high; cannot exceed 1000"); log_fatal("rb", "-rbtouchhz is set too high; cannot exceed 1000");
} }
// init stuff // init stuff
@@ -60,7 +60,7 @@ void games::rb::RBGame::attach() {
log_info("rb", "force increasing touch poll rate by hooking SleepEx ({}Hz)", TOUCH_POLL_RATE); log_info("rb", "force increasing touch poll rate by hooking SleepEx ({}Hz)", TOUCH_POLL_RATE);
SleepEx_orig = detour::iat_try("SleepEx", SleepEx_hook, avs::game::DLL_INSTANCE); SleepEx_orig = detour::iat_try("SleepEx", SleepEx_hook, avs::game::DLL_INSTANCE);
} else { } else {
log_info("rb", "not changing touch poll rate (120Hz by default)"); log_info("rb", "not changing touch poll rate (~125Hz by default)");
} }
} }
-1
View File
@@ -7,7 +7,6 @@
namespace games::rb { namespace games::rb {
extern uint16_t TOUCH_SCALING; extern uint16_t TOUCH_SCALING;
extern uint8_t TOUCH_SIZE;
extern uint16_t TOUCH_POLL_RATE; extern uint16_t TOUCH_POLL_RATE;
class RBGame : public games::Game { class RBGame : public games::Game {
+80 -51
View File
@@ -4,7 +4,10 @@
#include "avs/game.h" #include "avs/game.h"
#include "hooks/graphics/graphics.h" #include "hooks/graphics/graphics.h"
#include "rawinput/touch.h"
#include "touch/touch.h" #include "touch/touch.h"
#include "touch_debug.h"
#include "touch_defs.h"
#include "util/logging.h" #include "util/logging.h"
#include "util/time.h" #include "util/time.h"
#include "util/utils.h" #include "util/utils.h"
@@ -12,28 +15,38 @@
static std::string WINDOW_TITLE = "REFLEC BEAT"; static std::string WINDOW_TITLE = "REFLEC BEAT";
namespace games::rb { namespace games::rb {
uint16_t TOUCH_SCALING = 1000; static constexpr double TOUCH_POINT_OFFSET_DIVISOR = 3.0;
uint8_t TOUCH_SIZE = 1; static constexpr double X_INPUT_COORDINATE_COUNT = 54.0;
uint16_t TOUCH_SCALING = TOUCH_SCALE_DEFAULT;
static void packet_set_bit(unsigned char *packet, int bit) {
packet[TOUCH_PACKET_DATA_OFFSET + bit / 8] |=
static_cast<unsigned char>(1u << (bit % 8));
}
} }
games::rb::ReflecBeatTouchDeviceHandle::ReflecBeatTouchDeviceHandle(bool log_fps) { games::rb::ReflecBeatTouchDeviceHandle::ReflecBeatTouchDeviceHandle(bool log_fps) : log_fps(log_fps) {
this->log_fps = log_fps;
} }
void games::rb::ReflecBeatTouchDeviceHandle::grid_insert(unsigned char *data, int cursor_x, int cursor_y) { void games::rb::ReflecBeatTouchDeviceHandle::grid_insert(unsigned char *data, int cursor_x, int cursor_y) {
// scale to grid position - there are 48 columns and 76 rows of IR sensors. // read() contracts X uniformly so screen edges land within usable sensors 2..45
// for whatever reason, the last y row (#75) results in weird input few rows above; just drop it int grid_x = CLAMP(
int grid_x = CLAMP((cursor_x * 48) / window_width, 0, 47); (cursor_x * X_SENSOR_COUNT) / window_width,
int grid_y = CLAMP((cursor_y * 76) / window_height, 0, 74); 0,
X_SENSOR_COUNT - 1);
int grid_y = CLAMP(
(cursor_y * Y_SENSOR_COUNT) / window_height,
0,
Y_SENSOR_COUNT - 1);
// get bit positions // get bit positions
int bit_x = 88 + grid_x; int bit_x = X_SENSOR_FIRST_BIT + grid_x;
int bit_y = 74 - grid_y; int bit_y = Y_SENSOR_FIRST_BIT - grid_y;
// insert bits packet_set_bit(data, bit_x);
data[3 + bit_x / 8] |= (char) 1 << (bit_x % 8); packet_set_bit(data, bit_y);
data[3 + bit_y / 8] |= (char) 1 << (bit_y % 8);
} }
bool games::rb::ReflecBeatTouchDeviceHandle::open(LPCWSTR lpFileName) { bool games::rb::ReflecBeatTouchDeviceHandle::open(LPCWSTR lpFileName) {
@@ -49,6 +62,9 @@ bool games::rb::ReflecBeatTouchDeviceHandle::open(LPCWSTR lpFileName) {
if (wnd != nullptr) { if (wnd != nullptr) {
// cache the game window so read() can size against it regardless of focus
this->game_hwnd = wnd;
// reset window process to make the game not crash // reset window process to make the game not crash
SetWindowLongPtr(wnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(DefWindowProc)); SetWindowLongPtr(wnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(DefWindowProc));
@@ -68,18 +84,19 @@ bool games::rb::ReflecBeatTouchDeviceHandle::open(LPCWSTR lpFileName) {
// update window // update window
SetWindowPos(wnd, nullptr, 0, 0, 0, 0, SetWindowPos(wnd, nullptr, 0, 0, 0, 0,
SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER); SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOOWNERZORDER);
}
// create touch window // create touch window
touch_create_wnd(wnd); touch_create_wnd(wnd);
} else { // show fullscreen game window because it lost focus
if (!GRAPHICS_WINDOWED) {
// create touch window
touch_create_wnd(wnd);
// show game window because it lost focus
ShowWindow(wnd, SW_SHOW); ShowWindow(wnd, SW_SHOW);
} }
// request automatic aspect ratio fixes
::rawinput::touch::ASPECT_COMPENSATION_GAME = true;
} else { } else {
// fallback to dx hook // fallback to dx hook
@@ -91,18 +108,21 @@ bool games::rb::ReflecBeatTouchDeviceHandle::open(LPCWSTR lpFileName) {
ShowCursor(TRUE); ShowCursor(TRUE);
} }
touch_debug_attach();
return true; return true;
} }
int games::rb::ReflecBeatTouchDeviceHandle::read(LPVOID lpBuffer, DWORD nNumberOfBytesToRead) { int games::rb::ReflecBeatTouchDeviceHandle::read(LPVOID lpBuffer, DWORD nNumberOfBytesToRead) {
// check buffer size // check buffer size
if (nNumberOfBytesToRead < 20) { if (nNumberOfBytesToRead < TOUCH_PACKET_SIZE) {
return 0; return 0;
} }
// get window // size against the game window cached in open() (not GetForegroundWindow), so
HWND window = GetForegroundWindow(); // touch stays correctly scaled and keeps working when the game loses focus
HWND window = this->game_hwnd;
if (window == nullptr) { if (window == nullptr) {
return 0; return 0;
} }
@@ -122,17 +142,17 @@ int games::rb::ReflecBeatTouchDeviceHandle::read(LPVOID lpBuffer, DWORD nNumberO
} }
// create data // create data
unsigned char data[20] {}; unsigned char data[TOUCH_PACKET_SIZE] {};
// data header // data header
data[0] = 0x55; data[0] = 0x55;
data[2] = 0x4C; data[2] = 0x4C;
const auto SCALE_FACTOR = games::rb::TOUCH_SCALING / 1000.f; const float scale_factor = games::rb::TOUCH_SCALING / (float) games::rb::TOUCH_SCALE_DEFAULT;
// iterate all touch points // iterate all touch points
auto offset_x = (int) (window_width / 48.0 / 3.0); int offset_x = (int) (window_width / (double) X_SENSOR_COUNT / TOUCH_POINT_OFFSET_DIVISOR);
auto offset_y = (int) (window_height / 76.0 / 3.0); int offset_y = (int) (window_height / (double) Y_SENSOR_COUNT / TOUCH_POINT_OFFSET_DIVISOR);
std::vector<TouchPoint> touch_points; std::vector<TouchPoint> touch_points;
touch_get_points(touch_points); touch_get_points(touch_points);
for (auto &point : touch_points) { for (auto &point : touch_points) {
@@ -148,37 +168,44 @@ int games::rb::ReflecBeatTouchDeviceHandle::read(LPVOID lpBuffer, DWORD nNumberO
} }
// apply scaling // apply scaling
x = x - (window_width * (1.f - SCALE_FACTOR) / 2); x = x - (window_width * (1.f - scale_factor) / 2);
x = x / SCALE_FACTOR; x = x / scale_factor;
y = y - (window_height * (1.f - SCALE_FACTOR) / 2); y = y - (window_height * (1.f - scale_factor) / 2);
y = y / SCALE_FACTOR; y = y / scale_factor;
if (x < 0 || window_width <= x || y < 0 || window_height <= y) { if (x < 0 || window_width <= x || y < 0 || window_height <= y) {
continue; continue;
} }
// point scaling // point coordinates
const auto point_x = (int) ((x - window_width / 2.0) * 48.0 / 54.0 + window_width / 2.0); // keep the verified left edge fixed while adding up to one-third of a beam
const auto point_y = (int) (y - window_height / 76); // at the right edge, preventing the finger footprint from including sensor 44
const auto point_x = (int) (
(x - window_width / 2.0) * X_SENSOR_COUNT / X_INPUT_COORDINATE_COUNT +
window_width / 2.0 +
x / (X_SENSOR_COUNT * TOUCH_POINT_OFFSET_DIVISOR));
const auto point_y = (int) y;
// center // model a finger as a 3x3 block of IR sensors around the touch point
grid_insert(data, point_x, point_y); // this gives better accuracy (than just 1x1) since the logic below
// can toggle anywhere from 1x1 to 2x2, and the game engine calculates
// surrounding points // the center point, which means by inserting up to 8 extra blocks
if (games::rb::TOUCH_SIZE == 3) { // we are emulating a sub-"pixel" resolution
grid_insert(data, point_x - offset_x, point_y); // west grid_insert(data, point_x, point_y); // center
grid_insert(data, point_x - offset_x, point_y - offset_y); // northwest grid_insert(data, point_x - offset_x, point_y); // west
grid_insert(data, point_x - offset_x, point_y + offset_y); // southwest grid_insert(data, point_x - offset_x, point_y - offset_y); // northwest
grid_insert(data, point_x + offset_x, point_y); // east grid_insert(data, point_x - offset_x, point_y + offset_y); // southwest
grid_insert(data, point_x + offset_x, point_y + offset_y); // southeast grid_insert(data, point_x + offset_x, point_y); // east
grid_insert(data, point_x + offset_x, point_y - offset_y); // northeast grid_insert(data, point_x + offset_x, point_y + offset_y); // southeast
grid_insert(data, point_x, point_y - offset_y); // north grid_insert(data, point_x + offset_x, point_y - offset_y); // northeast
grid_insert(data, point_x, point_y + offset_y); // south grid_insert(data, point_x, point_y - offset_y); // north
} grid_insert(data, point_x, point_y + offset_y); // south
} }
touch_debug_publish(data, is_landscape);
// copy data to buffer // copy data to buffer
memcpy(lpBuffer, data, 20); memcpy(lpBuffer, data, TOUCH_PACKET_SIZE);
// update frame logging // update frame logging
if (log_fps) { if (log_fps) {
@@ -193,7 +220,7 @@ int games::rb::ReflecBeatTouchDeviceHandle::read(LPVOID lpBuffer, DWORD nNumberO
} }
// return amount of bytes written // return amount of bytes written
return 20; return TOUCH_PACKET_SIZE;
} }
int games::rb::ReflecBeatTouchDeviceHandle::write(LPCVOID lpBuffer, DWORD nNumberOfBytesToWrite) { int games::rb::ReflecBeatTouchDeviceHandle::write(LPCVOID lpBuffer, DWORD nNumberOfBytesToWrite) {
@@ -207,6 +234,8 @@ int games::rb::ReflecBeatTouchDeviceHandle::device_io(DWORD dwIoControlCode, LPV
bool games::rb::ReflecBeatTouchDeviceHandle::close() { bool games::rb::ReflecBeatTouchDeviceHandle::close() {
touch_debug_detach();
// detach touch module // detach touch module
touch_detach(); touch_detach();
+6
View File
@@ -12,6 +12,12 @@ namespace games::rb {
int window_width = 1080; int window_width = 1080;
int window_height = 1920; int window_height = 1920;
// game window resolved in open(); read() sizes against this instead of the
// foreground window so touch stays correctly scaled and keeps working when
// the game is not the foreground window (rawinput delivers touch regardless
// of focus)
HWND game_hwnd = nullptr;
// logging // logging
bool log_fps = false; bool log_fps = false;
uint64_t log_time = 0; uint64_t log_time = 0;
+143
View File
@@ -0,0 +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;
}
}
+14
View File
@@ -0,0 +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);
}
+17
View File
@@ -0,0 +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;
}
+72
View File
@@ -0,0 +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
+14
View File
@@ -0,0 +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
}
+19 -9
View File
@@ -32,18 +32,28 @@ namespace hooks::audio {
RegEnumKeyA(hkEnum, index, key_name, sizeof(key_name)) == ERROR_SUCCESS; RegEnumKeyA(hkEnum, index, key_name, sizeof(key_name)) == ERROR_SUCCESS;
index++) { index++) {
// read description (display name), fall back to the key name // 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 }; char desc[256] = { 0 };
DWORD size = sizeof(desc); DWORD size = sizeof(desc);
std::string name = key_name; std::string name = key_name;
if (RegGetValueA(hkEnum, HKEY hkDriver = nullptr;
key_name, if (RegOpenKeyExA(hkEnum, key_name, 0,
ASIO_REG_DESC, KEY_QUERY_VALUE | wow64_flag, &hkDriver) == ERROR_SUCCESS) {
RRF_RT_REG_SZ | wow64_flag, DWORD type = 0;
nullptr, if (RegQueryValueExA(hkDriver,
desc, ASIO_REG_DESC,
&size) == ERROR_SUCCESS && desc[0]) { nullptr,
name = desc; &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) // merge with an existing entry from the other view (match by name)
+96 -43
View File
@@ -154,36 +154,30 @@ namespace {
} }
} }
// live wrappers by CLSID. ASIO drivers are single-instance, so a host that creates a // one wrapper per CLSID, kept alive for the process lifetime. ASIO drivers are
// new instance without releasing the old one has leaked it; we track this to tear the // single-instance, and some hardware drivers (e.g. Neva Uno) crash if their COM object
// stale one down // is destroyed and re-created within a process - which the host triggers by leaking and
// re-instantiating the driver during startup probing. so we build the real driver and
// its wrapper once, hold a reference so it survives the host's Release calls, and hand
// the same wrapper back for every later CoCreate. as a result the real driver is created
// and initialized exactly once
std::mutex g_wrappers_mutex; std::mutex g_wrappers_mutex;
std::vector<std::pair<CLSID, WrappedAsio *>> g_wrappers; std::vector<std::pair<CLSID, WrappedAsio *>> g_wrappers;
// register a wrapper as the live instance for its CLSID, returning any stale wrapper it WrappedAsio *find_wrapper(REFCLSID clsid) {
// replaces so the caller can tear it down outside the lock
WrappedAsio *register_wrapper(REFCLSID clsid, WrappedAsio *wrapper) {
std::lock_guard lock(g_wrappers_mutex); std::lock_guard lock(g_wrappers_mutex);
for (auto &entry : g_wrappers) { for (auto &entry : g_wrappers) {
if (IsEqualCLSID(entry.first, clsid)) { if (IsEqualCLSID(entry.first, clsid)) {
WrappedAsio *stale = entry.second; return entry.second;
entry.second = wrapper;
return stale;
} }
} }
g_wrappers.emplace_back(clsid, wrapper);
return nullptr; return nullptr;
} }
void unregister_wrapper(WrappedAsio *wrapper) { void store_wrapper(REFCLSID clsid, WrappedAsio *wrapper) {
std::lock_guard lock(g_wrappers_mutex); std::lock_guard lock(g_wrappers_mutex);
for (auto it = g_wrappers.begin(); it != g_wrappers.end(); ++it) { g_wrappers.emplace_back(clsid, wrapper);
if (it->second == wrapper) {
g_wrappers.erase(it);
return;
}
}
} }
// ASIO drivers registered on this system (CLSID + registry name), scanned once // ASIO drivers registered on this system (CLSID + registry name), scanned once
@@ -256,12 +250,11 @@ WrappedAsio::StereoDownmix WrappedAsio::name_to_stereo_downmix(const char *name)
} }
WrappedAsio::~WrappedAsio() { WrappedAsio::~WrappedAsio() {
unregister_wrapper(this);
this->detach_post_process(); this->detach_post_process();
// our refcount is decoupled from the host's (see AddRef/Release), so pReal's count is // never runs mid-run: wrap() pins the wrapper so the refcount stays above zero until
// exactly one here and this releases/unloads it deterministically // release_all_wrappers() drops the pin at shutdown - the only point this can fire, and
// only once the host has released its own references. tears down the real driver
this->pReal->Release(); this->pReal->Release();
log_info("audio::wrappedasio", "destroying wrapped ASIO driver, clsid={}", guid2s(this->clsid)); log_info("audio::wrappedasio", "destroying wrapped ASIO driver, clsid={}", guid2s(this->clsid));
@@ -315,8 +308,17 @@ ULONG STDMETHODCALLTYPE WrappedAsio::Release() {
#pragma region IAsio #pragma region IAsio
AsioBool __thiscall WrappedAsio::init(void *sys_handle) { AsioBool __thiscall WrappedAsio::init(void *sys_handle) {
// the real driver is single-instance and kept alive; initialize it exactly once. the
// host re-calls init() on each CoCreate during probing, but re-initializing a live
// driver crashes some hardware drivers, so once we have a live driver we report success
if (this->initialized) {
log_misc("audio::wrappedasio", "init skipped, '{}' already initialized", this->driver_name);
return AsioTrue;
}
const AsioBool result = this->pReal->init(sys_handle); const AsioBool result = this->pReal->init(sys_handle);
if (result == AsioTrue) { if (result == AsioTrue) {
this->initialized = true;
log_info( log_info(
"audio::wrappedasio", "audio::wrappedasio",
"init succeeded for '{}' (driver version {})", "init succeeded for '{}' (driver version {})",
@@ -346,6 +348,7 @@ void __thiscall WrappedAsio::get_error_message(char *string) {
AsioError __thiscall WrappedAsio::start() { AsioError __thiscall WrappedAsio::start() {
const AsioError result = this->pReal->start(); const AsioError result = this->pReal->start();
if (result == ASE_OK) { if (result == ASE_OK) {
this->started = true;
log_info( log_info(
"audio::wrappedasio", "audio::wrappedasio",
"start succeeded, ASIO stream is now running on '{}'", "start succeeded, ASIO stream is now running on '{}'",
@@ -360,6 +363,7 @@ AsioError __thiscall WrappedAsio::start() {
AsioError __thiscall WrappedAsio::stop() { AsioError __thiscall WrappedAsio::stop() {
const AsioError result = this->pReal->stop(); const AsioError result = this->pReal->stop();
if (result == ASE_OK) { if (result == ASE_OK) {
this->started = false;
log_info("audio::wrappedasio", "stop succeeded, ASIO stream on '{}' halted", this->driver_name); log_info("audio::wrappedasio", "stop succeeded, ASIO stream on '{}' halted", this->driver_name);
} else { } else {
log_warning("audio::wrappedasio", "stop failed, err={}", static_cast<long>(result)); log_warning("audio::wrappedasio", "stop failed, err={}", static_cast<long>(result));
@@ -740,6 +744,27 @@ void WrappedAsio::detach_post_process() {
WrappedAsio::active_instance.compare_exchange_strong(expected, nullptr); WrappedAsio::active_instance.compare_exchange_strong(expected, nullptr);
} }
void WrappedAsio::quiesce_for_reuse() {
// a previous abandoned probing cycle may have left a running stream and live buffers on
// the real driver without calling stop/dispose; tear that down now (without destroying
// the driver) so the host's next create_buffers starts clean. stop() guarantees no
// further buffer_switch, and dispose_buffers detaches our trampolines
if (this->started) {
log_info(
"audio::wrappedasio",
"reuse: stopping leftover stream on '{}' before handing the driver back",
this->driver_name);
this->stop();
}
if (this->buffers_created) {
log_info(
"audio::wrappedasio",
"reuse: disposing leftover buffers on '{}' before handing the driver back",
this->driver_name);
this->dispose_buffers();
}
}
void WrappedAsio::apply_output_volume(long double_buffer_index) { void WrappedAsio::apply_output_volume(long double_buffer_index) {
if (double_buffer_index != 0 && double_buffer_index != 1) { if (double_buffer_index != 0 && double_buffer_index != 1) {
return; return;
@@ -838,6 +863,7 @@ AsioError __thiscall WrappedAsio::create_buffers(
} }
} }
this->publish_post_process(buffer_size); this->publish_post_process(buffer_size);
this->buffers_created = true;
} else { } else {
log_warning( log_warning(
"audio::wrappedasio", "audio::wrappedasio",
@@ -940,6 +966,7 @@ AsioError WrappedAsio::create_buffers_front_pair(
// device's 2.0 output. then publish ourselves to the realtime thread // device's 2.0 output. then publish ourselves to the realtime thread
this->record_downmix_channels(buffer_infos, num_channels, buffer_size); this->record_downmix_channels(buffer_infos, num_channels, buffer_size);
this->publish_post_process(buffer_size); this->publish_post_process(buffer_size);
this->buffers_created = true;
log_info( log_info(
"audio::wrappedasio", "audio::wrappedasio",
@@ -962,6 +989,7 @@ AsioError __thiscall WrappedAsio::dispose_buffers() {
this->volume_channels.clear(); this->volume_channels.clear();
this->volume_active = false; this->volume_active = false;
this->downmix_active = false; this->downmix_active = false;
this->buffers_created = false;
return result; return result;
} }
@@ -986,29 +1014,54 @@ namespace hooks::audio::asio {
auto *wrapper = new WrappedAsio( auto *wrapper = new WrappedAsio(
reinterpret_cast<IAsio *>(real), clsid, registered_asio_name(clsid)); reinterpret_cast<IAsio *>(real), clsid, registered_asio_name(clsid));
// if the host already had a live wrapper for this CLSID it leaked the previous // pin the wrapper (and the real driver it owns) for the process lifetime; later
// instance (ASIO is single-instance); tear it down now so the real driver is // CoCreate calls reuse this same instance via wrap_existing
// released before the reinit. works around games (iidx32+) that ref twice but wrapper->AddRef();
// deref once before re-initializing. store_wrapper(clsid, wrapper);
//
// FlexASIO 1.9 and many DAC ASIO drivers can't handle this; FlexASIO 1.10 and
// Xonar AE can
if (WrappedAsio *stale = register_wrapper(clsid, wrapper)) {
log_info(
"audio::wrappedasio",
"host did not release previous instance for clsid={}, forcing teardown",
guid2s(clsid));
// the stale instance may still have a running stream on the driver's realtime
// thread. stop and dispose it before deleting so no in-flight buffer switch
// (e.g. our volume trampoline) touches buffers we are about to free. ASIO
// guarantees no further buffer_switch once stop() returns, and dispose_buffers
// detaches our trampolines, fully quiescing the realtime path before delete
stale->stop();
stale->dispose_buffers();
delete stale;
}
return static_cast<IAsio *>(wrapper); return static_cast<IAsio *>(wrapper);
} }
IUnknown *wrap_existing(REFCLSID clsid) {
WrappedAsio *wrapper = find_wrapper(clsid);
if (wrapper == nullptr) {
return nullptr;
}
log_misc(
"audio::wrappedasio",
"reusing cached ASIO driver instance, clsid={}",
guid2s(clsid));
wrapper->quiesce_for_reuse();
// hand the host another reference to the one instance we keep alive
wrapper->AddRef();
return static_cast<IAsio *>(wrapper);
}
void release_all_wrappers() {
// collect the pinned wrappers under the lock, then drop our references outside it so
// a final Release (which can run the driver's own teardown) never happens while the
// lock is held
std::vector<std::pair<CLSID, WrappedAsio *>> wrappers;
{
std::lock_guard lock(g_wrappers_mutex);
wrappers = g_wrappers;
g_wrappers.clear();
}
for (auto &entry : wrappers) {
// drop only the pin wrap() took. if the host has already released its own
// references (the normal case at shutdown) this destroys the wrapper and releases
// the real driver; if the host is still using it, the refcount stays above zero
// and we leave its live stream/buffers untouched
log_misc(
"audio::wrappedasio",
"releasing cached ASIO driver instance, clsid={}",
guid2s(entry.first));
entry.second->Release();
}
}
} }
+25 -1
View File
@@ -18,8 +18,20 @@ namespace hooks::audio::asio {
bool is_asio_creation(REFCLSID rclsid, REFIID riid); bool is_asio_creation(REFCLSID rclsid, REFIID riid);
// wrap a real ASIO driver instance, taking ownership of the supplied reference, and // wrap a real ASIO driver instance, taking ownership of the supplied reference, and
// return a proxy that forwards every call to it // 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); 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 // transparent proxy around a real ASIO driver; a single place to intercept ASIO traffic
@@ -103,6 +115,10 @@ struct WrappedAsio final : IAsio {
AsioError __thiscall output_ready() override; AsioError __thiscall output_ready() override;
#pragma endregion #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: private:
// create_buffers implementation used when a stereo extraction is active: forwards only // 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 // the channels the real device has and hands the game throwaway buffers for the rest
@@ -170,6 +186,14 @@ private:
// unambiguous name; constant for our lifetime // unambiguous name; constant for our lifetime
std::string driver_name; 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 // our own reference count; we hold one reference on pReal and release it when this
// drops to zero // drops to zero
std::atomic<ULONG> ref_count {1}; std::atomic<ULONG> ref_count {1};
+13
View File
@@ -68,6 +68,15 @@ static HRESULT STDAPICALLTYPE CoCreateInstance_hook(
HRESULT ret; HRESULT ret;
// ASIO: if we already hold a cached instance for this CLSID, hand back a fresh
// wrapper over it instead of re-creating the real driver (see asio_proxy.cpp)
if (ppv != nullptr && hooks::audio::asio::is_asio_creation(rclsid, riid)) {
if (IUnknown *reused = hooks::audio::asio::wrap_existing(rclsid)) {
*ppv = reused;
return S_OK;
}
}
// call original // call original
ret = CoCreateInstance_orig(rclsid, pUnkOuter, dwClsContext, riid, ppv); ret = CoCreateInstance_orig(rclsid, pUnkOuter, dwClsContext, riid, ppv);
if (FAILED(ret)) { if (FAILED(ret)) {
@@ -130,5 +139,9 @@ namespace hooks::audio {
CLIENT = nullptr; CLIENT = nullptr;
} }
stop_low_latency(); stop_low_latency();
// release the ASIO drivers we kept pinned for the process lifetime now that we are
// at a controlled shutdown point (see asio_proxy.cpp)
hooks::audio::asio::release_all_wrappers();
} }
} }
@@ -1273,9 +1273,11 @@ static void graphics_d3d9_ldj_on_present(IDirect3DDevice9 *wrapped_device) {
wintouchemu::update(); wintouchemu::update();
// newer versions of exceed gear needs SUBSCREEN_FORCE_REDRAW // newer versions of exceed gear needs SUBSCREEN_FORCE_REDRAW
// (when enabled on older versions of EG, you end up with graphical glitches on the subscreen // (when enabled on older versions of EG, you end up with graphical glitches on the subscreen)
// early versions of popn HC needs this as well, otherwise the subscreen doesn't update at all //
if (GRAPHICS_WINDOWED || SUBSCREEN_FORCE_REDRAW || games::popn::is_pikapika_model()) { // early versions of popn HC needs this as well, but not on by default as it can cause
// graphical glitches on some GPUs
if (GRAPHICS_WINDOWED || SUBSCREEN_FORCE_REDRAW) {
SUB_SWAP_CHAIN->Present(nullptr, nullptr, nullptr, nullptr, 0); SUB_SWAP_CHAIN->Present(nullptr, nullptr, nullptr, nullptr, 0);
} }
} }
@@ -1,7 +1,11 @@
#include "d3d9_device.h" #include "d3d9_device.h"
#include <algorithm>
#include <cassert> #include <cassert>
#include <climits>
#include <mutex> #include <mutex>
#include <unordered_map>
#include <vector>
#include "avs/game.h" #include "avs/game.h"
#include "games/gitadora/gitadora.h" #include "games/gitadora/gitadora.h"
@@ -12,6 +16,7 @@
#include "cfg/screen_resize.h" #include "cfg/screen_resize.h"
#include "d3d9_backend.h" #include "d3d9_backend.h"
#include "d3d9_live2d.h"
#include "d3d9_texture.h" #include "d3d9_texture.h"
#ifndef SPICE64 #ifndef SPICE64
@@ -1301,6 +1306,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::DrawPrimitive(
UINT PrimitiveCount) UINT PrimitiveCount)
{ {
WRAP_DEBUG; WRAP_DEBUG;
if (d3d9_live2d::should_skip_draw()) [[unlikely]] {
return D3D_OK;
}
CHECK_RESULT(pReal->DrawPrimitive(PrimitiveType, StartVertex, PrimitiveCount)); CHECK_RESULT(pReal->DrawPrimitive(PrimitiveType, StartVertex, PrimitiveCount));
} }
@@ -1313,6 +1321,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::DrawIndexedPrimitive(
UINT PrimitiveCount) UINT PrimitiveCount)
{ {
WRAP_DEBUG; WRAP_DEBUG;
if (d3d9_live2d::should_skip_draw()) [[unlikely]] {
return D3D_OK;
}
CHECK_RESULT(pReal->DrawIndexedPrimitive( CHECK_RESULT(pReal->DrawIndexedPrimitive(
PrimitiveType, BaseVertexIndex, PrimitiveType, BaseVertexIndex,
MinVertexIndex, NumVertices, MinVertexIndex, NumVertices,
@@ -1328,6 +1339,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::DrawPrimitiveUP(
WRAP_DEBUG_FMT("DrawPrimitiveUP({}, {}, {}, {})", WRAP_DEBUG_FMT("DrawPrimitiveUP({}, {}, {}, {})",
PrimitiveType, PrimitiveCount, PrimitiveType, PrimitiveCount,
fmt::ptr(pVertexStreamZeroData), VertexStreamZeroStride); fmt::ptr(pVertexStreamZeroData), VertexStreamZeroStride);
if (d3d9_live2d::should_skip_draw()) [[unlikely]] {
return D3D_OK;
}
CHECK_RESULT(pReal->DrawPrimitiveUP( CHECK_RESULT(pReal->DrawPrimitiveUP(
PrimitiveType, PrimitiveCount, PrimitiveType, PrimitiveCount,
pVertexStreamZeroData, VertexStreamZeroStride)); pVertexStreamZeroData, VertexStreamZeroStride));
@@ -1344,6 +1358,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::DrawIndexedPrimitiveUP(
UINT VertexStreamZeroStride) UINT VertexStreamZeroStride)
{ {
WRAP_DEBUG; WRAP_DEBUG;
if (d3d9_live2d::should_skip_draw()) [[unlikely]] {
return D3D_OK;
}
CHECK_RESULT(pReal->DrawIndexedPrimitiveUP( CHECK_RESULT(pReal->DrawIndexedPrimitiveUP(
PrimitiveType, MinVertexIndex, NumVertices, PrimitiveCount, pIndexData, PrimitiveType, MinVertexIndex, NumVertices, PrimitiveCount, pIndexData,
IndexDataFormat, pVertexStreamZeroData, VertexStreamZeroStride)); IndexDataFormat, pVertexStreamZeroData, VertexStreamZeroStride));
@@ -1403,7 +1420,14 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::CreateVertexShader(
IDirect3DVertexShader9 **ppShader) IDirect3DVertexShader9 **ppShader)
{ {
WRAP_VERBOSE_FMT("CreateVertexShader({})", fmt::ptr(pFunction)); WRAP_VERBOSE_FMT("CreateVertexShader({})", fmt::ptr(pFunction));
CHECK_RESULT(pReal->CreateVertexShader(pFunction, ppShader)); HRESULT ret = pReal->CreateVertexShader(pFunction, ppShader);
if (SUCCEEDED(ret) && ppShader != nullptr) {
d3d9_live2d::on_create_vertex_shader(*ppShader, pFunction);
}
if (GRAPHICS_LOG_HRESULT && FAILED(ret)) [[unlikely]] {
log_warning("graphics::d3d9", "{} failed, hr={}", __FUNCTION__, FMT_HRESULT(ret));
}
return ret;
} }
HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::SetVertexShader( HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::SetVertexShader(
@@ -1411,6 +1435,8 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::SetVertexShader(
{ {
WRAP_DEBUG_FMT("SetVertexShader({})", fmt::ptr(pShader)); WRAP_DEBUG_FMT("SetVertexShader({})", fmt::ptr(pShader));
d3d9_live2d::on_set_vertex_shader(pShader);
#ifndef SPICE64 #ifndef SPICE64
// diagonal line fix // diagonal line fix
@@ -1557,13 +1583,21 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::CreatePixelShader(
IDirect3DPixelShader9 **ppShader) IDirect3DPixelShader9 **ppShader)
{ {
WRAP_VERBOSE_FMT("CreatePixelShader({})", fmt::ptr(pFunction)); WRAP_VERBOSE_FMT("CreatePixelShader({})", fmt::ptr(pFunction));
CHECK_RESULT(pReal->CreatePixelShader(pFunction, ppShader)); HRESULT ret = pReal->CreatePixelShader(pFunction, ppShader);
if (SUCCEEDED(ret) && ppShader != nullptr) {
d3d9_live2d::on_create_pixel_shader(*ppShader, pFunction);
}
if (GRAPHICS_LOG_HRESULT && FAILED(ret)) [[unlikely]] {
log_warning("graphics::d3d9", "{} failed, hr={}", __FUNCTION__, FMT_HRESULT(ret));
}
return ret;
} }
HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::SetPixelShader( HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::SetPixelShader(
IDirect3DPixelShader9 *pShader) IDirect3DPixelShader9 *pShader)
{ {
WRAP_DEBUG_FMT("SetPixelShader({})", fmt::ptr(pShader)); WRAP_DEBUG_FMT("SetPixelShader({})", fmt::ptr(pShader));
d3d9_live2d::on_set_pixel_shader(pShader);
CHECK_RESULT(pReal->SetPixelShader(pShader)); CHECK_RESULT(pReal->SetPixelShader(pShader));
} }
@@ -0,0 +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
@@ -0,0 +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
}
+7 -3
View File
@@ -23,7 +23,7 @@
#include "launcher/shutdown.h" #include "launcher/shutdown.h"
#include "overlay/overlay.h" #include "overlay/overlay.h"
#include "touch/touch.h" #include "touch/touch.h"
#include "touch/touch_indicators.h" #include "touch/touch_gestures.h"
#include "util/detour.h" #include "util/detour.h"
#include "util/logging.h" #include "util/logging.h"
#include "util/fileutils.h" #include "util/fileutils.h"
@@ -86,6 +86,10 @@ static bool monitor_layout_needs_reset = false;
bool GRAPHICS_CAPTURE_CURSOR = false; bool GRAPHICS_CAPTURE_CURSOR = false;
bool GRAPHICS_LOG_HRESULT = false; bool GRAPHICS_LOG_HRESULT = false;
bool GRAPHICS_SDVX_FORCE_720 = false; bool GRAPHICS_SDVX_FORCE_720 = false;
#ifdef SPICE64
SdvxLive2dMode GRAPHICS_SDVX_LIVE2D_MODE = SdvxLive2dMode::Off;
std::atomic<bool> GRAPHICS_SDVX_LIVE2D_IN_GAMEPLAY = false;
#endif // SPICE64
bool GRAPHICS_SHOW_CURSOR = false; bool GRAPHICS_SHOW_CURSOR = false;
bool GRAPHICS_WINDOWED = false; bool GRAPHICS_WINDOWED = false;
std::vector<HWND> GRAPHICS_WINDOWS; std::vector<HWND> GRAPHICS_WINDOWS;
@@ -623,7 +627,7 @@ static HWND WINAPI CreateWindowExA_hook(DWORD dwExStyle, LPCSTR lpClassName, LPC
} }
} }
disable_touch_indicators(result); disable_touch_gestures(result);
log_misc( log_misc(
"graphics", "graphics",
"CreateWindowExA returned {}, {}", "CreateWindowExA returned {}, {}",
@@ -718,7 +722,7 @@ static HWND WINAPI CreateWindowExW_hook(DWORD dwExStyle, LPCWSTR lpClassName, LP
fmt::ptr(result), fmt::ptr(result),
lpWindowName ? ws2s(lpWindowName) : "(null)"); lpWindowName ? ws2s(lpWindowName) : "(null)");
disable_touch_indicators(result); disable_touch_gestures(result);
return result; return result;
} }
+28
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <atomic>
#include <string> #include <string>
#include <vector> #include <vector>
#include <optional> #include <optional>
@@ -27,6 +28,33 @@ enum graphics_dx9on12_state {
DX9ON12_FORCE_ON, DX9ON12_FORCE_ON,
}; };
// SDVX Live2D suppression policy. the mode comes from the launcher
// option; the in-gameplay flag is maintained by the SDVX scene-detection hook in
// games/sdvx (which does not require the SDVX game module to be enabled). the
// d3d9 backend reads graphics_sdvx_live2d_should_skip() on every draw.
// only the Live2D-capable SDVX versions are 64-bit, so the whole feature is
// compiled out of 32-bit builds.
#ifdef SPICE64
enum class SdvxLive2dMode {
Off, // leave Live2D untouched (default)
Always, // always skip Live2D draws (also removes the menu navigator)
InGame, // skip Live2D draws only during a song
};
extern SdvxLive2dMode GRAPHICS_SDVX_LIVE2D_MODE;
extern std::atomic<bool> GRAPHICS_SDVX_LIVE2D_IN_GAMEPLAY;
inline bool graphics_sdvx_live2d_should_skip() {
switch (GRAPHICS_SDVX_LIVE2D_MODE) {
case SdvxLive2dMode::Always:
return true;
case SdvxLive2dMode::InGame:
return GRAPHICS_SDVX_LIVE2D_IN_GAMEPLAY.load(std::memory_order_relaxed);
default:
return false;
}
}
#endif // SPICE64
// flag settings // flag settings
extern bool GRAPHICS_CAPTURE_CURSOR; extern bool GRAPHICS_CAPTURE_CURSOR;
extern bool GRAPHICS_LOG_HRESULT; extern bool GRAPHICS_LOG_HRESULT;
+86 -8
View File
@@ -49,10 +49,12 @@
#include "games/popn/popn.h" #include "games/popn/popn.h"
#include "games/qma/qma.h" #include "games/qma/qma.h"
#include "games/rb/rb.h" #include "games/rb/rb.h"
#include "games/rb/touch_debug.h"
#include "games/rf3d/rf3d.h" #include "games/rf3d/rf3d.h"
#include "games/sc/sc.h" #include "games/sc/sc.h"
#include "games/scotto/scotto.h" #include "games/scotto/scotto.h"
#include "games/sdvx/sdvx.h" #include "games/sdvx/sdvx.h"
#include "games/sdvx/sdvx_live2d.h"
#include "games/shared/printer.h" #include "games/shared/printer.h"
#include "games/silentscope/silentscope.h" #include "games/silentscope/silentscope.h"
#include "games/mfc/mfc.h" #include "games/mfc/mfc.h"
@@ -102,6 +104,7 @@
#include "overlay/notifications.h" #include "overlay/notifications.h"
#include "overlay/windows/patch_manager.h" #include "overlay/windows/patch_manager.h"
#include "overlay/windows/iidx_seg.h" #include "overlay/windows/iidx_seg.h"
#include "overlay/windows/obs.h"
#include "rawinput/rawinput.h" #include "rawinput/rawinput.h"
#include "rawinput/touch.h" #include "rawinput/touch.h"
#include "reader/reader.h" #include "reader/reader.h"
@@ -643,6 +646,9 @@ int main_implementation(int argc, char *argv[]) {
if (options[launcher::Options::PopnNativeTouch].value_bool()) { if (options[launcher::Options::PopnNativeTouch].value_bool()) {
games::popn::NATIVE_TOUCH = true; games::popn::NATIVE_TOUCH = true;
} }
if (options[launcher::Options::PopnSubRedraw].value_bool()) {
SUBSCREEN_FORCE_REDRAW = true;
}
if (options[launcher::Options::LoadMetalGearArcadeModule].value_bool()) { if (options[launcher::Options::LoadMetalGearArcadeModule].value_bool()) {
attach_mga = true; attach_mga = true;
} }
@@ -1042,9 +1048,32 @@ int main_implementation(int argc, char *argv[]) {
if (options[launcher::Options::SDVXForce720p].value_bool()) { if (options[launcher::Options::SDVXForce720p].value_bool()) {
GRAPHICS_SDVX_FORCE_720 = true; GRAPHICS_SDVX_FORCE_720 = true;
} }
#ifdef SPICE64
// only the Live2D-capable SDVX versions are 64-bit, so this whole feature is
// gated out of 32-bit builds and only armed for SDVX (model KFC).
if (avs::game::is_model("KFC")) {
auto live2d = options[launcher::Options::SDVXDisableLive2D].value_text();
if (live2d == "always") {
GRAPHICS_SDVX_LIVE2D_MODE = SdvxLive2dMode::Always;
} else if (live2d == "ingame") {
GRAPHICS_SDVX_LIVE2D_MODE = SdvxLive2dMode::InGame;
// scene detection runs independently of the SDVX game module
games::sdvx::live2d_scene_detection_init();
}
}
#endif // SPICE64
if (options[launcher::Options::InvertTouchCoordinates].value_bool()) { if (options[launcher::Options::InvertTouchCoordinates].value_bool()) {
rawinput::touch::INVERTED = true; rawinput::touch::INVERTED = true;
} }
if (options[launcher::Options::RawInputTouchAspectRatio].is_active()) {
auto mode = options[launcher::Options::RawInputTouchAspectRatio].value_text();
if (mode == "on") {
rawinput::touch::ASPECT_COMPENSATION_MODE = rawinput::touch::AspectMode::On;
} else if (mode == "off") {
rawinput::touch::ASPECT_COMPENSATION_MODE = rawinput::touch::AspectMode::Off;
}
// "auto" leaves the default (per-game)
}
// DisableTouchCardInsert is no longer honored in spice2x // DisableTouchCardInsert is no longer honored in spice2x
// if (options[launcher::Options::DisableTouchCardInsert].value_bool()) { // if (options[launcher::Options::DisableTouchCardInsert].value_bool()) {
// SPICETOUCH_CARD_DISABLE = true; // SPICETOUCH_CARD_DISABLE = true;
@@ -1086,6 +1115,9 @@ int main_implementation(int argc, char *argv[]) {
if (options[launcher::Options::DisableOverlay].value_bool()) { if (options[launcher::Options::DisableOverlay].value_bool()) {
overlay::ENABLED = false; overlay::ENABLED = false;
} }
if (options[launcher::Options::OverlayKeyboardNavigation].value_bool()) {
overlay::ENABLE_KEYBOARD_NAVIGATION = true;
}
if (options[launcher::Options::OverlayScaling].is_active() && !cfg::CONFIGURATOR_STANDALONE && !cfg_run) { if (options[launcher::Options::OverlayScaling].is_active() && !cfg::CONFIGURATOR_STANDALONE && !cfg_run) {
const auto val = options[launcher::Options::OverlayScaling].value_uint32(); const auto val = options[launcher::Options::OverlayScaling].value_uint32();
if (10 <= val && val <= 400 && val != 100) { if (10 <= val && val <= 400 && val != 100) {
@@ -1286,21 +1318,36 @@ int main_implementation(int argc, char *argv[]) {
games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::AcAccurate; games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::AcAccurate;
} else if (options[launcher::Options::JubeatTouchAlgo].value_text() == "legacy") { } else if (options[launcher::Options::JubeatTouchAlgo].value_text() == "legacy") {
games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::Legacy; games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::Legacy;
} else if (options[launcher::Options::JubeatTouchAlgo].value_text() == "plus") {
games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::Plus;
} else { } else {
games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::Improved; games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::Improved;
} }
} }
if (options[launcher::Options::JubeatTouchDebug].is_active()) {
auto mode = options[launcher::Options::JubeatTouchDebug].value_text();
if (mode == "none") {
games::jb::TOUCH_DEBUG_OVERLAY = games::jb::JubeatTouchDebugMode::JbTouchDebugNone;
} else if (mode == "box") {
games::jb::TOUCH_DEBUG_OVERLAY = games::jb::JubeatTouchDebugMode::JbTouchDebugBox;
} else if (mode == "all") {
games::jb::TOUCH_DEBUG_OVERLAY = games::jb::JubeatTouchDebugMode::JbTouchDebugAll;
} else {
games::jb::TOUCH_DEBUG_OVERLAY = games::jb::JubeatTouchDebugMode::JbTouchDebugAuto;
}
}
if (options[launcher::Options::JubeatTouchDebounce].is_active()) {
games::jb::TOUCH_DEBOUNCE_MS =
options[launcher::Options::JubeatTouchDebounce].value_uint32();
}
// reflec beat touch emulation // reflec beat touch emulation
games::rb::TOUCH_DEBUG_OVERLAY = options[launcher::Options::RBTouchDebug].value_bool();
if (options[launcher::Options::spice2x_RBTouchScale].is_active()) { if (options[launcher::Options::spice2x_RBTouchScale].is_active()) {
games::rb::TOUCH_SCALING = options[launcher::Options::spice2x_RBTouchScale].value_uint32(); games::rb::TOUCH_SCALING = options[launcher::Options::spice2x_RBTouchScale].value_uint32();
} }
if (options[launcher::Options::RBTouchSize].is_active()) { // -rbtouchsize is deprecated and ignored; touch emulation always uses the 3x3 model
const auto text = options[launcher::Options::RBTouchSize].value_text();
if (text == "3") {
games::rb::TOUCH_SIZE = 3;
}
}
if (options[launcher::Options::RBTouchPollRate].is_active()) { if (options[launcher::Options::RBTouchPollRate].is_active()) {
games::rb::TOUCH_POLL_RATE = options[launcher::Options::RBTouchPollRate].value_uint32(); games::rb::TOUCH_POLL_RATE = options[launcher::Options::RBTouchPollRate].value_uint32();
} }
@@ -1434,6 +1481,26 @@ int main_implementation(int argc, char *argv[]) {
cfg::CONFIGURATOR_FORCE_SOFTWARE_RENDER = true; cfg::CONFIGURATOR_FORCE_SOFTWARE_RENDER = true;
} }
// OBS WebSocket overlay settings
if (options[launcher::Options::OBSWebSocketEnabled].value_bool()) {
overlay::windows::OBS_CONTROL_ENABLED = true;
}
if (options[launcher::Options::OBSWebSocketHost].is_active()) {
overlay::windows::OBS_CONTROL_HOST = options[launcher::Options::OBSWebSocketHost].value_text();
}
if (options[launcher::Options::OBSWebSocketPort].is_active()) {
const auto obs_port = options[launcher::Options::OBSWebSocketPort].value_uint32();
if (obs_port > 0 && obs_port <= 65535) {
overlay::windows::OBS_CONTROL_PORT = static_cast<uint16_t>(obs_port);
}
}
if (options[launcher::Options::OBSWebSocketPassword].is_active()) {
overlay::windows::OBS_CONTROL_PASSWORD = options[launcher::Options::OBSWebSocketPassword].value_text();
}
if (options[launcher::Options::OBSWebSocketDebug].value_bool()) {
overlay::windows::OBS_CONTROL_DEBUG = true;
}
// API debugging // API debugging
if (api_debug && !cfg::CONFIGURATOR_STANDALONE) { if (api_debug && !cfg::CONFIGURATOR_STANDALONE) {
API_CONTROLLER = std::make_unique<api::Controller>(api_port, api_pass, api_pretty); API_CONTROLLER = std::make_unique<api::Controller>(api_port, api_pass, api_pretty);
@@ -1546,8 +1613,19 @@ int main_implementation(int argc, char *argv[]) {
// print out conflicts // print out conflicts
size_t conflicts = 0; size_t conflicts = 0;
for (const auto &option : options) { for (size_t i = 0; i < options.size(); i++) {
if (option.conflicting && option.get_definition().type != OptionType::Bool) { // InjectHook / EarlyInjectHook accept multiple values, so command line and
// spicecfg entries are merged rather than conflicting; don't warn about them
if (i == (size_t) launcher::Options::InjectHook ||
i == (size_t) launcher::Options::EarlyInjectHook) {
continue;
}
const auto &option = options[i];
// ignore Boolean values
if (option.get_definition().type == OptionType::Bool) {
continue;
}
if (option.conflicting) {
conflicts += 1; conflicts += 1;
const auto& value = option.get_definition().sensitive ? "*****" : option.value; const auto& value = option.get_definition().sensitive ? "*****" : option.value;
if (launcher::USE_CMD_OVERRIDE) { if (launcher::USE_CMD_OVERRIDE) {
+175 -17
View File
@@ -52,6 +52,7 @@ static const std::vector<std::string> CATEGORY_ORDER_NETWORK = {
static const std::vector<std::string> CATEGORY_ORDER_OVERLAY = { static const std::vector<std::string> CATEGORY_ORDER_OVERLAY = {
"General Overlay", "General Overlay",
"Game Overlay", "Game Overlay",
"OBS Control",
}; };
static const std::vector<std::string> CATEGORY_ORDER_ADVANCED = { static const std::vector<std::string> CATEGORY_ORDER_ADVANCED = {
@@ -433,18 +434,23 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.category = "Advanced Network", .category = "Advanced Network",
}, },
{ {
.title = "Preferred NetAdapter IP", .title = "Preferred Network Adapter's IP",
.name = "network", .name = "network",
.desc = "This is NOT the EA service URL; use -url for that. " .display_name = "netadapterip",
"Force the use of an adapter with the specified network. Must also provide -subnet.", .aliases = "netadapterip",
.desc = "Instead of using the default network adapter, force the usage of another network adapter "
"with the specified IP address. You must also set -netadaptersubnet.",
.type = OptionType::Text, .type = OptionType::Text,
.category = "Advanced Network", .category = "Advanced Network",
.sensitive = true, .sensitive = true,
}, },
{ {
.title = "Preferred NetAdapter Subnet", .title = "Preferred Network Adapter's Subnet",
.name = "subnet", .name = "subnet",
.desc = "Force the use of an adapter with the specified subnet. Must also provide -network.", .display_name = "netadaptersubnet",
.aliases = "netadaptersubnet",
.desc = "Instead of using the default network adapter, force the usage of another network adapter "
"with the specified subnet. You must also set -netadapterip.",
.type = OptionType::Text, .type = OptionType::Text,
.category = "Advanced Network", .category = "Advanced Network",
}, },
@@ -504,6 +510,13 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.type = OptionType::Bool, .type = OptionType::Bool,
.category = "General Overlay", .category = "General Overlay",
}, },
{
.title = "Overlay Keyboard Navigation",
.name = "keyboardnav",
.desc = "Enables keyboard navigation in the in-game overlay.",
.type = OptionType::Bool,
.category = "General Overlay",
},
{ {
// OverlayScaling // OverlayScaling
.title = "Spice Overlay UI Scale %", .title = "Spice Overlay UI Scale %",
@@ -1025,6 +1038,24 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.category = "Game Options", .category = "Game Options",
.quick_setting_category = "Game", .quick_setting_category = "Game",
}, },
{
// SDVXDisableLive2D
.title = "SDVX Disable Live2D (EXPERIMENTAL)",
.name = "sdvxnolive2d",
.desc = "Skip rendering the SDVX Live2D graphics to save GPU.\n\n"
"off: leave Live2D as-is.\n"
"always: hide Live2D (also hides menu navigator).\n"
"ingame: only hide Live2D during a song; keeps the menu navigator. Scene detection relies on game logging.",
.type = OptionType::Enum,
.game_name = "Sound Voltex",
.category = "Advanced Game Options",
.elements = {
{"off", "Show Live2D"},
{"always", "Hide Live2D"},
{"ingame", "Hide during songs"},
},
.quick_setting_category = "Game",
},
{ {
// spice2x_SDVXSubPos // spice2x_SDVXSubPos
.title = "SDVX Subscreen Overlay Position", .title = "SDVX Subscreen Overlay Position",
@@ -1138,6 +1169,17 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.game_name = "Pop'n Music", .game_name = "Pop'n Music",
.category = "Advanced Game Options", .category = "Advanced Game Options",
}, },
{
// PopnSubRedraw
.title = "Pop'n Music PikaPika Subscreen Force Redraw",
.name = "popnsubredraw",
.desc = "Check if submonitor in fullscreen mode appears stuck; "
"this option forces subscreen to redraw every frame.",
.type = OptionType::Bool,
.game_name = "Pop'n Music",
.category = "Advanced Game Options",
.quick_setting_category = "Game",
},
{ {
.title = "Force Load HELLO! Pop'n Music Module", .title = "Force Load HELLO! Pop'n Music Module",
.name = "hpm", .name = "hpm",
@@ -1742,11 +1784,11 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.category = "I/O Modules", .category = "I/O Modules",
}, },
{ {
.title = "Disable Raw Input Touch", .title = "Touch Compatibility Mode (Disable Raw Input Touch)",
.name = "wintouch", .name = "wintouch",
.desc = "For touch screen input, disable usage of Raw Input API and instead use " .desc = "For touch screen input, disable usage of Raw Input API and instead use "
"Win8 Pointer API or Win7 Touch API. Only enable this if you have trouble " "Win8 Pointer API or Win7 Touch API. Results in better compatibility with some "
"using the default (raw input) touch input, as Raw Input performs better.", "touch screens, but may result in worse performance and higher latency.",
.type = OptionType::Bool, .type = OptionType::Bool,
.category = "Touch Parameters", .category = "Touch Parameters",
}, },
@@ -1766,6 +1808,24 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.type = OptionType::Bool, .type = OptionType::Bool,
.category = "Touch Parameters", .category = "Touch Parameters",
}, },
{
// RawInputTouchAspectRatio
.title = "Raw Input Touch Fix Aspect Ratio",
.name = "rawtouchaspect",
.desc = "Compensate for letterboxing/pillarboxing when the game runs at a display mode with a "
"different aspect ratio than the touch panel's native resolution. Only affects the default "
"raw input touch handler. This is automatically enabled, only force on or off if you have trouble.\n\n"
"auto (default): automatically enable on a per-game basis.\n"
"on: forced on\n"
"off: forced off",
.type = OptionType::Enum,
.category = "Touch Parameters",
.elements = {
{"auto", ""},
{"on", ""},
{"off", ""},
},
},
{ {
// DisableTouchCardInsert // DisableTouchCardInsert
.title = "Disable Touch Card Insert (DEPRECATED - use -touchcard instead)", .title = "Disable Touch Card Insert (DEPRECATED - use -touchcard instead)",
@@ -1777,12 +1837,14 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
}, },
{ {
// spice2x_TouchCardInsert // spice2x_TouchCardInsert
.title = "Show Insert Card button", .title = "Show Insert Card button (DEPRECATED)",
.name = "sp2x-touchcard", .name = "sp2x-touchcard",
.display_name = "touchcard", .display_name = "touchcard",
.aliases= "touchcard", .aliases= "touchcard",
.desc = "Show Insert Card touch button on main display.", .desc = "Show Insert Card touch button on main display. "
"DEPRECATED - only works in very specific situations (jubeat + wintouch)",
.type = OptionType::Bool, .type = OptionType::Bool,
.hidden = true,
.category = "Touch Parameters", .category = "Touch Parameters",
}, },
{ {
@@ -1854,7 +1916,7 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.title = "HID SmartCard Fix UID", .title = "HID SmartCard Fix UID",
.name = "scardfix", .name = "scardfix",
.desc = "Modify behavior of SmartCard UID logic.\n\n" .desc = "Modify behavior of SmartCard UID logic.\n\n"
"legacy: Preserve buggy old behavior (Default)\n\n" "legacy (default): Preserve buggy old behavior\n\n"
"fix: Add E00401 to non-FeliCa cards, scan FeliCa cards as-is (Recommended for most users)\n\n" "fix: Add E00401 to non-FeliCa cards, scan FeliCa cards as-is (Recommended for most users)\n\n"
"all: Add E00401 to all cards, including FeliCa cards (For really old games only)\n\n" "all: Add E00401 to all cards, including FeliCa cards (For really old games only)\n\n"
"The algorithm is simple; the prefix is applied, the scanned card number is appended up to 8 bytes, and remaining digits are discarded. " "The algorithm is simple; the prefix is applied, the scanned card number is appended up to 8 bytes, and remaining digits are discarded. "
@@ -2638,6 +2700,7 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.desc = "For touch screen players: choose the touch algorithm to use.\n\n" .desc = "For touch screen players: choose the touch algorithm to use.\n\n"
"legacy - evenly divide the grid into 16 squares; old spicetools behavior, slightly inaccurate in gaps\n\n" "legacy - evenly divide the grid into 16 squares; old spicetools behavior, slightly inaccurate in gaps\n\n"
"improved (default) - squares register as-is, gaps will trigger the closest square\n\n" "improved (default) - squares register as-is, gaps will trigger the closest square\n\n"
"plus - like improved, but gaps can trigger multiple buttons (like the mobile game)\n\n"
"accurate - only touches within squares will trigger; gaps do nothing (for AC size touch screens)", "accurate - only touches within squares will trigger; gaps do nothing (for AC size touch screens)",
.type = OptionType::Enum, .type = OptionType::Enum,
.game_name = "Jubeat", .game_name = "Jubeat",
@@ -2645,10 +2708,58 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.elements = { .elements = {
{"legacy", ""}, {"legacy", ""},
{"improved", ""}, {"improved", ""},
{"plus", ""},
{"accurate", ""}, {"accurate", ""},
}, },
.quick_setting_category = "Game", .quick_setting_category = "Game",
}, },
{
// JubeatTouchDebug
.title = "JB Touch Debug Overlay",
.name = "jubeattouchdebug",
.desc = "For touch screen players: draw a debug overlay on the main display. "
"Requires the Spice Overlay to be enabled.\n\n"
"auto (default) - show boundary boxes when a touch screen is detected; otherwise, none\n\n"
"none - draw nothing\n\n"
"box - show the 4x4 touch boundary boxes\n\n"
"all - show both the boxes and the touch circles",
.type = OptionType::Enum,
.game_name = "Jubeat",
.category = "Game Options",
.elements = {
{"auto", ""},
{"none", ""},
{"box", ""},
{"all", ""},
},
.quick_setting_category = "Game",
},
{
// JubeatTouchDebounce
.title = "JB Touch Debounce",
.name = "jubeattouchdebounce",
.desc = "For touch screen players: ignore extremely quick touches by requiring a "
"touch to be held for at least this many milliseconds before it registers.\n\n"
"Useful for filtering phantom touches on a noisy touch screen. A higher value "
"adds input latency, so keep it small. Default: off (0).",
.type = OptionType::Integer,
.setting_name = "8",
.game_name = "Jubeat",
.category = "Game Options",
.quick_setting_category = "Game",
},
{
// RBTouchDebug
.title = "RB Touch Debug Overlay",
.name = "rbtouchdebug",
.desc = "Draw lines to show IR sensor emulation state.\n\n"
"Note: lines will not perfectly align with touches; this is by design, it's showing the "
"IR sensor state, before the game applies its own touch detection algorithm.",
.type = OptionType::Bool,
.game_name = "Reflec Beat",
.category = "Game Options",
.quick_setting_category = "Game",
},
{ {
// spice2x_RBTouchScale // spice2x_RBTouchScale
.title = "RB Touch Emulation Scale", .title = "RB Touch Emulation Scale",
@@ -2663,14 +2774,15 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
}, },
{ {
// RBTouchSize // RBTouchSize
.title = "RB Touch Emulation Size", .title = "RB Touch Emulation Size (DEPRECATED - no longer has any effect)",
.name = "rbtouchsize", .name = "rbtouchsize",
.desc = "Size of the touch area; how many IR sensors a single finger activates. Default: 1 (1x1).", .desc = "This option is deprecated and no longer has any effect. "
"Reflec Beat touch emulation always uses the 3x3 sensor model.",
.type = OptionType::Enum, .type = OptionType::Enum,
.hidden = true,
.game_name = "Reflec Beat", .game_name = "Reflec Beat",
.category = "Game Options", .category = "Game Options",
.elements = { .elements = {
{"1", "1x1"},
{"3", "3x3"}, {"3", "3x3"},
}, },
}, },
@@ -2678,9 +2790,10 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
// RBTouchPollRate // RBTouchPollRate
.title = "RB Touch Emulation Poll Hz", .title = "RB Touch Emulation Poll Hz",
.name = "rbtouchhz", .name = "rbtouchhz",
.desc = "By default, the game polls for touch at 120Hz. " .desc = "By default, the game polls for touch at ~125Hz. "
"This option overrides that rate; enter a number betwen 1 and 1000.\n\n" "This option overrides that rate; enter a number between 1 and 1000.\n\n"
"It should be noted that higher poll does not necessarily improve accuracy or performance.", "Higher rates reduce input latency (the game sees a fresher touch position) "
"but do NOT improve spatial accuracy, and very high rates just waste CPU.",
.type = OptionType::Integer, .type = OptionType::Integer,
.setting_name = "250", .setting_name = "250",
.game_name = "Reflec Beat", .game_name = "Reflec Beat",
@@ -3190,6 +3303,51 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.type = OptionType::Bool, .type = OptionType::Bool,
.category = "Development", .category = "Development",
}, },
{
// OBSWebSocketEnabled
.title = "OBS WebSocket Enable",
.name = "obsenable",
.desc = "Enables the in-game OBS Control overlay and its connection to the OBS Studio "
"obs-websocket (v5) server.",
.type = OptionType::Bool,
.category = "OBS Control",
},
{
// OBSWebSocketHost
.title = "OBS WebSocket Host",
.name = "obshost",
.desc = "Host name or IP address of the OBS Studio obs-websocket (v5) server used by "
"the in-game OBS Control overlay. Defaults to 127.0.0.1 when left empty.",
.type = OptionType::Text,
.category = "OBS Control",
},
{
// OBSWebSocketPort
.title = "OBS WebSocket Port",
.name = "obsport",
.desc = "Port of the OBS Studio obs-websocket (v5) server. Defaults to 4455 when left empty.",
.type = OptionType::Integer,
.category = "OBS Control",
},
{
// OBSWebSocketPassword
.title = "OBS WebSocket Password",
.name = "obspass",
.desc = "Password for the OBS Studio obs-websocket (v5) server. Leave empty if "
"authentication is disabled in OBS.",
.type = OptionType::Text,
.category = "OBS Control",
.sensitive = true,
},
{
// OBSWebSocketDebug
.title = "OBS WebSocket Debug",
.name = "obsdebug",
.desc = "Writes the OBS WebSocket client's internal connection diagnostics to the log. "
"Only enable this when troubleshooting connection problems.",
.type = OptionType::Bool,
.category = "OBS Control",
},
}; };
const std::vector<std::string> &launcher::get_categories(Options::OptionsCategory category) { const std::vector<std::string> &launcher::get_categories(Options::OptionsCategory category) {
+13 -1
View File
@@ -53,6 +53,7 @@ namespace launcher {
SOFTID, SOFTID,
VREnable, VREnable,
DisableOverlay, DisableOverlay,
OverlayKeyboardNavigation,
OverlayScaling, OverlayScaling,
NotificationPosition, NotificationPosition,
spice2x_FpsAutoShow, spice2x_FpsAutoShow,
@@ -97,6 +98,7 @@ namespace launcher {
SDVXDigitalKnobSocd, SDVXDigitalKnobSocd,
spice2x_SDVXAsioDriver, spice2x_SDVXAsioDriver,
SDVXAsioTwoChannel, SDVXAsioTwoChannel,
SDVXDisableLive2D,
spice2x_SDVXSubPos, spice2x_SDVXSubPos,
SDVXSubMonitorOverride, SDVXSubMonitorOverride,
LoadDDRModule, LoadDDRModule,
@@ -108,6 +110,7 @@ namespace launcher {
PopnNoSub, PopnNoSub,
PopnSubMonitorOverride, PopnSubMonitorOverride,
PopnNativeTouch, PopnNativeTouch,
PopnSubRedraw,
LoadHelloPopnMusicModule, LoadHelloPopnMusicModule,
LoadGitaDoraModule, LoadGitaDoraModule,
GitaDoraTwoChannelAudio, GitaDoraTwoChannelAudio,
@@ -177,6 +180,7 @@ namespace launcher {
ForceWinTouch, ForceWinTouch,
ForceTouchEmulation, ForceTouchEmulation,
InvertTouchCoordinates, InvertTouchCoordinates,
RawInputTouchAspectRatio,
DisableTouchCardInsert, DisableTouchCardInsert,
spice2x_TouchCardInsert, spice2x_TouchCardInsert,
ICCAReaderPort, ICCAReaderPort,
@@ -260,6 +264,9 @@ namespace launcher {
IIDXWindowedSubscreenAlwaysOnTop, IIDXWindowedSubscreenAlwaysOnTop,
spice2x_JubeatLegacyTouch, spice2x_JubeatLegacyTouch,
JubeatTouchAlgo, JubeatTouchAlgo,
JubeatTouchDebug,
JubeatTouchDebounce,
RBTouchDebug,
spice2x_RBTouchScale, spice2x_RBTouchScale,
RBTouchSize, RBTouchSize,
RBTouchPollRate, RBTouchPollRate,
@@ -304,7 +311,12 @@ namespace launcher {
DisableHighResTimer, DisableHighResTimer,
EnableICMPHook, EnableICMPHook,
AutoElevate, AutoElevate,
CfgForceSoftwareRender CfgForceSoftwareRender,
OBSWebSocketEnabled,
OBSWebSocketHost,
OBSWebSocketPort,
OBSWebSocketPassword,
OBSWebSocketDebug
}; };
enum class OptionsCategory { enum class OptionsCategory {
+22
View File
@@ -1327,6 +1327,28 @@ 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
easywsclient (MIT)
-------------------------------------------
Copyright (c) 2012 Dhruv Matani, Daniel Baird
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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 OR COPYRIGHT HOLDERS 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.
Contributions Contributions
------------------------------------------- -------------------------------------------
cardio - Felix - MIT License cardio - Felix - MIT License
+34 -32
View File
@@ -366,6 +366,7 @@ static void __cdecl device_update() {
// update touch // update touch
games::jb::touch_update(); games::jb::touch_update();
auto touched = games::jb::touch_state();
// get buttons // get buttons
auto &buttons = games::jb::get_buttons(); auto &buttons = games::jb::get_buttons();
@@ -382,52 +383,52 @@ static void __cdecl device_update() {
if (Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::CoinMech))) { if (Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::CoinMech))) {
DEVICE_INPUT_STATE |= 1 << 29; DEVICE_INPUT_STATE |= 1 << 29;
} }
if (games::jb::TOUCH_STATE[3] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button1))) { if (touched[3] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button1))) {
DEVICE_INPUT_STATE |= 1 << 13; DEVICE_INPUT_STATE |= 1 << 13;
} }
if (games::jb::TOUCH_STATE[7] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button2))) { if (touched[7] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button2))) {
DEVICE_INPUT_STATE |= 1 << 9; DEVICE_INPUT_STATE |= 1 << 9;
} }
if (games::jb::TOUCH_STATE[11] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button3))) { if (touched[11] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button3))) {
DEVICE_INPUT_STATE |= 1 << 21; DEVICE_INPUT_STATE |= 1 << 21;
} }
if (games::jb::TOUCH_STATE[15] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button4))) { if (touched[15] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button4))) {
DEVICE_INPUT_STATE |= 1 << 17; DEVICE_INPUT_STATE |= 1 << 17;
} }
if (games::jb::TOUCH_STATE[2] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button5))) { if (touched[2] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button5))) {
DEVICE_INPUT_STATE |= 1 << 14; DEVICE_INPUT_STATE |= 1 << 14;
} }
if (games::jb::TOUCH_STATE[6] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button6))) { if (touched[6] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button6))) {
DEVICE_INPUT_STATE |= 1 << 10; DEVICE_INPUT_STATE |= 1 << 10;
} }
if (games::jb::TOUCH_STATE[10] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button7))) { if (touched[10] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button7))) {
DEVICE_INPUT_STATE |= 1 << 22; DEVICE_INPUT_STATE |= 1 << 22;
} }
if (games::jb::TOUCH_STATE[14] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button8))) { if (touched[14] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button8))) {
DEVICE_INPUT_STATE |= 1 << 18; DEVICE_INPUT_STATE |= 1 << 18;
} }
if (games::jb::TOUCH_STATE[1] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button9))) { if (touched[1] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button9))) {
DEVICE_INPUT_STATE |= 1 << 15; DEVICE_INPUT_STATE |= 1 << 15;
} }
if (games::jb::TOUCH_STATE[5] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button10))) { if (touched[5] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button10))) {
DEVICE_INPUT_STATE |= 1 << 11; DEVICE_INPUT_STATE |= 1 << 11;
} }
if (games::jb::TOUCH_STATE[9] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button11))) { if (touched[9] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button11))) {
DEVICE_INPUT_STATE |= 1 << 23; DEVICE_INPUT_STATE |= 1 << 23;
} }
if (games::jb::TOUCH_STATE[13] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button12))) { if (touched[13] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button12))) {
DEVICE_INPUT_STATE |= 1 << 19; DEVICE_INPUT_STATE |= 1 << 19;
} }
if (games::jb::TOUCH_STATE[0] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button13))) { if (touched[0] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button13))) {
DEVICE_INPUT_STATE |= 1 << 24; DEVICE_INPUT_STATE |= 1 << 24;
} }
if (games::jb::TOUCH_STATE[4] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button14))) { if (touched[4] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button14))) {
DEVICE_INPUT_STATE |= 1 << 12; DEVICE_INPUT_STATE |= 1 << 12;
} }
if (games::jb::TOUCH_STATE[8] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button15))) { if (touched[8] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button15))) {
DEVICE_INPUT_STATE |= 1 << 26; DEVICE_INPUT_STATE |= 1 << 26;
} }
if (games::jb::TOUCH_STATE[12] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button16))) { if (touched[12] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button16))) {
DEVICE_INPUT_STATE |= 1 << 20; DEVICE_INPUT_STATE |= 1 << 20;
} }
@@ -439,6 +440,7 @@ static void __cdecl device_update() {
// update touch // update touch
games::jb::touch_update(); games::jb::touch_update();
auto touched = games::jb::touch_state();
// get buttons // get buttons
auto &buttons = games::jb::get_buttons(); auto &buttons = games::jb::get_buttons();
@@ -455,52 +457,52 @@ static void __cdecl device_update() {
if (Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::CoinMech))) { if (Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::CoinMech))) {
DEVICE_INPUT_STATE |= 1 << 24; DEVICE_INPUT_STATE |= 1 << 24;
} }
if (games::jb::TOUCH_STATE[0] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button1))) { if (touched[0] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button1))) {
DEVICE_INPUT_STATE |= 1 << 5; DEVICE_INPUT_STATE |= 1 << 5;
} }
if (games::jb::TOUCH_STATE[1] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button2))) { if (touched[1] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button2))) {
DEVICE_INPUT_STATE |= 1 << 1; DEVICE_INPUT_STATE |= 1 << 1;
} }
if (games::jb::TOUCH_STATE[2] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button3))) { if (touched[2] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button3))) {
DEVICE_INPUT_STATE |= 1 << 13; DEVICE_INPUT_STATE |= 1 << 13;
} }
if (games::jb::TOUCH_STATE[3] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button4))) { if (touched[3] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button4))) {
DEVICE_INPUT_STATE |= 1 << 9; DEVICE_INPUT_STATE |= 1 << 9;
} }
if (games::jb::TOUCH_STATE[4] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button5))) { if (touched[4] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button5))) {
DEVICE_INPUT_STATE |= 1 << 6; DEVICE_INPUT_STATE |= 1 << 6;
} }
if (games::jb::TOUCH_STATE[5] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button6))) { if (touched[5] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button6))) {
DEVICE_INPUT_STATE |= 1 << 2; DEVICE_INPUT_STATE |= 1 << 2;
} }
if (games::jb::TOUCH_STATE[6] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button7))) { if (touched[6] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button7))) {
DEVICE_INPUT_STATE |= 1 << 14; DEVICE_INPUT_STATE |= 1 << 14;
} }
if (games::jb::TOUCH_STATE[7] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button8))) { if (touched[7] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button8))) {
DEVICE_INPUT_STATE |= 1 << 10; DEVICE_INPUT_STATE |= 1 << 10;
} }
if (games::jb::TOUCH_STATE[8] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button9))) { if (touched[8] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button9))) {
DEVICE_INPUT_STATE |= 1 << 7; DEVICE_INPUT_STATE |= 1 << 7;
} }
if (games::jb::TOUCH_STATE[9] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button10))) { if (touched[9] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button10))) {
DEVICE_INPUT_STATE |= 1 << 3; DEVICE_INPUT_STATE |= 1 << 3;
} }
if (games::jb::TOUCH_STATE[10] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button11))) { if (touched[10] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button11))) {
DEVICE_INPUT_STATE |= 1 << 15; DEVICE_INPUT_STATE |= 1 << 15;
} }
if (games::jb::TOUCH_STATE[11] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button12))) { if (touched[11] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button12))) {
DEVICE_INPUT_STATE |= 1 << 11; DEVICE_INPUT_STATE |= 1 << 11;
} }
if (games::jb::TOUCH_STATE[12] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button13))) { if (touched[12] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button13))) {
DEVICE_INPUT_STATE |= 1 << 16; DEVICE_INPUT_STATE |= 1 << 16;
} }
if (games::jb::TOUCH_STATE[13] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button14))) { if (touched[13] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button14))) {
DEVICE_INPUT_STATE |= 1 << 4; DEVICE_INPUT_STATE |= 1 << 4;
} }
if (games::jb::TOUCH_STATE[14] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button15))) { if (touched[14] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button15))) {
DEVICE_INPUT_STATE |= 1 << 20; DEVICE_INPUT_STATE |= 1 << 20;
} }
if (games::jb::TOUCH_STATE[15] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button16))) { if (touched[15] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button16))) {
DEVICE_INPUT_STATE |= 1 << 12; DEVICE_INPUT_STATE |= 1 << 12;
} }
} }
+9 -3
View File
@@ -20,6 +20,7 @@ static cardunit_card_cardnumber_t cardunit_card_cardnumber = nullptr;
static HINSTANCE SCIUNIT_INSTANCE; static HINSTANCE SCIUNIT_INSTANCE;
static std::string SCIUNIT_INSTANCE_NAME = "sciunit.dll"; static std::string SCIUNIT_INSTANCE_NAME = "sciunit.dll";
static bool SCIUNIT_INITIALIZED = false; static bool SCIUNIT_INITIALIZED = false;
static int SCIUNIT_STATUS = 0;
static bool CARD_IN = false; static bool CARD_IN = false;
static bool CARD_PRESSED = false; static bool CARD_PRESSED = false;
static uint8_t CARD_UID[8]; static uint8_t CARD_UID[8];
@@ -150,11 +151,11 @@ static int __cdecl sciunit_finalize() {
} }
static int __cdecl sciunit_get_errorunit() { static int __cdecl sciunit_get_errorunit() {
return 0; return -1;
} }
static int __cdecl sciunit_get_stat() { static int __cdecl sciunit_get_stat() {
return 0; return SCIUNIT_STATUS;
} }
static int __cdecl sciunit_get_version(int a1, int a2) { static int __cdecl sciunit_get_version(int a1, int a2) {
@@ -163,6 +164,7 @@ static int __cdecl sciunit_get_version(int a1, int a2) {
static int __cdecl sciunit_initialize() { static int __cdecl sciunit_initialize() {
SCIUNIT_INITIALIZED = true; SCIUNIT_INITIALIZED = true;
SCIUNIT_STATUS = 1;
return 0; return 0;
} }
@@ -223,8 +225,12 @@ static int __cdecl sciunit_reset() {
} }
static int __cdecl sciunit_update() { static int __cdecl sciunit_update() {
if (SCIUNIT_INITIALIZED) if (SCIUNIT_INITIALIZED) {
if (SCIUNIT_STATUS == 1) {
SCIUNIT_STATUS = 2;
}
update_card(); update_card();
}
return 0; return 0;
} }
+26 -11
View File
@@ -1,7 +1,9 @@
#include "extensions.h" #include "extensions.h"
#include <algorithm>
#include <cmath> #include <cmath>
#include "external/imgui/imgui.h" #include "external/imgui/imgui.h"
#include "external/imgui/imgui_internal.h"
#include "overlay/overlay.h" #include "overlay/overlay.h"
@@ -160,6 +162,19 @@ namespace ImGui {
return clicked; return clicked;
} }
bool ColoredButton(const char* label, const ImVec4& base, const ImVec2& size) {
const auto brighten = [](const ImVec4& c, float d) {
return ImVec4((std::min)(c.x + d, 1.0f), (std::min)(c.y + d, 1.0f),
(std::min)(c.z + d, 1.0f), c.w);
};
ImGui::PushStyleColor(ImGuiCol_Button, base);
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, brighten(base, 0.12f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, brighten(base, 0.22f));
const bool clicked = ImGui::Button(label, size);
ImGui::PopStyleColor(3);
return clicked;
}
bool ClearButton(const std::string& tooltip) { bool ClearButton(const std::string& tooltip) {
ImGui::PushID(tooltip.c_str()); ImGui::PushID(tooltip.c_str());
// same colors as a checkbox // same colors as a checkbox
@@ -209,17 +224,17 @@ namespace ImGui {
return open; return open;
} }
void InvisibleTableRowSelectable() { void HighlightTableRowOnHover() {
ImGui::TableSetColumnIndex(0); // hit-test the row rect directly so the row layout and height are untouched
ImGui::PushStyleColor(ImGuiCol_Header, 0); ImGuiTable *table = ImGui::GetCurrentTable();
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, 0); if (table == nullptr) {
ImGui::PushStyleColor(ImGuiCol_HeaderActive, 0); return;
ImGui::PushTabStop(false); // prevent tab navigation }
ImGui::Selectable("##row", false, if (ImGui::IsWindowHovered() &&
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap); ImGui::IsMouseHoveringRect(
ImGui::PopTabStop(); ImVec2(table->WorkRect.Min.x, table->RowPosY1),
ImGui::PopStyleColor(3); ImVec2(table->WorkRect.Max.x, table->RowPosY2),
if (ImGui::IsItemHovered()) { false)) {
ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, IM_COL32(200, 200, 200, 24)); ImGui::TableSetBgColor(ImGuiTableBgTarget_RowBg1, IM_COL32(200, 200, 200, 24));
} }
} }
+6 -1
View File
@@ -20,7 +20,12 @@ namespace ImGui {
void TextTruncated(const std::string& p_text, float p_truncated_width); void TextTruncated(const std::string& p_text, float p_truncated_width);
bool DeleteButton(const std::string& tooltip); bool DeleteButton(const std::string& tooltip);
bool ClearButton(const std::string& tooltip); bool ClearButton(const std::string& tooltip);
void InvisibleTableRowSelectable(); void HighlightTableRowOnHover();
// a Button with the given base fill color; the hovered/active shades are
// derived by brightening the base. size defaults to auto (fit the label).
bool ColoredButton(const char* label, const ImVec4& base,
const ImVec2& size = ImVec2(0, 0));
// Config tab bar with extra label padding and uniform, centered tab widths. // Config tab bar with extra label padding and uniform, centered tab widths.
// Wrap items in BeginPaddedTabItem between BeginPaddedTabBar/EndTabBar. // Wrap items in BeginPaddedTabItem between BeginPaddedTabBar/EndTabBar.
+15 -3
View File
@@ -210,7 +210,15 @@ bool ImGui_ImplSpice_UpdateMouseCursor() {
// update cursor // update cursor
ImGuiMouseCursor imgui_cursor = ImGui::GetMouseCursor(); ImGuiMouseCursor imgui_cursor = ImGui::GetMouseCursor();
if (imgui_cursor == ImGuiMouseCursor_None || io.MouseDrawCursor) {
// in auto-hide mode imgui owns cursor drawing, so the OS cursor must stay
// hidden. this function is also called from the game window's WM_SETCURSOR
// handler, which fires on every mouse move; without forcing it hidden here,
// the else branch below would set IDC_ARROW on the next mouse move once the
// overlay is closed. that arrow is conspicuous on games whose window class
// has hCursor==NULL (e.g. DDR), since those normally show no client-area
// cursor at all.
if (imgui_cursor == ImGuiMouseCursor_None || io.MouseDrawCursor || g_MouseCursorAutoHide) {
// hide OS mouse cursor if imgui is drawing it or if it wants no cursor // hide OS mouse cursor if imgui is drawing it or if it wants no cursor
::SetCursor(nullptr); ::SetCursor(nullptr);
@@ -592,8 +600,12 @@ void ImGui_ImplSpice_NewFrame() {
} }
} }
} else { } else {
// update OS mouse cursor with the cursor requested by imgui // in auto-hide mode imgui owns the cursor, so keep the OS cursor hidden
ImGuiMouseCursor mouse_cursor = io.MouseDrawCursor ? ImGuiMouseCursor_None : ImGui::GetMouseCursor(); // even when the overlay is closed (io.MouseDrawCursor is false then).
ImGuiMouseCursor mouse_cursor =
(io.MouseDrawCursor || g_MouseCursorAutoHide)
? ImGuiMouseCursor_None
: ImGui::GetMouseCursor();
if (g_LastMouseCursor != mouse_cursor) { if (g_LastMouseCursor != mouse_cursor) {
g_LastMouseCursor = mouse_cursor; g_LastMouseCursor = mouse_cursor;
ImGui_ImplSpice_UpdateMouseCursor(); ImGui_ImplSpice_UpdateMouseCursor();
+22 -1
View File
@@ -6,6 +6,7 @@
#include "games/gitadora/gitadora.h" #include "games/gitadora/gitadora.h"
#include "games/iidx/iidx.h" #include "games/iidx/iidx.h"
#include "games/popn/popn.h" #include "games/popn/popn.h"
#include "games/rb/touch_debug.h"
#include "hooks/graphics/graphics.h" #include "hooks/graphics/graphics.h"
#include "misc/eamuse.h" #include "misc/eamuse.h"
#include "touch/touch.h" #include "touch/touch.h"
@@ -50,6 +51,7 @@
#include "windows/sdvx_sub.h" #include "windows/sdvx_sub.h"
#include "windows/keypad.h" #include "windows/keypad.h"
#include "windows/log.h" #include "windows/log.h"
#include "windows/obs.h"
#include "windows/patch_manager.h" #include "windows/patch_manager.h"
#include "windows/exitprompt.cpp" #include "windows/exitprompt.cpp"
@@ -65,6 +67,7 @@ namespace overlay {
bool AUTO_SHOW_IOPANEL = false; bool AUTO_SHOW_IOPANEL = false;
bool AUTO_SHOW_KEYPAD_P1 = false; bool AUTO_SHOW_KEYPAD_P1 = false;
bool AUTO_SHOW_KEYPAD_P2 = false; bool AUTO_SHOW_KEYPAD_P2 = false;
bool ENABLE_KEYBOARD_NAVIGATION = false;
bool USE_WM_CHAR_FOR_IMGUI_CHAR_INPUT = false; bool USE_WM_CHAR_FOR_IMGUI_CHAR_INPUT = false;
FpsLocation FPS_LOCATION = FpsLocation::TopRight; FpsLocation FPS_LOCATION = FpsLocation::TopRight;
std::optional<uint32_t> UI_SCALE_PERCENT; std::optional<uint32_t> UI_SCALE_PERCENT;
@@ -310,6 +313,9 @@ void overlay::SpiceOverlay::init() {
auto &io = ImGui::GetIO(); auto &io = ImGui::GetIO();
io.UserData = this; io.UserData = this;
io.ConfigFlags = ImGuiConfigFlags_None; io.ConfigFlags = ImGuiConfigFlags_None;
if (cfg::CONFIGURATOR_STANDALONE || ENABLE_KEYBOARD_NAVIGATION) {
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
}
if (!cfg::CONFIGURATOR_STANDALONE) { if (!cfg::CONFIGURATOR_STANDALONE) {
io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; io.ConfigFlags |= ImGuiConfigFlags_DockingEnable;
} }
@@ -416,6 +422,12 @@ void overlay::SpiceOverlay::init() {
} }
this->window_add(new overlay::windows::PatchManager(this)); this->window_add(new overlay::windows::PatchManager(this));
// OBS control spawns a background WebSocket worker; skip it in the standalone
// configurator, where there is no running game to stream/record
if (!cfg::CONFIGURATOR_STANDALONE) {
this->window_add(window_obs = new overlay::windows::OBSControl(this));
}
{ {
window_keypad1 = new overlay::windows::Keypad(this, 0); window_keypad1 = new overlay::windows::Keypad(this, 0);
this->window_add(window_keypad1); this->window_add(window_keypad1);
@@ -544,9 +556,13 @@ void overlay::SpiceOverlay::new_frame() {
const bool draw_fps_persistent = this->renderer != OverlayRenderer::SOFTWARE const bool draw_fps_persistent = this->renderer != OverlayRenderer::SOFTWARE
&& this->window_fps->get_active(); && this->window_fps->get_active();
// draw RB touch diagnostics
const bool draw_rb_touch_debug = this->renderer != OverlayRenderer::SOFTWARE
&& games::rb::touch_debug_overlay_enabled();
// check if there is nothing to draw // check if there is nothing to draw
this->has_pending_frame = false; this->has_pending_frame = false;
if (!this->active && !draw_notifications && !draw_fps_persistent) { if (!this->active && !draw_notifications && !draw_fps_persistent && !draw_rb_touch_debug) {
return; return;
} }
@@ -571,6 +587,10 @@ void overlay::SpiceOverlay::new_frame() {
ImGui::NewFrame(); ImGui::NewFrame();
this->has_pending_frame = true; this->has_pending_frame = true;
if (draw_rb_touch_debug) {
games::rb::touch_draw_debug_overlay();
}
// build windows only when the overlay itself is active // build windows only when the overlay itself is active
if (this->active) { if (this->active) {
for (auto &window : this->windows) { for (auto &window : this->windows) {
@@ -588,6 +608,7 @@ void overlay::SpiceOverlay::new_frame() {
if (draw_fps_persistent) { if (draw_fps_persistent) {
this->window_fps->build(); this->window_fps->build();
} }
// draw notifications last so they paint on top of any overlay windows // draw notifications last so they paint on top of any overlay windows
if (draw_notifications) { if (draw_notifications) {
overlay::notifications::draw(); overlay::notifications::draw();
+2
View File
@@ -44,6 +44,7 @@ namespace overlay {
extern bool AUTO_SHOW_IOPANEL; extern bool AUTO_SHOW_IOPANEL;
extern bool AUTO_SHOW_KEYPAD_P1; extern bool AUTO_SHOW_KEYPAD_P1;
extern bool AUTO_SHOW_KEYPAD_P2; extern bool AUTO_SHOW_KEYPAD_P2;
extern bool ENABLE_KEYBOARD_NAVIGATION;
extern bool USE_WM_CHAR_FOR_IMGUI_CHAR_INPUT; extern bool USE_WM_CHAR_FOR_IMGUI_CHAR_INPUT;
extern FpsLocation FPS_LOCATION; extern FpsLocation FPS_LOCATION;
extern bool SHOW_DEBUG_LOG_WINDOW; extern bool SHOW_DEBUG_LOG_WINDOW;
@@ -77,6 +78,7 @@ namespace overlay {
Window *window_camera = nullptr; Window *window_camera = nullptr;
Window *window_sub = nullptr; Window *window_sub = nullptr;
Window *window_log = nullptr; Window *window_log = nullptr;
Window *window_obs = nullptr;
// not part of `windows`: drawn/updated on the persistent layer (like // not part of `windows`: drawn/updated on the persistent layer (like
// notifications), independent of the overlay's active state and input gates. // notifications), independent of the overlay's active state and input gates.
+18 -20
View File
@@ -928,18 +928,14 @@ namespace overlay::windows {
// primary // primary
build_button(name, primary_button, &primary_button, button_it, button_it_max, 0); build_button(name, primary_button, &primary_button, button_it, button_it_max, 0);
ImGui::PushID(&primary_button); ImGui::HighlightTableRowOnHover();
ImGui::InvisibleTableRowSelectable();
ImGui::PopID();
// alternatives // alternatives
int alt_index = 1; // 0 is primary int alt_index = 1; // 0 is primary
for (auto &alt : primary_button.getAlternatives()) { for (auto &alt : primary_button.getAlternatives()) {
if (alt.isValid()) { if (alt.isValid()) {
build_button(name, primary_button, &alt, button_it, button_it_max, alt_index); build_button(name, primary_button, &alt, button_it, button_it_max, alt_index);
ImGui::PushID(&alt); ImGui::HighlightTableRowOnHover();
ImGui::InvisibleTableRowSelectable();
ImGui::PopID();
} }
alt_index++; alt_index++;
} }
@@ -2519,8 +2515,7 @@ namespace overlay::windows {
edit_analog_popup(analog, title); edit_analog_popup(analog, title);
// row hover detection (invisible selectable that spans entire row) ImGui::HighlightTableRowOnHover();
ImGui::InvisibleTableRowSelectable();
// clean up // clean up
ImGui::PopID(); ImGui::PopID();
@@ -3174,16 +3169,12 @@ namespace overlay::windows {
} }
build_light(light, &light, i, 0); build_light(light, &light, i, 0);
ImGui::PushID(&light); ImGui::HighlightTableRowOnHover();
ImGui::InvisibleTableRowSelectable();
ImGui::PopID();
int alt_index = 1; int alt_index = 1;
for (auto &alt : light.getAlternatives()) { for (auto &alt : light.getAlternatives()) {
if (alt.isValid()) { if (alt.isValid()) {
build_light(light, &alt, i, alt_index); build_light(light, &alt, i, alt_index);
ImGui::PushID(&alt); ImGui::HighlightTableRowOnHover();
ImGui::InvisibleTableRowSelectable();
ImGui::PopID();
} }
alt_index++; alt_index++;
} }
@@ -4209,8 +4200,10 @@ namespace overlay::windows {
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "Card overrides"); ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "Card overrides");
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextUnformatted( ImGui::TextWrapped(
"Specify hardcoded card numbers here. This will always take priority when Insert Card is pressed."); "%s",
"Specify hardcoded card numbers here.\n\n"
"Overrides will always take priority when Insert Card is pressed.");
ImGui::Spacing(); ImGui::Spacing();
// read in values from options // read in values from options
@@ -4387,7 +4380,8 @@ namespace overlay::windows {
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "Card from text files"); ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "Card from text files");
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextUnformatted( ImGui::TextWrapped(
"%s",
"Use text files on disk; its content will be read when Insert Card is pressed."); "Use text files on disk; its content will be read when Insert Card is pressed.");
ImGui::Spacing(); ImGui::Spacing();
@@ -5293,8 +5287,7 @@ namespace overlay::windows {
} }
} }
// row hover detection (invisible selectable that spans entire row) ImGui::HighlightTableRowOnHover();
ImGui::InvisibleTableRowSelectable();
// next item // next item
ImGui::PopID(); ImGui::PopID();
@@ -5787,23 +5780,28 @@ namespace overlay::windows {
// name // name
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::TextTruncated( ImGui::TextTruncated(
t.name, ImGui::GetContentRegionAvail().x - overlay::apply_scaling(20)); t.name, ImGui::GetContentRegionAvail().x - overlay::apply_scaling(20));
// type // type
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(t.is_builtin ? "Built-in" : "User"); ImGui::TextUnformatted(t.is_builtin ? "Built-in" : "User");
// buttons // buttons
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::Text("%d", (int)t.buttons.size()); ImGui::Text("%d", (int)t.buttons.size());
// analogs // analogs
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::Text("%d", (int)t.analogs.size()); ImGui::Text("%d", (int)t.analogs.size());
// lights // lights
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::Text("%d", (int)t.lights.size()); ImGui::Text("%d", (int)t.lights.size());
// actions // actions
@@ -5831,7 +5829,7 @@ namespace overlay::windows {
} }
} }
ImGui::InvisibleTableRowSelectable(); ImGui::HighlightTableRowOnHover();
ImGui::PopID(); ImGui::PopID();
} }
+3 -2
View File
@@ -119,8 +119,9 @@ namespace overlay::windows {
ImGui::TextDisabled("Graphics"); ImGui::TextDisabled("Graphics");
build_button(this->overlay->window_camera, "Camera control", size, NextItem::NEW_LINE); build_button(this->overlay->window_camera, "Camera control", size, NextItem::NEW_LINE);
build_button(this->overlay->window_fps.get(), "FPS", size_half, NextItem::SAME_LINE); build_button(this->overlay->window_fps.get(), "FPS", size_third, NextItem::SAME_LINE);
build_button(this->overlay->window_resize, "Resize", size_half, NextItem::NEW_LINE); build_button(this->overlay->window_obs, "OBS", size_third, NextItem::SAME_LINE);
build_button(this->overlay->window_resize, "Resize", size_third, NextItem::NEW_LINE);
ImGui::TextDisabled("I/O"); ImGui::TextDisabled("I/O");
build_button(this->overlay->window_cards, "Card Manager", size, NextItem::NEW_LINE); build_button(this->overlay->window_cards, "Card Manager", size, NextItem::NEW_LINE);
+64 -22
View File
@@ -1,6 +1,7 @@
#include <algorithm> #include <algorithm>
#include "external/fmt/include/fmt/chrono.h" #include "external/fmt/include/fmt/chrono.h"
#include "fps.h" #include "fps.h"
#include "obs.h"
namespace overlay::windows { namespace overlay::windows {
@@ -14,6 +15,7 @@ namespace overlay::windows {
this->title = "Stats"; this->title = "Stats";
this->flags = ImGuiWindowFlags_NoTitleBar this->flags = ImGuiWindowFlags_NoTitleBar
| ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoResize
| ImGuiWindowFlags_AlwaysAutoResize
| ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoCollapse
| ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoFocusOnAppearing
| ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoBringToFrontOnFocus
@@ -29,25 +31,7 @@ namespace overlay::windows {
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now()); std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
} }
void FPS::calculate_initial_window() { ImVec2 FPS::anchored_pos(const ImVec2 &size) const {
// size the window explicitly (no AlwaysAutoResize) so the corner anchoring
// below is exact; the footprint mirrors the fixed-fit table in build_content()
const float line_h = ImGui::GetTextLineHeight();
const int rows = 3;
// widest label and widest value drive the two fixed-fit columns
const float label_w = (std::max)(
ImGui::CalcTextSize("Time").x,
ImGui::CalcTextSize("Game").x);
const float value_w = ImGui::CalcTextSize("00:00:00").x;
const float win_w = label_w + value_w
+ FPS_CELL_PADDING.x * 2
+ FPS_WINDOW_PADDING.x * 2;
const float win_h = (line_h + FPS_CELL_PADDING.y * 2) * rows
+ FPS_WINDOW_PADDING.y * 2;
this->init_size = ImVec2(win_w, win_h);
// bottom-anchored windows use a larger edge margin (matching notification // bottom-anchored windows use a larger edge margin (matching notification
// toasts) since they overlap the same on-screen UI; other edges hug closer // toasts) since they overlap the same on-screen UI; other edges hug closer
const float edge_margin = overlay::apply_scaling(4); const float edge_margin = overlay::apply_scaling(4);
@@ -61,9 +45,27 @@ namespace overlay::windows {
overlay::FPS_LOCATION == overlay::FpsLocation::BottomLeft || overlay::FPS_LOCATION == overlay::FpsLocation::BottomLeft ||
overlay::FPS_LOCATION == overlay::FpsLocation::BottomRight; overlay::FPS_LOCATION == overlay::FpsLocation::BottomRight;
const float pos_x = right ? display.x - win_w - edge_margin : edge_margin; const float pos_x = right ? display.x - size.x - edge_margin : edge_margin;
const float pos_y = bottom ? display.y - win_h - bottom_margin : edge_margin; const float pos_y = bottom ? display.y - size.y - bottom_margin : edge_margin;
this->init_pos = ImVec2(pos_x, pos_y); return ImVec2(pos_x, pos_y);
}
void FPS::calculate_initial_window() {
// first-frame size estimate for the base 3 rows; AlwaysAutoResize handles
// the exact size (incl. any OBS rows) and build_content re-anchors each frame
const float line_h = ImGui::GetTextLineHeight();
const float label_w = (std::max)(
ImGui::CalcTextSize("Time").x,
ImGui::CalcTextSize("Game").x);
const float value_w = ImGui::CalcTextSize("00:00:00").x;
const float win_w = label_w + value_w
+ FPS_CELL_PADDING.x * 2
+ FPS_WINDOW_PADDING.x * 2;
const float win_h = (line_h + FPS_CELL_PADDING.y * 2) * 3
+ FPS_WINDOW_PADDING.y * 2;
this->init_size = ImVec2(win_w, win_h);
this->init_pos = this->anchored_pos(this->init_size);
} }
void FPS::build_content() { void FPS::build_content() {
@@ -79,6 +81,21 @@ namespace overlay::windows {
const auto uptime = now_s - this->start_time; const auto uptime = now_s - this->start_time;
// OBS status (only adds rows while streaming live or recording/paused)
OBSStatus obs_status;
bool show_stream = false;
bool show_record = false;
if (auto *obs = static_cast<OBSControl *>(this->overlay->window_obs)) {
obs_status = obs->get_status();
show_stream = obs_status.streaming;
show_record = obs_status.recording;
}
// AlwaysAutoResize sizes the window to its content, so adding/removing OBS
// rows never clips; just re-anchor it to the configured corner each frame
// using the actual (auto-sized) dimensions
ImGui::SetWindowPos(this->anchored_pos(ImGui::GetWindowSize()), ImGuiCond_Always);
// right-align a label within the current cell so the label column reads // right-align a label within the current cell so the label column reads
// flush against the value column instead of looking ragged. the label is // flush against the value column instead of looking ragged. the label is
// only slightly dimmer than normal text (not the much darker "disabled" tone) // only slightly dimmer than normal text (not the much darker "disabled" tone)
@@ -117,6 +134,31 @@ namespace overlay::windows {
fmt::format("{:%H:%M:%S}", fmt::format("{:%H:%M:%S}",
std::chrono::floor<std::chrono::seconds>(uptime)).c_str()); std::chrono::floor<std::chrono::seconds>(uptime)).c_str());
// OBS rows - only present while live or recording
const ImVec4 col_red(0.90f, 0.30f, 0.30f, 1.0f);
const ImVec4 col_yellow(0.95f, 0.80f, 0.30f, 1.0f);
if (show_stream) {
const int64_t ms = OBSControl::live_duration_ms(
obs_status.stream_duration_ms, obs_status.stream_duration_base_tick, true);
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
label("Live");
ImGui::TableSetColumnIndex(1);
ImGui::TextColored(col_red, "%s",
fmt::format("{:%H:%M:%S}", std::chrono::seconds(ms / 1000)).c_str());
}
if (show_record) {
const int64_t ms = OBSControl::live_duration_ms(
obs_status.record_duration_ms, obs_status.record_duration_base_tick,
!obs_status.record_paused);
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
label("Rec");
ImGui::TableSetColumnIndex(1);
ImGui::TextColored(obs_status.record_paused ? col_yellow : col_red, "%s",
fmt::format("{:%H:%M:%S}", std::chrono::seconds(ms / 1000)).c_str());
}
ImGui::EndTable(); ImGui::EndTable();
} }
ImGui::PopStyleVar(); ImGui::PopStyleVar();
+3
View File
@@ -9,6 +9,9 @@ namespace overlay::windows {
private: private:
std::chrono::system_clock::time_point start_time; std::chrono::system_clock::time_point start_time;
// anchored top-left position for a window of the given size, per FPS_LOCATION
ImVec2 anchored_pos(const ImVec2 &size) const;
public: public:
FPS(SpiceOverlay *overlay); FPS(SpiceOverlay *overlay);
+290
View File
@@ -0,0 +1,290 @@
#include "obs.h"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include "external/imgui/imgui.h"
#include "games/io.h"
#include "overlay/overlay.h"
#include "overlay/imgui/extensions.h"
using namespace std::chrono;
// OBS WebSocket protocol/worker thread lives in obs_websocket.cpp; this file
// owns the ImGui control window and the connection lifecycle.
namespace {
// status text colors
const ImVec4 COL_GREEN(0.40f, 0.85f, 0.40f, 1.0f);
const ImVec4 COL_RED(0.90f, 0.30f, 0.30f, 1.0f);
const ImVec4 COL_YELLOW(0.95f, 0.80f, 0.30f, 1.0f);
const ImVec4 COL_GREY(0.60f, 0.60f, 0.60f, 1.0f);
// muted action-button fills (start = green, stop = red, pause = yellow); the
// hovered/active shades are derived by brightening the base
const ImVec4 COL_BTN_GREEN(0.20f, 0.45f, 0.24f, 1.0f);
const ImVec4 COL_BTN_RED(0.52f, 0.20f, 0.20f, 1.0f);
const ImVec4 COL_BTN_YELLOW(0.52f, 0.42f, 0.16f, 1.0f);
// an in-flight request lingers for at most this long before the button frees
// itself, so a dropped state event can never wedge a control permanently
const int64_t PENDING_TIMEOUT_MS = 5000;
int64_t now_tick_ms() {
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
std::string format_duration(int64_t ms) {
if (ms < 0) {
ms = 0;
}
const int64_t total_seconds = ms / 1000;
const int64_t hours = total_seconds / 3600;
const int64_t minutes = (total_seconds % 3600) / 60;
const int64_t seconds = total_seconds % 60;
char buf[16];
snprintf(buf, sizeof(buf), "%02lld:%02lld:%02lld",
static_cast<long long>(hours),
static_cast<long long>(minutes),
static_cast<long long>(seconds));
return buf;
}
}
namespace overlay::windows {
OBSControl::OBSControl(SpiceOverlay *overlay) : Window(overlay) {
this->title = "OBS Control";
this->flags |= ImGuiWindowFlags_AlwaysAutoResize;
this->init_pos = overlay::apply_scaling_to_vector(120, 120);
this->toggle_button = games::OverlayButtons::ToggleOBSControl;
this->worker_running.store(true);
this->worker_thread = std::thread(&OBSControl::worker_main, this);
}
OBSControl::~OBSControl() {
// signal stop and wake any in-progress interruptible_sleep at once; the
// lock around the store pairs with the wait predicate to avoid a lost wakeup
{
std::lock_guard<std::mutex> lock(this->worker_mutex);
this->worker_running.store(false);
}
this->worker_cv.notify_all();
if (this->worker_thread.joinable()) {
// note: if the worker is mid-connect, WebSocket::from_url performs a
// blocking getaddrinfo/connect that does not observe worker_running,
// so this join can stall for the OS connect timeout. the default
// 127.0.0.1 host fails fast (connection refused); only a misconfigured
// unreachable remote OBS_CONTROL_HOST would delay shutdown here.
this->worker_thread.join();
}
}
OBSStatus OBSControl::get_status() {
std::lock_guard<std::mutex> lock(this->status_mutex);
return this->status;
}
int64_t OBSControl::live_duration_ms(int64_t base_ms, int64_t base_tick, bool ticking) {
if (!ticking) {
return (std::max<int64_t>)(base_ms, 0);
}
const int64_t now =
duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
// clamp so a stale base tick / clock hiccup can never yield a negative
// duration; callers (FPS rows, build_content) format this directly
return (std::max<int64_t>)(base_ms + (now - base_tick), 0);
}
void OBSControl::build_content() {
const OBSStatus s = this->get_status();
// label + colored value on a single line
const auto status_line = [](const char *label, const ImVec4 &col, const char *value) {
ImGui::Text("%s", label);
ImGui::SameLine();
ImGui::TextColored(col, "%s", value);
};
if (!s.connected) {
if (s.disabled) {
ImGui::TextColored(COL_GREY, "%s", "OBS Control is disabled");
return;
}
if (s.identifying) {
status_line("OBS WebSocket:", COL_YELLOW, "Connecting...");
} else {
status_line("OBS WebSocket:", COL_GREY, "Not connected");
}
const std::string url =
"ws://" + OBS_CONTROL_HOST + ":" + std::to_string(OBS_CONTROL_PORT);
status_line("Address:", COL_GREY, url.c_str());
if (!s.connection_error.empty()) {
ImGui::TextColored(COL_RED, "%s", s.connection_error.c_str());
}
return;
}
status_line("OBS WebSocket:", COL_GREEN, "Connected");
// one fixed content width drives the whole panel so it never resizes as
// the scene name or button labels change; every row is sized to fit it
const float spacing = ImGui::GetStyle().ItemSpacing.x;
const float row_w = overlay::apply_scaling(240);
if (s.current_scene.empty()) {
status_line("Scene:", COL_GREY, "(unknown)");
} else {
ImGui::Text("Scene:");
ImGui::SameLine();
// truncate to the remaining row width so "Scene:" + value together
// never overflow and push the window wider
const float label_w = ImGui::CalcTextSize("Scene:").x;
ImGui::PushStyleColor(ImGuiCol_Text, COL_GREY);
ImGui::TextTruncated(s.current_scene, row_w - label_w - spacing);
ImGui::PopStyleColor();
}
ImGui::Separator();
const int64_t now = now_tick_ms();
// every button shares one fixed size; two side-by-side fill the row width,
// single buttons keep that same size rather than stretching to fill
const ImVec2 btn((row_w - spacing) * 0.5f, 0);
// has OBS reached the state a pending action was waiting for?
const auto reached = [&](OBSAction a) {
switch (a) {
case OBSAction::StreamStart: return s.streaming;
case OBSAction::StreamStop: return !s.streaming;
case OBSAction::RecordStart: return s.recording;
case OBSAction::RecordStop: return !s.recording;
case OBSAction::RecordPause: return s.record_paused;
case OBSAction::RecordResume: return !s.record_paused;
default: return true;
}
};
// drop a pending action once OBS confirms the new state, or once the
// safety deadline lapses (so a dropped event can't wedge the button)
const auto settle = [&](OBSAction &slot, int64_t deadline) {
if (slot != OBSAction::None && (reached(slot) || now >= deadline)) {
slot = OBSAction::None;
}
};
settle(this->stream_pending, this->stream_pending_deadline);
settle(this->record_pending, this->record_pending_deadline);
// a colored button that fires a request and marks the output busy on click
const auto action_button =
[&](const char *label,
const ImVec4 &color,
const char *request,
OBSAction &slot,
int64_t &deadline,
OBSAction action) {
if (ImGui::ColoredButton(label, color, btn)) {
enqueue_request(request);
slot = action;
deadline = now + PENDING_TIMEOUT_MS;
}
};
// streaming
{
const bool pending = this->stream_pending != OBSAction::None;
if (s.streaming) {
const int64_t ms = live_duration_ms(
s.stream_duration_ms, s.stream_duration_base_tick, true);
status_line("Streaming:", COL_RED, ("LIVE " + format_duration(ms)).c_str());
} else {
status_line("Streaming:", COL_GREY, pending ? "Starting..." : "Idle");
}
ImGui::BeginDisabled(pending);
if (s.streaming) {
action_button(
pending ? "Stopping...##stream" : "Stop Streaming##stream",
COL_BTN_RED,
"StopStream",
this->stream_pending,
this->stream_pending_deadline,
OBSAction::StreamStop);
} else {
action_button(
pending ? "Starting...##stream" : "Start Streaming##stream",
COL_BTN_GREEN,
"StartStream",
this->stream_pending,
this->stream_pending_deadline,
OBSAction::StreamStart);
}
ImGui::EndDisabled();
}
ImGui::Separator();
// recording
{
const bool pending = this->record_pending != OBSAction::None;
if (!s.recording) {
status_line("Recording:", COL_GREY, pending ? "Starting..." : "Idle");
ImGui::BeginDisabled(pending);
action_button(
pending ? "Starting...##record" : "Start Recording##record",
COL_BTN_GREEN,
"StartRecord",
this->record_pending,
this->record_pending_deadline,
OBSAction::RecordStart);
ImGui::EndDisabled();
return;
}
const int64_t ms = live_duration_ms(
s.record_duration_ms, s.record_duration_base_tick, !s.record_paused);
if (s.record_paused) {
status_line("Recording:", COL_YELLOW, ("PAUSED " + format_duration(ms)).c_str());
} else {
status_line("Recording:", COL_RED, ("REC " + format_duration(ms)).c_str());
}
ImGui::BeginDisabled(pending);
action_button(
this->record_pending == OBSAction::RecordStop ? "Stopping...##record" : "Stop Recording##record",
COL_BTN_RED,
"StopRecord",
this->record_pending,
this->record_pending_deadline, OBSAction::RecordStop);
ImGui::SameLine();
if (s.record_paused) {
action_button(
this->record_pending == OBSAction::RecordResume ? "Resuming...##record_toggle" : "Resume##record_toggle",
COL_BTN_GREEN,
"ResumeRecord",
this->record_pending,
this->record_pending_deadline,
OBSAction::RecordResume);
} else {
action_button(
this->record_pending == OBSAction::RecordPause ? "Pausing...##record_toggle" : "Pause##record_toggle",
COL_BTN_YELLOW,
"PauseRecord",
this->record_pending,
this->record_pending_deadline,
OBSAction::RecordPause);
}
ImGui::EndDisabled();
}
}
}
+131
View File
@@ -0,0 +1,131 @@
#pragma once
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include "external/rapidjson/fwd.h"
#include "overlay/window.h"
namespace easywsclient {
class WebSocket;
}
namespace overlay::windows {
// OBS WebSocket connection settings, resolved once at launch from the merged
// launcher options (command line + saved config) following the same pattern
// as the other global launch settings in launcher.cpp
extern bool OBS_CONTROL_ENABLED;
extern std::string OBS_CONTROL_HOST;
extern uint16_t OBS_CONTROL_PORT;
extern std::string OBS_CONTROL_PASSWORD;
// when true, easywsclient's internal diagnostics are routed to the logger
extern bool OBS_CONTROL_DEBUG;
// status snapshot shared between the OBS worker thread and the render thread
struct OBSStatus {
bool disabled = true;
bool connected = false;
bool identifying = false;
std::string connection_error;
// name of the active program scene (read-only, from obs-websocket)
std::string current_scene;
bool streaming = false;
bool recording = false;
bool record_paused = false;
// duration base values (milliseconds) and the local timestamp (ms since
// steady epoch) at which they were last refreshed, so the UI can tick a
// smooth timer between polls
int64_t stream_duration_ms = 0;
int64_t record_duration_ms = 0;
int64_t stream_duration_base_tick = 0;
int64_t record_duration_base_tick = 0;
};
// in-flight user action used purely for UI feedback: when the user clicks a
// control we remember what we asked for so the button can show a transitional
// label and stay disabled until the observed OBS state matches the request
// (or a short deadline lapses). owned solely by the render thread.
enum class OBSAction {
None,
StreamStart, StreamStop,
RecordStart, RecordStop,
RecordPause, RecordResume,
};
class OBSControl : public Window {
public:
OBSControl(SpiceOverlay *overlay);
~OBSControl() override;
void build_content() override;
// thread-safe snapshot of the current status for external widgets (e.g. FPS)
OBSStatus get_status();
// live (ticked) duration in ms from a base value/tick captured at last poll
static int64_t live_duration_ms(int64_t base_ms, int64_t base_tick, bool ticking);
private:
// worker thread entry + helpers (implementation owns the WebSocket)
void worker_main();
// run one connected session loop until the socket closes or we stop;
// returns true if the obs-websocket handshake reached "Identified", false
// if the socket closed first (e.g. OBS rejected our auth)
bool run_session(easywsclient::WebSocket *ws, const std::string &password,
uint64_t &request_id);
// handle a single inbound obs-websocket message (parses + dispatches)
void handle_message(easywsclient::WebSocket *ws, const std::string &message,
const std::string &password, uint64_t &request_id,
bool &identified);
// per-opcode handlers dispatched from handle_message
using request_fn = std::function<void(const char *request_type)>;
void handle_identified(bool &identified, const request_fn &request);
void handle_event(const rapidjson::Value &d, const request_fn &request);
void handle_response(const rapidjson::Value &d);
void enqueue_request(const std::string &request_type);
// sleep up to total_ms, waking early if the worker is asked to stop
void interruptible_sleep(int total_ms);
// worker thread
std::thread worker_thread;
std::atomic<bool> worker_running { false };
// wakes interruptible_sleep immediately when worker_running is cleared,
// so shutdown (and the reconnect backoff) never waits out a fixed delay
std::mutex worker_mutex;
std::condition_variable worker_cv;
// shared status (guarded by status_mutex)
std::mutex status_mutex;
OBSStatus status;
// outgoing user commands (guarded by command_mutex)
std::mutex command_mutex;
std::deque<std::string> command_queue;
// transient action feedback, touched only by the render thread (no sync):
// remembers the last start/stop/pause request per output so the button can
// show a "Starting.../Stopping..." label and stay disabled until OBS reports
// the matching state, with *_deadline as a fallback if the update is missed
OBSAction stream_pending = OBSAction::None;
OBSAction record_pending = OBSAction::None;
int64_t stream_pending_deadline = 0;
int64_t record_pending_deadline = 0;
};
}
@@ -0,0 +1,434 @@
#include <winsock2.h>
#include "obs.h"
#include <chrono>
#include "external/easywsclient/easywsclient.hpp"
#include "external/rapidjson/document.h"
#include "external/rapidjson/stringbuffer.h"
#include "external/rapidjson/writer.h"
#include "external/hash-library/sha256.h"
#include "overlay/notifications.h"
#include "util/crypt.h"
#include "util/logging.h"
// defined in easywsclient.cpp; gates its internal diagnostic output
extern bool EASYWSCLIENT_LOGGING_ENABLED;
using easywsclient::WebSocket;
using namespace std::chrono;
// obs-websocket v5 message flow (https://github.com/obsproject/obs-websocket):
// server -> op 0 Hello (may include an auth challenge)
// client -> op 1 Identify (answers the challenge, picks rpcVersion)
// server -> op 2 Identified (handshake done; requests may now be sent)
// server -> op 5 Event (state changes: stream/record/scene/...)
// client -> op 6 Request (e.g. GetStreamStatus, StartRecord)
// server -> op 7 RequestResponse (reply to a Request, carries responseData)
// Every message is { "op": <int>, "d": { ... } }. Event fields are nested under
// d["eventData"] and request replies under d["responseData"], not in d directly.
namespace {
int64_t now_ms() {
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
// raw SHA256 digest -> base64 (obs-websocket v5 auth primitive)
std::string sha256_base64(const std::string &input) {
SHA256 hasher;
hasher.add(input.data(), input.size());
unsigned char digest[SHA256::HashBytes];
hasher.getHash(digest);
return crypt::base64_encode(reinterpret_cast<const uint8_t *>(digest), SHA256::HashBytes);
}
// auth = base64(sha256(base64(sha256(password + salt)) + challenge))
std::string compute_auth(const std::string &password, const std::string &salt,
const std::string &challenge) {
const std::string secret = sha256_base64(password + salt);
return sha256_base64(secret + challenge);
}
std::string build_identify(int rpc_version, const std::string &authentication) {
rapidjson::StringBuffer sb;
rapidjson::Writer<rapidjson::StringBuffer> w(sb);
w.StartObject();
w.Key("op"); w.Int(1);
w.Key("d");
w.StartObject();
w.Key("rpcVersion"); w.Int(rpc_version);
if (!authentication.empty()) {
w.Key("authentication"); w.String(authentication.c_str());
}
w.EndObject();
w.EndObject();
return sb.GetString();
}
std::string build_request(const std::string &request_type, uint64_t request_id) {
rapidjson::StringBuffer sb;
rapidjson::Writer<rapidjson::StringBuffer> w(sb);
w.StartObject();
w.Key("op"); w.Int(6);
w.Key("d");
w.StartObject();
w.Key("requestType"); w.String(request_type.c_str());
w.Key("requestId"); w.String(std::to_string(request_id).c_str());
w.EndObject();
w.EndObject();
return sb.GetString();
}
// read a numeric field as int64 ms (obs sends durations as integers/doubles)
int64_t json_number(const rapidjson::Value &obj, const char *key) {
if (obj.HasMember(key) && obj[key].IsNumber()) {
return static_cast<int64_t>(obj[key].GetDouble());
}
return 0;
}
bool json_bool(const rapidjson::Value &obj, const char *key) {
return obj.HasMember(key) && obj[key].IsBool() && obj[key].GetBool();
}
std::string json_string(const rapidjson::Value &obj, const char *key) {
if (obj.HasMember(key) && obj[key].IsString()) {
return obj[key].GetString();
}
return "";
}
// build the Identify (op 1) reply to a Hello (op 0), answering the auth
// challenge if the server requires one
std::string build_hello_response(const rapidjson::Value &d, const std::string &password) {
int rpc_version = 1;
if (d.HasMember("rpcVersion") && d["rpcVersion"].IsInt()) {
rpc_version = d["rpcVersion"].GetInt();
}
std::string auth;
if (d.HasMember("authentication") && d["authentication"].IsObject()) {
const rapidjson::Value &a = d["authentication"];
const std::string challenge = json_string(a, "challenge");
const std::string salt = json_string(a, "salt");
if (!challenge.empty()) {
auth = compute_auth(password, salt, challenge);
}
}
return build_identify(rpc_version, auth);
}
// map an obs-websocket outputState to a user notification. `label` is the
// output kind ("Streaming" or "Recording"). transitional states are ignored.
void notify_output_state(const char *label, const std::string &state) {
using overlay::notifications::Severity;
struct StateToast {
const char *state;
Severity severity;
const char *verb;
};
static const StateToast TOASTS[] = {
{ "OBS_WEBSOCKET_OUTPUT_STARTED", Severity::Success, "started" },
{ "OBS_WEBSOCKET_OUTPUT_STOPPED", Severity::Info, "stopped" },
{ "OBS_WEBSOCKET_OUTPUT_PAUSED", Severity::Warning, "paused" },
{ "OBS_WEBSOCKET_OUTPUT_RESUMED", Severity::Info, "resumed" },
};
for (const auto &toast : TOASTS) {
if (state == toast.state) {
overlay::notifications::add(toast.severity,
"OBS: " + std::string(label) + " " + toast.verb);
return;
}
}
}
}
namespace overlay::windows {
// connection settings resolved at launch (see launcher.cpp)
bool OBS_CONTROL_ENABLED = false;
std::string OBS_CONTROL_HOST = "127.0.0.1";
uint16_t OBS_CONTROL_PORT = 4455;
std::string OBS_CONTROL_PASSWORD;
bool OBS_CONTROL_DEBUG = false;
void OBSControl::enqueue_request(const std::string &request_type) {
std::lock_guard<std::mutex> lock(this->command_mutex);
this->command_queue.push_back(request_type);
}
void OBSControl::interruptible_sleep(int total_ms) {
std::unique_lock<std::mutex> lock(this->worker_mutex);
this->worker_cv.wait_for(lock, milliseconds(total_ms),
[this] { return !this->worker_running.load(); });
}
void OBSControl::handle_message(WebSocket *ws, const std::string &message,
const std::string &password, uint64_t &request_id, bool &identified) {
rapidjson::Document doc;
if (doc.Parse(message.c_str()).HasParseError() || !doc.IsObject()) {
return;
}
if (!doc.HasMember("op") || !doc["op"].IsInt()
|| !doc.HasMember("d") || !doc["d"].IsObject()) {
return;
}
const int op = doc["op"].GetInt();
const rapidjson::Value &d = doc["d"];
// send an op 6 Request; each needs a unique id (we never match replies
// back, so a simple incrementing counter is enough)
const request_fn request = [&](const char *request_type) {
ws->send(build_request(request_type, ++request_id));
};
switch (op) {
case 0: // Hello
// server greeted us: reply with Identify, solving the auth
// challenge inline if the server set a password
ws->send(build_hello_response(d, password));
break;
case 2: // Identified
this->handle_identified(identified, request);
break;
case 5: // Event
this->handle_event(d, request);
break;
case 7: // RequestResponse
this->handle_response(d);
break;
default:
break;
}
}
void OBSControl::handle_identified(bool &identified, const request_fn &request) {
// handshake complete: the connection is now usable for requests
identified = true;
{
std::lock_guard<std::mutex> lock(this->status_mutex);
this->status.connected = true;
this->status.identifying = false;
this->status.connection_error.clear();
}
log_info("obs", "connected and identified");
// pull the current scene/stream/record state so the UI starts accurate
request("GetCurrentProgramScene");
request("GetStreamStatus");
request("GetRecordStatus");
}
void OBSControl::handle_event(const rapidjson::Value &d, const request_fn &request) {
const std::string type = json_string(d, "eventType");
const bool has_data = d.HasMember("eventData") && d["eventData"].IsObject();
if (type == "StreamStateChanged") {
if (has_data) {
notify_output_state("Streaming", json_string(d["eventData"], "outputState"));
}
request("GetStreamStatus");
} else if (type == "RecordStateChanged") {
if (has_data) {
notify_output_state("Recording", json_string(d["eventData"], "outputState"));
}
request("GetRecordStatus");
} else if (type == "CurrentProgramSceneChanged" && has_data) {
std::lock_guard<std::mutex> lock(this->status_mutex);
this->status.current_scene = json_string(d["eventData"], "sceneName");
}
}
void OBSControl::handle_response(const rapidjson::Value &d) {
const std::string type = json_string(d, "requestType");
if (type.empty() || !d.HasMember("responseData") || !d["responseData"].IsObject()) {
return;
}
const rapidjson::Value &rd = d["responseData"];
std::lock_guard<std::mutex> lock(this->status_mutex);
if (type == "GetCurrentProgramScene") {
// newer obs returns sceneName; older builds used the now-deprecated
// currentProgramSceneName, so prefer it then fall back
std::string scene = json_string(rd, "currentProgramSceneName");
if (scene.empty()) {
scene = json_string(rd, "sceneName");
}
this->status.current_scene = scene;
} else if (type == "GetStreamStatus") {
this->status.streaming = json_bool(rd, "outputActive");
this->status.stream_duration_ms = json_number(rd, "outputDuration");
this->status.stream_duration_base_tick = now_ms();
} else if (type == "GetRecordStatus") {
this->status.recording = json_bool(rd, "outputActive");
this->status.record_paused = json_bool(rd, "outputPaused");
this->status.record_duration_ms = json_number(rd, "outputDuration");
this->status.record_duration_base_tick = now_ms();
}
}
bool OBSControl::run_session(WebSocket *ws, const std::string &password, uint64_t &request_id) {
// one iteration of a live connection: pump socket I/O, dispatch any
// inbound messages, flush queued user commands, then refresh status
bool identified = false;
// handle_identified() issues the first GetStreamStatus/GetRecordStatus on
// identify, so the periodic poll below just maintains the ~1s cadence
auto last_status_poll = steady_clock::now();
// send a request with the next sequential id
const auto request = [&](const char *request_type) {
ws->send(build_request(request_type, ++request_id));
};
while (this->worker_running.load() && ws->getReadyState() != WebSocket::CLOSED) {
ws->poll(100);
ws->dispatch([&](const std::string &message) {
this->handle_message(ws, message, password, request_id, identified);
});
if (ws->getReadyState() == WebSocket::CLOSED) {
break;
}
// nothing may be sent until the op 2 Identified handshake completes
if (!identified) {
continue;
}
// drain user commands
std::deque<std::string> pending;
{
std::lock_guard<std::mutex> lock(this->command_mutex);
pending.swap(this->command_queue);
}
for (const auto &cmd : pending) {
ws->send(build_request(cmd, ++request_id));
}
// periodic status refresh (~1s) for live duration
const auto now = steady_clock::now();
if (now - last_status_poll >= milliseconds(1000)) {
last_status_poll = now;
request("GetStreamStatus");
request("GetRecordStatus");
}
}
return identified;
}
void OBSControl::worker_main() {
// connection settings are resolved once at launch into globals
// (launcher.cpp, from the merged command-line + saved config options)
if (!OBS_CONTROL_ENABLED) {
log_info("obs", "disabled, not connecting");
std::lock_guard<std::mutex> lock(this->status_mutex);
this->status.disabled = true;
return;
}
const std::string url = "ws://" + OBS_CONTROL_HOST + ":" + std::to_string(OBS_CONTROL_PORT);
const std::string password = OBS_CONTROL_PASSWORD;
// opt easywsclient's internal diagnostics in/out per the debug option
EASYWSCLIENT_LOGGING_ENABLED = OBS_CONTROL_DEBUG;
// winsock is reference-counted: the app performs its own WSAStartup at
// launch (which outlives this worker), so this paired Startup/Cleanup only
// bumps the refcount and the WSACleanup below never tears down winsock for
// the rest of the process
WSADATA wsa_data;
WSAStartup(MAKEWORD(2, 2), &wsa_data);
log_info("obs", "enabled, connecting to {}", url);
uint64_t request_id = 0;
// the reconnect loop retries every 5s; latch the auth-failure warning so a
// wrong password logs once, not on every retry. reset after any identified
// session so a later genuine failure is reported again
bool auth_warning_logged = false;
// reconnect loop: keep a session alive while enabled, retrying on drop
while (this->worker_running.load()) {
// mark "connecting" for the UI before each attempt
{
std::lock_guard<std::mutex> lock(this->status_mutex);
this->status.disabled = false;
this->status.connected = false;
this->status.identifying = true;
this->status.connection_error.clear();
}
// open the TCP socket and perform the WebSocket handshake; null means
// OBS is unreachable (not running / wrong port / obs-websocket off)
WebSocket::pointer ws = WebSocket::from_url(url);
if (ws == nullptr) {
{
std::lock_guard<std::mutex> lock(this->status_mutex);
this->status.identifying = false;
this->status.connection_error = "Unable to connect";
}
interruptible_sleep(5000);
continue;
}
// blocks here pumping the connection until it closes or we stop.
// a session that never reaches "Identified" was rejected by OBS,
// overwhelmingly because the password is wrong or missing
const bool identified = this->run_session(ws, password, request_id);
// session ended: close the socket cleanly and free it
ws->close();
ws->poll();
delete ws;
if (!identified && this->worker_running.load()) {
if (!auth_warning_logged) {
log_warning("obs", "connection closed before identify; "
"OBS likely rejected authentication (check the password)");
auth_warning_logged = true;
}
} else if (identified) {
// a good session resets the latch so a future failure logs again
auth_warning_logged = false;
}
// connection dropped: clear live state so the UI doesn't show stale
// scene/stream/record info while disconnected
{
std::lock_guard<std::mutex> lock(this->status_mutex);
this->status.connected = false;
this->status.identifying = false;
if (this->status.connection_error.empty()) {
this->status.connection_error =
identified ? "Disconnected" : "Auth failed (check password)";
}
this->status.streaming = false;
this->status.recording = false;
this->status.record_paused = false;
this->status.current_scene.clear();
}
// clear any commands queued while disconnected
{
std::lock_guard<std::mutex> lock(this->command_mutex);
this->command_queue.clear();
}
// wait before reconnecting (interruptible)
interruptible_sleep(5000);
}
WSACleanup();
log_info("obs", "OBS overlay worker stopped");
}
}
@@ -876,8 +876,7 @@ namespace overlay::windows {
ImGui::EndDisabled(); ImGui::EndDisabled();
} }
// row hover detection (invisible selectable that spans entire row) ImGui::HighlightTableRowOnHover();
ImGui::InvisibleTableRowSelectable();
ImGui::PopID(); ImGui::PopID();
} }
+7 -3
View File
@@ -54,7 +54,7 @@ namespace rawinput {
switch (hdr->dbch_devicetype) { switch (hdr->dbch_devicetype) {
case DBT_DEVTYP_DEVICEINTERFACE: { case DBT_DEVTYP_DEVICEINTERFACE: {
auto dev = (const DEV_BROADCAST_DEVICEINTERFACE *) hdr; auto dev = (const DEV_BROADCAST_DEVICEINTERFACE *) hdr;
this->ri_mgr->devices_scan_midi(); this->ri_mgr->midi_scan_start();
// check if class is not HID // check if class is not HID
if (memcmp(&dev->dbcc_classguid, &GUID_HID, sizeof(GUID_HID)) != 0) if (memcmp(&dev->dbcc_classguid, &GUID_HID, sizeof(GUID_HID)) != 0)
@@ -112,6 +112,7 @@ namespace rawinput {
std::string name(dev->dbcc_name); std::string name(dev->dbcc_name);
// destruct device // destruct device
log_misc("hotplug", "device interface removal: {}", name);
this->ri_mgr->devices_remove(name); this->ri_mgr->devices_remove(name);
} }
} }
@@ -120,8 +121,11 @@ namespace rawinput {
return TRUE; return TRUE;
} }
case DBT_DEVNODES_CHANGED: { case DBT_DEVNODES_CHANGED: {
// TODO: this can be a little noisy as it gets called on every device // catch-all device-tree change: MIDI and XInput have no targeted
this->ri_mgr->devices_scan_midi(); // arrival/removal notification, so this is our only hook to detect them
// can be a little noisy as it gets called on every device
log_misc("hotplug", "device tree changed, rescanning MIDI + XInput");
this->ri_mgr->midi_scan_start();
this->ri_mgr->devices_scan_xinput(); this->ri_mgr->devices_scan_xinput();
break; break;
} }
+727
View File
@@ -0,0 +1,727 @@
#include "rawinput.h"
#include <sstream>
#include "util/logging.h"
#include "util/time.h"
#include "util/utils.h"
namespace rawinput {
static MidiNoteAlgorithm MIDI_NOTE_ALGORITHM = MidiNoteAlgorithm::V2;
}
rawinput::MidiNoteAlgorithm rawinput::get_midi_algorithm() {
return rawinput::MIDI_NOTE_ALGORITHM;
}
void rawinput::set_midi_algorithm(rawinput::MidiNoteAlgorithm new_algo) {
rawinput::MIDI_NOTE_ALGORITHM = new_algo;
std::string s = "Unknown";
switch (new_algo) {
case rawinput::MidiNoteAlgorithm::LEGACY:
s = "legacy";
break;
case rawinput::MidiNoteAlgorithm::V2:
s = "v2";
break;
case rawinput::MidiNoteAlgorithm::V2_DRUM:
s = "v2_drum";
break;
default:
log_info("rawinput", "assert failed: invalid midi algorithm");
break;
}
log_info("rawinput", "using MIDI algorithm: {}", s);
}
void rawinput::RawInputManager::midi_scan_start() {
// single-flight: only one scan runs at a time. if one is already running, set
// the pending flag so it rescans once more when it finishes - MIDI hotplug
// events fire while the slow enumeration is still going and must not be lost.
// the scheduler mutex makes this check-and-set atomic with the worker's
// exit-or-rescan decision below, so a request set while a scan is running is
// never dropped
{
std::lock_guard<std::mutex> lock(this->midi_scan_m);
if (this->midi_scan_active) {
this->midi_scan_pending = true;
log_misc("rawinput", "MIDI scan already running, queued rescan");
return;
}
this->midi_scan_active = true;
this->midi_scan_pending = false;
}
// clean up the previous (already finished) scan thread handle
this->midi_scan_join();
// run the (potentially slow) MIDI enumeration on its own thread so callers are
// not blocked while the Windows MIDI subsystem starts up. rescan if a request
// arrived while we were scanning
log_misc("rawinput", "starting async MIDI scan thread");
this->midi_thread = new std::thread([this]() {
for (;;) {
this->devices_scan_midi();
// decide whether to exit under the scheduler lock, atomically with any
// concurrent midi_scan_start(): if a rescan was requested, consume it
// and loop; otherwise clear active and exit. because both sides take
// the same lock, a request set while active is true is never lost, so
// we never strand a hotplug event waiting for a future one
std::lock_guard<std::mutex> lock(this->midi_scan_m);
if (!this->midi_scan_pending) {
this->midi_scan_active = false;
log_misc("rawinput", "async MIDI scan thread finished");
return;
}
this->midi_scan_pending = false;
log_misc("rawinput", "async MIDI scan rescanning (event arrived during scan)");
}
});
}
void rawinput::RawInputManager::midi_scan_join() {
if (this->midi_thread) {
if (this->midi_thread->joinable()) {
// this blocks until the scan worker returns. if it ever hangs here the
// worker is stuck - most likely in midi_close_deferred_flush() waiting
// on a WinMM close. a missing "joined" line pinpoints the hang
log_misc("rawinput", "joining MIDI scan thread...");
this->midi_thread->join();
log_misc("rawinput", "MIDI scan thread joined");
}
delete this->midi_thread;
this->midi_thread = nullptr;
}
}
void rawinput::RawInputManager::midi_close_deferred_flush() {
// take the queued handles under the lock, then close them without it. WinMM
// midiInReset/midiInClose block until in-flight input_midi_proc callbacks
// return, and those callbacks take devices_mutex, so closing under the lock
// would deadlock
std::vector<HMIDIIN> handles;
{
std::lock_guard<std::recursive_mutex> lock(this->devices_mutex);
handles.swap(this->midi_close_deferred);
}
if (handles.empty()) {
return;
}
// if a hang is ever reported here it is the classic WinMM deadlock: an
// in-flight input_midi_proc callback is blocked on devices_mutex while
// midiInReset/midiInClose waits for that callback to return. the per-handle
// log below pinpoints exactly which close did not come back
log_misc("rawinput", "closing {} deferred MIDI handle(s)", handles.size());
for (size_t i = 0; i < handles.size(); i++) {
log_misc("rawinput", "closing deferred MIDI handle {}/{}", i + 1, handles.size());
midiInReset(handles[i]);
midiInClose(handles[i]);
}
log_misc("rawinput", "deferred MIDI handles closed");
}
void rawinput::RawInputManager::devices_scan_midi() {
log_misc("rawinput", "scan MIDI devices...");
// note: the WinMM MIDI calls below (midiInGetNumDevs / midiInGetDevCaps /
// midiInOpen / midiInStart) can block for seconds while the Windows MIDI
// subsystem starts up, so they must NOT run under devices_mutex. only the
// list mutation at the end of each iteration is guarded.
// identifiers of every MIDI device seen in this scan; used below to
// tombstone devices that have since been unplugged
std::vector<std::string> present_identifiers;
// add midi devices
auto midi_device_count = midiInGetNumDevs();
for (size_t midi_device_id = 0; midi_device_id < midi_device_count; midi_device_id++) {
// get dev caps
MIDIINCAPS midi_device_caps{};
if (midiInGetDevCaps(midi_device_id, &midi_device_caps, sizeof(MIDIINCAPS)) != MMSYSERR_NOERROR) {
continue;
}
log_misc("rawinput", "found MIDI device: id {}, name {}, mid {}, pid {}",
midi_device_id, midi_device_caps.szPname, midi_device_caps.wMid, midi_device_caps.wPid);
// build identifier for MIDI
// ;MIDI; format is now set in stone (in other parts of the code base and in the config xml file)
// so it should never be changed
std::ostringstream midi_identifier_stream;
midi_identifier_stream << ";" << "MIDI";
midi_identifier_stream << ";" << midi_device_id;
midi_identifier_stream << ";" << midi_device_caps.szPname;
midi_identifier_stream << ";" << midi_device_caps.wMid;
midi_identifier_stream << ";" << midi_device_caps.wPid;
const auto midi_identifier = midi_identifier_stream.str();
// record that this device is currently present
present_identifiers.push_back(midi_identifier);
// if already open, leave it alone: hotplug fires many change events, and
// reopening on every rescan would drop the WinMM handle (and its input).
// only (re)open when the device is missing or a destroyed tombstone
{
std::lock_guard<std::recursive_mutex> lock(this->devices_mutex);
bool already_open = false;
for (auto &device : this->devices) {
if (device.type == MIDI && device.name == midi_identifier) {
already_open = true;
break;
}
}
if (already_open) {
continue;
}
}
// open device
HMIDIIN midi_device_handle;
if (midiInOpen(&midi_device_handle,
(UINT) midi_device_id,
(DWORD_PTR) &input_midi_proc,
(DWORD_PTR) this,
CALLBACK_FUNCTION) != MMSYSERR_NOERROR)
{
continue;
}
// start input
if (midiInStart(midi_device_handle) != MMSYSERR_NOERROR) {
// close the handle we just opened so it does not leak on repeated rescans
midiInClose(midi_device_handle);
continue;
}
// device info
DeviceInfo midi_device_info {};
// device midi info
auto midi_device_midi_info = new DeviceMIDIInfo();
midi_device_midi_info->states = std::vector<bool>(16 * 128);
midi_device_midi_info->states_events = std::vector<uint8_t>(16 * 128);
midi_device_midi_info->bind_states = std::vector<bool>(16 * 128);
midi_device_midi_info->v2_last_on_time = std::vector<double>(16 * 128);
midi_device_midi_info->v2_last_off_time = std::vector<double>(16 * 128);
midi_device_midi_info->v2_velocity_threshold = std::vector<uint8_t>(16 * 128);
midi_device_midi_info->v2_velocity_threshold_set_on_device = std::vector<bool>(16 * 128);
midi_device_midi_info->velocity = std::vector<uint8_t>(16 * 128);
midi_device_midi_info->freeze = false;
midi_device_midi_info->controls_precision = std::vector<uint16_t>(16 * 32);
midi_device_midi_info->controls_precision_bind = std::vector<uint16_t>(16 * 32);
midi_device_midi_info->controls_precision_msb = std::vector<bool>(16 * 32);
midi_device_midi_info->controls_precision_lsb = std::vector<bool>(16 * 32);
midi_device_midi_info->controls_precision_set = std::vector<bool>(16 * 32);
midi_device_midi_info->controls_single = std::vector<uint8_t>(16 * 44);
midi_device_midi_info->controls_single_bind = std::vector<uint8_t>(16 * 44);
midi_device_midi_info->controls_single_set = std::vector<bool>(16 * 44);
midi_device_midi_info->controls_onoff = std::vector<bool>(16 * 6);
midi_device_midi_info->controls_onoff_bind = std::vector<bool>(16 * 6);
midi_device_midi_info->controls_onoff_set = std::vector<bool>(16 * 6);
midi_device_midi_info->v2_controls_onoff_last_on_time = std::vector<double>(16 * 6);
midi_device_midi_info->v2_controls_onoff_last_off_time = std::vector<double>(16 * 6);
midi_device_midi_info->pitch_bend = std::vector<int16_t>(16 * 6);
midi_device_midi_info->pitch_bend_set = std::vector<bool>(16 * 6);
// build device
Device midi_device {};
midi_device.type = MIDI;
midi_device.handle = midi_device_handle;
midi_device.name = midi_identifier;
midi_device.desc = to_string(midi_device_caps.szPname);
midi_device.info = midi_device_info;
midi_device.mutex = new std::mutex();
midi_device.mutex_out = new std::mutex();
midi_device.midiInfo = midi_device_midi_info;
// mutate the shared device list under lock (the slow WinMM calls above
// ran without it so other threads were not blocked)
std::lock_guard<std::recursive_mutex> lock(this->devices_mutex);
midi_device.id = devices.size() + 1;
// reuse a previously destroyed tombstone with the same identifier, if any.
// (a live device with this identifier was already skipped above)
bool replaced = false;
for (auto &device : this->devices) {
if (device.name == midi_identifier) {
// carry over ID
midi_device.id = device.id;
// destruct and replace, reusing the slot's existing mutexes
this->devices_destruct(&device);
reuse_device_mutexes(midi_device, device);
device = midi_device;
// notify change
for (auto &cb : this->callback_change) {
cb.f(cb.data, &device);
}
replaced = true;
break;
}
}
if (replaced) {
continue;
}
// add device to list
auto &device = this->devices.emplace_back(midi_device);
// notify add
for (auto &cb : this->callback_add) {
cb.f(cb.data, &device);
}
}
// tombstone MIDI devices that were open but are no longer present (unplugged).
// otherwise a replugged device matches the stale live entry in the skip check
// above and never gets reopened, silently losing its input
{
std::lock_guard<std::recursive_mutex> lock(this->devices_mutex);
for (auto &device : this->devices) {
if (device.type != MIDI) {
continue;
}
bool present = false;
for (const auto &identifier : present_identifiers) {
if (identifier == device.name) {
present = true;
break;
}
}
if (!present) {
log_info("rawinput", "MIDI device unplugged, releasing: {}", device.desc);
this->devices_destruct(&device);
}
}
}
// close the MIDI handles detached above, now that devices_mutex is released
this->midi_close_deferred_flush();
log_misc("rawinput", "scan MIDI devices done ({} enumerated)", (unsigned) midi_device_count);
}
void CALLBACK rawinput::RawInputManager::input_midi_proc(HMIDIIN hMidiIn, UINT wMsg, DWORD_PTR dwInstance,
DWORD_PTR dwParam1, DWORD_PTR dwParam2) {
// get instance
auto ri_mgr = reinterpret_cast<RawInputManager *>(dwInstance);
// handle message
switch (wMsg) {
case MIM_OPEN:
case MIM_CLOSE:
break;
case MIM_MOREDATA:
case MIM_DATA: {
// lock the device list so a concurrent scan can't mutate it while we iterate
std::lock_guard<std::recursive_mutex> devices_lock(ri_mgr->devices_mutex);
// param mapping
auto dwMidiMessage = dwParam1;
//auto dwTimestamp = dwParam2;
// message unpacking
auto midi_status = LOBYTE(LOWORD(dwMidiMessage));
auto midi_status_command = (midi_status & 0xF0u) >> 4u;
auto midi_status_channel = (midi_status & 0x0Fu);
auto midi_byte1 = HIBYTE(LOWORD(dwMidiMessage));
auto midi_byte2 = LOBYTE(HIWORD(dwMidiMessage));
// callbacks
for (auto &callback : ri_mgr->callback_midi) {
// find device
for (auto &device : ri_mgr->devices_get()) {
if (device.type == MIDI && device.handle == hMidiIn) {
// call function
callback.f(callback.data, &device,
midi_status_command, midi_status_channel,
midi_byte1, midi_byte2);
}
}
}
// skip unused messages types early for performance
bool skip = false;
switch (midi_status_command) {
case 0xA: // POLYPHONIC PRESSURE
case 0xC: // PROGRAM CHANGE
case 0xD: // CHANNEL PRESSURE
case 0xF: // SYSTEM EXCLUSIVE
skip = true;
break;
default:
break;
}
if (skip) {
break;
}
// find device
for (auto &device : ri_mgr->devices_get()) {
// filter non MIDI devices
if (device.type != MIDI) {
continue;
}
// filter wrong handles
if (device.handle != hMidiIn) {
continue;
}
// get input time
const auto input_time = get_performance_seconds();
// lock device
std::lock_guard<std::mutex> lock(*device.mutex);
// update hz
auto diff_time = input_time - device.input_time;
if (diff_time > 0.0001) {
device.input_hz = 1.f / diff_time;
device.input_hz_max = MAX(device.input_hz_max, device.input_hz);
device.input_time = input_time;
}
// command logic
switch (midi_status_command) {
case 0x8: { // NOTE OFF
// param mapping
const auto midi_note = midi_byte1 & 127u;
// log_misc("midi", "[{}] OFF", midi_note);
// get index
const auto midi_index = midi_status_channel * 128 + midi_note;
if (midi_index < 16 * 128) {
if (MIDI_NOTE_ALGORITHM == MidiNoteAlgorithm::LEGACY) {
// update velocity
device.midiInfo->velocity[midi_index] = 0;
// disable note
if (device.midiInfo->states_events[midi_index]) {
device.midiInfo->states[midi_index] = false;
}
device.updated = true;
} else {
// v2 logic
// exactly the same as NOTE ON with 0 velocity
// velocity is kept; api will ignore it if button is not pressed
if (MIDI_NOTE_ALGORITHM == MidiNoteAlgorithm::V2) {
device.midiInfo->v2_last_off_time[midi_index] = get_performance_milliseconds();
device.updated = true;
}
// for v2_drum, NOTE OFF is ignored
}
}
break;
}
case 0x9: { // NOTE ON
// param mapping
const auto midi_note = midi_byte1 & 127u;
// per MIDI spec, if NOTE ON is sent with 0 velocity, it's the same thing as NOTE OFF.
const auto midi_velocity = midi_byte2 & 127u;
// log_misc("midi", "[{}] ON v={}", midi_note, midi_velocity);
// get index
const auto midi_index = midi_status_channel * 128 + midi_note;
if (midi_index < 16 * 128) {
if (MIDI_NOTE_ALGORITHM == MidiNoteAlgorithm::LEGACY) {
// update velocity
device.midiInfo->velocity[midi_index] = (uint8_t) midi_velocity;
if (midi_velocity) {
// update events (for legacy logic)
// how does this work? see the comment in api.cpp around the check for
// get_midi_algorithm() for an explanation
// so currently it's meant to be turned on
device.midiInfo->states[midi_index] = true;
// if its already on just increase it by one to turn it off
if (device.midiInfo->states_events[midi_index] % 2)
device.midiInfo->states_events[midi_index]++;
else
device.midiInfo->states_events[midi_index] += 2;
} else if (!device.midiInfo->freeze) {
// velocity 0 means turn it off
device.midiInfo->states[midi_index] = false;
}
device.updated = true;
} else {
// v2 logic
const auto now = get_performance_milliseconds();
auto threshold = device.midiInfo->v2_velocity_threshold[midi_index];
// when device is frozen (binding is happening) ignore the velocity threshold
// this allows users to bind keys even if the midi note is set to high threshold at
// rawinput layer, either from a previous binding that was cleared, or existing binding
// for another button
if (device.midiInfo->freeze) {
threshold = 0;
}
if (threshold < midi_velocity) {
device.midiInfo->velocity[midi_index] = (uint8_t)midi_velocity;
device.midiInfo->v2_last_on_time[midi_index] = now;
// disable holds and release all notes immediately
if (MIDI_NOTE_ALGORITHM == MidiNoteAlgorithm::V2_DRUM) {
device.midiInfo->v2_last_off_time[midi_index] = now;
}
device.updated = true;
} else {
if (MIDI_NOTE_ALGORITHM == MidiNoteAlgorithm::V2) {
// insufficient velocity ON == exactly the same as NOTE OFF
device.midiInfo->v2_last_off_time[midi_index] = now;
device.updated = true;
}
// for v2_drum, NOTE ON with insufficient velocity is ignored
}
}
}
break;
}
case 0xA: // POLYPHONIC PRESSURE
break; // skipped above (!)
case 0xB: { // CONTROL CHANGE
// param mapping
auto midi_control = midi_byte1 & 127;
auto midi_value = midi_byte2 & 127u;
// get index
auto channel_offset = midi_status_channel * 128;
auto midi_index = channel_offset + midi_control;
if (midi_index < 16 * 128) {
// continuous controller MSB
if (midi_control >= 0x00 && midi_control <= 0x1F) {
// update index
midi_index = midi_status_channel * 32 + midi_control;
device.midiInfo->controls_precision_set[midi_index] = true;
// check if MSB wasn't sent yet
if (!device.midiInfo->controls_precision_msb[midi_index]) {
device.midiInfo->controls_precision_msb[midi_index] = true;
// move LSB value to actual position
device.midiInfo->controls_precision[midi_index] >>= 7u;
}
// update MSB
auto tmp = device.midiInfo->controls_precision[midi_index];
tmp = (tmp & 127u) | midi_value << 7u;
if (!device.midiInfo->controls_precision_lsb[midi_index])
tmp = (tmp & (127u << 7u)) | midi_value;
if (device.midiInfo->controls_precision[midi_index] != tmp) {
device.midiInfo->controls_precision[midi_index] = tmp;
device.updated = true;
}
}
// continuous controller LSB
else if (midi_control >= 0x20 && midi_control <= 0x3F) {
// update index
midi_index = midi_status_channel * 32 + midi_control - 0x20;
device.midiInfo->controls_precision_set[midi_index] = true;
device.midiInfo->controls_precision_lsb[midi_index] = true;
// check for MSB flag
if (device.midiInfo->controls_precision_msb[midi_index]) {
// update LSB only
auto tmp = device.midiInfo->controls_precision[midi_index];
tmp &= 127u << 7u;
tmp |= midi_value;
if (device.midiInfo->controls_precision[midi_index] != tmp) {
device.midiInfo->controls_precision[midi_index] = tmp;
device.updated = true;
}
} else {
// cast to MSB
if (device.midiInfo->controls_precision[midi_index] != midi_value << 7u) {
device.midiInfo->controls_precision[midi_index] = midi_value << 7u | midi_value;
device.updated = true;
}
}
}
// on/off controls
else if (midi_control >= 0x40 && midi_control <= 0x45) {
// update index
midi_index = midi_status_channel * 6 + midi_control - 0x40;
device.midiInfo->controls_onoff_set[midi_index] = true;
// get on/off state
const auto onoff_state = midi_value >= 64;
// update device
if (MIDI_NOTE_ALGORITHM == MidiNoteAlgorithm::LEGACY) {
if (device.midiInfo->controls_onoff[midi_index] != onoff_state) {
device.midiInfo->controls_onoff[midi_index] = onoff_state;
device.updated = true;
}
} else {
// v2 and v2_drum:
// unlike notes (drum pads), controls can send continuous ON signal
// therefore, check for rising and falling edges
const auto now = get_performance_milliseconds();
const auto previous_value = device.midiInfo->controls_onoff[midi_index];
if (!previous_value && onoff_state) {
device.midiInfo->v2_controls_onoff_last_on_time[midi_index] = now;
device.updated = true;
} else if (previous_value && !onoff_state) {
device.midiInfo->v2_controls_onoff_last_off_time[midi_index] = now;
device.updated = true;
}
device.midiInfo->controls_onoff[midi_index] = onoff_state;
}
}
// single byte controllers
else if (midi_control >= 0x46 && midi_control <= 0x5F) {
// update index
midi_index = midi_status_channel * 44 + midi_control - 0x46;
device.midiInfo->controls_single_set[midi_index] = true;
// update device
if (device.midiInfo->controls_single[midi_index] != midi_value) {
device.midiInfo->controls_single[midi_index] = midi_value;
device.updated = true;
}
}
// increment/decrement and parameter numbers
else if (midi_control >= 0x60 && midi_control <= 0x65) {
// skip
}
// undefined single-byte controllers
else if (midi_control >= 0x66 && midi_control <= 0x77) {
// update index
auto sbc_count = 0x5F - 0x46 + 1;
midi_index = midi_status_channel * 44 + midi_control - 0x66 + sbc_count;
device.midiInfo->controls_single_set[midi_index] = true;
// update device
if (device.midiInfo->controls_single[midi_index] != midi_value) {
device.midiInfo->controls_single[midi_index] = midi_value;
device.updated = true;
}
}
// channel mode messages
else if (midi_control >= 0x78 && midi_control <= 0x7F) {
switch (midi_control) {
case 0x78: // all sound off
break;
case 0x79: { // reset all controllers
for (int i = 0; i < 32; i++)
device.midiInfo->controls_precision[midi_status_channel * 32 + i] = 0;
for (int i = 0; i < 44; i++)
device.midiInfo->controls_single[midi_status_channel * 44 + i] = 0;
for (int i = 0; i < 6; i++) {
const auto index = midi_status_channel * 6 + i;
device.midiInfo->controls_onoff[index] = false;
device.midiInfo->v2_controls_onoff_last_on_time[index] = 0;
device.midiInfo->v2_controls_onoff_last_off_time[index] = 0;
}
device.updated = true;
break;
}
case 0x7A: // local control on/off
break;
case 0x7B: // all notes off
case 0x7C: // omni mode off + all notes off
case 0x7D: // omni mode on + all notes off
case 0x7E: // mono mode on + poly off + all notes off
case 0x7F: // poly mode on + mono off + all notes off
for (int i = 0; i < 128; i++) {
// common
device.midiInfo->velocity[channel_offset + i] = 0;
device.midiInfo->bind_states[channel_offset + i] = false;
// legacy
device.midiInfo->states[channel_offset + i] = false;
device.midiInfo->states_events[channel_offset + i] = 0;
// v2
device.midiInfo->v2_last_off_time[channel_offset + i] = 0.0;
device.midiInfo->v2_last_on_time[channel_offset + i] = 0.0;
}
device.updated = true;
break;
default:
break;
}
break;
}
}
break;
}
case 0xC: // PROGRAM CHANGE
break; // skipped above (!)
case 0xD: // CHANNEL PRESSURE
break; // skipped above (!)
case 0xE: { // PITCH BENDING
// raw values range from [0, 0x3FFF] (16383)
// build value, centered around zero [-8192, 8191]
int16_t value = ((midi_byte1) | (midi_byte2 << 7u)) - 0x2000;
// update device
if (device.midiInfo->pitch_bend[midi_status_channel] != value) {
device.midiInfo->pitch_bend[midi_status_channel] = value;
device.midiInfo->pitch_bend_set[midi_status_channel] = true;
device.updated = true;
}
break;
}
case 0xF: // SYSTEM EXCLUSIVE
break; // skipped above (!)
default:
break;
}
// don't iterate through the other devices
break;
}
break;
}
case MIM_LONGDATA:
case MIM_ERROR:
case MIM_LONGERROR:
break;
default:
break;
}
}
File diff suppressed because it is too large Load Diff
+44 -3
View File
@@ -3,6 +3,8 @@
#include <functional> #include <functional>
#include <thread> #include <thread>
#include <condition_variable> #include <condition_variable>
#include <list>
#include <mutex>
#include <vector> #include <vector>
#include <windows.h> #include <windows.h>
@@ -63,10 +65,38 @@ namespace rawinput {
class RawInputManager { class RawInputManager {
private: private:
HotplugManager *hotplug; HotplugManager *hotplug = nullptr;
std::vector<Device> devices;
// std::list (not std::vector) so adding/removing a device never invalidates
// pointers to other elements. devices_get() hands out pointers that escape the
// lock and are dereferenced later by pollers, so a concurrent scan must not
// relocate existing devices
std::list<Device> devices;
std::recursive_mutex devices_mutex;
WNDCLASSEX input_hwnd_class {}; WNDCLASSEX input_hwnd_class {};
std::thread *input_thread = nullptr; std::thread *input_thread = nullptr;
std::thread *midi_thread = nullptr;
// guards the MIDI scan scheduler flags below. only held around the flag
// checks, never across the (slow) enumeration, so it makes "is a scan
// running?" and the worker's "exit or rescan?" a single atomic decision -
// a request can never slip into the gap between the two
std::mutex midi_scan_m;
// true while a scan worker thread is running. only one runs at a time
bool midi_scan_active = false;
// set when a scan is requested while one is already running, so the worker
// rescans once instead of dropping the request (events that arrive during
// the slow initial scan would otherwise be lost)
bool midi_scan_pending = false;
// MIDI handles pending close. devices_destruct() runs under devices_mutex but
// midiInReset/midiInClose must not (they block on the MIDI callback, which
// also takes devices_mutex), so handles are queued here and closed by
// midi_close_deferred_flush() once the lock is released
std::vector<HMIDIIN> midi_close_deferred;
std::thread *flush_thread = nullptr; std::thread *flush_thread = nullptr;
bool flush_thread_running = false; bool flush_thread_running = false;
std::mutex flush_thread_m; std::mutex flush_thread_m;
@@ -83,6 +113,8 @@ namespace rawinput {
void input_hwnd_create(); void input_hwnd_create();
void input_hwnd_destroy(); void input_hwnd_destroy();
void devices_reload(); void devices_reload();
void midi_scan_join();
void midi_close_deferred_flush();
void devices_scan_rawinput(RAWINPUTDEVICELIST *device, bool log = true); void devices_scan_rawinput(RAWINPUTDEVICELIST *device, bool log = true);
void devices_scan_piuio(); void devices_scan_piuio();
void devices_scan_smxstage(); void devices_scan_smxstage();
@@ -93,6 +125,7 @@ namespace rawinput {
void flush_stop(); void flush_stop();
void output_start(); void output_start();
void output_stop(); void output_stop();
void devices_write_output_snapshot(bool only_updated);
static std::string rawinput_get_device_name(HANDLE hDevice); static std::string rawinput_get_device_name(HANDLE hDevice);
static std::string rawinput_get_device_description(const DeviceInfo& info, const std::string &device_name); static std::string rawinput_get_device_description(const DeviceInfo& info, const std::string &device_name);
@@ -101,6 +134,11 @@ namespace rawinput {
static void CALLBACK input_midi_proc(HMIDIIN, UINT, DWORD_PTR, DWORD_PTR, DWORD_PTR); static void CALLBACK input_midi_proc(HMIDIIN, UINT, DWORD_PTR, DWORD_PTR, DWORD_PTR);
static DeviceInfo get_device_info(const std::string &device_name); static DeviceInfo get_device_info(const std::string &device_name);
// shared by the rawinput / xinput / midi device scans when replacing a slot
// in place; keeps the existing slot's mutex pair so snapshot pointers held by
// other threads stay valid (see the definition in rawinput.cpp)
static void reuse_device_mutexes(Device &replacement, const Device &existing);
public: public:
HWND input_hwnd = nullptr; HWND input_hwnd = nullptr;
@@ -118,6 +156,7 @@ namespace rawinput {
void devices_scan_rawinput(const std::string &device_name = ""); void devices_scan_rawinput(const std::string &device_name = "");
void devices_scan_midi(); void devices_scan_midi();
void midi_scan_start();
void devices_scan_xinput(); void devices_scan_xinput();
void devices_remove(const std::string &name); void devices_remove(const std::string &name);
@@ -130,11 +169,12 @@ namespace rawinput {
void __stdcall devices_print(); void __stdcall devices_print();
Device *devices_get(const std::string &name, bool updated = false); Device *devices_get(const std::string &name, bool updated = false);
inline std::vector<Device> &devices_get() { inline std::list<Device> &devices_get() {
return devices; return devices;
} }
inline std::vector<Device *> devices_get_updated() { inline std::vector<Device *> devices_get_updated() {
std::lock_guard<std::recursive_mutex> lock(devices_mutex);
std::vector<Device *> updated; std::vector<Device *> updated;
for (auto &device : devices_get()) { for (auto &device : devices_get()) {
device.mutex->lock(); device.mutex->lock();
@@ -150,6 +190,7 @@ namespace rawinput {
} }
inline void devices_midi_freeze(bool freeze) { inline void devices_midi_freeze(bool freeze) {
std::lock_guard<std::recursive_mutex> lock(devices_mutex);
for (auto &device : devices_get()) { for (auto &device : devices_get()) {
if (device.type == MIDI) { if (device.type == MIDI) {
device.midiInfo->freeze = freeze; device.midiInfo->freeze = freeze;
+157 -15
View File
@@ -24,13 +24,116 @@ namespace rawinput::touch {
// settings // settings
bool DISABLED = false; bool DISABLED = false;
bool INVERTED = false; bool INVERTED = false;
AspectMode ASPECT_COMPENSATION_MODE = AspectMode::Auto;
bool ASPECT_COMPENSATION_GAME = false;
// state // state
DWORD DISPLAY_ORIENTATION = DMDO_DEFAULT; DWORD DISPLAY_ORIENTATION = DMDO_DEFAULT;
long DISPLAY_SIZE_X = 1920L; long DISPLAY_SIZE_X = 1920L;
long DISPLAY_SIZE_Y = 1080L; long DISPLAY_SIZE_Y = 1080L;
long DISPLAY_NATIVE_X = 0L;
long DISPLAY_NATIVE_Y = 0L;
bool DISPLAY_INITIALIZED = false; bool DISPLAY_INITIALIZED = false;
bool aspect_compensation_enabled() {
switch (ASPECT_COMPENSATION_MODE) {
case AspectMode::On:
return true;
case AspectMode::Off:
return false;
default:
return ASPECT_COMPENSATION_GAME;
}
}
// compute the centered letterbox inset (fraction cropped from each side) that results
// from showing the current display mode on a panel with a different native aspect
// ratio. only one of inset_x / inset_y is ever non-zero.
static void compute_letterbox_inset(double &inset_x, double &inset_y) {
inset_x = 0.0;
inset_y = 0.0;
// native resolution in the current display orientation
long native_x = DISPLAY_NATIVE_X;
long native_y = DISPLAY_NATIVE_Y;
if ((DISPLAY_SIZE_Y >= DISPLAY_SIZE_X) != (native_y >= native_x)) {
long tmp = native_x;
native_x = native_y;
native_y = tmp;
}
// compare the displayed aspect ratio against the panel's native one; ignore
// tiny mismatches (e.g. 1366x768 vs true 16:9) that would only add a sub-pixel
// inset, so near-matched panels stay an exact no-op
double current_aspect = (double) DISPLAY_SIZE_X / (double) DISPLAY_SIZE_Y;
double native_aspect = (double) native_x / (double) native_y;
constexpr double aspect_epsilon = 0.01;
if (current_aspect < native_aspect - aspect_epsilon) {
// image is narrower than the panel -> bars on left/right
inset_x = (1.0 - current_aspect / native_aspect) / 2.0;
} else if (current_aspect > native_aspect + aspect_epsilon) {
// image is wider than the panel -> bars on top/bottom
inset_y = (1.0 - native_aspect / current_aspect) / 2.0;
}
}
// undo letterboxing: remap the normalized (0.0 - 1.0) digitizer coordinates, which span
// the whole physical panel, into the displayed image region. returns false when the
// touch lands in the black bars (outside the image) and should be ignored.
static bool remap_aspect_compensation(double &norm_x, double &norm_y) {
if (DISPLAY_NATIVE_X <= 0 || DISPLAY_NATIVE_Y <= 0) {
return true;
}
double inset_x, inset_y;
compute_letterbox_inset(inset_x, inset_y);
// log once the first time the compensation actually engages (non-zero inset)
static bool logged_active = false;
if (!logged_active && (inset_x > 0.0 || inset_y > 0.0)) {
logged_active = true;
log_info("rawinput", "aspect compensation active: display {}x{}, native {}x{}, inset x={:.4f} y={:.4f}",
(int) DISPLAY_SIZE_X, (int) DISPLAY_SIZE_Y,
(int) DISPLAY_NATIVE_X, (int) DISPLAY_NATIVE_Y,
inset_x, inset_y);
}
// ignore touches inside the black bars (outside the displayed image)
if ((inset_x > 0.0 && (norm_x < inset_x || norm_x > 1.0 - inset_x)) ||
(inset_y > 0.0 && (norm_y < inset_y || norm_y > 1.0 - inset_y))) {
return false;
}
// remap the displayed image region back to the full 0.0 - 1.0 range
if (inset_x > 0.0) {
norm_x = (norm_x - inset_x) / (1.0 - 2.0 * inset_x);
}
if (inset_y > 0.0) {
norm_y = (norm_y - inset_y) / (1.0 - 2.0 * inset_y);
}
return true;
}
// determine the native (maximum) resolution advertised by the primary display device.
// used to detect and undo letterboxing when a non-native display mode is in use.
// falls back to the current display size if enumeration yields nothing larger.
static void detect_native_resolution() {
DISPLAY_NATIVE_X = DISPLAY_SIZE_X;
DISPLAY_NATIVE_Y = DISPLAY_SIZE_Y;
DEVMODE native_mode{};
native_mode.dmSize = sizeof(native_mode);
for (int i = 0; EnumDisplaySettings(nullptr, i, &native_mode); i++) {
if ((long) native_mode.dmPelsWidth * (long) native_mode.dmPelsHeight >
DISPLAY_NATIVE_X * DISPLAY_NATIVE_Y) {
DISPLAY_NATIVE_X = (long) native_mode.dmPelsWidth;
DISPLAY_NATIVE_Y = (long) native_mode.dmPelsHeight;
}
}
}
bool is_touchscreen(Device *device) { bool is_touchscreen(Device *device) {
// check if disabled // check if disabled
@@ -322,6 +425,18 @@ namespace rawinput::touch {
std::vector<DWORD> touch_removes; std::vector<DWORD> touch_removes;
std::vector<TouchPoint> touch_writes; std::vector<TouchPoint> touch_writes;
std::vector<DWORD> touch_modifications; std::vector<DWORD> touch_modifications;
// drop every touch point with the given id (marking it as released)
auto remove_touch_point = [&] (DWORD id) {
touch_removes.push_back(id);
touch.touch_points.erase(std::remove_if(
touch.touch_points.begin(),
touch.touch_points.end(),
[id] (const HIDTouchPoint &x) {
return x.id == id;
}), touch.touch_points.end());
};
for (auto &hid_tp : touch_points) { for (auto &hid_tp : touch_points) {
// check if existing // check if existing
@@ -353,23 +468,29 @@ namespace rawinput::touch {
// only remove if it exists // only remove if it exists
if (existing != touch.touch_points.end()) { if (existing != touch.touch_points.end()) {
remove_touch_point(hid_tp.id);
// remove all touch points with this ID
touch_removes.push_back(hid_tp.id);
touch.touch_points.erase(std::remove_if(
touch.touch_points.begin(),
touch.touch_points.end(),
[hid_tp] (const HIDTouchPoint &x) {
return x.id == hid_tp.id;
}), touch.touch_points.end());
} }
} else { } else {
// write touch point // start from the normalized (0.0 - 1.0) digitizer coordinates
double norm_x = hid_tp.x;
double norm_y = hid_tp.y;
// undo letterboxing so raw input touches line up with the displayed
// image; touches that land in the black bars are treated as released
if (aspect_compensation_enabled() && !remap_aspect_compensation(norm_x, norm_y)) {
if (existing != touch.touch_points.end()) {
remove_touch_point(hid_tp.id);
}
continue;
}
// scale the normalized coordinates to the monitor to obtain the
// physical screen position of the touch
TouchPoint tp { TouchPoint tp {
.id = hid_tp.id, .id = hid_tp.id,
.x = (long) (hid_tp.x * DISPLAY_SIZE_X), .x = (long) (norm_x * DISPLAY_SIZE_X),
.y = (long) (hid_tp.y * DISPLAY_SIZE_Y), .y = (long) (norm_y * DISPLAY_SIZE_Y),
.mouse = false, .mouse = false,
}; };
touch_writes.push_back(tp); touch_writes.push_back(tp);
@@ -390,13 +511,28 @@ namespace rawinput::touch {
} }
// set TTL and last report time // set TTL and last report time
if (!touch.elements_x.empty() && !touch_modifications.empty()) { if (!touch.elements_x.empty()) {
// a HID digitizer reports only a fixed number of contacts per report and
// cycles the remaining ones through following reports, so a contact can be
// absent for up to this many reports before it comes back around
auto ttl = touch.touch_points.size() / touch.elements_x.size() + 1; auto ttl = touch.touch_points.size() / touch.elements_x.size() + 1;
auto system_time_ms = get_system_milliseconds(); auto system_time_ms = get_system_milliseconds();
// extend every tracked contact to the current cycle length without ever
// shrinking it; otherwise a rising contact count (e.g. rapid taps) lengthens
// the cycle and can starve a stationary held contact into being reaped before
// it is reported again. the 50ms last-report timeout remains the hard backstop
for (auto &hid_tp : touch.touch_points) {
if (hid_tp.ttl < ttl) {
hid_tp.ttl = ttl;
}
}
// only contacts actually seen this report refresh their last-report time
for (auto &hid_tp_id : touch_modifications) { for (auto &hid_tp_id : touch_modifications) {
for (auto &hid_tp : touch.touch_points) { for (auto &hid_tp : touch.touch_points) {
if (hid_tp.id == hid_tp_id) { if (hid_tp.id == hid_tp_id) {
hid_tp.ttl = ttl;
hid_tp.last_report = system_time_ms; hid_tp.last_report = system_time_ms;
} }
} }
@@ -513,7 +649,13 @@ namespace rawinput::touch {
GetWindowRect(GetDesktopWindow(), &display_rect); GetWindowRect(GetDesktopWindow(), &display_rect);
DISPLAY_SIZE_X = display_rect.right - display_rect.left; DISPLAY_SIZE_X = display_rect.right - display_rect.left;
DISPLAY_SIZE_Y = display_rect.bottom - display_rect.top; DISPLAY_SIZE_Y = display_rect.bottom - display_rect.top;
log_info("rawinput", "display size: {}x{}", (int) DISPLAY_SIZE_X, (int) DISPLAY_SIZE_Y); log_info("rawinput", "primary display size: {}x{}", (int) DISPLAY_SIZE_X, (int) DISPLAY_SIZE_Y);
// determine the native (maximum) resolution of the primary display; used to
// detect and undo letterboxing when a non-native display mode is in use
detect_native_resolution();
log_info("rawinput", "primary display native size: {}x{}",
(int) DISPLAY_NATIVE_X, (int) DISPLAY_NATIVE_Y);
// determine monitor orientation // determine monitor orientation
DEVMODE display_mode{}; DEVMODE display_mode{};
+19
View File
@@ -9,6 +9,25 @@ namespace rawinput::touch {
extern bool DISABLED; extern bool DISABLED;
extern bool INVERTED; extern bool INVERTED;
// aspect-ratio compensation: when the current display mode has a different aspect
// ratio than the panel's native resolution, the image is letterboxed/pillarboxed
// on the physical panel while the digitizer still spans the whole panel. this
// remaps the touch into the displayed image region.
enum class AspectMode {
Auto, // decided per game via ASPECT_COMPENSATION_GAME
On, // always compensate
Off, // never compensate
};
// global override, driven by the -rawtouchaspect launcher option
extern AspectMode ASPECT_COMPENSATION_MODE;
// per-game request, honored only when ASPECT_COMPENSATION_MODE is Auto
extern bool ASPECT_COMPENSATION_GAME;
// effective decision combining the global mode and the per-game request
bool aspect_compensation_enabled();
// global state // global state
extern DWORD DISPLAY_ORIENTATION; extern DWORD DISPLAY_ORIENTATION;
extern long DISPLAY_SIZE_X; extern long DISPLAY_SIZE_X;
+18 -2
View File
@@ -179,11 +179,27 @@ XInputSetState(
XInputManager::XInputManager() { XInputManager::XInputManager() {
log_info("xinput", "initialize..."); log_info("xinput", "initialize...");
this->xinput_lib = LoadLibraryA("xinput1_3.dll");
// many systems ship only a newer XInput runtime, so try the known
// DLL names in order of preference until one loads
static const char *xinput_dll_names[] = {
"xinput1_4.dll",
"xinput1_3.dll",
"xinput9_1_0.dll",
};
const char *loaded_name = nullptr;
for (const char *name : xinput_dll_names) {
this->xinput_lib = LoadLibraryA(name);
if (this->xinput_lib) {
loaded_name = name;
break;
}
}
if (!this->xinput_lib) { if (!this->xinput_lib) {
log_warning("xinput", "failed to load xinput1_3.dll"); log_warning("xinput", "failed to load any XInput DLL");
return; return;
} }
log_info("xinput", "loaded {}", loaded_name);
XInputGetState_addr = reinterpret_cast<decltype(XInputGetState) *>( XInputGetState_addr = reinterpret_cast<decltype(XInputGetState) *>(
GetProcAddress(this->xinput_lib, "XInputGetState")); GetProcAddress(this->xinput_lib, "XInputGetState"));
if (!XInputGetState_addr) { if (!XInputGetState_addr) {
+213
View File
@@ -0,0 +1,213 @@
#include "gdi_overlay.h"
#include <cstddef>
#include "util/logging.h"
namespace {
// the back buffer matches the window DC; the software buffer has a fixed 32-bit layout
enum class BufferType {
TargetCompatible,
Bgra32,
};
struct GdiBuffer {
HDC dc = nullptr;
HBITMAP bitmap = nullptr;
// bitmap originally selected into the memory DC, restored before cleanup
HGDIOBJ old_bitmap = nullptr;
int width = 0;
int height = 0;
};
// back buffer holds the complete frame; overlay buffer holds ImGui software pixels
GdiBuffer BACK_BUFFER;
GdiBuffer OVERLAY_BUFFER;
void release_buffer(GdiBuffer &buffer) {
// destroy the DC before the bitmap so cleanup is safe even if restoration fails
if (buffer.dc != nullptr) {
if (buffer.old_bitmap != nullptr && buffer.old_bitmap != HGDI_ERROR) {
SelectObject(buffer.dc, buffer.old_bitmap);
}
DeleteDC(buffer.dc);
}
if (buffer.bitmap != nullptr) {
DeleteObject(buffer.bitmap);
}
buffer = {};
}
// ensures the buffer has a memory DC with a bitmap of the requested size and type
// selected into it. a matching allocation is reused; otherwise the old resources are
// released and recreated. returns false if the dimensions or any GDI operation fail.
bool ensure_buffer(
GdiBuffer &buffer,
HDC target_dc,
int width,
int height,
BufferType type,
const char *name) {
if (width <= 0 || height <= 0) {
return false;
}
if (buffer.dc != nullptr && buffer.bitmap != nullptr &&
buffer.width == width && buffer.height == height) {
return true;
}
// keep allocations across frames and recreate only after a size change
release_buffer(buffer);
buffer.dc = CreateCompatibleDC(target_dc);
if (buffer.dc == nullptr) {
log_warning("touch", "failed to create {} DC: {}", name, GetLastError());
return false;
}
// compatible bitmaps are fast presentation targets; BGRA bitmaps accept raw pixels
if (type == BufferType::TargetCompatible) {
buffer.bitmap = CreateCompatibleBitmap(target_dc, width, height);
} else {
buffer.bitmap = CreateBitmap(width, height, 1, sizeof(uint32_t) * 8, nullptr);
}
if (buffer.bitmap == nullptr) {
log_warning("touch", "failed to create {} bitmap: {}", name, GetLastError());
release_buffer(buffer);
return false;
}
buffer.old_bitmap = SelectObject(buffer.dc, buffer.bitmap);
if (buffer.old_bitmap == nullptr || buffer.old_bitmap == HGDI_ERROR) {
log_warning("touch", "failed to select {} bitmap: {}", name, GetLastError());
release_buffer(buffer);
return false;
}
buffer.width = width;
buffer.height = height;
return true;
}
bool update_overlay_buffer(
HDC target_dc,
const uint32_t *pixels,
bool pixels_dirty,
int width,
int height) {
if (pixels == nullptr) {
return false;
}
bool needs_update = pixels_dirty || OVERLAY_BUFFER.bitmap == nullptr ||
OVERLAY_BUFFER.width != width || OVERLAY_BUFFER.height != height;
if (!ensure_buffer(
OVERLAY_BUFFER,
target_dc,
width,
height,
BufferType::Bgra32,
"software overlay")) {
return false;
}
if (!needs_update) {
return true;
}
// SetDIBits requires the destination bitmap not to be selected into a DC
HGDIOBJ overlay_bitmap =
SelectObject(OVERLAY_BUFFER.dc, OVERLAY_BUFFER.old_bitmap);
if (overlay_bitmap == nullptr || overlay_bitmap == HGDI_ERROR) {
log_warning("touch", "failed to deselect software overlay bitmap: {}", GetLastError());
release_buffer(OVERLAY_BUFFER);
return false;
}
BITMAPINFO bitmap_info {};
bitmap_info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bitmap_info.bmiHeader.biWidth = width;
bitmap_info.bmiHeader.biHeight = -height;
bitmap_info.bmiHeader.biPlanes = 1;
bitmap_info.bmiHeader.biBitCount = sizeof(uint32_t) * 8;
bitmap_info.bmiHeader.biCompression = BI_RGB;
int copied_lines = SetDIBits(
target_dc,
OVERLAY_BUFFER.bitmap,
0,
height,
pixels,
&bitmap_info,
DIB_RGB_COLORS);
HGDIOBJ old_bitmap = SelectObject(OVERLAY_BUFFER.dc, OVERLAY_BUFFER.bitmap);
if (old_bitmap == nullptr || old_bitmap == HGDI_ERROR) {
log_warning("touch", "failed to reselect software overlay bitmap: {}", GetLastError());
release_buffer(OVERLAY_BUFFER);
return false;
}
OVERLAY_BUFFER.old_bitmap = old_bitmap;
if (copied_lines != height) {
log_warning("touch", "failed to update software overlay bitmap: {} of {} lines copied",
copied_lines, height);
release_buffer(OVERLAY_BUFFER);
return false;
}
return true;
}
}
HDC touch_gdi_overlay_begin_frame(
HDC target_dc,
HBRUSH background_brush,
int width,
int height,
const uint32_t *overlay_pixels,
bool overlay_pixels_dirty,
int overlay_width,
int overlay_height) {
if (!ensure_buffer(
BACK_BUFFER,
target_dc,
width,
height,
BufferType::TargetCompatible,
"overlay back buffer")) {
return nullptr;
}
HDC draw_dc = BACK_BUFFER.dc;
SetBkMode(draw_dc, TRANSPARENT);
// start each frame from the transparent color-key background
RECT buffer_rect {0, 0, width, height};
FillRect(draw_dc, &buffer_rect, background_brush);
if (update_overlay_buffer(
target_dc,
overlay_pixels,
overlay_pixels_dirty,
overlay_width,
overlay_height) &&
!BitBlt(draw_dc, 0, 0, overlay_width, overlay_height,
OVERLAY_BUFFER.dc, 0, 0, SRCCOPY)) {
log_warning("touch", "failed to draw software overlay bitmap: {}", GetLastError());
}
return draw_dc;
}
void touch_gdi_overlay_present(HDC target_dc) {
// one full-window blit exposes the completed frame without an intermediate erase
if (!BitBlt(target_dc, 0, 0, BACK_BUFFER.width, BACK_BUFFER.height,
BACK_BUFFER.dc, 0, 0, SRCCOPY)) {
log_warning("touch", "failed to present overlay back buffer: {}", GetLastError());
}
}
void touch_gdi_overlay_release() {
release_buffer(BACK_BUFFER);
release_buffer(OVERLAY_BUFFER);
}
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include <cstdint>
#include <windows.h>
// prepares a complete offscreen frame and returns its drawing DC; returns null on failure
HDC touch_gdi_overlay_begin_frame(
HDC target_dc,
HBRUSH background_brush,
int width,
int height,
const uint32_t *overlay_pixels,
bool overlay_pixels_dirty,
int overlay_width,
int overlay_height);
// presents the frame prepared by the most recent successful begin call
void touch_gdi_overlay_present(HDC target_dc);
// releases all cached GDI resources
void touch_gdi_overlay_release();
+91 -57
View File
@@ -11,6 +11,7 @@
#include "avs/game.h" #include "avs/game.h"
#include "external/imgui/imgui.h" #include "external/imgui/imgui.h"
#include "games/jb/jb.h"
#include "hooks/graphics/graphics.h" #include "hooks/graphics/graphics.h"
#include "misc/eamuse.h" #include "misc/eamuse.h"
#include "overlay/overlay.h" #include "overlay/overlay.h"
@@ -19,9 +20,12 @@
#include "util/detour.h" #include "util/detour.h"
#include "util/libutils.h" #include "util/libutils.h"
#include "util/logging.h" #include "util/logging.h"
#include "util/time.h"
#include "util/utils.h" #include "util/utils.h"
#include "gdi_overlay.h"
#include "handler.h" #include "handler.h"
#include "touch_gestures.h"
#include "win7.h" #include "win7.h"
#include "win8.h" #include "win8.h"
@@ -35,6 +39,16 @@ static const int TOUCH_EVENT_BUFFER_SIZE = 1024 * 4;
static const int TOUCH_EVENT_BUFFER_THRESHOLD1 = 1024 * 2; static const int TOUCH_EVENT_BUFFER_THRESHOLD1 = 1024 * 2;
static const int TOUCH_EVENT_BUFFER_THRESHOLD2 = 1024 * 3; static const int TOUCH_EVENT_BUFFER_THRESHOLD2 = 1024 * 3;
// timer id for the overlay repaint tick
static const UINT_PTR SPICETOUCH_OVERLAY_TIMER_ID = 1;
// overlay repaint interval; the WinXP-compat build stays at 30 FPS
#if !SPICE_XP
static const int SPICETOUCH_OVERLAY_TIMER_MS = 1000 / 60;
#else
static const int SPICETOUCH_OVERLAY_TIMER_MS = 1000 / 30;
#endif // !SPICE_XP
// in mainline spicetools, this was false (show by default) // in mainline spicetools, this was false (show by default)
// in spice2x, this is true (hide by default) // in spice2x, this is true (hide by default)
bool SPICETOUCH_CARD_DISABLE = true; bool SPICETOUCH_CARD_DISABLE = true;
@@ -247,16 +261,15 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP
if (!SPICETOUCH_REGISTERED_TOUCH) { if (!SPICETOUCH_REGISTERED_TOUCH) {
SPICETOUCH_REGISTERED_TOUCH = true; SPICETOUCH_REGISTERED_TOUCH = true;
// check if touch is available // register the handler when a touch screen is present
if (is_touch_available("SpiceTouchWndProc")) { if (is_touch_available("SpiceTouchWndProc")) {
// notify the handler of our window
TOUCH_HANDLER->window_register(hWnd); TOUCH_HANDLER->window_register(hWnd);
}
// enable card unless the feature is disabled // enable the card button whenever the option is set, even without a touch
if (!SPICETOUCH_CARD_DISABLE) { // screen (mouse clicks are handled as touch input and can trigger it)
SPICETOUCH_CARD_ENABLED = true; if (!SPICETOUCH_CARD_DISABLE) {
} SPICETOUCH_CARD_ENABLED = true;
} }
} }
@@ -307,7 +320,10 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP
break; break;
} }
case WM_TIMER: { case WM_TIMER: {
InvalidateRect(hWnd, NULL, TRUE);
// request a repaint; the frame is composed into an offscreen buffer, so no
// background erase is needed (bErase = FALSE avoids a transparent flash)
InvalidateRect(hWnd, NULL, FALSE);
break; break;
} }
case WM_PAINT: { case WM_PAINT: {
@@ -339,52 +355,52 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP
SWP_NOZORDER | SWP_NOREDRAW | SWP_NOREPOSITION | SWP_NOACTIVATE); SWP_NOZORDER | SWP_NOREDRAW | SWP_NOREPOSITION | SWP_NOACTIVATE);
} }
// draw overlay // render the software overlay before BeginPaint so only GDI composition
// and the final blit happen while the window is being painted
int overlay_width = 0, overlay_height = 0;
uint32_t *overlay_pixels = nullptr;
bool overlay_pixels_dirty = false;
if (overlay_enabled) { if (overlay_enabled) {
// update and render
overlay::OVERLAY->update(); overlay::OVERLAY->update();
overlay::OVERLAY->new_frame(); overlay::OVERLAY->new_frame();
overlay::OVERLAY->render(); overlay::OVERLAY->render();
// get pixel data overlay_pixels = overlay::OVERLAY.get()->sw_get_pixel_data(
int width, height; &overlay_width, &overlay_height);
uint32_t *pixel_data = overlay::OVERLAY.get()->sw_get_pixel_data(&width, &height); overlay_pixels_dirty = overlay::OVERLAY->sw_pixels_dirty;
if (width > 0 && height > 0) { }
bool overlay_active = overlay_enabled && overlay::OVERLAY->get_active();
// create bitmap // compose the whole frame into an offscreen back buffer and present it with a
HBITMAP bitmap = CreateBitmap(width, height, 1, 8 * sizeof(uint32_t), pixel_data); // single blit; the window never shows a half-erased (transparent) surface
// mid-paint, which is what caused the occasional flicker at higher frame rates
PAINTSTRUCT paint {};
HDC hdc = BeginPaint(hWnd, &paint);
// prepare paint RECT bufferRect {};
PAINTSTRUCT paint {}; GetClientRect(hWnd, &bufferRect);
HDC hdc = BeginPaint(hWnd, &paint); int buffer_width = bufferRect.right - bufferRect.left;
HDC hdcMem = CreateCompatibleDC(hdc); int buffer_height = bufferRect.bottom - bufferRect.top;
SetBkMode(hdc, TRANSPARENT);
/* HBRUSH color_key_brush =
* draw bitmap (HBRUSH) GetClassLongPtr(hWnd, GCLP_HBRBACKGROUND);
* - this currently sets the background to black because of SRCCOPY HDC draw_dc = touch_gdi_overlay_begin_frame(
* - SRCPAINT will blend but colors are wrong hdc,
* - once this is figured out we could also try hooking WM_PAINT and color_key_brush,
* draw directly to the game window buffer_width,
*/ buffer_height,
SelectObject(hdcMem, bitmap); overlay_pixels,
BitBlt(hdc, 0, 0, width, height, hdcMem, 0, 0, SRCCOPY); overlay_pixels_dirty,
overlay_width,
// clean up overlay_height);
DeleteObject(bitmap); if (draw_dc == nullptr) {
DeleteDC(hdcMem); EndPaint(hWnd, &paint);
EndPaint(hWnd, &paint); return 0;
}
} }
// draw card input // draw the insert-card button below the jubeat debug overlay; it is
if (SPICETOUCH_CARD_ENABLED && (SPICETOUCH_FONT != nullptr)) { // hidden while the overlay is active
if (SPICETOUCH_CARD_ENABLED && SPICETOUCH_FONT != nullptr && !overlay_active) {
// prepare paint
PAINTSTRUCT paint {};
HDC hdc = BeginPaint(hWnd, &paint);
SetBkMode(hdc, TRANSPARENT);
// create brushes // create brushes
HBRUSH brushBorder = CreateSolidBrush(RGB(0, 196, 0)); HBRUSH brushBorder = CreateSolidBrush(RGB(0, 196, 0));
@@ -414,7 +430,7 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP
SPICETOUCH_CARD_RECT = boxRect; SPICETOUCH_CARD_RECT = boxRect;
// draw borders // draw borders
FillRect(hdc, &boxRect, brushBorder); FillRect(draw_dc, &boxRect, brushBorder);
// modify box rect // modify box rect
boxRect.left += 1; boxRect.left += 1;
@@ -423,7 +439,7 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP
boxRect.bottom -= 1; boxRect.bottom -= 1;
// fill box // fill box
FillRect(hdc, &boxRect, brushFill); FillRect(draw_dc, &boxRect, brushFill);
// modify box rect // modify box rect
if (should_rotate) { if (should_rotate) {
@@ -435,21 +451,27 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP
} }
// draw text // draw text
SelectObject(hdc, SPICETOUCH_FONT); SelectObject(draw_dc, SPICETOUCH_FONT);
SetTextColor(hdc, RGB(0, 196, 0)); SetTextColor(draw_dc, RGB(0, 196, 0));
DrawText(hdc, INSERT_CARD_TEXT, -1, &boxRect, DT_LEFT | DT_BOTTOM | DT_NOCLIP); DrawText(draw_dc, INSERT_CARD_TEXT, -1, &boxRect, DT_LEFT | DT_BOTTOM | DT_NOCLIP);
// delete objects // delete objects
DeleteObject(brushFill); DeleteObject(brushFill);
DeleteObject(brushBorder); DeleteObject(brushBorder);
// end paint
EndPaint(hWnd, &paint);
return 0;
} }
// call default window procedure #if !SPICE_XP
return DefWindowProc(hWnd, msg, wParam, lParam); // draw the jubeat debug overlay on top (hidden while the overlay is active)
if (overlay_enabled && !overlay_active && games::jb::touch_debug_overlay_enabled()) {
games::jb::touch_draw_debug_overlay(draw_dc);
}
#endif // !SPICE_XP
// present the composed frame in a single blit
touch_gdi_overlay_present(hdc);
EndPaint(hWnd, &paint);
return 0;
} }
case WM_CREATE: { case WM_CREATE: {
@@ -479,6 +501,11 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP
return 0; return 0;
} }
case WM_DESTROY: { case WM_DESTROY: {
touch_gdi_overlay_release();
if (SPICETOUCH_FONT != nullptr) {
DeleteObject(SPICETOUCH_FONT);
SPICETOUCH_FONT = nullptr;
}
PostQuitMessage(0); PostQuitMessage(0);
return 0; return 0;
} }
@@ -538,6 +565,7 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP
.x = GET_X_LPARAM(lParam), .x = GET_X_LPARAM(lParam),
.y = GET_Y_LPARAM(lParam), .y = GET_Y_LPARAM(lParam),
.mouse = true, .mouse = true,
.down_ms = get_performance_milliseconds(),
}; };
TOUCH_POINTS.push_back(tp); TOUCH_POINTS.push_back(tp);
@@ -751,6 +779,9 @@ void touch_create_wnd(HWND hWnd, bool overlay) {
ShowWindow(touch_window, SW_SHOWNOACTIVATE); ShowWindow(touch_window, SW_SHOWNOACTIVATE);
UpdateWindow(touch_window); UpdateWindow(touch_window);
// disable the OS touch contact visualization for our own touch window
disable_touch_gestures(touch_window);
// register // register
touch_register_window(touch_window); touch_register_window(touch_window);
@@ -763,8 +794,8 @@ void touch_create_wnd(HWND hWnd, bool overlay) {
// create instance // create instance
overlay::OVERLAY.reset(new overlay::SpiceOverlay(touch_window)); overlay::OVERLAY.reset(new overlay::SpiceOverlay(touch_window));
// draw overlay in 30 FPS // draw overlay repaint timer (30 FPS on WinXP, 60 FPS otherwise)
SetTimer(touch_window, 1, 1000 / 30, NULL); SetTimer(touch_window, SPICETOUCH_OVERLAY_TIMER_ID, SPICETOUCH_OVERLAY_TIMER_MS, NULL);
} }
} }
@@ -848,6 +879,9 @@ void touch_write_points(std::vector<TouchPoint> *touch_points) {
// create new touch point when not found // create new touch point when not found
if (!found) { if (!found) {
// stamp the landing time so debounce can measure the contact's age
tp.down_ms = get_performance_milliseconds();
// add touch point // add touch point
TOUCH_POINTS.push_back(tp); TOUCH_POINTS.push_back(tp);
+1
View File
@@ -7,6 +7,7 @@ struct TouchPoint {
DWORD id; DWORD id;
LONG x, y; LONG x, y;
bool mouse; bool mouse;
double down_ms = 0.0; // performance-counter milliseconds when the contact first landed
}; };
enum TouchEventType { enum TouchEventType {
TOUCH_DOWN, TOUCH_DOWN,
+168
View File
@@ -0,0 +1,168 @@
// set version to Windows 8 to enable Windows 8 touch functions
#define _WIN32_WINNT 0x0602
#include <propsys.h>
#include <mutex>
#include "touch_gestures.h"
#include "util/libutils.h"
#include "util/logging.h"
// tablet/pen service flags (MicrosoftTabletPenServiceProperty atom)
// these are not present in the mingw headers
#ifndef TABLET_DISABLE_PRESSANDHOLD
#define TABLET_DISABLE_PRESSANDHOLD 0x00000001
#define TABLET_DISABLE_PENTAPFEEDBACK 0x00000008
#define TABLET_DISABLE_PENBARRELFEEDBACK 0x00000010
#define TABLET_DISABLE_TOUCHUIFORCEON 0x00000100
#define TABLET_DISABLE_TOUCHUIFORCEOFF 0x00000200
#define TABLET_DISABLE_TOUCHSWITCH 0x00008000
#define TABLET_DISABLE_FLICKS 0x00010000
#define TABLET_DISABLE_SMOOTHSCROLLING 0x00080000
#define TABLET_DISABLE_FLICKFALLBACKKEYS 0x00100000
#endif
static const char TABLET_ATOM_NAME[] = "MicrosoftTabletPenServiceProperty";
// PKEY_EdgeGesture_DisableTouchWhenFullscreen format GUID
// (not defined in the mingw headers); defined inline to avoid an initguid.h
// symbol clash with touch/win8.cpp which declares the same name
// {32CE38B2-2C9A-41B1-9BC5-B3784394AA44}
static const GUID EDGEGESTURE_DISABLE_FMT =
{ 0x32CE38B2, 0x2C9A, 0x41B1, { 0x9B, 0xC5, 0xB3, 0x78, 0x43, 0x94, 0xAA, 0x44 } };
static HINSTANCE USER32_INSTANCE = nullptr;
typedef BOOL (WINAPI *SetWindowFeedbackSetting_t)(HWND, FEEDBACK_TYPE, DWORD, UINT32, const VOID *);
static SetWindowFeedbackSetting_t pSetWindowFeedbackSetting = nullptr;
static HINSTANCE SHELL32_INSTANCE = nullptr;
typedef HRESULT (WINAPI *SHGetPropertyStoreForWindow_t)(HWND, REFIID, void **);
static SHGetPropertyStoreForWindow_t pSHGetPropertyStoreForWindow = nullptr;
static std::once_flag INIT_FLAG;
// resolve the libraries and entry points once; disable_touch_gestures may be
// called concurrently from the CreateWindowEx hooks and the touch thread
static void init_procs() {
std::call_once(INIT_FLAG, []() {
USER32_INSTANCE = libutils::load_library("user32.dll");
if (USER32_INSTANCE != nullptr) {
pSetWindowFeedbackSetting = libutils::try_proc<SetWindowFeedbackSetting_t>(
USER32_INSTANCE, "SetWindowFeedbackSetting");
}
SHELL32_INSTANCE = libutils::try_library("shell32.dll");
if (SHELL32_INSTANCE != nullptr) {
pSHGetPropertyStoreForWindow = libutils::try_proc<SHGetPropertyStoreForWindow_t>(
SHELL32_INSTANCE, "SHGetPropertyStoreForWindow");
}
});
}
static void disable_feedback_visuals(HWND hwnd) {
if (pSetWindowFeedbackSetting == nullptr) {
return;
}
BOOL enabled = FALSE;
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_TOUCH_CONTACTVISUALIZATION,
0, sizeof(enabled), &enabled);
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_TOUCH_TAP,
0, sizeof(enabled), &enabled);
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_TOUCH_DOUBLETAP,
0, sizeof(enabled), &enabled);
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_TOUCH_PRESSANDHOLD,
0, sizeof(enabled), &enabled);
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_TOUCH_RIGHTTAP,
0, sizeof(enabled), &enabled);
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_GESTURE_PRESSANDTAP,
0, sizeof(enabled), &enabled);
}
static void disable_gesture_behaviors(HWND hwnd) {
// the tablet/pen service reads this window property to decide which
// touch/pen gestures to suppress for the window. this covers:
// - press-and-hold (touch right-click / long-press ring)
// - pen tap/barrel feedback
// - flicks (edge/directional flick navigation gestures)
// - the touch keyboard invocation UI
// - smooth scrolling / flick fallback keys
DWORD tablet_flags = TABLET_DISABLE_PRESSANDHOLD |
TABLET_DISABLE_PENTAPFEEDBACK |
TABLET_DISABLE_PENBARRELFEEDBACK |
TABLET_DISABLE_FLICKS |
TABLET_DISABLE_TOUCHUIFORCEOFF |
TABLET_DISABLE_TOUCHSWITCH |
TABLET_DISABLE_SMOOTHSCROLLING |
TABLET_DISABLE_FLICKFALLBACKKEYS;
ATOM atom_id = GlobalAddAtomA(TABLET_ATOM_NAME);
if (atom_id > 0) {
SetPropA(hwnd, TABLET_ATOM_NAME, (HANDLE) ((ULONG_PTR) tablet_flags));
}
}
static void disable_edge_gestures(HWND hwnd) {
// suppress the touch edge swipes (charms/back/app bar) for the window while
// it is fullscreen. this is normally done by the touch handler's
// window_register(), but that is not called for every handler (e.g. the
// rawinput handler is a no-op), so apply it here as well.
if (pSHGetPropertyStoreForWindow == nullptr) {
return;
}
IPropertyStore *ps = nullptr;
HRESULT hr = pSHGetPropertyStoreForWindow(hwnd, IID_IPropertyStore, (void **) &ps);
if (SUCCEEDED(hr) && ps != nullptr) {
PROPERTYKEY key = { EDGEGESTURE_DISABLE_FMT, 2 };
PROPVARIANT var {};
var.vt = VT_BOOL;
var.boolVal = VARIANT_TRUE;
hr = ps->SetValue(key, var);
ps->Release();
if (FAILED(hr)) {
log_warning("touch_gestures",
"failed to disable edge gestures on HWND={}", fmt::ptr(hwnd));
}
}
}
void disable_touch_gestures(HWND hwnd) {
init_procs();
if (USER32_INSTANCE == nullptr) {
return;
}
log_misc("touch_gestures",
"disable visual feedback and gestures for touch events for HWND={}", fmt::ptr(hwnd));
// disable the visual feedback (contact circles, tap/double-tap stars, etc.)
disable_feedback_visuals(hwnd);
// disable the actual gesture behaviors (press-and-hold, flicks, etc.)
disable_gesture_behaviors(hwnd);
// disable the fullscreen touch edge swipes
disable_edge_gestures(hwnd);
}
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <windows.h>
// disable the OS touch UX for a window: visual feedback (contact circles,
// tap/press-and-hold indicators) and gesture behaviors (press-and-hold
// right-click, flicks, etc.)
void disable_touch_gestures(HWND hwnd);
-57
View File
@@ -1,57 +0,0 @@
// set version to Windows 8 to enable Windows 8 touch functions
#define _WIN32_WINNT 0x0602
#include "touch_indicators.h"
#include "util/libutils.h"
#include "util/logging.h"
static HINSTANCE USER32_INSTANCE = nullptr;
typedef BOOL (WINAPI *SetWindowFeedbackSetting_t)(HWND, FEEDBACK_TYPE, DWORD, UINT32, const VOID *);
static SetWindowFeedbackSetting_t pSetWindowFeedbackSetting = nullptr;
void disable_touch_indicators(HWND hwnd) {
if (USER32_INSTANCE == nullptr) {
USER32_INSTANCE = libutils::load_library("user32.dll");
}
if (USER32_INSTANCE == nullptr) {
return;
}
if (pSetWindowFeedbackSetting == nullptr) {
pSetWindowFeedbackSetting = libutils::try_proc<SetWindowFeedbackSetting_t>(
USER32_INSTANCE, "SetWindowFeedbackSetting");
}
if (pSetWindowFeedbackSetting == nullptr) {
return;
}
log_info("touch_indicators", "disable visual feedback for touch events for HWND={}", fmt::ptr(hwnd));
BOOL enabled = FALSE;
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_TOUCH_CONTACTVISUALIZATION,
0, sizeof(enabled), &enabled);
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_TOUCH_TAP,
0, sizeof(enabled), &enabled);
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_TOUCH_DOUBLETAP,
0, sizeof(enabled), &enabled);
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_TOUCH_PRESSANDHOLD,
0, sizeof(enabled), &enabled);
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_TOUCH_RIGHTTAP,
0, sizeof(enabled), &enabled);
pSetWindowFeedbackSetting(
hwnd,
FEEDBACK_GESTURE_PRESSANDTAP,
0, sizeof(enabled), &enabled);
}
-5
View File
@@ -1,5 +0,0 @@
#pragma once
#include <windows.h>
void disable_touch_indicators(HWND hwnd);
+2
View File
@@ -11,6 +11,7 @@
#include "util/libutils.h" #include "util/libutils.h"
#include "util/logging.h" #include "util/logging.h"
#include "util/time.h"
#include "rawinput/touch.h" #include "rawinput/touch.h"
// mingw issue #2205 workaround // mingw issue #2205 workaround
@@ -177,6 +178,7 @@ void Win7Handler::handle_message(msg_handler_result &result, HWND hWnd, UINT msg
.x = point.x, .x = point.x,
.y = point.y, .y = point.y,
.mouse = false, .mouse = false,
.down_ms = get_performance_milliseconds(),
}; };
TOUCH_POINTS.push_back(tp); TOUCH_POINTS.push_back(tp);
+9 -1
View File
@@ -12,6 +12,7 @@
#include "util/libutils.h" #include "util/libutils.h"
#include "util/logging.h" #include "util/logging.h"
#include "util/time.h"
#include "rawinput/touch.h" #include "rawinput/touch.h"
// mingw does not seem to have this either // mingw does not seem to have this either
@@ -114,10 +115,16 @@ bool Win8Handler::is_available() {
bool Win8Handler::window_register(HWND hWnd) { bool Win8Handler::window_register(HWND hWnd) {
// atom settings // atom settings
// keep this in sync with touch/touch_gestures.cpp so registering a touch
// window does not downgrade the flag set applied there
DWORD dwHwndTabletProperty = TABLET_DISABLE_PRESSANDHOLD | DWORD dwHwndTabletProperty = TABLET_DISABLE_PRESSANDHOLD |
TABLET_DISABLE_PENTAPFEEDBACK | TABLET_DISABLE_PENTAPFEEDBACK |
TABLET_DISABLE_PENBARRELFEEDBACK | TABLET_DISABLE_PENBARRELFEEDBACK |
TABLET_DISABLE_FLICKS; TABLET_DISABLE_FLICKS |
TABLET_DISABLE_TOUCHUIFORCEOFF |
TABLET_DISABLE_TOUCHSWITCH |
TABLET_DISABLE_SMOOTHSCROLLING |
TABLET_DISABLE_FLICKFALLBACKKEYS;
// get atom ID // get atom ID
ATOM atomID = GlobalAddAtom(ATOM_NAME); ATOM atomID = GlobalAddAtom(ATOM_NAME);
@@ -214,6 +221,7 @@ void Win8Handler::handle_message(msg_handler_result &result, HWND hWnd, UINT msg
.x = point.x, .x = point.x,
.y = point.y, .y = point.y,
.mouse = false, .mouse = false,
.down_ms = get_performance_milliseconds(),
}; };
TOUCH_POINTS.push_back(tp); TOUCH_POINTS.push_back(tp);
+66
View File
@@ -0,0 +1,66 @@
$ErrorActionPreference = 'Stop'
# --- configuration ----------------------------------------------------------
$scriptDir = if ($env:SPICE_DIR) { $env:SPICE_DIR } elseif ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
$repo = 'spice2x/spice2x.github.io'
$targets = @('spice.exe', 'spice64.exe', 'spicecfg.exe')
$beta = ($env:SPICE_CHANNEL -match 'beta')
$rc = 0
$title = if ($beta) { '=== spice2x updater (beta channel) ===' } else { '=== spice2x updater ===' }
Write-Host "`n$title"
Write-Host "Target folder: $scriptDir`n"
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$headers = @{ 'User-Agent' = 'spice2x-updater'; 'Accept' = 'application/vnd.github+json' }
# --- find the newest release (beta = include pre-releases) --------------
Write-Host 'Querying latest release...'
$url = if ($beta) { "https://api.github.com/repos/$repo/releases?per_page=1" } else { "https://api.github.com/repos/$repo/releases/latest" }
$rel = Invoke-RestMethod -Headers $headers -Uri $url | Select-Object -First 1
if (-not $rel) { throw 'No releases found.' }
# --- pick the distribution zip (spice2x-<date>.zip, not the -full one) --
$asset = $rel.assets | Where-Object { $_.name -like 'spice2x-*.zip' -and $_.name -notlike '*-full.zip' } | Select-Object -First 1
if (-not $asset) { throw 'No .zip asset found in the release.' }
Write-Host "Latest release: $($rel.tag_name)$(if ($rel.prerelease) { ' [pre-release]' }) (asset: $($asset.name))"
# --- download the zip into memory ---------------------------------------
# note: on Windows PowerShell 5.1 .Content is a String (empty for binary
# responses), so read the raw byte stream instead
Write-Host 'Downloading...'
$bytes = (Invoke-WebRequest -Headers $headers -Uri $asset.browser_download_url -UseBasicParsing).RawContentStream.ToArray()
# --- extract just the three executables straight into this folder -------
# PS 5.1 needs these assemblies loaded; PS 7 already has the types (and the
# FileSystem assembly name no longer resolves there), so only load if missing
if (-not ('System.IO.Compression.ZipFile' -as [type])) {
Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem
}
$zip = [IO.Compression.ZipArchive]::new([IO.MemoryStream]::new([byte[]]$bytes))
try {
$updated = 0
foreach ($name in $targets) {
$entry = $zip.Entries | Where-Object { $_.Name -eq $name } | Select-Object -First 1
if (-not $entry) { Write-Warning " $name not found in the archive"; continue }
try {
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry, (Join-Path $scriptDir $name), $true)
Write-Host " updated $name"; $updated++
} catch {
Write-Warning " FAILED to write $name (running / read-only?): $($_.Exception.Message)"
}
}
} finally { $zip.Dispose() }
Write-Host "`nDone. $updated of $($targets.Count) executables updated to $($rel.tag_name)."
Write-Host "Only the .exe files are updated; if you copied any DLL stubs, they were not changed."
if ($updated -ne $targets.Count) { $rc = 1 }
} catch {
Write-Host ''; Write-Error $_.Exception.Message; $rc = 1
}
# --- result ------------------------------------------------------------------
Write-Host $(if ($rc) { "`nUpdate FAILED. See the error above." } else { "`nUpdate finished successfully." })
Start-Sleep -Seconds 5
exit $rc