From 7b903d35e29408ce69adfeaf69529d3afb0a5e0f Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Sun, 4 Jan 2026 19:50:11 -0800 Subject: [PATCH] iidx, sdvx: SOCD cleaner for knobs and turntables (#497) ## Link to GitHub Issue, if one exists n/a ## Description of change Apply SOCD cleaner algorithm to deal with the case where both counter-clockwise and clockwise buttons are pressed at the same time. In SDVX, default algorithm is to prefer the most recently pressed direction. This is better for dealing with slams in rapid succession, for example, as you don't have to completely let go of one direction to hit the other now. In IIDX, default algorithm is neutral (same behavior as before). Using last or first algorithm results in double scratches which means you'll likely end up with an excessive poor after hitting a scratch, so neutral is actually beneficial. Besides, if you are serious about playing on the keyboard, you should be using `TT +/-` and `TT +/- Alternate`. ## Testing Tested SDVX 3, EG (old cab), EG valk cab modes, IIDX 24, 27, 33 (tdj/ldj) --- src/spice2x/CMakeLists.txt | 1 + src/spice2x/acio/bi2a/bi2a.cpp | 36 ++++++++++-- src/spice2x/acio/kfca/kfca.cpp | 32 ++++++++-- src/spice2x/games/iidx/iidx.cpp | 21 +++++-- src/spice2x/games/sdvx/bi2x_hook.cpp | 37 ++++++++++-- src/spice2x/games/sdvx/sdvx.cpp | 12 ++++ src/spice2x/launcher/options.cpp | 36 +++++++++++- src/spice2x/launcher/options.h | 2 + src/spice2x/util/socd_cleaner.cpp | 87 ++++++++++++++++++++++++++++ src/spice2x/util/socd_cleaner.h | 22 +++++++ 10 files changed, 262 insertions(+), 24 deletions(-) create mode 100644 src/spice2x/util/socd_cleaner.cpp create mode 100644 src/spice2x/util/socd_cleaner.h diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index 787b058..62941f6 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -624,6 +624,7 @@ set(SOURCE_FILES ${SOURCE_FILES} util/execexe.cpp util/dependencies.cpp util/deferlog.cpp + util/socd_cleaner.cpp ) source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} PREFIX "Source Files" FILES ${SOURCE_FILES}) diff --git a/src/spice2x/acio/bi2a/bi2a.cpp b/src/spice2x/acio/bi2a/bi2a.cpp index 68b2235..9722333 100644 --- a/src/spice2x/acio/bi2a/bi2a.cpp +++ b/src/spice2x/acio/bi2a/bi2a.cpp @@ -9,11 +9,22 @@ #include "games/drs/drs.h" #include "misc/eamuse.h" #include "util/logging.h" +#include "util/socd_cleaner.h" +#include "util/time.h" #include "util/utils.h" #include "util/tapeled.h" using namespace GameAPI; +#define DEBUG_VERBOSE 0 + +#if DEBUG_VERBOSE +#define log_debug(module, format_str, ...) logger::push( \ + LOG_FORMAT("M", module, format_str, ## __VA_ARGS__), logger::Style::GREY) +#else +#define log_debug(module, format_str, ...) +#endif + // state static uint8_t STATUS_BUFFER[272] {}; static bool STATUS_BUFFER_FREEZE = false; @@ -94,18 +105,25 @@ static bool __cdecl ac_io_bi2a_update_control_status_buffer() { } // volume left - if (Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_L_Left))) { + const auto now = get_performance_milliseconds(); + const auto vol_l_state = socd::socd_clean(0, + Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_L_Left)), + Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_L_Right)), + now); + if (vol_l_state == socd::SocdCCW) { BI2A_VOLL = (BI2A_VOLL - games::sdvx::DIGITAL_KNOB_SENS) & 1023; - } - if (Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_L_Right))) { + } else if (vol_l_state == socd::SocdCW) { BI2A_VOLL = (BI2A_VOLL + games::sdvx::DIGITAL_KNOB_SENS) & 1023; } // volume right - if (Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_R_Left))) { + const auto vol_r_state = socd::socd_clean(1, + Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_R_Left)), + Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_R_Right)), + now); + if (vol_r_state == socd::SocdCCW) { BI2A_VOLR = (BI2A_VOLR - games::sdvx::DIGITAL_KNOB_SENS) & 1023; - } - if (Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_R_Right))) { + } else if (vol_r_state == socd::SocdCW) { BI2A_VOLR = (BI2A_VOLR + games::sdvx::DIGITAL_KNOB_SENS) & 1023; } @@ -127,6 +145,12 @@ static bool __cdecl ac_io_bi2a_update_control_status_buffer() { // save volumes in buffer *((uint16_t*) &STATUS_BUFFER[17]) = (uint16_t) ((vol_left) << 2); *((uint16_t*) &STATUS_BUFFER[19]) = (uint16_t) ((vol_right) << 2); + + log_debug( + "bi2a", + "knobs = {} {}", + *((uint16_t*) &STATUS_BUFFER[17]), + *((uint16_t*) &STATUS_BUFFER[19])); } // DanceDanceRevolution diff --git a/src/spice2x/acio/kfca/kfca.cpp b/src/spice2x/acio/kfca/kfca.cpp index 079208c..3ead38e 100644 --- a/src/spice2x/acio/kfca/kfca.cpp +++ b/src/spice2x/acio/kfca/kfca.cpp @@ -8,10 +8,21 @@ #include "games/sdvx/io.h" #include "misc/eamuse.h" #include "rawinput/rawinput.h" +#include "util/socd_cleaner.h" +#include "util/time.h" #include "util/utils.h" using namespace GameAPI; +#define DEBUG_VERBOSE 0 + +#if DEBUG_VERBOSE +#define log_debug(module, format_str, ...) logger::push( \ + LOG_FORMAT("M", module, format_str, ## __VA_ARGS__), logger::Style::GREY) +#else +#define log_debug(module, format_str, ...) +#endif + // globals uint8_t KFCA_VOL_SOUND = 96; uint8_t KFCA_VOL_HEADPHONE = 96; @@ -342,18 +353,25 @@ static char __cdecl ac_io_kfca_update_control_status_buffer() { } // volume left - if (Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_L_Left))) { + const auto now = get_performance_milliseconds(); + const auto vol_l_state = socd::socd_clean(0, + Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_L_Left)), + Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_L_Right)), + now); + if (vol_l_state == socd::SocdCCW) { KFCA_VOLL = (KFCA_VOLL - games::sdvx::DIGITAL_KNOB_SENS) & 1023; - } - if (Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_L_Right))) { + } else if (vol_l_state == socd::SocdCW) { KFCA_VOLL = (KFCA_VOLL + games::sdvx::DIGITAL_KNOB_SENS) & 1023; } // volume right - if (Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_R_Left))) { + const auto vol_r_state = socd::socd_clean(1, + Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_R_Left)), + Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_R_Right)), + now); + if (vol_r_state == socd::SocdCCW) { KFCA_VOLR = (KFCA_VOLR - games::sdvx::DIGITAL_KNOB_SENS) & 1023; - } - if (Buttons::getState(RI_MGR, buttons.at(games::sdvx::Buttons::VOL_R_Right))) { + } else if (vol_r_state == socd::SocdCW) { KFCA_VOLR = (KFCA_VOLR + games::sdvx::DIGITAL_KNOB_SENS) & 1023; } @@ -371,6 +389,8 @@ static char __cdecl ac_io_kfca_update_control_status_buffer() { // proper loops vol_left %= 1024; vol_right %= 1024; + + log_debug("kfca", "knobs = {} {}", vol_left, vol_right); // save volumes in buffer STATUS_BUFFER[input_offset + 16 + 0] |= (unsigned char) ((vol_left << 6) & 0xFF); diff --git a/src/spice2x/games/iidx/iidx.cpp b/src/spice2x/games/iidx/iidx.cpp index 967c6dc..b1f05d4 100644 --- a/src/spice2x/games/iidx/iidx.cpp +++ b/src/spice2x/games/iidx/iidx.cpp @@ -29,6 +29,8 @@ #include "util/libutils.h" #include "util/memutils.h" #include "util/sigscan.h" +#include "util/socd_cleaner.h" +#include "util/time.h" #include "util/utils.h" #include "launcher/signal.h" @@ -507,6 +509,16 @@ namespace games::iidx { " monitor in Windows settings before launching the game" }); } + + // socd + socd::ALGORITHM = socd::SocdAlgorithm::Neutral; + if (options->at(launcher::Options::IIDXDigitalTTSocd).is_active()) { + if (options->at(launcher::Options::IIDXDigitalTTSocd).value_text() == "last") { + socd::ALGORITHM = socd::SocdAlgorithm::PreferRecent; + } else if (options->at(launcher::Options::IIDXDigitalTTSocd).value_text() == "first") { + socd::ALGORITHM = socd::SocdAlgorithm::PreferFirst; + } + } } void IIDXGame::detach() { @@ -730,13 +742,12 @@ namespace games::iidx { bool ttpm_alt = GameAPI::Buttons::getState(RI_MGR, buttons.at( player != 0 ? Buttons::P2_TTPlusMinusAlt : Buttons::P1_TTPlusMinusAlt)); - // TT+ - if (ttp) + const auto tt_socd = socd::socd_clean(player, ttm, ttp, get_performance_milliseconds()); + if (tt_socd == socd::SocdCW) { IIDXIO_TT_STATE[player] += change; - - // TT- - if (ttm) + } else if (tt_socd == socd::SocdCCW) { IIDXIO_TT_STATE[player] -= change; + } // TT+/- bool ttpm_rising_edge = !IIDXIO_TT_PRESSED[player] && ttpm; diff --git a/src/spice2x/games/sdvx/bi2x_hook.cpp b/src/spice2x/games/sdvx/bi2x_hook.cpp index 38f8a1c..5da92c2 100644 --- a/src/spice2x/games/sdvx/bi2x_hook.cpp +++ b/src/spice2x/games/sdvx/bi2x_hook.cpp @@ -12,9 +12,20 @@ #include "launcher/options.h" #include "io.h" #include "games/sdvx/sdvx.h" +#include "util/socd_cleaner.h" #include "util/tapeled.h" +#include "util/time.h" #include "acioemu/icca.h" +#define DEBUG_VERBOSE 0 + +#if DEBUG_VERBOSE +#define log_debug(module, format_str, ...) logger::push( \ + LOG_FORMAT("M", module, format_str, ## __VA_ARGS__), logger::Style::GREY) +#else +#define log_debug(module, format_str, ...) +#endif + namespace games::sdvx { constexpr bool BI2X_PASSTHROUGH = false; @@ -176,19 +187,27 @@ namespace games::sdvx { if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::Headphone])) status->buffer[22] = 0x01; + const auto now = get_performance_milliseconds(); + // volume left - if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::VOL_L_Left])) { + const auto vol_l_state = socd::socd_clean(0, + GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::VOL_L_Left]), + GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::VOL_L_Right]), + now); + if (vol_l_state == socd::SocdCCW) { VOL_L -= ((uint16_t)DIGITAL_KNOB_SENS * 4); - } - if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::VOL_L_Right])) { + } else if (vol_l_state == socd::SocdCW) { VOL_L += ((uint16_t)DIGITAL_KNOB_SENS * 4); } // volume right - if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::VOL_R_Left])) { + const auto vol_r_state = socd::socd_clean(1, + GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::VOL_R_Left]), + GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::VOL_R_Right]), + now); + if (vol_r_state == socd::SocdCCW) { VOL_R -= ((uint16_t)DIGITAL_KNOB_SENS * 4); - } - if (GameAPI::Buttons::getState(RI_MGR, buttons[Buttons::VOL_R_Right])) { + } else if (vol_r_state == socd::SocdCW) { VOL_R += ((uint16_t)DIGITAL_KNOB_SENS * 4); } @@ -203,6 +222,12 @@ namespace games::sdvx { *((uint16_t*) &status->buffer[312]) = vol_left; *((uint16_t*) &status->buffer[314]) = vol_right; + + log_debug( + "bi2x_hook", + "knobs = {} {}", + *((uint16_t*) &status->buffer[312]), + *((uint16_t*) &status->buffer[314])); } static void __fastcall AIO_IOB2_BI2X_UFC__IoReset(AIO_IOB2_BI2X_UFC *This, diff --git a/src/spice2x/games/sdvx/sdvx.cpp b/src/spice2x/games/sdvx/sdvx.cpp index 98525d0..fe56996 100644 --- a/src/spice2x/games/sdvx/sdvx.cpp +++ b/src/spice2x/games/sdvx/sdvx.cpp @@ -18,6 +18,8 @@ #include "util/detour.h" #include "util/logging.h" #include "util/sigscan.h" +#include "util/socd_cleaner.h" +#include "util/time.h" #include "util/libutils.h" #include "misc/wintouchemu.h" #include "misc/eamuse.h" @@ -360,6 +362,16 @@ namespace games::sdvx { this->detect_sound_output_device(); #endif + // SOCD + auto options = games::get_options(eamuse_get_game()); + socd::ALGORITHM = socd::SocdAlgorithm::PreferRecent; + if (options->at(launcher::Options::SDVXDigitalKnobSocd).is_active()) { + if (options->at(launcher::Options::SDVXDigitalKnobSocd).value_text() == "neutral") { + socd::ALGORITHM = socd::SocdAlgorithm::Neutral; + } else if (options->at(launcher::Options::SDVXDigitalKnobSocd).value_text() == "last") { + socd::ALGORITHM = socd::SocdAlgorithm::PreferRecent; + } + } } void SDVXGame::attach() { diff --git a/src/spice2x/launcher/options.cpp b/src/spice2x/launcher/options.cpp index 295f570..563cd3e 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -604,7 +604,7 @@ static const std::vector OPTION_DEFINITIONS = { { .title = "IIDX TDJ Mode (Lightning Model)", .name = "iidxtdj", - .desc = "Enables TDJ cabinet mode. Ensure you also set -iidxsounddevice to desired option", + .desc = "Enables TDJ mode (Lightning Model cabinet).", .type = OptionType::Bool, .game_name = "Beatmania IIDX", .category = "Game Options", @@ -621,6 +621,23 @@ static const std::vector OPTION_DEFINITIONS = { .game_name = "Beatmania IIDX", .category = "Game Options (Advanced)", }, + { + // IIDXDigitalTTSocd + .title = "IIDX Digital TT SOCD Cleaner", + .name = "iidxsocd", + .desc = "SOCD for turntables when using button input; what happens when both directions are pressed.\n\n" + "last: most recently pressed direction takes priority\n\n" + "first: first pressed direction takes priority\n\n" + "neutral (default): TT does not move when both directions are pressed, recommended to avoid excessive POORs", + .type = OptionType::Enum, + .game_name = "Beatmania IIDX", + .category = "Game Options (Advanced)", + .elements = { + {"last", ""}, + {"first", ""}, + {"neutral", ""}, + }, + }, { // spice2x_IIDXLDJForce720p .title = "IIDX LDJ Force 720p (HD)", @@ -804,6 +821,23 @@ static const std::vector OPTION_DEFINITIONS = { .game_name = "Sound Voltex", .category = "Game Options (Advanced)", }, + { + // SDVXDigitalKnobSocd + .title = "SDVX Digital Knob SOCD Cleaner", + .name = "sdvxsocd", + .desc = "SOCD for knobs when using button input; what happens when both directions are pressed.\n\n" + "last (default): most recently pressed direction takes priority, recommended to deal with slams\n\n" + "first: first pressed direction takes priority\n\n" + "neutral: knob does not move when both directions are pressed", + .type = OptionType::Enum, + .game_name = "Sound Voltex", + .category = "Game Options (Advanced)", + .elements = { + {"last", ""}, + {"first", ""}, + {"neutral", ""}, + }, + }, { // spice2x_SDVXAsioDriver .title = "SDVX ASIO driver", diff --git a/src/spice2x/launcher/options.h b/src/spice2x/launcher/options.h index 00a66f9..530d09c 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -67,6 +67,7 @@ namespace launcher { IIDXBIO2FW, IIDXTDJMode, spice2x_IIDXDigitalTTSensitivity, + IIDXDigitalTTSocd, spice2x_IIDXLDJForce720p, spice2x_IIDXTDJSubSize, spice2x_IIDXLEDFontSize, @@ -84,6 +85,7 @@ namespace launcher { SDVXDisableCameras, SDVXNativeTouch, spice2x_SDVXDigitalKnobSensitivity, + SDVXDigitalKnobSocd, spice2x_SDVXAsioDriver, spice2x_SDVXSubPos, spice2x_SDVXSubRedraw, diff --git a/src/spice2x/util/socd_cleaner.cpp b/src/spice2x/util/socd_cleaner.cpp new file mode 100644 index 0000000..0f7eb45 --- /dev/null +++ b/src/spice2x/util/socd_cleaner.cpp @@ -0,0 +1,87 @@ +#include "socd_cleaner.h" + +#include "util/logging.h" + +#define DEBUG_VERBOSE 0 + +#if DEBUG_VERBOSE +#define log_debug(module, format_str, ...) logger::push( \ + LOG_FORMAT("M", module, format_str, ## __VA_ARGS__), logger::Style::GREY) +#else +#define log_debug(module, format_str, ...) +#endif + +namespace socd { + + SocdAlgorithm ALGORITHM = SocdAlgorithm::Neutral; + + static double last_rising_edge[2][2] = {}; + static bool last_button_state[2][2] = {}; + + // this has no locking, use only if you know there is only one i/o thread that will call this + SocdResult socd_clean(uint8_t device, bool ccw, bool cw, double time_now) { + if (device >= 2) { + log_fatal("socd", "invalid device index in socd_clean: {}", device); + } + + // SOCD last input algorithm needs to keep track of rising edge times + if (ALGORITHM != SocdAlgorithm::Neutral) { + // detect rising edges + if (!last_button_state[device][SocdCCW] && ccw) { + last_rising_edge[device][SocdCCW] = time_now; + } + if (!last_button_state[device][SocdCW] && cw) { + last_rising_edge[device][SocdCW] = time_now; + } + + // update button state for next time + last_button_state[device][SocdCCW] = ccw; + last_button_state[device][SocdCW] = cw; + } + + // determine direction: easy cases + if (!ccw && !cw) { + return SocdNone; + } + if (ccw && !cw) { + return SocdCCW; + } + if (!ccw && cw) { + return SocdCW; + } + + // SOCD logic; depends on algorithm in use + const auto ccw_time = last_rising_edge[device][SocdCCW]; + const auto cw_time = last_rising_edge[device][SocdCW]; + log_debug("socd", "ccw={}, cw ={}", ccw_time, cw_time); + + if (ALGORITHM == SocdAlgorithm::PreferRecent) { + // SOCD: prefer last input + if (ccw_time < cw_time) { + // while CCW is being held, CW got pressed + return SocdCW; + } else if (ccw_time > cw_time) { + // while CW is being held, CCW got pressed + return SocdCCW; + } else { + // it's a tie; instead of none, we'll pick a direction + return SocdCW; + } + } else if (ALGORITHM == SocdAlgorithm::PreferFirst) { + // SOCD: keep first input + if (ccw_time < cw_time) { + // while CCW is being held, CW got pressed + return SocdCCW; + } else if (ccw_time > cw_time) { + // while CW is being held, CCW got pressed + return SocdCW; + } else { + // it's a tie; instead of none, we'll pick a direction + return SocdCW; + } + } else { + // SOCD: neutral when both are pressed + return SocdNone; + } + } +} diff --git a/src/spice2x/util/socd_cleaner.h b/src/spice2x/util/socd_cleaner.h new file mode 100644 index 0000000..0f662ad --- /dev/null +++ b/src/spice2x/util/socd_cleaner.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +namespace socd { + + enum class SocdAlgorithm { + Neutral, + PreferRecent, + PreferFirst + }; + + extern SocdAlgorithm ALGORITHM; + + typedef enum _SocdResult { + SocdCCW = 0, + SocdCW = 1, + SocdNone = 2 + } SocdResult; + + SocdResult socd_clean(uint8_t device, bool ccw, bool cw, double time_now); +} \ No newline at end of file