From 311404f56b839ebab99dac7cec3b4af31dc1c3ba Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Wed, 27 May 2026 00:45:27 -0700 Subject: [PATCH] overlay: toast notifications (#704) ## Link to GitHub Issue or related Pull Request, if one exists n/a ## Description of change Adds toast notifications to the overlay. Toast notifications are transient windows (shown for about 4 seconds). These are drawn on top of the game even when the overlay is inactive (i.e., when no window is open), and does not cause any input sink behavior. Add toasts for some common user-driven actions that could use a notification, such as: * screenshot * card insert * PIN macro * API client connect/disconnect * virtual printer * screen resize toggle / scene switch Add an option to change where the toasts are displayed, or to turn it off entirely. Add SDK function for it, and add it to the samples. ## Testing Tested DDR (shown at bottom right, as is for most games) and RB (shown top right by default). --- src/spice2x/CMakeLists.txt | 1 + src/spice2x/api/controller.cpp | 8 + src/spice2x/api/websocket.cpp | 7 + src/spice2x/games/shared/printer.cpp | 7 + .../graphics/backends/d3d9/d3d9_backend.cpp | 9 + src/spice2x/launcher/launcher.cpp | 19 ++ src/spice2x/launcher/options.cpp | 15 ++ src/spice2x/launcher/options.h | 1 + src/spice2x/misc/eamuse.cpp | 38 ++- src/spice2x/overlay/notifications.cpp | 254 ++++++++++++++++++ src/spice2x/overlay/notifications.h | 55 ++++ src/spice2x/overlay/overlay.cpp | 39 ++- src/spice2x/overlay/overlay.h | 4 + src/spice2x/overlay/windows/screen_resize.cpp | 12 + src/spice2x/sdk/include/spicesdk.h | 46 +++- src/spice2x/sdk/sample/v0/cpp/v0_cpp.cpp | 10 +- src/spice2x/sdk/sample/v0/flat_c/v0_flat_c.c | 36 +-- src/spice2x/sdk/sdk.cpp | 46 ++++ src/spice2x/util/fileutils.cpp | 8 + src/spice2x/util/fileutils.h | 2 + 20 files changed, 574 insertions(+), 43 deletions(-) create mode 100644 src/spice2x/overlay/notifications.cpp create mode 100644 src/spice2x/overlay/notifications.h diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index 343998a..336c4f7 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -558,6 +558,7 @@ set(SOURCE_FILES ${SOURCE_FILES} # overlay overlay/overlay.cpp + overlay/notifications.cpp overlay/window.cpp overlay/imgui/extensions.cpp overlay/imgui/impl_spice.cpp diff --git a/src/spice2x/api/controller.cpp b/src/spice2x/api/controller.cpp index 1c08e38..19713fa 100644 --- a/src/spice2x/api/controller.cpp +++ b/src/spice2x/api/controller.cpp @@ -11,6 +11,8 @@ #include "util/logging.h" #include "util/utils.h" +#include "overlay/notifications.h" + #include "module.h" #include "modules/analogs.h" #include "modules/buttons.h" @@ -197,6 +199,9 @@ void Controller::connection_handler(api::ClientState client_state) { // log connection log_info("api", "client connected: {}", client_address_str); + overlay::notifications::add( + overlay::notifications::Severity::Success, + fmt::format("API client connected ({})", client_address_str)); client_states_m.lock(); client_states.emplace_back(&client_state); client_states_m.unlock(); @@ -277,6 +282,9 @@ void Controller::connection_handler(api::ClientState client_state) { // log disconnect log_info("api", "client disconnected: {}", client_address_str); + overlay::notifications::add( + overlay::notifications::Severity::Info, + fmt::format("API client disconnected ({})", client_address_str)); client_states_m.lock(); client_states.erase(std::remove(client_states.begin(), client_states.end(), &client_state)); client_states_m.unlock(); diff --git a/src/spice2x/api/websocket.cpp b/src/spice2x/api/websocket.cpp index e453588..eaab384 100644 --- a/src/spice2x/api/websocket.cpp +++ b/src/spice2x/api/websocket.cpp @@ -5,6 +5,7 @@ #include "util/utils.h" #include "util/rc4.h" #include "util/logging.h" +#include "overlay/notifications.h" #include "controller.h" using namespace headsocket; @@ -91,12 +92,18 @@ namespace api { // log connection log_info("api::websocket", "client connected"); + overlay::notifications::add( + overlay::notifications::Severity::Success, + "API websocket client connected"); } void WebSocketClient::on_disconnect() { // log disconnection log_info("api::websocket", "client disconnected"); + overlay::notifications::add( + overlay::notifications::Severity::Info, + "API websocket client disconnected"); // get pointer to server auto srv = reinterpret_cast(server().get()); diff --git a/src/spice2x/games/shared/printer.cpp b/src/spice2x/games/shared/printer.cpp index 26a776a..530dfa6 100644 --- a/src/spice2x/games/shared/printer.cpp +++ b/src/spice2x/games/shared/printer.cpp @@ -7,6 +7,7 @@ #include "hooks/sleephook.h" #include "hooks/libraryhook.h" #include "launcher/launcher.h" +#include "overlay/notifications.h" #include "util/detour.h" #include "util/fileutils.h" #include "util/libutils.h" @@ -397,8 +398,14 @@ namespace games::shared { // logging if (success) { log_info("printer", "printer emulation has written an image to {}", image_path); + overlay::notifications::add( + overlay::notifications::Severity::Success, + fmt::format("Printer: saved {}", fileutils::basename(image_path))); } else { log_warning("printer", "printer emulation failed to write image to {}", image_path); + overlay::notifications::add( + overlay::notifications::Severity::Error, + fmt::format("Printer: failed to write {}", fileutils::basename(image_path))); } } } diff --git a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp index 7c288f9..a361024 100644 --- a/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp +++ b/src/spice2x/hooks/graphics/backends/d3d9/d3d9_backend.cpp @@ -28,8 +28,10 @@ #include "misc/eamuse.h" #include "misc/wintouchemu.h" #include "overlay/overlay.h" +#include "overlay/notifications.h" #include "util/detour.h" #include "util/deferlog.h" +#include "util/fileutils.h" #include "util/flags_helper.h" #include "util/libutils.h" #include "util/logging.h" @@ -1434,11 +1436,18 @@ static void save_screenshot(const std::string &file_path, UINT height, IDirect3D if (FAILED(hr)) { log_warning("graphics::d3d9", "Failed to save screenshot"); + overlay::notifications::add( + overlay::notifications::Severity::Error, + "Screenshot failed to save"); return; } // save to clipboard clipboard::copy_image(file_path); + + overlay::notifications::add( + overlay::notifications::Severity::Success, + fmt::format("Screenshot saved: {}", fileutils::basename(file_path))); } else { log_warning("graphics::d3d9", "Direct3D save helper function not available"); } diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index 0135018..317db44 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -95,6 +95,7 @@ #include "misc/sde.h" #include "misc/wintouchemu.h" #include "overlay/overlay.h" +#include "overlay/notifications.h" #include "overlay/windows/patch_manager.h" #include "overlay/windows/iidx_seg.h" #include "rawinput/rawinput.h" @@ -2467,6 +2468,24 @@ int main_implementation(int argc, char *argv[]) { // eamuse init eamuse_autodetect_game(); + // notification position: apply per-game default first, then the explicit + // user option (if set) wins over the default. + overlay::notifications::apply_game_default_position(eamuse_get_game()); + if (options[launcher::Options::NotificationPosition].is_active()) { + const auto txt = options[launcher::Options::NotificationPosition].value_text(); + if (txt == "topleft") { + overlay::notifications::POSITION = overlay::notifications::Position::TopLeft; + } else if (txt == "topright") { + overlay::notifications::POSITION = overlay::notifications::Position::TopRight; + } else if (txt == "bottomleft") { + overlay::notifications::POSITION = overlay::notifications::Position::BottomLeft; + } else if (txt == "bottomright") { + overlay::notifications::POSITION = overlay::notifications::Position::BottomRight; + } else if (txt == "off") { + overlay::notifications::ENABLED = false; + } + } + // unis device hook unisintrhook_init(); diff --git a/src/spice2x/launcher/options.cpp b/src/spice2x/launcher/options.cpp index ce88348..c7f13c1 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -468,6 +468,21 @@ static const std::vector OPTION_DEFINITIONS = { .setting_name = "200", .category = "Overlay", }, + { + // NotificationPosition + .title = "Notifications", + .name = "toast", + .desc = "Select where notifications will appear on the screen.", + .type = OptionType::Enum, + .category = "Overlay", + .elements = { + {"off", ""}, + {"topleft", ""}, + {"topright", ""}, + {"bottomleft", ""}, + {"bottomright", ""}, + }, + }, { // spice2x_FpsAutoShow .title = "Auto Show FPS/Clock", diff --git a/src/spice2x/launcher/options.h b/src/spice2x/launcher/options.h index c250cb4..1b308e0 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -53,6 +53,7 @@ namespace launcher { VREnable, DisableOverlay, OverlayScaling, + NotificationPosition, spice2x_FpsAutoShow, spice2x_FpsOpposite, spice2x_SubScreenAutoShow, diff --git a/src/spice2x/misc/eamuse.cpp b/src/spice2x/misc/eamuse.cpp index 0d24ef1..d04c9fa 100644 --- a/src/spice2x/misc/eamuse.cpp +++ b/src/spice2x/misc/eamuse.cpp @@ -14,14 +14,16 @@ #include "util/time.h" #include "util/utils.h" #include "overlay/overlay.h" +#include "overlay/notifications.h" #include "bt5api.h" // state +static constexpr double NOTIFICATION_THROTTLE_SECONDS = 3.0; static bool CARD_INSERT[2] = {false, false}; static double CARD_INSERT_TIME[2] = {0, 0}; static double CARD_INSERT_TIMEOUT = 2.0; -static char CARD_INSERT_UID[2][8]; +static char CARD_INSERT_UID[2][8] = {{0}, {0}}; static char CARD_INSERT_UID_ENABLE[2] = {false, false}; static int COIN_STOCK = 0; static bool COIN_BLOCK = false; @@ -119,6 +121,12 @@ bool eamuse_get_card(const std::filesystem::path &path, uint8_t *card, int index "{} card override contains an invalid character sequence at byte {} (16 characters, 0-9/A-F only)", card_override, n); + overlay::notifications::add_throttled( + overlay::notifications::Severity::Error, + fmt::format("eamuse.card_override_error.p{}", index + 1), + NOTIFICATION_THROTTLE_SECONDS, + fmt::format("[P{}] invalid card override", index + 1)); + return false; } } @@ -149,6 +157,11 @@ bool eamuse_get_card_from_file(const std::filesystem::path &path, uint8_t *card, std::ifstream f(path); if (!f) { log_warning("eamuse", "{} can not be opened!", path); + overlay::notifications::add_throttled( + overlay::notifications::Severity::Error, + fmt::format("eamuse.card_file_error.p{}", index + 1), + NOTIFICATION_THROTTLE_SECONDS, + fmt::format("[P{}] can't open card file", index + 1)); return false; } @@ -160,6 +173,12 @@ bool eamuse_get_card_from_file(const std::filesystem::path &path, uint8_t *card, // check size if (length < 16) { log_warning("eamuse", "{} is too small (must be at least 16 characters)", path); + overlay::notifications::add_throttled( + overlay::notifications::Severity::Error, + fmt::format("eamuse.card_file_error.p{}", index + 1), + NOTIFICATION_THROTTLE_SECONDS, + fmt::format("[P{}] card file error", index + 1)); + return false; } @@ -179,6 +198,11 @@ bool eamuse_get_card_from_file(const std::filesystem::path &path, uint8_t *card, "{} contains an invalid character sequence at byte {} (16 characters, 0-9/A-F only)", path, n); + overlay::notifications::add_throttled( + overlay::notifications::Severity::Error, + fmt::format("eamuse.card_file_error.p{}", index + 1), + NOTIFICATION_THROTTLE_SECONDS, + fmt::format("[P{}] card file error", index + 1)); return false; } } @@ -251,8 +275,16 @@ bool eamuse_card_insert_consume(int active_count, int unit_id) { auto offset = unit_id * games::KeypadButtons::Size; if ((CARD_INSERT[index] && fabs(get_performance_seconds() - CARD_INSERT_TIME[index]) < CARD_INSERT_TIMEOUT) || GameAPI::Buttons::getState(RI_MGR, keypad_buttons->at(games::KeypadButtons::InsertCard + offset))) { + log_info("eamuse", "[P{}] Card insert on reader (total active count: {})", unit_id+1, active_count); CARD_INSERT[index] = false; + + overlay::notifications::add_throttled( + overlay::notifications::Severity::Info, + fmt::format("eamuse.card_inserted.p{}", unit_id + 1), + NOTIFICATION_THROTTLE_SECONDS, + fmt::format("[P{}] card inserted", unit_id + 1)); + return true; } @@ -394,6 +426,10 @@ void eamuse_pin_macro_start_thread() { log_info("eamuse", "AUTO_PIN_MACRO_REQUEST detected for P{}", unit+1); } if (key_press || auto_request) { + overlay::notifications::add( + overlay::notifications::Severity::Info, + fmt::format("[P{}] PIN macro fired ({})", + unit + 1, auto_request ? "auto" : "manual")); active_unit = unit; // Reset key index pin_index[unit] = 0; diff --git a/src/spice2x/overlay/notifications.cpp b/src/spice2x/overlay/notifications.cpp new file mode 100644 index 0000000..1f2fa9b --- /dev/null +++ b/src/spice2x/overlay/notifications.cpp @@ -0,0 +1,254 @@ +#include "notifications.h" + +#include +#include +#include +#include + +#include "external/imgui/imgui.h" +#include "external/imgui/imgui_internal.h" +#include "external/fmt/include/fmt/format.h" + +#include "overlay/overlay.h" +#include "util/time.h" + +namespace overlay::notifications { + + bool ENABLED = true; + Position POSITION = Position::BottomRight; + + struct Notification { + uint64_t id; + std::string text; + Severity severity; + double created_ms; + float duration_s; + }; + + static std::mutex g_mutex; + static std::deque g_items; + static std::atomic g_next_id { 1 }; + static std::atomic g_count { 0 }; + + // duration in seconds each notification stays visible + static constexpr float DURATION_S = 3.0f; + + // maximum number of notifications kept in the queue (oldest dropped beyond this) + static constexpr size_t MAX_NOTIFICATIONS = 6; + + // time (ms) over which a toast fades out at the end of its lifetime + static constexpr float FADE_OUT_MS = 400.0f; + + // fixed width of each toast window, in unscaled pixels + static constexpr float TOAST_WIDTH = 320.0f; + + // gap between the toast stack and the screen edges (right + bottom) + static constexpr float TOAST_MARGIN = 20.0f; + + // vertical gap between stacked toasts + static constexpr float TOAST_SPACING = 8.0f; + + // inner padding inside a toast window (horizontal / vertical) + static constexpr float TOAST_PAD_X = 10.0f; + static constexpr float TOAST_PAD_Y = 8.0f; + + // width of the colored severity accent bar drawn on the left edge + static constexpr float TOAST_ACCENT_W = 6.0f; + + // base opacity of the toast background (0..1), multiplied by the fade alpha + static constexpr float TOAST_BG_ALPHA = 0.85f; + + static constexpr ImGuiWindowFlags TOAST_FLAGS = + ImGuiWindowFlags_NoDecoration + | ImGuiWindowFlags_NoInputs + | ImGuiWindowFlags_NoNav + | ImGuiWindowFlags_NoMove + | ImGuiWindowFlags_NoSavedSettings + | ImGuiWindowFlags_NoFocusOnAppearing + | ImGuiWindowFlags_NoBringToFrontOnFocus + | ImGuiWindowFlags_AlwaysAutoResize; + + static ImU32 severity_accent(Severity sev) { + switch (sev) { + case Severity::Success: return IM_COL32(80, 200, 120, 255); + case Severity::Warning: return IM_COL32(230, 180, 60, 255); + case Severity::Error: return IM_COL32(220, 60, 60, 255); + case Severity::Info: + default: return IM_COL32(90, 160, 230, 255); + } + } + + static bool is_expired(const Notification &n, double now_ms) { + return (now_ms - n.created_ms) >= (n.duration_s * 1000.0); + } + + // returns 0.0 .. 1.0 fade alpha based on time remaining + static float compute_alpha(const Notification &n, double now_ms) { + const double remaining_ms = (n.duration_s * 1000.0) - (now_ms - n.created_ms); + if (remaining_ms >= FADE_OUT_MS) { + return 1.0f; + } + if (remaining_ms <= 0.0) { + return 0.0f; + } + return static_cast(remaining_ms / FADE_OUT_MS); + } + + // drop expired items and copy the rest under a single lock acquisition + static std::vector snapshot_and_prune(double now_ms) { + std::vector snapshot; + std::lock_guard lock(g_mutex); + for (auto it = g_items.begin(); it != g_items.end();) { + if (is_expired(*it, now_ms)) { + it = g_items.erase(it); + } else { + ++it; + } + } + g_count.store(g_items.size(), std::memory_order_release); + snapshot.assign(g_items.begin(), g_items.end()); + return snapshot; + } + + // is the configured anchor on the right edge of the screen? + static bool position_is_right(Position p) { + return p == Position::BottomRight || p == Position::TopRight; + } + + // is the configured anchor on the bottom edge of the screen? + static bool position_is_bottom(Position p) { + return p == Position::BottomRight || p == Position::BottomLeft; + } + + // draw a single toast anchored to the configured corner; `cursor_y` is the + // y-coordinate of the toast edge nearest the anchor (top edge for Top* anchors, + // bottom edge for Bottom* anchors). returns its height in pixels. + static float draw_toast(const Notification &n, float cursor_y, float alpha) { + const float toast_width = apply_scaling(TOAST_WIDTH); + const float margin = apply_scaling(TOAST_MARGIN); + const ImVec2 &display = ImGui::GetIO().DisplaySize; + const Position pos = POSITION; + + const auto window_id = fmt::format("##spice_notif_{}", n.id); + + // anchor x/pivot.x select the screen edge; pivot.y matches cursor_y semantics + const float anchor_x = position_is_right(pos) ? (display.x - margin) : margin; + const float pivot_x = position_is_right(pos) ? 1.0f : 0.0f; + const float pivot_y = position_is_bottom(pos) ? 1.0f : 0.0f; + ImGui::SetNextWindowPos(ImVec2(anchor_x, cursor_y), + ImGuiCond_Always, ImVec2(pivot_x, pivot_y)); + ImGui::SetNextWindowSize(ImVec2(toast_width, 0.f), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(TOAST_BG_ALPHA * alpha); + + ImGui::PushStyleVar(ImGuiStyleVar_Alpha, alpha); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, + ImVec2(apply_scaling(TOAST_PAD_X), apply_scaling(TOAST_PAD_Y))); + + float height = 0.f; + if (ImGui::Begin(window_id.c_str(), nullptr, TOAST_FLAGS)) { + const ImVec2 win_pos = ImGui::GetWindowPos(); + const ImVec2 win_size = ImGui::GetWindowSize(); + + // accent bar on the left edge of the window + const ImU32 accent = severity_accent(n.severity); + const ImU32 accent_faded = + (accent & 0x00FFFFFFu) | (static_cast(alpha * 255.0f) << 24); + ImGui::GetWindowDrawList()->AddRectFilled( + win_pos, + ImVec2(win_pos.x + apply_scaling(TOAST_ACCENT_W), win_pos.y + win_size.y), + accent_faded); + + // small gutter past the accent bar, then wrapped text + ImGui::Dummy(ImVec2(apply_scaling(2.0f), 0.f)); + ImGui::SameLine(); + ImGui::PushTextWrapPos(win_pos.x + win_size.x - apply_scaling(TOAST_PAD_X)); + ImGui::TextUnformatted(n.text.c_str()); + ImGui::PopTextWrapPos(); + + height = ImGui::GetWindowSize().y; + } + ImGui::End(); + + ImGui::PopStyleVar(2); + return height; + } + + uint64_t add(Severity severity, std::string text) { + if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) { + return 0; + } + + Notification n { + .id = g_next_id.fetch_add(1, std::memory_order_relaxed), + .text = std::move(text), + .severity = severity, + .created_ms = get_performance_milliseconds(), + .duration_s = DURATION_S, + }; + + { + std::lock_guard lock(g_mutex); + g_items.push_back(std::move(n)); + while (g_items.size() > MAX_NOTIFICATIONS) { + g_items.pop_front(); + } + g_count.store(g_items.size(), std::memory_order_release); + } + return n.id; + } + + uint64_t add_throttled(Severity severity, const std::string &key, + double cooldown_seconds, std::string text) { + if (!ENABLED || !overlay::ENABLED || overlay::OVERLAY == nullptr) { + return 0; + } + // per-key last-emit timestamps live behind their own lock so we don't + // hold g_mutex across the map lookup. + static std::mutex throttle_mutex; + static std::unordered_map last_emit_ms; + const double now_ms = get_performance_milliseconds(); + { + std::lock_guard lock(throttle_mutex); + auto it = last_emit_ms.find(key); + if (it != last_emit_ms.end() + && (now_ms - it->second) < (cooldown_seconds * 1000.0)) { + return 0; + } + last_emit_ms[key] = now_ms; + } + return add(severity, std::move(text)); + } + + bool has_pending() { + return g_count.load(std::memory_order_acquire) > 0; + } + + void draw() { + const double now_ms = get_performance_milliseconds(); + const auto snapshot = snapshot_and_prune(now_ms); + if (snapshot.empty()) { + return; + } + + // stack from the anchored edge with newest toast at the anchor. + // Bottom* anchors stack upward; Top* anchors stack downward. + const float spacing = apply_scaling(TOAST_SPACING); + const float margin = apply_scaling(TOAST_MARGIN); + const bool bottom = position_is_bottom(POSITION); + float cursor_y = bottom + ? (ImGui::GetIO().DisplaySize.y - margin) + : margin; + for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) { + const float alpha = compute_alpha(*it, now_ms); + const float height = draw_toast(*it, cursor_y, alpha); + cursor_y += bottom ? -(height + spacing) : (height + spacing); + } + } + + void apply_game_default_position(const std::string &game_name) { + if (game_name == "Reflec Beat") { + POSITION = Position::TopRight; + } + // others keep the default (BottomRight) + } +} diff --git a/src/spice2x/overlay/notifications.h b/src/spice2x/overlay/notifications.h new file mode 100644 index 0000000..d74db2a --- /dev/null +++ b/src/spice2x/overlay/notifications.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +namespace overlay::notifications { + + // master switch for the notification system; when false, add() is a no-op. + // controlled by selecting "none" for the -notifypos launcher option. + extern bool ENABLED; + + enum class Severity { + Info, + Success, + Warning, + Error, + }; + + // screen anchor for the toast stack. toasts stack away from the anchored edge. + enum class Position { + BottomRight, + BottomLeft, + TopRight, + TopLeft, + }; + + // current toast anchor. defaults to BottomRight; may be reassigned by + // apply_game_default_position() or by the user via -notifypos. + extern Position POSITION; + + // apply the default toast position appropriate for a game (by display name, + // as returned by eamuse_get_game()). called once after game autodetect, before + // any user -notifypos override is applied. + void apply_game_default_position(const std::string &game_name); + + // add a notification (thread-safe). returns the assigned id, or 0 if the + // notification was dropped (overlay disabled or notifications disabled). + uint64_t add(Severity severity, std::string text); + + // rate-limited variant of add(). suppresses the toast if another call with + // the same `key` succeeded within the last `cooldown_seconds`. useful for + // events that can fire every frame (e.g. a button held down). returns the + // assigned id, or 0 if the toast was suppressed or dropped. thread-safe. + uint64_t add_throttled(Severity severity, const std::string &key, + double cooldown_seconds, std::string text); + + // true if there is at least one notification that still needs to be drawn. + // safe to call from the render thread without locking the underlying store. + bool has_pending(); + + // draw all active notifications and prune expired ones. + // must be called from the ImGui render thread inside a NewFrame/EndFrame pair. + void draw(); +} diff --git a/src/spice2x/overlay/overlay.cpp b/src/spice2x/overlay/overlay.cpp index 157141a..46d39cd 100644 --- a/src/spice2x/overlay/overlay.cpp +++ b/src/spice2x/overlay/overlay.cpp @@ -17,6 +17,7 @@ #include "external/imgui/backends/imgui_impl_dx9.h" #include "overlay/imgui/impl_spice.h" #include "overlay/imgui/impl_sw.h" +#include "overlay/notifications.h" #include "window.h" #ifdef SPICE64 @@ -465,8 +466,16 @@ void overlay::SpiceOverlay::new_frame() { ImGui_ImplSpice_NewFrame(); this->total_elapsed += ImGui::GetIO().DeltaTime; - // check if inactive - if (!this->active) { + // notifications draw on top of the game without flipping `active`, so the input gates + // (see touch/touch.cpp WndProc, touch_get_points/events, graphics.cpp WM_CHAR) stay + // disabled and input continues to flow to the game. + // SOFTWARE renderer (spicecfg / configurator) never gets notifications - no room. + const bool draw_notifications = this->renderer != OverlayRenderer::SOFTWARE + && overlay::notifications::has_pending(); + + // check if there is nothing to draw + this->has_pending_frame = false; + if (!this->active && !draw_notifications) { return; } @@ -480,14 +489,22 @@ void overlay::SpiceOverlay::new_frame() { break; } ImGui::NewFrame(); + this->has_pending_frame = true; - // build windows - for (auto &window : this->windows) { - window->build(); + // build windows only when the overlay itself is active + if (this->active) { + for (auto &window : this->windows) { + window->build(); + } + + if (SHOW_DEBUG_LOG_WINDOW) { + ImGui::ShowDebugLogWindow(&SHOW_DEBUG_LOG_WINDOW); + } } - if (SHOW_DEBUG_LOG_WINDOW) { - ImGui::ShowDebugLogWindow(&SHOW_DEBUG_LOG_WINDOW); + // draw notifications last so they paint on top of any overlay windows + if (draw_notifications) { + overlay::notifications::draw(); } // end frame @@ -496,8 +513,8 @@ void overlay::SpiceOverlay::new_frame() { void overlay::SpiceOverlay::render() { - // check if inactive - if (!this->active) { + // skip if new_frame() didn't begin a frame this tick + if (!this->has_pending_frame) { return; } @@ -541,6 +558,8 @@ void overlay::SpiceOverlay::render() { for (auto &window : this->windows) { window->after_render(); } + + this->has_pending_frame = false; } void overlay::SpiceOverlay::update() { @@ -700,7 +719,7 @@ void overlay::SpiceOverlay::input_char(unsigned int c) { uint32_t *overlay::SpiceOverlay::sw_get_pixel_data(int *width, int *height) { - // check if active + // check if active (notifications never draw in software renderer, so no extra gate here) if (!this->active) { *width = 0; *height = 0; diff --git a/src/spice2x/overlay/overlay.h b/src/spice2x/overlay/overlay.h index c478081..54f1229 100644 --- a/src/spice2x/overlay/overlay.h +++ b/src/spice2x/overlay/overlay.h @@ -141,6 +141,10 @@ namespace overlay { bool hotkey_toggle = false; bool hotkey_toggle_last = false; + // true between new_frame()'s ImGui::NewFrame() and render()'s ImGui::Render(), + // so render() never runs without a matching NewFrame. + bool has_pending_frame = false; + void init(); void add_font(const char* font, ImFontConfig* config, const ImWchar* glyphs); }; diff --git a/src/spice2x/overlay/windows/screen_resize.cpp b/src/spice2x/overlay/windows/screen_resize.cpp index b87afde..6bda121 100644 --- a/src/spice2x/overlay/windows/screen_resize.cpp +++ b/src/spice2x/overlay/windows/screen_resize.cpp @@ -5,6 +5,7 @@ #include "cfg/screen_resize.h" #include "hooks/graphics/graphics.h" #include "overlay/imgui/extensions.h" +#include "overlay/notifications.h" #include "misc/eamuse.h" #include "util/logging.h" #include "util/utils.h" @@ -323,6 +324,11 @@ namespace overlay::windows { if (toggle_screen_resize_new && !this->toggle_screen_resize_state) { cfg::SCREENRESIZE->enable_screen_resize = !cfg::SCREENRESIZE->enable_screen_resize; + overlay::notifications::add( + overlay::notifications::Severity::Info, + cfg::SCREENRESIZE->enable_screen_resize + ? "Screen resize enabled" + : "Screen resize disabled"); } this->toggle_screen_resize_state = toggle_screen_resize_new; } @@ -347,10 +353,16 @@ namespace overlay::windows { cfg::SCREENRESIZE->enable_screen_resize) { // this scene is already active, turn scaling off cfg::SCREENRESIZE->enable_screen_resize = false; + overlay::notifications::add( + overlay::notifications::Severity::Info, + "Screen resize disabled"); } else { // switch to scene cfg::SCREENRESIZE->enable_screen_resize = true; cfg::SCREENRESIZE->screen_resize_current_scene = i; + overlay::notifications::add( + overlay::notifications::Severity::Info, + fmt::format("Screen resize: scene {}", i + 1)); } break; } diff --git a/src/spice2x/sdk/include/spicesdk.h b/src/spice2x/sdk/include/spicesdk.h index 2461ea3..b66167a 100644 --- a/src/spice2x/sdk/include/spicesdk.h +++ b/src/spice2x/sdk/include/spicesdk.h @@ -2,18 +2,18 @@ #ifndef SPICE_SDK_H #define SPICE_SDK_H -#include -#include - -#ifdef __cplusplus -#define SPICE_SDK_ENTRY_POINT extern "C" __declspec(dllexport) int __cdecl -#else -#define SPICE_SDK_ENTRY_POINT __declspec(dllexport) int __cdecl -#endif - -#ifdef __cplusplus -extern "C" { -#endif +#include +#include + +#ifdef __cplusplus +#define SPICE_SDK_ENTRY_POINT extern "C" __declspec(dllexport) int __cdecl +#else +#define SPICE_SDK_ENTRY_POINT __declspec(dllexport) int __cdecl +#endif + +#ifdef __cplusplus +extern "C" { +#endif typedef enum SPICE_SDK_STATUS_CODE { SPICE_SDK_STATUS_SUCCESS = 0, @@ -41,6 +41,13 @@ typedef enum SPICE_SDK_LOG_LEVEL { SPICE_SDK_LOG_LEVEL_FATAL = 3, } SPICE_SDK_LOG_LEVEL; +typedef enum SPICE_SDK_TOAST_SEVERITY { + SPICE_SDK_TOAST_LEVEL_INFO = 0, + SPICE_SDK_TOAST_LEVEL_SUCCESS = 1, + SPICE_SDK_TOAST_LEVEL_WARNING = 2, + SPICE_SDK_TOAST_LEVEL_ERROR = 3, +} SPICE_SDK_TOAST_SEVERITY; + typedef struct SPICE_SDK_TOUCH_POINT { uint32_t id; int x; @@ -219,6 +226,17 @@ typedef SPICE_SDK_STATUS_CODE (__cdecl spice_sdk_set_keypad_func)( char key ); +// add_toast (v0.2 and up) +// adds an overlay toast notification +// +// severity: see SPICE_SDK_TOAST_SEVERITY; controls the accent color (purely cosmetic) +// text: null-terminated UTF-8 message to display; wraps inside the toast + +typedef SPICE_SDK_STATUS_CODE (__cdecl spice_sdk_add_toast_func)( + SPICE_SDK_TOAST_SEVERITY severity, + const char *text +); + typedef struct SPICE_SDK_V0 { uint32_t size; @@ -242,6 +260,8 @@ typedef struct SPICE_SDK_V0 { spice_sdk_insert_card_func *insert_card; spice_sdk_set_keypad_func *set_keypad; + spice_sdk_add_toast_func *add_toast; + } SPICE_SDK_V0; typedef void (__cdecl spice_sdk_destroy_callback_func)( @@ -270,4 +290,4 @@ typedef int (__cdecl spice_sdk_entry_point_func)( } // extern "C" #endif -#endif // SPICE_SDK_H +#endif // SPICE_SDK_H diff --git a/src/spice2x/sdk/sample/v0/cpp/v0_cpp.cpp b/src/spice2x/sdk/sample/v0/cpp/v0_cpp.cpp index f6c0441..8511230 100644 --- a/src/spice2x/sdk/sample/v0/cpp/v0_cpp.cpp +++ b/src/spice2x/sdk/sample/v0/cpp/v0_cpp.cpp @@ -84,7 +84,15 @@ static void worker_thread_main(std::stop_token stop_token) { spice.set_button(arrow.button, true, 1.f); if (!arrow.previous_state) { - LOG_INFO(std::format("let me hear you say: {}", arrow.name).c_str()); + + if (spice.add_toast) { + spice.add_toast( + SPICE_SDK_TOAST_LEVEL_INFO, + std::format("let me hear you say: {}", arrow.name).c_str()); + } else { + LOG_INFO(std::format("let me hear you say: {}", arrow.name).c_str()); + } + arrow.previous_state = true; } } else { diff --git a/src/spice2x/sdk/sample/v0/flat_c/v0_flat_c.c b/src/spice2x/sdk/sample/v0/flat_c/v0_flat_c.c index 8e4a8ae..7733313 100644 --- a/src/spice2x/sdk/sample/v0/flat_c/v0_flat_c.c +++ b/src/spice2x/sdk/sample/v0/flat_c/v0_flat_c.c @@ -38,10 +38,10 @@ static unsigned __stdcall worker_thread(void *arg); // this sample assumes that the game is IIDX, but it doesn't check for it. -SPICE_SDK_ENTRY_POINT -spice_sdk_entry_point( - spice_sdk_init_func *init -) +SPICE_SDK_ENTRY_POINT +spice_sdk_entry_point( + spice_sdk_init_func *init +) { SPICE_SDK_STATUS_CODE status; @@ -108,59 +108,59 @@ static unsigned __stdcall worker_thread(void *arg) { phase += 1; switch (phase) { case 1: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "get buttons..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_INFO, "get buttons..."); get_buttons(); break; case 2: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "set buttons..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_WARNING, "set buttons..."); set_buttons(); break; case 3: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "clear buttons..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_ERROR, "clear buttons..."); clear_buttons(); break; case 4: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "get analogs..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_INFO, "get analogs..."); get_analogs(); break; case 5: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "set analogs..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_WARNING, "set analogs..."); set_analogs(); break; case 6: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "clear analogs..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_ERROR, "clear analogs..."); clear_analogs(); break; case 7: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "get lights..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_INFO, "get lights..."); get_lights(); break; case 8: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "set lights..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_WARNING, "set lights..."); set_lights(); break; case 9: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "clear lights..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_ERROR, "clear lights..."); clear_lights(); break; case 10: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "set touch..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_INFO, "set touch..."); set_touch(); break; case 11: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "clear touch..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_ERROR, "clear touch..."); clear_touch(); break; case 12: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "insert card..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_SUCCESS, "insert card..."); insert_card(); break; case 13: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "set keypad..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_SUCCESS, "set keypad..."); set_keypad(); break; case 14: - spice.log(SPICE_SDK_LOG_LEVEL_INFO, "sample_v0", "clear keypad..."); + spice.add_toast(SPICE_SDK_TOAST_LEVEL_ERROR, "clear keypad..."); clear_keypad(); break; default: diff --git a/src/spice2x/sdk/sdk.cpp b/src/spice2x/sdk/sdk.cpp index d1e2bc3..10ddb6b 100644 --- a/src/spice2x/sdk/sdk.cpp +++ b/src/spice2x/sdk/sdk.cpp @@ -7,6 +7,7 @@ #include "games/io.h" #include "launcher/launcher.h" #include "misc/eamuse.h" +#include "overlay/notifications.h" #include "sdk/include/spicesdk.h" #include "touch/touch.h" #include "util/logging.h" @@ -29,6 +30,7 @@ static spice_sdk_set_touch_func sdk_set_touch; static spice_sdk_clear_touch_func sdk_clear_touch; static spice_sdk_insert_card_func sdk_insert_card; static spice_sdk_set_keypad_func sdk_set_keypad; +static spice_sdk_add_toast_func sdk_add_toast; struct SdkModule { std::string dll; @@ -169,6 +171,12 @@ sdk_init( v0->insert_card = sdk_insert_card; v0->set_keypad = sdk_set_keypad; // end of 0.1 + + if (v0->size >= RTL_SIZEOF_THROUGH_FIELD(SPICE_SDK_V0, add_toast)) { + v0->add_toast = sdk_add_toast; + } + + // end of 0.2 // any newer minor iterations will need to check the size { @@ -594,5 +602,43 @@ sdk_set_keypad( return SPICE_SDK_STATUS_SUCCESS; } +SPICE_SDK_STATUS_CODE +__cdecl +sdk_add_toast( + SPICE_SDK_TOAST_SEVERITY severity, + const char *text +) +{ + std::shared_lock lock(sdk_global_mutex); + if (!sdk_initialized) { + return SPICE_SDK_STATUS_TOO_LATE; + } + + if (!text) { + return SPICE_SDK_STATUS_INVALID_ARGUMENT_2; + } + + overlay::notifications::Severity sev; + switch (severity) { + case SPICE_SDK_TOAST_LEVEL_INFO: + sev = overlay::notifications::Severity::Info; + break; + case SPICE_SDK_TOAST_LEVEL_SUCCESS: + sev = overlay::notifications::Severity::Success; + break; + case SPICE_SDK_TOAST_LEVEL_WARNING: + sev = overlay::notifications::Severity::Warning; + break; + case SPICE_SDK_TOAST_LEVEL_ERROR: + sev = overlay::notifications::Severity::Error; + break; + default: + return SPICE_SDK_STATUS_INVALID_ARGUMENT_1; + } + + overlay::notifications::add(sev, text); + return SPICE_SDK_STATUS_SUCCESS; +} + } // namespace sdk \ No newline at end of file diff --git a/src/spice2x/util/fileutils.cpp b/src/spice2x/util/fileutils.cpp index 984d290..9d05598 100644 --- a/src/spice2x/util/fileutils.cpp +++ b/src/spice2x/util/fileutils.cpp @@ -26,6 +26,14 @@ bool fileutils::file_exists(const std::filesystem::path &file_path) { return file_exists(file_path.c_str()); } +std::string fileutils::basename(std::string_view path) { + const auto slash = path.find_last_of("\\/"); + if (slash == std::string_view::npos) { + return std::string(path); + } + return std::string(path.substr(slash + 1)); +} + bool fileutils::verify_header_pe(const std::filesystem::path &file_path) { if (!file_exists(file_path)) { return false; diff --git a/src/spice2x/util/fileutils.h b/src/spice2x/util/fileutils.h index 14ddecd..3eb6f80 100644 --- a/src/spice2x/util/fileutils.h +++ b/src/spice2x/util/fileutils.h @@ -35,4 +35,6 @@ namespace fileutils { std::filesystem::path get_config_file_path(const std::string module, const std::string filename, bool* file_exists=nullptr); bool write_config_file(const std::string_view &module, const std::filesystem::path path, std::string text); + + std::string basename(std::string_view path); }