From 4009bb8a840fb5ceabeef4ba9610a1eeb2804378 Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:04:43 -0700 Subject: [PATCH] sc: bone eater preliminary support (#895) ## Link to GitHub Issue or related Pull Request, if one exists Fixes #114 ## Description of change Initial support for Silent Scope: Bone Eater, enough to be playable; doesn't include QoS work needed to make this a smoother experience. * Game detection * Fix DX11 overlay to work with non-Unity games * Implements sub monitor i/o (only really needed to get past I/O check) * Try to make the game reasonably playable with a mouse * Ignore the game's attempt to move windows, position them in a reasonable place * Fix language hook ## Testing --- src/spice2x/CMakeLists.txt | 1 + src/spice2x/acio/bmpu/bmpu.cpp | 38 ++++- src/spice2x/games/silentscope/projector.cpp | 150 ++++++++++++++++++ src/spice2x/games/silentscope/projector.h | 30 ++++ src/spice2x/games/silentscope/silentscope.cpp | 6 +- .../graphics/backends/d3d11/d3d11_backend.cpp | 10 +- src/spice2x/hooks/graphics/graphics.cpp | 47 ++++++ src/spice2x/hooks/graphics/graphics.h | 1 + src/spice2x/hooks/lang.cpp | 9 +- src/spice2x/misc/eamuse.cpp | 2 + 10 files changed, 278 insertions(+), 16 deletions(-) create mode 100644 src/spice2x/games/silentscope/projector.cpp create mode 100644 src/spice2x/games/silentscope/projector.h diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index 0faaa2b..3ee6c02 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -531,6 +531,7 @@ set(SOURCE_FILES ${SOURCE_FILES} games/otoca/p4io.cpp games/silentscope/silentscope.cpp games/silentscope/io.cpp + games/silentscope/projector.cpp games/pcm/pcm.cpp games/pcm/io.cpp games/onpara/onpara.cpp diff --git a/src/spice2x/acio/bmpu/bmpu.cpp b/src/spice2x/acio/bmpu/bmpu.cpp index e8ccb5f..6e183cf 100644 --- a/src/spice2x/acio/bmpu/bmpu.cpp +++ b/src/spice2x/acio/bmpu/bmpu.cpp @@ -1,5 +1,7 @@ #include "bmpu.h" +#include + #include "acio/icca/icca.h" #include "avs/game.h" #include "cfg/api.h" @@ -9,6 +11,7 @@ #include "games/ftt/io.h" #include "games/museca/io.h" #include "games/silentscope/io.h" +#include "hooks/graphics/graphics.h" #include "launcher/launcher.h" #include "misc/eamuse.h" @@ -530,15 +533,38 @@ static bool __cdecl ac_io_bmpu_update_control_status_buffer() { STATUS_BUFFER[4] |= 0x20; } - // joy stick raw input + // gun position, sent big endian; the game keeps the top 12 bits of each pair auto &analogs = games::silentscope::get_analogs(); + auto &gun_x = analogs.at(games::silentscope::Analogs::GUN_X); + auto &gun_y = analogs.at(games::silentscope::Analogs::GUN_Y); unsigned short joy_x = 0x7FFF; unsigned short joy_y = 0x7FFF; - if (analogs.at(games::silentscope::Analogs::GUN_X).isSet()) { - joy_x = (unsigned short) (Analogs::getState(RI_MGR, analogs.at(games::silentscope::Analogs::GUN_X)) * USHRT_MAX); - } - if (analogs.at(games::silentscope::Analogs::GUN_Y).isSet()) { - joy_y = (unsigned short) (Analogs::getState(RI_MGR, analogs.at(games::silentscope::Analogs::GUN_Y)) * USHRT_MAX); + + if (gun_x.isSet() || gun_y.isSet()) { + if (gun_x.isSet()) { + joy_x = (unsigned short) (Analogs::getState(RI_MGR, gun_x) * USHRT_MAX); + } + if (gun_y.isSet()) { + joy_y = (unsigned short) (Analogs::getState(RI_MGR, gun_y) * USHRT_MAX); + } + } else { + + // the gun reports where it is aimed, which a relative pointer cannot express - the + // calibration screen asks for the screen centre and two corners, so read the cursor + POINT cursor {}; + RECT client {}; + POINT origin {}; + if (NDD_MAIN_WINDOW != nullptr && + GetCursorPos(&cursor) && + GetClientRect(NDD_MAIN_WINDOW, &client) && + ClientToScreen(NDD_MAIN_WINDOW, &origin) && + client.right > 1 && client.bottom > 1) + { + const LONG x = std::clamp(cursor.x - origin.x, 0L, client.right - 1); + const LONG y = std::clamp(cursor.y - origin.y, 0L, client.bottom - 1); + joy_x = (unsigned short) (x * USHRT_MAX / (client.right - 1)); + joy_y = (unsigned short) (y * USHRT_MAX / (client.bottom - 1)); + } } // invert X axis diff --git a/src/spice2x/games/silentscope/projector.cpp b/src/spice2x/games/silentscope/projector.cpp new file mode 100644 index 0000000..8a4e636 --- /dev/null +++ b/src/spice2x/games/silentscope/projector.cpp @@ -0,0 +1,150 @@ +#include "projector.h" + +#include +#include +#include + +#include "util/logging.h" + +namespace { + + // request/response layout: identifier, command, two zero bytes, payload length, + // the payload itself and a checksum over everything before it + constexpr size_t HEADER_SIZE = 5; + constexpr uint8_t RESPONSE_ID = 0x23; + + constexpr uint8_t COMMAND_COMMON_DATA = 0x8A; + constexpr uint8_t COMMAND_TEMPERATURE = 0x99; + constexpr uint8_t COMMAND_LAMP_CURRENT = 0x9B; + + // shown in the test menu as PROJTIM, PROJHEATIN and PROJHEAT + constexpr uint32_t LAMP_SECONDS = 0; + constexpr uint32_t TEMPERATURE_INTAKE = 25; + constexpr uint32_t TEMPERATURE_EXHAUST = 35; + + // anything below 1000 is treated as a lamp anomaly and raises PROJERROR unless the exact + // value was already recorded in /projecter/current on an earlier boot + constexpr uint32_t LAMP_CURRENT = 1000; + + void put32(uint8_t *dest, uint32_t value) { + dest[0] = (uint8_t) (value & 0xFF); + dest[1] = (uint8_t) ((value >> 8) & 0xFF); + dest[2] = (uint8_t) ((value >> 16) & 0xFF); + dest[3] = (uint8_t) ((value >> 24) & 0xFF); + } +} + +bool games::silentscope::ProjectorHandle::open(LPCWSTR lpFileName) { + if (wcscmp(lpFileName, L"COM2") != 0 && wcscmp(lpFileName, L"\\\\.\\COM2") != 0) { + return false; + } + + log_info("silentscope", "Opened COM2 (projector)"); + + return true; +} + +int games::silentscope::ProjectorHandle::read(LPVOID lpBuffer, DWORD nNumberOfBytesToRead) { + std::lock_guard lock(this->mutex); + + auto buffer = reinterpret_cast(lpBuffer); + + DWORD bytes_read = 0; + while (bytes_read < nNumberOfBytesToRead && !this->response.empty()) { + buffer[bytes_read++] = this->response.front(); + this->response.pop_front(); + } + + return (int) bytes_read; +} + +int games::silentscope::ProjectorHandle::write(LPCVOID lpBuffer, DWORD nNumberOfBytesToWrite) { + std::lock_guard lock(this->mutex); + + auto buffer = reinterpret_cast(lpBuffer); + this->request.insert(this->request.end(), buffer, buffer + nNumberOfBytesToWrite); + + while (this->request.size() >= HEADER_SIZE) { + const size_t packet_size = HEADER_SIZE + this->request[4] + 1; + if (this->request.size() < packet_size) { + break; + } + + this->process_request(this->request.data()); + this->request.erase(this->request.begin(), this->request.begin() + packet_size); + } + + return (int) nNumberOfBytesToWrite; +} + +size_t games::silentscope::ProjectorHandle::bytes_available() { + std::lock_guard lock(this->mutex); + + return this->response.size(); +} + +bool games::silentscope::ProjectorHandle::close() { + std::lock_guard lock(this->mutex); + + this->request.clear(); + this->response.clear(); + + log_info("silentscope", "Closed COM2 (projector)"); + + return true; +} + +void games::silentscope::ProjectorHandle::process_request(const uint8_t *packet) { + const uint8_t command = packet[1]; + const uint8_t length = packet[4]; + const uint8_t *data = packet + HEADER_SIZE; + + switch (command) { + case COMMAND_COMMON_DATA: { + + // the game only looks at the lamp usage time near the end of the block + std::vector payload(98, 0); + put32(&payload[94], LAMP_SECONDS); + this->reply(command, payload); + break; + } + case COMMAND_TEMPERATURE: { + + // the requested sensor is echoed back along with its reading + const uint8_t sensor = length > 0 ? data[0] : 0; + std::vector payload(5, 0); + payload[0] = sensor; + put32(&payload[1], sensor == 0 ? TEMPERATURE_INTAKE : TEMPERATURE_EXHAUST); + this->reply(command, payload); + break; + } + case COMMAND_LAMP_CURRENT: { + + // the three byte item selector is echoed back along with the measurement + std::vector payload(7, 0); + memcpy(payload.data(), data, std::min(length, 3)); + put32(&payload[3], LAMP_CURRENT); + this->reply(command, payload); + break; + } + default: + log_misc("silentscope", "unknown projector command {:#04x}", command); + this->reply(command, {}); + break; + } +} + +void games::silentscope::ProjectorHandle::reply(uint8_t command, const std::vector &data) { + std::vector packet { + RESPONSE_ID, command, 0x00, 0x00, (uint8_t) data.size() + }; + packet.insert(packet.end(), data.begin(), data.end()); + + uint8_t checksum = 0; + for (auto byte : packet) { + checksum += byte; + } + packet.push_back(checksum); + + this->response.insert(this->response.end(), packet.begin(), packet.end()); +} diff --git a/src/spice2x/games/silentscope/projector.h b/src/spice2x/games/silentscope/projector.h new file mode 100644 index 0000000..993ad06 --- /dev/null +++ b/src/spice2x/games/silentscope/projector.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include + +#include "hooks/devicehook.h" + +namespace games::silentscope { + + // The cabinet talks to its projector over COM2. Without an answer the game stops at + // I/O error 5-1560-0004 (IOCOM2, "the projector is not connected correctly"). + class ProjectorHandle : public CustomHandle { + public: + bool open(LPCWSTR lpFileName) override; + int read(LPVOID lpBuffer, DWORD nNumberOfBytesToRead) override; + int write(LPCVOID lpBuffer, DWORD nNumberOfBytesToWrite) override; + size_t bytes_available() override; + bool close() override; + + private: + std::mutex mutex; + std::vector request; + std::deque response; + + void process_request(const uint8_t *packet); + void reply(uint8_t command, const std::vector &data); + }; +} diff --git a/src/spice2x/games/silentscope/silentscope.cpp b/src/spice2x/games/silentscope/silentscope.cpp index 60a4c50..4586d8c 100644 --- a/src/spice2x/games/silentscope/silentscope.cpp +++ b/src/spice2x/games/silentscope/silentscope.cpp @@ -1,10 +1,11 @@ #include "silentscope.h" -#include "acioemu/handle.h" #include "cfg/configurator.h" #include "hooks/devicehook.h" #include "util/libutils.h" +#include "projector.h" + namespace games::silentscope { SilentScopeGame::SilentScopeGame() : Game("Silent Scope") { @@ -16,9 +17,8 @@ namespace games::silentscope { // load the game DLL so hooks apply libutils::try_library("gamendd.dll"); - // TODO(felix): implement stuff on this port devicehook_init(); - devicehook_add(new acioemu::ACIOHandle(L"COM2")); + devicehook_add(new ProjectorHandle()); } void SilentScopeGame::detach() { diff --git a/src/spice2x/hooks/graphics/backends/d3d11/d3d11_backend.cpp b/src/spice2x/hooks/graphics/backends/d3d11/d3d11_backend.cpp index f5d0c8f..b698fd4 100644 --- a/src/spice2x/hooks/graphics/backends/d3d11/d3d11_backend.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d11/d3d11_backend.cpp @@ -30,6 +30,7 @@ void graphics_d3d11_shutdown() {} #include #include +#include "avs/game.h" #include "d3d11_internal.h" #include "util/nt_loader.h" @@ -237,10 +238,11 @@ bool d3dcompiler_available() { } // namespace void graphics_d3d11_init() { - // dx11 titles always run under execexe. skipping on pure-dx9 games keeps - // their startup path completely untouched (no exports patched, no poll - // thread, no LDR callback). - if (!GetModuleHandleW(L"execexe.dll")) { + // dx11 titles run under execexe, except Silent Scope: Bone Eater, whose Aska + // engine LoadLibrary's d3d11 itself. skipping on pure-dx9 games keeps their + // startup path completely untouched (no exports patched, no poll thread, no + // LDR callback). + if (!GetModuleHandleW(L"execexe.dll") && !avs::game::is_model("NDD")) { return; } diff --git a/src/spice2x/hooks/graphics/graphics.cpp b/src/spice2x/hooks/graphics/graphics.cpp index 61c5701..7bb967b 100644 --- a/src/spice2x/hooks/graphics/graphics.cpp +++ b/src/spice2x/hooks/graphics/graphics.cpp @@ -52,6 +52,8 @@ static HWND GFDM_RIGHT_WINDOW = nullptr; static HMONITOR GFDM_TWO_HEAD_SMALL_MONITOR = nullptr; static HWND GFDM_TWO_HEAD_SMALL_WINDOW = nullptr; HWND POPN_SUBSCREEN_WINDOW = nullptr; +static HWND NDD_SUBSCREEN_WINDOW = nullptr; +HWND NDD_MAIN_WINDOW = nullptr; bool FAKE_SUBSCREEN_ADAPTER = false; // icon @@ -634,6 +636,9 @@ static HWND WINAPI CreateWindowExA_hook(DWORD dwExStyle, LPCSTR lpClassName, LPC bool is_sdvx_sub_window = is_sdvx && window_name.ends_with(" Sub Screen"); bool is_sdvx_main_window = is_sdvx && window_name.ends_with(" Main Screen"); bool is_popn_sub_window = avs::game::is_model("M39") && window_name.ends_with("Sub Screen"); + const bool is_ndd = avs::game::is_model("NDD"); + bool is_ndd_sub_window = is_ndd && window_name.starts_with("Aska MultiDisplay"); + bool is_ndd_main_window = is_ndd && window_name == "ASKA"; const std::string gfdm_window_name = games::gitadora::is_arena_model() ? gitadora_canonical_window_name(effective_window_name) : ""; @@ -777,6 +782,14 @@ static HWND WINAPI CreateWindowExA_hook(DWORD dwExStyle, LPCSTR lpClassName, LPC } } + if (is_ndd_sub_window) { + NDD_SUBSCREEN_WINDOW = result; + } + + if (is_ndd_main_window) { + NDD_MAIN_WINDOW = result; + } + disable_touch_gestures(result); log_misc( "graphics", @@ -902,6 +915,26 @@ static BOOL WINAPI EnumDisplayDevicesA_hook(LPCTSTR lpDevice, DWORD iDevNum, return value; } +// the sub screen renders into a fixed 800x480 buffer, but the game's saved layout asks for rects +// that do not match it, and dxgi stretches the buffer to fill whatever the client area ends up as +static void ndd_subscreen_size(HWND hWnd, int &width, int &height) { + RECT rect {}; + SetRect(&rect, 0, 0, 800, 480); + AdjustWindowRect(&rect, GetWindowLongA(hWnd, GWL_STYLE), 0); + + width = rect.right - rect.left; + height = rect.bottom - rect.top; +} + +// the saved layout drops the sub window wherever it sat on the machine that wrote the file +static void ndd_subscreen_position(int &x, int &y) { + RECT main {}; + if (NDD_MAIN_WINDOW != nullptr && GetWindowRect(NDD_MAIN_WINDOW, &main)) { + x = main.right; + y = main.top; + } +} + static BOOL WINAPI MoveWindow_hook(HWND hWnd, int X, int Y, int nWidth, int nHeight, BOOL bRepaint) { log_misc("graphics", "MoveWindow hook hit ({}, {}, {}, {}, {}, {})", fmt::ptr(hWnd), @@ -931,6 +964,11 @@ static BOOL WINAPI MoveWindow_hook(HWND hWnd, int X, int Y, int nWidth, int nHei nHeight = rect.bottom - rect.top; } + if (GRAPHICS_WINDOWED && NDD_SUBSCREEN_WINDOW && hWnd == NDD_SUBSCREEN_WINDOW) { + ndd_subscreen_size(hWnd, nWidth, nHeight); + ndd_subscreen_position(X, Y); + } + // iidx windowed TDJ mode if (GRAPHICS_WINDOWED && TDJ_SUBSCREEN_WINDOW && hWnd == TDJ_SUBSCREEN_WINDOW) { if (GRAPHICS_IIDX_WSUB) { @@ -1073,6 +1111,15 @@ static LONG WINAPI SetWindowLongW_hook(HWND hWnd, int nIndex, LONG dwNewLong) { static BOOL WINAPI SetWindowPos_hook(HWND hWnd, HWND hWndInsertAfter, int X, int Y, int cx, int cy, UINT uFlags) { + if (GRAPHICS_WINDOWED && NDD_SUBSCREEN_WINDOW && hWnd == NDD_SUBSCREEN_WINDOW) { + if (!(uFlags & SWP_NOSIZE)) { + ndd_subscreen_size(hWnd, cx, cy); + } + if (!(uFlags & SWP_NOMOVE)) { + ndd_subscreen_position(X, Y); + } + } + if (is_gfdm_two_head_small_window(hWnd) && ((uFlags & SWP_HIDEWINDOW) || (uFlags & (SWP_NOMOVE | SWP_NOSIZE)) != (SWP_NOMOVE | SWP_NOSIZE))) { diff --git a/src/spice2x/hooks/graphics/graphics.h b/src/spice2x/hooks/graphics/graphics.h index 72e7d13..fe83955 100644 --- a/src/spice2x/hooks/graphics/graphics.h +++ b/src/spice2x/hooks/graphics/graphics.h @@ -103,6 +103,7 @@ extern HWND TDJ_SUBSCREEN_WINDOW; extern HWND SDVX_SUBSCREEN_WINDOW; extern HWND POPN_SUBSCREEN_WINDOW; extern HWND GFDM_SUBSCREEN_WINDOW; +extern HWND NDD_MAIN_WINDOW; extern bool SUBSCREEN_FORCE_REDRAW; extern bool FAKE_SUBSCREEN_ADAPTER; diff --git a/src/spice2x/hooks/lang.cpp b/src/spice2x/hooks/lang.cpp index 0567dc2..8354712 100644 --- a/src/spice2x/hooks/lang.cpp +++ b/src/spice2x/hooks/lang.cpp @@ -336,9 +336,11 @@ void hooks::lang::early_init() { &GetLocaleInfoA_orig); } - // for TDJ subscreen search keyboard and T44 narrow-string handling + // for TDJ subscreen search keyboard + // T44 narrow-string handling + // NDD text measuring if ((avs::game::is_model("LDJ") && games::iidx::TDJ_MODE) || - avs::game::is_model("T44")) { + avs::game::is_model({ "T44", "NDD" })) { log_info("hooks::lang", "hooking IsDBCSLeadByte"); detour::trampoline_try( "kernel32.dll", @@ -358,8 +360,9 @@ void hooks::lang::early_init() { #endif #ifdef SPICE64 + // NDD renders through GetTextExtentPoint32A, so its wide strings go back through CP_ACP first const auto hook_wide_char_to_multi_byte = - games::gitadora::is_arena_model() || avs::game::is_model("T44"); + games::gitadora::is_arena_model() || avs::game::is_model({ "T44", "NDD" }); #else // XG2 converts UTF-8 property strings through CP_ACP before rendering. const auto hook_wide_char_to_multi_byte = avs::game::is_model({ "K32", "K33" }); diff --git a/src/spice2x/misc/eamuse.cpp b/src/spice2x/misc/eamuse.cpp index 8968a00..bacf85a 100644 --- a/src/spice2x/misc/eamuse.cpp +++ b/src/spice2x/misc/eamuse.cpp @@ -730,6 +730,8 @@ void eamuse_autodetect_game() { eamuse_set_game("Mahjong Fight Girl"); else if (avs::game::is_model("XIF")) eamuse_set_game("Polaris Chord"); + else if (avs::game::is_model("NDD")) + eamuse_set_game("Silent Scope: Bone Eater"); else { log_warning("eamuse", "unknown game model: {}", avs::game::MODEL); eamuse_set_game("unknown");