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); }