Compare commits

..

11 Commits

Author SHA1 Message Date
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
34 changed files with 2459 additions and 169 deletions
+47
View File
@@ -408,6 +408,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
games/iidx/mf_wrappers.cpp
games/sdvx/bi2x_hook.cpp
games/sdvx/sdvx.cpp
games/sdvx/sdvx_live2d.cpp
games/sdvx/io.cpp
games/sdvx/camera.cpp
games/jb/jb.cpp
@@ -529,6 +530,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
hooks/graphics/nvenc_hook.cpp
hooks/graphics/backends/d3d9/d3d9_backend.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_swapchain.cpp
hooks/graphics/backends/d3d9/d3d9_texture.cpp
@@ -605,10 +607,15 @@ set(SOURCE_FILES ${SOURCE_FILES}
overlay/windows/keypad.cpp
overlay/windows/log.cpp
overlay/windows/midi.cpp
overlay/windows/obs.cpp
overlay/windows/obs_websocket.cpp
overlay/windows/patch_manager.cpp
overlay/windows/popn_sub.cpp
overlay/windows/wnd_manager.cpp
# external
external/easywsclient/easywsclient.cpp
# rawinput
rawinput/rawinput.cpp
rawinput/sextet.cpp
@@ -671,6 +678,33 @@ set(SOURCE_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 and spice_laa.exe are compiled identically; the only difference is
@@ -955,3 +989,16 @@ set_target_properties(spicetools_spice64 spicetools_spice64_linux spicetools_stu
ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/archive64"
LIBRARY_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()
+21 -1
View File
@@ -3,6 +3,7 @@
#include <d3d9.h>
#include "overlay/overlay.h"
#include "util/libutils.h"
#include "util/logging.h"
namespace cfg {
@@ -35,7 +36,26 @@ namespace cfg {
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) {
log_warning("configurator", "Direct3DCreate9 returned NULL, falling back to software renderer");
return false;
+38 -1
View File
@@ -21,6 +21,8 @@ static const char *CLASS_NAME = "ConfiguratorWindow";
static std::string WINDOW_TITLE;
static int WINDOW_SIZE_X = 800;
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 const UINT_PTR RENDER_TIMER_ID = 1;
@@ -85,6 +87,23 @@ static cfg::ConfiguratorWindow *get_state(HWND hWnd) {
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,
// 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").
@@ -243,8 +262,10 @@ cfg::ConfiguratorWindow::~ConfiguratorWindow() {
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
SetWindowPos(this->hWnd, HWND_TOP, 0, 0, WINDOW_SIZE_X, WINDOW_SIZE_Y, 0);
ShowWindow(this->hWnd, SW_SHOWNORMAL);
UpdateWindow(this->hWnd);
@@ -290,6 +311,22 @@ LRESULT CALLBACK cfg::ConfiguratorWindow::window_proc(HWND hWnd, UINT uMsg, WPAR
}
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: {
// 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,
UINT cbSize) {
auto result = GetRawInputDeviceList_orig(pRawInputDeviceList, puiNumDevices, cbSize);
if (result == 0xFFFFFFFF)
if (result == 0xFFFFFFFF) {
return result;
}
if (pRawInputDeviceList == NULL) {
(*puiNumDevices)++;
} else if (result < *puiNumDevices) {
pRawInputDeviceList[result] = {fakeHandle, 0};
pRawInputDeviceList[result] = { fakeHandle, 0 };
result++;
}
@@ -47,8 +48,9 @@ namespace games::ccj {
}
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);
}
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) {
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);
}
hWnd = _hWnd;
wndProc = (WNDPROC)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) {
if (hRawInput != fakeHandle)
if (hRawInput != fakeHandle) {
return GetRawInputData_orig(hRawInput, uiCommand, pData, pcbSize, cbSizeHeader);
}
if (pData == NULL) {
if (uiCommand == RID_HEADER)
if (uiCommand == RID_HEADER) {
*pcbSize = sizeof(RAWINPUTHEADER);
else
} else {
*pcbSize = sizeof(RAWINPUT);
}
return 0;
}
@@ -107,92 +243,9 @@ namespace games::ccj {
RAWMOUSE rawMouse {};
if (MOUSE_TRACKBALL) {
static bool active = false;
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;
}
trackball_mouse_input(rawMouse);
} else {
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;
trackball_mapped_input(rawMouse);
}
*((RAWINPUT*)pData) = { header, { rawMouse } };
@@ -224,9 +277,8 @@ namespace games::ccj {
static bool initialized = false;
if (initialized) {
return;
} else {
initialized = true;
}
initialized = true;
// announce
log_info("trackball", "init");
@@ -246,8 +298,6 @@ namespace games::ccj {
}
void trackball_thread_start() {
using namespace std::chrono_literals;
tbThreadRunning = true;
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);
}
if (!tbThreadRunning)
if (!tbThreadRunning) {
break;
}
timer.sleep(10);
}
@@ -269,8 +320,9 @@ namespace games::ccj {
void trackball_thread_stop() {
tbThreadRunning = false;
if (tbThread)
if (tbThread) {
tbThread->join();
}
log_info("trackball", "thread stop");
+2
View File
@@ -474,6 +474,8 @@ namespace games {
vkey_defaults.push_back(VK_F12);
names.emplace_back("Toggle Camera Control");
vkey_defaults.push_back(0xFF);
names.emplace_back("Toggle OBS Control");
vkey_defaults.push_back(0xFF);
names.emplace_back("Player 1 PIN Macro");
vkey_defaults.push_back(0xFF);
names.emplace_back("Player 2 PIN Macro");
+1
View File
@@ -23,6 +23,7 @@ namespace games {
ToggleScreenResize,
ToggleFps,
ToggleCameraControl,
ToggleOBSControl,
TriggerPinMacroP1,
TriggerPinMacroP2,
ScreenResize,
+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
}
@@ -1273,9 +1273,11 @@ static void graphics_d3d9_ldj_on_present(IDirect3DDevice9 *wrapped_device) {
wintouchemu::update();
// 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
// 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()) {
// (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, 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);
}
}
@@ -1,7 +1,11 @@
#include "d3d9_device.h"
#include <algorithm>
#include <cassert>
#include <climits>
#include <mutex>
#include <unordered_map>
#include <vector>
#include "avs/game.h"
#include "games/gitadora/gitadora.h"
@@ -12,6 +16,7 @@
#include "cfg/screen_resize.h"
#include "d3d9_backend.h"
#include "d3d9_live2d.h"
#include "d3d9_texture.h"
#ifndef SPICE64
@@ -1301,6 +1306,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::DrawPrimitive(
UINT PrimitiveCount)
{
WRAP_DEBUG;
if (d3d9_live2d::should_skip_draw()) [[unlikely]] {
return D3D_OK;
}
CHECK_RESULT(pReal->DrawPrimitive(PrimitiveType, StartVertex, PrimitiveCount));
}
@@ -1313,6 +1321,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::DrawIndexedPrimitive(
UINT PrimitiveCount)
{
WRAP_DEBUG;
if (d3d9_live2d::should_skip_draw()) [[unlikely]] {
return D3D_OK;
}
CHECK_RESULT(pReal->DrawIndexedPrimitive(
PrimitiveType, BaseVertexIndex,
MinVertexIndex, NumVertices,
@@ -1328,6 +1339,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::DrawPrimitiveUP(
WRAP_DEBUG_FMT("DrawPrimitiveUP({}, {}, {}, {})",
PrimitiveType, PrimitiveCount,
fmt::ptr(pVertexStreamZeroData), VertexStreamZeroStride);
if (d3d9_live2d::should_skip_draw()) [[unlikely]] {
return D3D_OK;
}
CHECK_RESULT(pReal->DrawPrimitiveUP(
PrimitiveType, PrimitiveCount,
pVertexStreamZeroData, VertexStreamZeroStride));
@@ -1344,6 +1358,9 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::DrawIndexedPrimitiveUP(
UINT VertexStreamZeroStride)
{
WRAP_DEBUG;
if (d3d9_live2d::should_skip_draw()) [[unlikely]] {
return D3D_OK;
}
CHECK_RESULT(pReal->DrawIndexedPrimitiveUP(
PrimitiveType, MinVertexIndex, NumVertices, PrimitiveCount, pIndexData,
IndexDataFormat, pVertexStreamZeroData, VertexStreamZeroStride));
@@ -1403,7 +1420,14 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::CreateVertexShader(
IDirect3DVertexShader9 **ppShader)
{
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(
@@ -1411,6 +1435,8 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::SetVertexShader(
{
WRAP_DEBUG_FMT("SetVertexShader({})", fmt::ptr(pShader));
d3d9_live2d::on_set_vertex_shader(pShader);
#ifndef SPICE64
// diagonal line fix
@@ -1557,13 +1583,21 @@ HRESULT STDMETHODCALLTYPE WrappedIDirect3DDevice9::CreatePixelShader(
IDirect3DPixelShader9 **ppShader)
{
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(
IDirect3DPixelShader9 *pShader)
{
WRAP_DEBUG_FMT("SetPixelShader({})", fmt::ptr(pShader));
d3d9_live2d::on_set_pixel_shader(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
}
+4
View File
@@ -86,6 +86,10 @@ static bool monitor_layout_needs_reset = false;
bool GRAPHICS_CAPTURE_CURSOR = false;
bool GRAPHICS_LOG_HRESULT = 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_WINDOWED = false;
std::vector<HWND> GRAPHICS_WINDOWS;
+28
View File
@@ -1,5 +1,6 @@
#pragma once
#include <atomic>
#include <string>
#include <vector>
#include <optional>
@@ -27,6 +28,33 @@ enum graphics_dx9on12_state {
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
extern bool GRAPHICS_CAPTURE_CURSOR;
extern bool GRAPHICS_LOG_HRESULT;
+52 -2
View File
@@ -53,6 +53,7 @@
#include "games/sc/sc.h"
#include "games/scotto/scotto.h"
#include "games/sdvx/sdvx.h"
#include "games/sdvx/sdvx_live2d.h"
#include "games/shared/printer.h"
#include "games/silentscope/silentscope.h"
#include "games/mfc/mfc.h"
@@ -102,6 +103,7 @@
#include "overlay/notifications.h"
#include "overlay/windows/patch_manager.h"
#include "overlay/windows/iidx_seg.h"
#include "overlay/windows/obs.h"
#include "rawinput/rawinput.h"
#include "rawinput/touch.h"
#include "reader/reader.h"
@@ -643,6 +645,9 @@ int main_implementation(int argc, char *argv[]) {
if (options[launcher::Options::PopnNativeTouch].value_bool()) {
games::popn::NATIVE_TOUCH = true;
}
if (options[launcher::Options::PopnSubRedraw].value_bool()) {
SUBSCREEN_FORCE_REDRAW = true;
}
if (options[launcher::Options::LoadMetalGearArcadeModule].value_bool()) {
attach_mga = true;
}
@@ -1042,6 +1047,20 @@ int main_implementation(int argc, char *argv[]) {
if (options[launcher::Options::SDVXForce720p].value_bool()) {
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()) {
rawinput::touch::INVERTED = true;
}
@@ -1434,6 +1453,26 @@ int main_implementation(int argc, char *argv[]) {
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
if (api_debug && !cfg::CONFIGURATOR_STANDALONE) {
API_CONTROLLER = std::make_unique<api::Controller>(api_port, api_pass, api_pretty);
@@ -1546,8 +1585,19 @@ int main_implementation(int argc, char *argv[]) {
// print out conflicts
size_t conflicts = 0;
for (const auto &option : options) {
if (option.conflicting && option.get_definition().type != OptionType::Bool) {
for (size_t i = 0; i < options.size(); i++) {
// 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;
const auto& value = option.get_definition().sensitive ? "*****" : option.value;
if (launcher::USE_CMD_OVERRIDE) {
+75
View File
@@ -52,6 +52,7 @@ static const std::vector<std::string> CATEGORY_ORDER_NETWORK = {
static const std::vector<std::string> CATEGORY_ORDER_OVERLAY = {
"General Overlay",
"Game Overlay",
"OBS Control",
};
static const std::vector<std::string> CATEGORY_ORDER_ADVANCED = {
@@ -1025,6 +1026,24 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.category = "Game Options",
.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: never render Live2D (also removes the menu navigator).\n"
"During songs: only skip 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", "Never show Live2D"},
{"ingame", "Show navigators only"},
},
.quick_setting_category = "Game",
},
{
// spice2x_SDVXSubPos
.title = "SDVX Subscreen Overlay Position",
@@ -1138,6 +1157,17 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.game_name = "Pop'n Music",
.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",
.name = "hpm",
@@ -3190,6 +3220,51 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.type = OptionType::Bool,
.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) {
+8 -1
View File
@@ -97,6 +97,7 @@ namespace launcher {
SDVXDigitalKnobSocd,
spice2x_SDVXAsioDriver,
SDVXAsioTwoChannel,
SDVXDisableLive2D,
spice2x_SDVXSubPos,
SDVXSubMonitorOverride,
LoadDDRModule,
@@ -108,6 +109,7 @@ namespace launcher {
PopnNoSub,
PopnSubMonitorOverride,
PopnNativeTouch,
PopnSubRedraw,
LoadHelloPopnMusicModule,
LoadGitaDoraModule,
GitaDoraTwoChannelAudio,
@@ -304,7 +306,12 @@ namespace launcher {
DisableHighResTimer,
EnableICMPHook,
AutoElevate,
CfgForceSoftwareRender
CfgForceSoftwareRender,
OBSWebSocketEnabled,
OBSWebSocketHost,
OBSWebSocketPort,
OBSWebSocketPassword,
OBSWebSocketDebug
};
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
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
-------------------------------------------
cardio - Felix - MIT License
+26 -11
View File
@@ -1,7 +1,9 @@
#include "extensions.h"
#include <algorithm>
#include <cmath>
#include "external/imgui/imgui.h"
#include "external/imgui/imgui_internal.h"
#include "overlay/overlay.h"
@@ -160,6 +162,19 @@ namespace ImGui {
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) {
ImGui::PushID(tooltip.c_str());
// same colors as a checkbox
@@ -209,17 +224,17 @@ namespace ImGui {
return open;
}
void InvisibleTableRowSelectable() {
ImGui::TableSetColumnIndex(0);
ImGui::PushStyleColor(ImGuiCol_Header, 0);
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, 0);
ImGui::PushStyleColor(ImGuiCol_HeaderActive, 0);
ImGui::PushTabStop(false); // prevent tab navigation
ImGui::Selectable("##row", false,
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap);
ImGui::PopTabStop();
ImGui::PopStyleColor(3);
if (ImGui::IsItemHovered()) {
void HighlightTableRowOnHover() {
// hit-test the row rect directly so the row layout and height are untouched
ImGuiTable *table = ImGui::GetCurrentTable();
if (table == nullptr) {
return;
}
if (ImGui::IsWindowHovered() &&
ImGui::IsMouseHoveringRect(
ImVec2(table->WorkRect.Min.x, table->RowPosY1),
ImVec2(table->WorkRect.Max.x, table->RowPosY2),
false)) {
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);
bool DeleteButton(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.
// Wrap items in BeginPaddedTabItem between BeginPaddedTabBar/EndTabBar.
+15 -3
View File
@@ -210,7 +210,15 @@ bool ImGui_ImplSpice_UpdateMouseCursor() {
// update cursor
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
::SetCursor(nullptr);
@@ -592,8 +600,12 @@ void ImGui_ImplSpice_NewFrame() {
}
}
} else {
// update OS mouse cursor with the cursor requested by imgui
ImGuiMouseCursor mouse_cursor = io.MouseDrawCursor ? ImGuiMouseCursor_None : ImGui::GetMouseCursor();
// in auto-hide mode imgui owns the cursor, so keep the OS cursor hidden
// 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) {
g_LastMouseCursor = mouse_cursor;
ImGui_ImplSpice_UpdateMouseCursor();
+7
View File
@@ -50,6 +50,7 @@
#include "windows/sdvx_sub.h"
#include "windows/keypad.h"
#include "windows/log.h"
#include "windows/obs.h"
#include "windows/patch_manager.h"
#include "windows/exitprompt.cpp"
@@ -416,6 +417,12 @@ void overlay::SpiceOverlay::init() {
}
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);
this->window_add(window_keypad1);
+1
View File
@@ -77,6 +77,7 @@ namespace overlay {
Window *window_camera = nullptr;
Window *window_sub = nullptr;
Window *window_log = nullptr;
Window *window_obs = nullptr;
// not part of `windows`: drawn/updated on the persistent layer (like
// notifications), independent of the overlay's active state and input gates.
+18 -20
View File
@@ -928,18 +928,14 @@ namespace overlay::windows {
// primary
build_button(name, primary_button, &primary_button, button_it, button_it_max, 0);
ImGui::PushID(&primary_button);
ImGui::InvisibleTableRowSelectable();
ImGui::PopID();
ImGui::HighlightTableRowOnHover();
// alternatives
int alt_index = 1; // 0 is primary
for (auto &alt : primary_button.getAlternatives()) {
if (alt.isValid()) {
build_button(name, primary_button, &alt, button_it, button_it_max, alt_index);
ImGui::PushID(&alt);
ImGui::InvisibleTableRowSelectable();
ImGui::PopID();
ImGui::HighlightTableRowOnHover();
}
alt_index++;
}
@@ -2519,8 +2515,7 @@ namespace overlay::windows {
edit_analog_popup(analog, title);
// row hover detection (invisible selectable that spans entire row)
ImGui::InvisibleTableRowSelectable();
ImGui::HighlightTableRowOnHover();
// clean up
ImGui::PopID();
@@ -3174,16 +3169,12 @@ namespace overlay::windows {
}
build_light(light, &light, i, 0);
ImGui::PushID(&light);
ImGui::InvisibleTableRowSelectable();
ImGui::PopID();
ImGui::HighlightTableRowOnHover();
int alt_index = 1;
for (auto &alt : light.getAlternatives()) {
if (alt.isValid()) {
build_light(light, &alt, i, alt_index);
ImGui::PushID(&alt);
ImGui::InvisibleTableRowSelectable();
ImGui::PopID();
ImGui::HighlightTableRowOnHover();
}
alt_index++;
}
@@ -4209,8 +4200,10 @@ namespace overlay::windows {
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "Card overrides");
ImGui::Spacing();
ImGui::TextUnformatted(
"Specify hardcoded card numbers here. This will always take priority when Insert Card is pressed.");
ImGui::TextWrapped(
"%s",
"Specify hardcoded card numbers here.\n\n"
"Overrides will always take priority when Insert Card is pressed.");
ImGui::Spacing();
// read in values from options
@@ -4387,7 +4380,8 @@ namespace overlay::windows {
ImGui::Spacing();
ImGui::TextColored(ImVec4(1, 0.7f, 0, 1), "Card from text files");
ImGui::Spacing();
ImGui::TextUnformatted(
ImGui::TextWrapped(
"%s",
"Use text files on disk; its content will be read when Insert Card is pressed.");
ImGui::Spacing();
@@ -5293,8 +5287,7 @@ namespace overlay::windows {
}
}
// row hover detection (invisible selectable that spans entire row)
ImGui::InvisibleTableRowSelectable();
ImGui::HighlightTableRowOnHover();
// next item
ImGui::PopID();
@@ -5787,23 +5780,28 @@ namespace overlay::windows {
// name
ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::TextTruncated(
t.name, ImGui::GetContentRegionAvail().x - overlay::apply_scaling(20));
// type
ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(t.is_builtin ? "Built-in" : "User");
// buttons
ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::Text("%d", (int)t.buttons.size());
// analogs
ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::Text("%d", (int)t.analogs.size());
// lights
ImGui::TableNextColumn();
ImGui::AlignTextToFramePadding();
ImGui::Text("%d", (int)t.lights.size());
// actions
@@ -5831,7 +5829,7 @@ namespace overlay::windows {
}
}
ImGui::InvisibleTableRowSelectable();
ImGui::HighlightTableRowOnHover();
ImGui::PopID();
}
+3 -2
View File
@@ -119,8 +119,9 @@ namespace overlay::windows {
ImGui::TextDisabled("Graphics");
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_resize, "Resize", size_half, NextItem::NEW_LINE);
build_button(this->overlay->window_fps.get(), "FPS", size_third, NextItem::SAME_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");
build_button(this->overlay->window_cards, "Card Manager", size, NextItem::NEW_LINE);
+64 -22
View File
@@ -1,6 +1,7 @@
#include <algorithm>
#include "external/fmt/include/fmt/chrono.h"
#include "fps.h"
#include "obs.h"
namespace overlay::windows {
@@ -14,6 +15,7 @@ namespace overlay::windows {
this->title = "Stats";
this->flags = ImGuiWindowFlags_NoTitleBar
| ImGuiWindowFlags_NoResize
| ImGuiWindowFlags_AlwaysAutoResize
| ImGuiWindowFlags_NoCollapse
| ImGuiWindowFlags_NoFocusOnAppearing
| ImGuiWindowFlags_NoBringToFrontOnFocus
@@ -29,25 +31,7 @@ namespace overlay::windows {
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now());
}
void FPS::calculate_initial_window() {
// 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);
ImVec2 FPS::anchored_pos(const ImVec2 &size) const {
// bottom-anchored windows use a larger edge margin (matching notification
// toasts) since they overlap the same on-screen UI; other edges hug closer
const float edge_margin = overlay::apply_scaling(4);
@@ -61,9 +45,27 @@ namespace overlay::windows {
overlay::FPS_LOCATION == overlay::FpsLocation::BottomLeft ||
overlay::FPS_LOCATION == overlay::FpsLocation::BottomRight;
const float pos_x = right ? display.x - win_w - edge_margin : edge_margin;
const float pos_y = bottom ? display.y - win_h - bottom_margin : edge_margin;
this->init_pos = ImVec2(pos_x, pos_y);
const float pos_x = right ? display.x - size.x - edge_margin : edge_margin;
const float pos_y = bottom ? display.y - size.y - bottom_margin : edge_margin;
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() {
@@ -79,6 +81,21 @@ namespace overlay::windows {
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
// flush against the value column instead of looking ragged. the label is
// only slightly dimmer than normal text (not the much darker "disabled" tone)
@@ -117,6 +134,31 @@ namespace overlay::windows {
fmt::format("{:%H:%M:%S}",
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::PopStyleVar();
+3
View File
@@ -9,6 +9,9 @@ namespace overlay::windows {
private:
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:
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();
}
// row hover detection (invisible selectable that spans entire row)
ImGui::InvisibleTableRowSelectable();
ImGui::HighlightTableRowOnHover();
ImGui::PopID();
}