From c255e3a1db920204ddecc9f2614417862244e7b0 Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Mon, 13 Jul 2026 00:31:05 -0700 Subject: [PATCH] jb: option to draw debug graphics to show touch interactions (#797) ## Link to GitHub Issue or related Pull Request, if one exists related to #697 ## Description of change Add a new option that paints touch targets and touch points for jubeat; gets enabled if there is a touch screen connected. This is to help explain / debug issues when users have their touch screen issues, which is usually due to misalignment. Fix the touch targets being off by 1 pixel (center gap is 1px wider in both directions). Also, simplify the `improved` algorithm to simulate a finger and trigger nearest-neighbor for the AC dimensions. In terms of touch performance this is near identical to what we had before, but it allows us to draw a nice debug overlay instead of just grids. ## Testing Tested portrait and landscape versions of jb. --- src/spice2x/CMakeLists.txt | 1 + src/spice2x/games/jb/jb.cpp | 219 +-------------- src/spice2x/games/jb/jb.h | 12 +- src/spice2x/games/jb/jb_touch.cpp | 443 ++++++++++++++++++++++++++++++ src/spice2x/games/jb/jb_touch.h | 45 +++ src/spice2x/launcher/launcher.cpp | 14 + src/spice2x/launcher/options.cpp | 23 ++ src/spice2x/launcher/options.h | 1 + src/spice2x/misc/device.cpp | 66 ++--- src/spice2x/touch/touch.cpp | 99 ++++--- 10 files changed, 616 insertions(+), 307 deletions(-) create mode 100644 src/spice2x/games/jb/jb_touch.cpp create mode 100644 src/spice2x/games/jb/jb_touch.h diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index d63e9c3..66db3e8 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -412,6 +412,7 @@ set(SOURCE_FILES ${SOURCE_FILES} games/sdvx/io.cpp games/sdvx/camera.cpp games/jb/jb.cpp + games/jb/jb_touch.cpp games/jb/io.cpp games/nost/nost.cpp games/nost/io.cpp diff --git a/src/spice2x/games/jb/jb.cpp b/src/spice2x/games/jb/jb.cpp index 4351f55..331b546 100644 --- a/src/spice2x/games/jb/jb.cpp +++ b/src/spice2x/games/jb/jb.cpp @@ -3,216 +3,24 @@ #include #include -#include "avs/game.h" #include "cfg/configurator.h" -#include "hooks/graphics/graphics.h" -#include "touch/touch.h" -#include "rawinput/touch.h" #include "util/logging.h" -#include "util/utils.h" #include "util/detour.h" #include "util/libutils.h" -#define JB_BUTTON_SIZE 160 -#define JB_BUTTON_GAP 37 -#define JB_BUTTON_HITBOX (JB_BUTTON_SIZE + JB_BUTTON_GAP) - namespace games::jb { - // touch stuff - JubeatTouchAlgorithm TOUCH_ALGORITHM = Improved; - static bool TOUCH_ENABLE = false; - static bool TOUCH_ATTACHED = false; - static bool IS_PORTRAIT = true; - static std::vector TOUCH_POINTS; - bool TOUCH_STATE[16]; - - void touch_update() { - - // check if touch enabled - if (!TOUCH_ENABLE) { - return; - } - - // attach touch module - if (!TOUCH_ATTACHED) { - - /* - * Find the game window. - * We check the foreground window first, then fall back to searching for the window title - * All game versions seem to have their model first in the window title - */ - HWND wnd = GetForegroundWindow(); - if (!string_begins_with(GetActiveWindowTitle(), avs::game::MODEL)) { - wnd = FindWindowBeginsWith(avs::game::MODEL); - } - - // check if we have a window handle - if (!wnd) { - log_warning("jubeat", "could not find window handle for touch"); - TOUCH_ENABLE = false; - return; - } - - // attach touch hook - log_info("jubeat", "using window handle for touch: {}", fmt::ptr(wnd)); - touch_create_wnd(wnd, true); - - // request automatic aspect ratio fixes - ::rawinput::touch::ASPECT_COMPENSATION_GAME = true; - - // show cursor - if (GRAPHICS_SHOW_CURSOR) { - ShowCursor(TRUE); - } - - // earlier games use a different screen orientation - if (!avs::game::is_model("L44")) { - IS_PORTRAIT = false; - } - - // set attached - TOUCH_ATTACHED = true; - } - - // reset touch state - memset(TOUCH_STATE, 0, sizeof(TOUCH_STATE)); - - // check touch points - // note that the IO code in device.cpp will correctly compensate for orientation, depending on the model. - TOUCH_POINTS.clear(); - touch_get_points(TOUCH_POINTS); - if (TOUCH_ALGORITHM == Legacy) { - auto offset = IS_PORTRAIT ? 580 : 0; - for (auto &tp : TOUCH_POINTS) { - - // get grid coordinates - int x = tp.x * 4 / 768; - int y = (tp.y - offset) * 4 / (1360 - 580); - - // set the corresponding state - int index = y * 4 + x; - if (index >= 0 && index < 16) { - TOUCH_STATE[index] = true; - } - } - } else { - for (auto &tp : TOUCH_POINTS) { - // check window out of bounds - if (IS_PORTRAIT) { - if (tp.x > 768 || tp.y > 1360) { - continue; - } - } else { - if (tp.x > 1360 || tp.y > 768) { - continue; - } - } - - int x_relative = tp.x; - int y_relative = tp.y; - - // x_relative and y_relative are relative to the top-left pixel of the first button - if (IS_PORTRAIT) { - // which is at (8, 602) in portrait: - // X: [8...759] (752 pixels wide) - // Y: [602...1353] (752 pixels high) - x_relative -= 8; - y_relative -= 602; - } else { - // and at (8, 8) in landscape - x_relative -= 8; - y_relative -= 8; - } - - int x_index = -1; - int x_hitbox = 0; - int y_index = -1; - int y_hitbox = 0; - - // x_hitbox and y_hitbox is relative to top-left pixel of each button - if (x_relative >= 0) { - x_index = x_relative / JB_BUTTON_HITBOX; - x_hitbox = x_relative % JB_BUTTON_HITBOX; - } - if (y_relative >= 0) { - y_index = y_relative / JB_BUTTON_HITBOX; - y_hitbox = y_relative % JB_BUTTON_HITBOX; - } - - // log_info("jb", "raw={}, idx={}, hitbox={}", tp.x, x_index, x_hitbox); - - if (TOUCH_ALGORITHM == Improved) { - if (IS_PORTRAIT) { - // extend to left border - if (x_relative < 0) { - x_index = 0; - } - // right and bottom borders are covered by the hit box - } else { - // extend to top border - if (y_relative < 0) { - y_index = 0; - } - // extend to left border - if (x_relative < 0) { - x_index = 0; - } - // bottom border is covered by the hit box - // rightmost edge - ignore them entirely - if (x_index == 3 && JB_BUTTON_SIZE < x_hitbox) { - continue; - } - } - } - - if (x_index < 0 || y_index < 0 || x_index > 3 || y_index > 3) { - continue; - } - - // check if the gap was touched - if (TOUCH_ALGORITHM == AcAccurate) { - // in ac-accurate mode, touching the gap is ignored - if (x_hitbox > JB_BUTTON_SIZE || y_hitbox > JB_BUTTON_SIZE) { - continue; - } - - } else if (TOUCH_ALGORITHM == Improved) { - // in improved mode, touching the gap triggers the closest button - if (x_index <= 2 && (JB_BUTTON_SIZE + JB_BUTTON_GAP / 2) < x_hitbox) { - x_index++; - } - if (y_index <= 2 && (JB_BUTTON_SIZE + JB_BUTTON_GAP / 2) < y_hitbox) { - y_index++; - } - } - - // set the corresponding state - int index = y_index * 4 + x_index; - if (0 <= index && index < 16) { - TOUCH_STATE[index] = true; - } - } - } - } - - /* - * to fix "IP ADDR CHANGE" errors on boot and in-game when using weird network setups such as a VPN - */ + // fixes "IP ADDR CHANGE" errors with unusual network setups (e.g. a VPN) static BOOL __stdcall network_addr_is_changed() { return 0; } - /* - * to fix lag spikes when game tries to ping "eamuse.konami.fun" every few minutes - */ + // fixes lag spikes from the periodic ping to "eamuse.konami.fun" static BOOL __stdcall network_get_network_check_info() { return 0; } - /* - * to fix network error on non DHCP interfaces - */ + // fixes network errors on non-DHCP interfaces static BOOL __cdecl network_get_dhcp_result() { return 1; } @@ -252,23 +60,7 @@ namespace games::jb { void JBGame::attach() { Game::attach(); - // enable touch - TOUCH_ENABLE = true; - - switch (TOUCH_ALGORITHM) { - case Legacy: - log_info("jubeat", "using 'legacy' touch targets"); - break; - case Improved: - log_info("jubeat", "using 'improved' touch targets"); - break; - case AcAccurate: - log_info("jubeat", "using 'ac accurate' touch targets"); - break; - default: - log_fatal("jubeat", "unknown touch algo detected in touch_update, this is a bug"); - break; - } + touch_attach(); // enable debug logging of gftools HMODULE gftools = libutils::try_module("gftools.dll"); @@ -288,7 +80,6 @@ namespace games::jb { void JBGame::detach() { Game::detach(); - // disable touch - TOUCH_ENABLE = false; + touch_detach(); } } diff --git a/src/spice2x/games/jb/jb.h b/src/spice2x/games/jb/jb.h index 08c69a8..7f882a4 100644 --- a/src/spice2x/games/jb/jb.h +++ b/src/spice2x/games/jb/jb.h @@ -1,20 +1,10 @@ #pragma once #include "games/game.h" +#include "games/jb/jb_touch.h" namespace games::jb { - enum JubeatTouchAlgorithm { - Legacy, - Improved, - AcAccurate - }; - - // touch stuff - extern JubeatTouchAlgorithm TOUCH_ALGORITHM; - extern bool TOUCH_STATE[16]; - void touch_update(); - class JBGame : public games::Game { public: JBGame(); diff --git a/src/spice2x/games/jb/jb_touch.cpp b/src/spice2x/games/jb/jb_touch.cpp new file mode 100644 index 0000000..990c4c3 --- /dev/null +++ b/src/spice2x/games/jb/jb_touch.cpp @@ -0,0 +1,443 @@ +#include "jb_touch.h" + +#include +#include +#include +#include +#include + +#include "avs/game.h" +#include "hooks/graphics/graphics.h" +#include "launcher/launcher.h" +#include "touch/touch.h" +#include "rawinput/touch.h" +#include "util/logging.h" +#include "util/utils.h" + +// touch layout: a 4x4 grid of 160px buttons separated by 37 / 38 / 37 px gaps +// (752px across). the first button's top-left is at (8, 602) in portrait and +// (6, 8) in landscape. +#define JB_BUTTON_SIZE 160 +#define JB_MAX_BUTTON_GAP 38 + +// improved and plus modes use this reach around each button. must be >= the +// diagonal half of the widest gap (~27px) so the grid centre still reaches a button. +#define JB_TOUCH_RADIUS 38 + +namespace games::jb { + + static_assert(std::atomic_bool::is_always_lock_free); + static_assert(std::atomic_uint16_t::is_always_lock_free); + static_assert(std::atomic::is_always_lock_free); + static constexpr int JB_MAX_GAP_DISTANCE = (JB_MAX_BUTTON_GAP + 1) / 2; + static_assert(JB_TOUCH_RADIUS * JB_TOUCH_RADIUS >= + 2 * JB_MAX_GAP_DISTANCE * JB_MAX_GAP_DISTANCE); + + // touch state + JubeatTouchAlgorithm TOUCH_ALGORITHM = Improved; + JubeatTouchDebugMode TOUCH_DEBUG_OVERLAY = JbTouchDebugAuto; + static std::atomic_bool TOUCH_ENABLE = false; + static bool TOUCH_ATTACHED = false; + static bool IS_PORTRAIT = true; + static std::atomic_uint16_t TOUCH_STATE = 0; + + // fixed-size contact view used by the debug overlay + static const size_t JB_MAX_TOUCH_POINTS = 16; + static constexpr LONG JB_INVALID_TOUCH_COORD = std::numeric_limits::max(); + static constexpr POINT JB_INVALID_TOUCH_POINT { + JB_INVALID_TOUCH_COORD, + JB_INVALID_TOUCH_COORD + }; + static std::atomic TOUCH_POINTS[JB_MAX_TOUCH_POINTS] {}; + + static void clear_touch_points() { + for (auto &point : TOUCH_POINTS) { + point.store(JB_INVALID_TOUCH_POINT, std::memory_order_relaxed); + } + } + + static void publish_touch_points(const std::vector &touch_points) { + size_t count = touch_points.size(); + if (count > JB_MAX_TOUCH_POINTS) { + count = JB_MAX_TOUCH_POINTS; + } + + for (size_t i = count; i < JB_MAX_TOUCH_POINTS; i++) { + TOUCH_POINTS[i].store(JB_INVALID_TOUCH_POINT, std::memory_order_relaxed); + } + for (size_t i = 0; i < count; i++) { + POINT point { touch_points[i].x, touch_points[i].y }; + TOUCH_POINTS[i].store(point, std::memory_order_relaxed); + } + } + + // --- touch geometry ------------------------------------------------------ + // gaps between the four buttons along one axis (the middle gap is 1px wider) + static const int JB_BUTTON_GAPS[3] = { 37, JB_MAX_BUTTON_GAP, 37 }; + + struct AxisGeometry { + int button[4]; // left/top edge of each button + }; + + // left/top edges of the four buttons along one axis, starting at `first` + static AxisGeometry axis_geometry(int first) { + AxisGeometry g {}; + g.button[0] = first; + for (int i = 1; i < 4; i++) { + g.button[i] = g.button[i - 1] + JB_BUTTON_SIZE + JB_BUTTON_GAPS[i - 1]; + } + return g; + } + + // button edges for the current orientation + static void touch_geometry(AxisGeometry &gx, AxisGeometry &gy) { + if (IS_PORTRAIT) { + gx = axis_geometry(8); + gy = axis_geometry(602); + } else { + gx = axis_geometry(6); + gy = axis_geometry(8); + } + } + + // distance from `p` to a button along one axis (0 when inside) + static int axis_distance(int p, int button) { + int end = button + JB_BUTTON_SIZE - 1; + if (p < button) { + return button - p; + } + if (p > end) { + return p - end; + } + return 0; + } + + // index (0..15) of the nearest button to (px, py) within `radius`, or -1 if none; + // shared by detection and the debug overlay so both agree which button a touch hits + static int nearest_button( + int px, int py, const AxisGeometry &gx, const AxisGeometry &gy, int radius) { + int best_index = -1; + int best_dist = 0; + for (int r = 0; r < 4; r++) { + for (int c = 0; c < 4; c++) { + int dx = axis_distance(px, gx.button[c]); + int dy = axis_distance(py, gy.button[r]); + int dist = dx * dx + dy * dy; + if (dist <= radius * radius && (best_index < 0 || dist < best_dist)) { + best_dist = dist; + best_index = r * 4 + c; + } + } + } + return best_index; + } + + // detection reach for the current algorithm (0 = register only inside a button) + static int touch_radius() { + return TOUCH_ALGORITHM == AcAccurate ? 0 : JB_TOUCH_RADIUS; + } + + // mark the buttons a touch at (px, py) hits: only the nearest within `radius`, or + // every button within `radius` when `multi` (edge/gap presses trigger several) + static void mark_buttons(uint16_t &state, int px, int py, + const AxisGeometry &gx, const AxisGeometry &gy, + int radius, bool multi) { + if (!multi) { + int index = nearest_button(px, py, gx, gy, radius); + if (index >= 0) { + state |= uint16_t(1) << index; + } + return; + } + for (int r = 0; r < 4; r++) { + for (int c = 0; c < 4; c++) { + int dx = axis_distance(px, gx.button[c]); + int dy = axis_distance(py, gy.button[r]); + if (dx * dx + dy * dy <= radius * radius) { + state |= uint16_t(1) << (r * 4 + c); + } + } + } + } + + std::bitset<16> touch_state() { + return std::bitset<16>(TOUCH_STATE.load(std::memory_order_acquire)); + } + + void touch_update() { + + if (!TOUCH_ENABLE.load(std::memory_order_acquire)) { + return; + } + + // one-time touch window attach + if (!TOUCH_ATTACHED) { + + // find the game window: prefer the foreground window, else search by + // title (the model name prefixes the window title in every version) + HWND wnd = GetForegroundWindow(); + if (!string_begins_with(GetActiveWindowTitle(), avs::game::MODEL)) { + wnd = FindWindowBeginsWith(avs::game::MODEL); + } + if (!wnd) { + log_warning("jubeat", "could not find window handle for touch"); + TOUCH_ENABLE.store(false, std::memory_order_release); + TOUCH_STATE.store(0, std::memory_order_release); + return; + } + + // only the L44 model runs in portrait; set this before starting the + // touch-window thread so the renderer only observes the final value + IS_PORTRAIT = avs::game::is_model("L44"); + + log_info("jubeat", "using window handle for touch: {}", fmt::ptr(wnd)); + touch_create_wnd(wnd, true); + + // let the rawinput stack correct the aspect ratio + ::rawinput::touch::ASPECT_COMPENSATION_GAME = true; + + if (GRAPHICS_SHOW_CURSOR) { + ShowCursor(TRUE); + } + + TOUCH_ATTACHED = true; + } + + // calculate the next state locally and publish it after processing every touch + uint16_t next_state = 0; + std::vector touch_points; + touch_get_points(touch_points); + publish_touch_points(touch_points); + if (TOUCH_ALGORITHM == Legacy) { + + // legacy: evenly divide the play area into a 4x4 grid + auto offset = IS_PORTRAIT ? 580 : 0; + for (auto &tp : touch_points) { + int x = tp.x * 4 / 768; + int y = (tp.y - offset) * 4 / (1360 - 580); + int index = y * 4 + x; + if (index >= 0 && index < 16) { + next_state |= uint16_t(1) << index; + } + } + } else { + + // accurate registers only a touch inside a button; improved snaps each touch + // to the single nearest button within reach (so a gap or centre touch still + // triggers exactly one button); plus marks every button within the same reach, + // so an edge or gap touch can trigger several at once (like the mobile game) + AxisGeometry gx, gy; + touch_geometry(gx, gy); + int radius = touch_radius(); + bool multi = (TOUCH_ALGORITHM == Plus); + for (auto &tp : touch_points) { + mark_buttons(next_state, tp.x, tp.y, gx, gy, radius, multi); + } + } + + TOUCH_STATE.store(next_state, std::memory_order_release); + } + + // true when any supported touch handler detects a touchscreen (checked once) + static bool touchscreen_detected() { + static const bool detected = is_touch_available("jubeat touch debug overlay"); + return detected; + } + + // auto draws the boxes only when a touch screen is present + static bool debug_show_boxes() { + switch (TOUCH_DEBUG_OVERLAY) { + case JbTouchDebugBox: + case JbTouchDebugAll: + return true; + case JbTouchDebugAuto: + return touchscreen_detected(); + default: + return false; + } + } + + static bool debug_show_taps() { + return TOUCH_DEBUG_OVERLAY == JbTouchDebugAll; + } + + bool touch_debug_overlay_enabled() { + return debug_show_boxes() || debug_show_taps(); + } + + // the 16 boundary rects: legacy divides the area evenly, others use button squares + static void debug_cells( + bool legacy, const AxisGeometry &gx, const AxisGeometry &gy, RECT cells[16]) { + if (legacy) { + int offset = IS_PORTRAIT ? 580 : 0; + int x_edges[5]; + int y_edges[5]; + for (int i = 0; i <= 4; i++) { + x_edges[i] = i * 768 / 4; + y_edges[i] = offset + i * (1360 - 580) / 4; + } + for (int r = 0; r < 4; r++) { + for (int c = 0; c < 4; c++) { + cells[r * 4 + c] = { x_edges[c], y_edges[r], x_edges[c + 1], y_edges[r + 1] }; + } + } + } else { + for (int r = 0; r < 4; r++) { + for (int c = 0; c < 4; c++) { + cells[r * 4 + c] = { + gx.button[c], gy.button[r], + gx.button[c] + JB_BUTTON_SIZE, gy.button[r] + JB_BUTTON_SIZE + }; + } + } + } + } + + // hollow box outlines: grey idle, thick green when pressed (PS_INSIDEFRAME stays inside) + static void draw_debug_boxes( + HDC hdc, bool legacy, const AxisGeometry &gx, const AxisGeometry &gy) { + RECT cells[16]; + debug_cells(legacy, gx, gy, cells); + + HPEN pen_idle = CreatePen(PS_SOLID, 1, RGB(160, 160, 160)); + HPEN pen_active = CreatePen(PS_INSIDEFRAME, 4, RGB(0, 200, 0)); + auto state = touch_state(); + for (int i = 0; i < 16; i++) { + HGDIOBJ old_pen = SelectObject(hdc, state[i] ? pen_active : pen_idle); + Rectangle(hdc, cells[i].left, cells[i].top, cells[i].right, cells[i].bottom); + SelectObject(hdc, old_pen); + } + DeleteObject(pen_idle); + DeleteObject(pen_active); + } + + // whether a touch at (px, py) presses at least one button: legacy has no gaps, plus + // reaches across gaps to nearby buttons, and the others only fire inside a button square + static bool touch_presses_button(int px, int py, const AxisGeometry &gx, const AxisGeometry &gy, + bool legacy, int radius) { + if (legacy) { + return true; + } + int reach = (TOUCH_ALGORITHM == Plus) ? radius : 0; + return nearest_button(px, py, gx, gy, reach) >= 0; + } + + // 90-degree arc on the circle rim facing the centre of button `index` + static void draw_tap_arc(HDC hdc, int px, int py, int index, + const AxisGeometry &gx, const AxisGeometry &gy, int arc_radius) { + int c = index % 4; + int r = index / 4; + double mid = std::atan2((gy.button[r] + JB_BUTTON_SIZE / 2) - py, + (gx.button[c] + JB_BUTTON_SIZE / 2) - px); + const double quarter = 3.14159265358979323846 / 2.0; + const int segments = 16; + POINT arc[segments + 1]; + for (int i = 0; i <= segments; i++) { + double a = mid - quarter / 2.0 + quarter * i / segments; + arc[i].x = px + static_cast(std::lround(arc_radius * std::cos(a))); + arc[i].y = py + static_cast(std::lround(arc_radius * std::sin(a))); + } + Polyline(hdc, arc, segments + 1); + } + + // circle at each touch: white when it presses a button, grey otherwise; in single-button + // modes a gap touch also gets a white arc facing the button it snapped to (skipped in + // plus, where a touch can trigger several buttons at once) + static void draw_debug_taps( + HDC hdc, bool legacy, const AxisGeometry &gx, const AxisGeometry &gy) { + // PS_INSIDEFRAME keeps the stroke inside the circle radius + const int stroke = 4; + HPEN pen_white = CreatePen(PS_INSIDEFRAME, stroke, RGB(255, 255, 255)); + HPEN pen_gray = CreatePen(PS_INSIDEFRAME, stroke, RGB(128, 128, 128)); + HGDIOBJ old_pen = SelectObject(hdc, pen_white); + + // accurate has no reach, so fall back to a visible marker size when drawing the circle + int radius = touch_radius(); + int draw_radius = radius > 0 ? radius : JB_TOUCH_RADIUS; + + for (auto &touch_point : TOUCH_POINTS) { + POINT point = touch_point.load(std::memory_order_relaxed); + if (point.x == JB_INVALID_TOUCH_COORD) { + continue; + } + bool pressed = touch_presses_button(point.x, point.y, gx, gy, legacy, radius); + + SelectObject(hdc, pressed ? pen_white : pen_gray); + Ellipse(hdc, point.x - draw_radius, point.y - draw_radius, + point.x + draw_radius, point.y + draw_radius); + + // the arc would point at a single snapped-to button; plus can trigger several at + // once, so skip it there + if (pressed || TOUCH_ALGORITHM == Plus) { + continue; + } + int index = nearest_button(point.x, point.y, gx, gy, radius); + if (index >= 0) { + SelectObject(hdc, pen_white); + draw_tap_arc(hdc, point.x, point.y, index, gx, gy, draw_radius - stroke / 2); + } + } + + SelectObject(hdc, old_pen); + DeleteObject(pen_white); + DeleteObject(pen_gray); + } + + void touch_draw_debug_overlay(HDC hdc) { + + // only draw while touch is active + if (!TOUCH_ENABLE.load(std::memory_order_acquire)) { + return; + } + + bool show_boxes = debug_show_boxes(); + bool show_taps = debug_show_taps(); + if (!show_boxes && !show_taps) { + return; + } + + // legacy divides the field evenly; the other algorithms use button squares + bool legacy = (TOUCH_ALGORITHM == Legacy); + AxisGeometry gx {}, gy {}; + if (!legacy) { + touch_geometry(gx, gy); + } + + HGDIOBJ old_brush = SelectObject(hdc, GetStockObject(NULL_BRUSH)); + if (show_boxes) { + draw_debug_boxes(hdc, legacy, gx, gy); + } + if (show_taps) { + draw_debug_taps(hdc, legacy, gx, gy); + } + SelectObject(hdc, old_brush); + } + + void touch_attach() { + clear_touch_points(); + TOUCH_ENABLE.store(true, std::memory_order_release); + + switch (TOUCH_ALGORITHM) { + case Legacy: + log_info("jubeat", "using 'legacy' touch targets"); + break; + case Improved: + log_info("jubeat", "using 'improved' touch targets"); + break; + case Plus: + log_info("jubeat", "using 'plus' touch targets"); + break; + case AcAccurate: + log_info("jubeat", "using 'ac accurate' touch targets"); + break; + default: + log_fatal("jubeat", "unknown touch algo, this is a bug"); + break; + } + } + + void touch_detach() { + TOUCH_ENABLE.store(false, std::memory_order_release); + TOUCH_STATE.store(0, std::memory_order_release); + } +} diff --git a/src/spice2x/games/jb/jb_touch.h b/src/spice2x/games/jb/jb_touch.h new file mode 100644 index 0000000..dcf0461 --- /dev/null +++ b/src/spice2x/games/jb/jb_touch.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +namespace games::jb { + + // touch detection algorithm + enum JubeatTouchAlgorithm { + Legacy, + Improved, + Plus, + AcAccurate + }; + + // debug touch overlay mode + enum JubeatTouchDebugMode { + JbTouchDebugAuto, // boundary boxes when a touch screen is detected, else nothing + JbTouchDebugNone, // draw nothing + JbTouchDebugBox, // boundary boxes + JbTouchDebugAll, // boundary boxes and touch circles + }; + + // selected algorithm and debug mode (set from the launcher options) + extern JubeatTouchAlgorithm TOUCH_ALGORITHM; + extern JubeatTouchDebugMode TOUCH_DEBUG_OVERLAY; + + // atomically published panel state, read by the I/O layer + std::bitset<16> touch_state(); + + // read the current touch points and refresh the panel state + void touch_update(); + + // enable/disable touch handling on game attach/detach + void touch_attach(); + void touch_detach(); + + // whether the debug overlay should currently draw anything + bool touch_debug_overlay_enabled(); + + // draw the debug touch overlay (boundary boxes and/or touch circles per the + // selected mode) onto the spice touch overlay window's device context + void touch_draw_debug_overlay(HDC hdc); +} diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index 380474b..b833e36 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -1314,10 +1314,24 @@ int main_implementation(int argc, char *argv[]) { games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::AcAccurate; } else if (options[launcher::Options::JubeatTouchAlgo].value_text() == "legacy") { games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::Legacy; + } else if (options[launcher::Options::JubeatTouchAlgo].value_text() == "plus") { + games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::Plus; } else { games::jb::TOUCH_ALGORITHM = games::jb::JubeatTouchAlgorithm::Improved; } } + if (options[launcher::Options::JubeatTouchDebug].is_active()) { + auto mode = options[launcher::Options::JubeatTouchDebug].value_text(); + if (mode == "none") { + games::jb::TOUCH_DEBUG_OVERLAY = games::jb::JubeatTouchDebugMode::JbTouchDebugNone; + } else if (mode == "box") { + games::jb::TOUCH_DEBUG_OVERLAY = games::jb::JubeatTouchDebugMode::JbTouchDebugBox; + } else if (mode == "all") { + games::jb::TOUCH_DEBUG_OVERLAY = games::jb::JubeatTouchDebugMode::JbTouchDebugAll; + } else { + games::jb::TOUCH_DEBUG_OVERLAY = games::jb::JubeatTouchDebugMode::JbTouchDebugAuto; + } + } // reflec beat touch emulation if (options[launcher::Options::spice2x_RBTouchScale].is_active()) { diff --git a/src/spice2x/launcher/options.cpp b/src/spice2x/launcher/options.cpp index 823cf30..e4e2818 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -2691,6 +2691,7 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "For touch screen players: choose the touch algorithm to use.\n\n" "legacy - evenly divide the grid into 16 squares; old spicetools behavior, slightly inaccurate in gaps\n\n" "improved (default) - squares register as-is, gaps will trigger the closest square\n\n" + "plus - like improved, but gaps can trigger multiple buttons (like the mobile game)\n\n" "accurate - only touches within squares will trigger; gaps do nothing (for AC size touch screens)", .type = OptionType::Enum, .game_name = "Jubeat", @@ -2698,10 +2699,32 @@ static const std::vector OPTION_DEFINITIONS = { .elements = { {"legacy", ""}, {"improved", ""}, + {"plus", ""}, {"accurate", ""}, }, .quick_setting_category = "Game", }, + { + // JubeatTouchDebug + .title = "JB Touch Debug Overlay", + .name = "jubeattouchdebug", + .desc = "For touch screen players: draw a debug overlay on the main display. " + "Requires the Spice Overlay to be enabled.\n\n" + "auto (default) - show boundary boxes when a touch screen is detected; otherwise, none\n\n" + "none - draw nothing\n\n" + "box - show the 4x4 touch boundary boxes\n\n" + "all - show both the boxes and the touch circles", + .type = OptionType::Enum, + .game_name = "Jubeat", + .category = "Game Options", + .elements = { + {"auto", ""}, + {"none", ""}, + {"box", ""}, + {"all", ""}, + }, + .quick_setting_category = "Game", + }, { // spice2x_RBTouchScale .title = "RB Touch Emulation Scale", diff --git a/src/spice2x/launcher/options.h b/src/spice2x/launcher/options.h index 7e913eb..d9e6b2d 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -263,6 +263,7 @@ namespace launcher { IIDXWindowedSubscreenAlwaysOnTop, spice2x_JubeatLegacyTouch, JubeatTouchAlgo, + JubeatTouchDebug, spice2x_RBTouchScale, RBTouchSize, RBTouchPollRate, diff --git a/src/spice2x/misc/device.cpp b/src/spice2x/misc/device.cpp index 3827ddc..54ae9b7 100644 --- a/src/spice2x/misc/device.cpp +++ b/src/spice2x/misc/device.cpp @@ -366,6 +366,7 @@ static void __cdecl device_update() { // update touch games::jb::touch_update(); + auto touched = games::jb::touch_state(); // get buttons auto &buttons = games::jb::get_buttons(); @@ -382,52 +383,52 @@ static void __cdecl device_update() { if (Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::CoinMech))) { DEVICE_INPUT_STATE |= 1 << 29; } - if (games::jb::TOUCH_STATE[3] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button1))) { + if (touched[3] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button1))) { DEVICE_INPUT_STATE |= 1 << 13; } - if (games::jb::TOUCH_STATE[7] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button2))) { + if (touched[7] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button2))) { DEVICE_INPUT_STATE |= 1 << 9; } - if (games::jb::TOUCH_STATE[11] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button3))) { + if (touched[11] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button3))) { DEVICE_INPUT_STATE |= 1 << 21; } - if (games::jb::TOUCH_STATE[15] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button4))) { + if (touched[15] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button4))) { DEVICE_INPUT_STATE |= 1 << 17; } - if (games::jb::TOUCH_STATE[2] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button5))) { + if (touched[2] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button5))) { DEVICE_INPUT_STATE |= 1 << 14; } - if (games::jb::TOUCH_STATE[6] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button6))) { + if (touched[6] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button6))) { DEVICE_INPUT_STATE |= 1 << 10; } - if (games::jb::TOUCH_STATE[10] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button7))) { + if (touched[10] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button7))) { DEVICE_INPUT_STATE |= 1 << 22; } - if (games::jb::TOUCH_STATE[14] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button8))) { + if (touched[14] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button8))) { DEVICE_INPUT_STATE |= 1 << 18; } - if (games::jb::TOUCH_STATE[1] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button9))) { + if (touched[1] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button9))) { DEVICE_INPUT_STATE |= 1 << 15; } - if (games::jb::TOUCH_STATE[5] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button10))) { + if (touched[5] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button10))) { DEVICE_INPUT_STATE |= 1 << 11; } - if (games::jb::TOUCH_STATE[9] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button11))) { + if (touched[9] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button11))) { DEVICE_INPUT_STATE |= 1 << 23; } - if (games::jb::TOUCH_STATE[13] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button12))) { + if (touched[13] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button12))) { DEVICE_INPUT_STATE |= 1 << 19; } - if (games::jb::TOUCH_STATE[0] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button13))) { + if (touched[0] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button13))) { DEVICE_INPUT_STATE |= 1 << 24; } - if (games::jb::TOUCH_STATE[4] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button14))) { + if (touched[4] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button14))) { DEVICE_INPUT_STATE |= 1 << 12; } - if (games::jb::TOUCH_STATE[8] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button15))) { + if (touched[8] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button15))) { DEVICE_INPUT_STATE |= 1 << 26; } - if (games::jb::TOUCH_STATE[12] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button16))) { + if (touched[12] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button16))) { DEVICE_INPUT_STATE |= 1 << 20; } @@ -439,6 +440,7 @@ static void __cdecl device_update() { // update touch games::jb::touch_update(); + auto touched = games::jb::touch_state(); // get buttons auto &buttons = games::jb::get_buttons(); @@ -455,52 +457,52 @@ static void __cdecl device_update() { if (Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::CoinMech))) { DEVICE_INPUT_STATE |= 1 << 24; } - if (games::jb::TOUCH_STATE[0] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button1))) { + if (touched[0] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button1))) { DEVICE_INPUT_STATE |= 1 << 5; } - if (games::jb::TOUCH_STATE[1] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button2))) { + if (touched[1] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button2))) { DEVICE_INPUT_STATE |= 1 << 1; } - if (games::jb::TOUCH_STATE[2] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button3))) { + if (touched[2] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button3))) { DEVICE_INPUT_STATE |= 1 << 13; } - if (games::jb::TOUCH_STATE[3] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button4))) { + if (touched[3] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button4))) { DEVICE_INPUT_STATE |= 1 << 9; } - if (games::jb::TOUCH_STATE[4] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button5))) { + if (touched[4] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button5))) { DEVICE_INPUT_STATE |= 1 << 6; } - if (games::jb::TOUCH_STATE[5] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button6))) { + if (touched[5] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button6))) { DEVICE_INPUT_STATE |= 1 << 2; } - if (games::jb::TOUCH_STATE[6] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button7))) { + if (touched[6] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button7))) { DEVICE_INPUT_STATE |= 1 << 14; } - if (games::jb::TOUCH_STATE[7] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button8))) { + if (touched[7] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button8))) { DEVICE_INPUT_STATE |= 1 << 10; } - if (games::jb::TOUCH_STATE[8] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button9))) { + if (touched[8] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button9))) { DEVICE_INPUT_STATE |= 1 << 7; } - if (games::jb::TOUCH_STATE[9] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button10))) { + if (touched[9] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button10))) { DEVICE_INPUT_STATE |= 1 << 3; } - if (games::jb::TOUCH_STATE[10] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button11))) { + if (touched[10] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button11))) { DEVICE_INPUT_STATE |= 1 << 15; } - if (games::jb::TOUCH_STATE[11] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button12))) { + if (touched[11] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button12))) { DEVICE_INPUT_STATE |= 1 << 11; } - if (games::jb::TOUCH_STATE[12] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button13))) { + if (touched[12] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button13))) { DEVICE_INPUT_STATE |= 1 << 16; } - if (games::jb::TOUCH_STATE[13] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button14))) { + if (touched[13] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button14))) { DEVICE_INPUT_STATE |= 1 << 4; } - if (games::jb::TOUCH_STATE[14] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button15))) { + if (touched[14] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button15))) { DEVICE_INPUT_STATE |= 1 << 20; } - if (games::jb::TOUCH_STATE[15] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button16))) { + if (touched[15] || Buttons::getState(RI_MGR, buttons.at(games::jb::Buttons::Button16))) { DEVICE_INPUT_STATE |= 1 << 12; } } diff --git a/src/spice2x/touch/touch.cpp b/src/spice2x/touch/touch.cpp index 890bd65..f9853d4 100644 --- a/src/spice2x/touch/touch.cpp +++ b/src/spice2x/touch/touch.cpp @@ -11,6 +11,7 @@ #include "avs/game.h" #include "external/imgui/imgui.h" +#include "games/jb/jb.h" #include "hooks/graphics/graphics.h" #include "misc/eamuse.h" #include "overlay/overlay.h" @@ -248,16 +249,15 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP if (!SPICETOUCH_REGISTERED_TOUCH) { SPICETOUCH_REGISTERED_TOUCH = true; - // check if touch is available + // register the handler when a touch screen is present if (is_touch_available("SpiceTouchWndProc")) { - - // notify the handler of our window TOUCH_HANDLER->window_register(hWnd); + } - // enable card unless the feature is disabled - if (!SPICETOUCH_CARD_DISABLE) { - SPICETOUCH_CARD_ENABLED = true; - } + // enable the card button whenever the option is set, even without a touch + // screen (mouse clicks are handled as touch input and can trigger it) + if (!SPICETOUCH_CARD_DISABLE) { + SPICETOUCH_CARD_ENABLED = true; } } @@ -340,52 +340,50 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP SWP_NOZORDER | SWP_NOREDRAW | SWP_NOREPOSITION | SWP_NOACTIVATE); } - // draw overlay + // render the software overlay to a bitmap BEFORE BeginPaint: the imgui + // render is expensive, and running it between the background erase and the + // blit leaves the window transparent long enough to flicker + HBITMAP overlay_bitmap = nullptr; + int overlay_width = 0, overlay_height = 0; if (overlay_enabled) { - - // update and render overlay::OVERLAY->update(); overlay::OVERLAY->new_frame(); overlay::OVERLAY->render(); - // get pixel data - int width, height; - uint32_t *pixel_data = overlay::OVERLAY.get()->sw_get_pixel_data(&width, &height); - if (width > 0 && height > 0) { - - // create bitmap - HBITMAP bitmap = CreateBitmap(width, height, 1, 8 * sizeof(uint32_t), pixel_data); - - // prepare paint - PAINTSTRUCT paint {}; - HDC hdc = BeginPaint(hWnd, &paint); - HDC hdcMem = CreateCompatibleDC(hdc); - SetBkMode(hdc, TRANSPARENT); - - /* - * draw bitmap - * - this currently sets the background to black because of SRCCOPY - * - SRCPAINT will blend but colors are wrong - * - once this is figured out we could also try hooking WM_PAINT and - * draw directly to the game window - */ - SelectObject(hdcMem, bitmap); - BitBlt(hdc, 0, 0, width, height, hdcMem, 0, 0, SRCCOPY); - - // clean up - DeleteObject(bitmap); - DeleteDC(hdcMem); - EndPaint(hWnd, &paint); + uint32_t *pixel_data = overlay::OVERLAY.get()->sw_get_pixel_data(&overlay_width, &overlay_height); + if (overlay_width > 0 && overlay_height > 0) { + overlay_bitmap = CreateBitmap(overlay_width, overlay_height, 1, 8 * sizeof(uint32_t), pixel_data); } } + bool overlay_active = overlay_enabled && overlay::OVERLAY->get_active(); - // draw card input - if (SPICETOUCH_CARD_ENABLED && (SPICETOUCH_FONT != nullptr)) { + // draw everything in a single BeginPaint/EndPaint (a WM_PAINT has one update + // region, so a second BeginPaint would get an empty region), keeping only + // fast blits between the background erase and EndPaint + PAINTSTRUCT paint {}; + HDC hdc = BeginPaint(hWnd, &paint); + SetBkMode(hdc, TRANSPARENT); - // prepare paint - PAINTSTRUCT paint {}; - HDC hdc = BeginPaint(hWnd, &paint); - SetBkMode(hdc, TRANSPARENT); + // blit the overlay bitmap + if (overlay_bitmap) { + + /* + * draw bitmap + * - this currently sets the background to black because of SRCCOPY + * - SRCPAINT will blend but colors are wrong + * - once this is figured out we could also try hooking WM_PAINT and + * draw directly to the game window + */ + HDC hdcMem = CreateCompatibleDC(hdc); + SelectObject(hdcMem, overlay_bitmap); + BitBlt(hdc, 0, 0, overlay_width, overlay_height, hdcMem, 0, 0, SRCCOPY); + DeleteDC(hdcMem); + DeleteObject(overlay_bitmap); + } + + // draw the insert-card button below the jubeat debug overlay; it is + // hidden while the overlay is active + if (SPICETOUCH_CARD_ENABLED && SPICETOUCH_FONT != nullptr && !overlay_active) { // create brushes HBRUSH brushBorder = CreateSolidBrush(RGB(0, 196, 0)); @@ -443,14 +441,15 @@ static LRESULT CALLBACK SpiceTouchWndProc(HWND hWnd, UINT msg, WPARAM wParam, LP // delete objects DeleteObject(brushFill); DeleteObject(brushBorder); - - // end paint - EndPaint(hWnd, &paint); - return 0; } - // call default window procedure - return DefWindowProc(hWnd, msg, wParam, lParam); + // draw the jubeat debug overlay on top (hidden while the overlay is active) + if (overlay_enabled && !overlay_active && games::jb::touch_debug_overlay_enabled()) { + games::jb::touch_draw_debug_overlay(hdc); + } + + EndPaint(hWnd, &paint); + return 0; } case WM_CREATE: {