From ac12ed0bdb6586273f47e08728e2d03855def31e Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 01:15:47 -0500 Subject: [PATCH 1/6] Add cross-platform notification audio cues --- example_community_patch_settings_da.toml | 8 + example_community_patch_settings_de.toml | 8 + ...munity_patch_settings_en-GB-x-cockney.toml | 8 + ...mmunity_patch_settings_en-x-minionese.toml | 8 + example_community_patch_settings_en.toml | 8 + example_community_patch_settings_es.toml | 8 + example_community_patch_settings_fr.toml | 8 + example_community_patch_settings_nl.toml | 8 + example_community_patch_settings_ru.toml | 8 + example_community_patch_settings_tlh.toml | 8 + mods/src/config.cc | 28 +++ mods/src/config.h | 7 + mods/src/defaultconfig.h | 5 + mods/src/notification_audio_mac.mm | 27 ++ mods/src/patches/notification_audio.cc | 236 ++++++++++++++++++ mods/src/patches/notification_audio.h | 23 ++ .../src/patches/notification_audio_platform.h | 6 + mods/src/patches/notification_service.cc | 26 +- mods/xmake.lua | 1 + 19 files changed, 436 insertions(+), 3 deletions(-) create mode 100644 mods/src/notification_audio_mac.mm create mode 100644 mods/src/patches/notification_audio.cc create mode 100644 mods/src/patches/notification_audio.h create mode 100644 mods/src/patches/notification_audio_platform.h diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 4e4003d2f..ff8585a4e 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[======================================================================================================]> # # ***** * * * diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2dda59892..2a8fc320b 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 0ccf711ca..e16874cd2 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 9f1e1e3a6..20d316196 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9ffe927fe..22eeb26c2 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index b89c9a2e8..6ea0664d8 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=====================================================]> # # * * * diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index fdbc5f79d..66441cb22 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b2b39b208..00361a2ae 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=======================================]> # # **** ** ** diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index ad3db036a..120e2c2a1 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[============================================================]> # # diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index b9bce7597..fbe4b4606 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -37,6 +37,14 @@ disabled_events = "" # Write audio event names to the trace log for troubleshooting trace_events = false +# Optional generated notification cues; "none" disables an alert. +# Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + # <[=====================================================]> # # * * **** * * diff --git a/mods/src/config.cc b/mods/src/config.cc index 95deab836..e11aaca62 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -328,6 +328,25 @@ T get_config_or_default(toml::table& config, toml::table& new_config, std::strin return (T)final_value; } +NotificationSound get_notification_sound(toml::table& config, toml::table& new_config, std::string_view item, + std::string_view default_value, bool write_log) +{ + const auto value = get_config_or_default(config, new_config, "audio", item, + std::string(default_value), false); + auto sound = notification_sound_from_name(StripAsciiWhitespace(value)); + if (!sound.has_value()) { + spdlog::warn("invalid config value audio.{}: '{}'; using {}", item, value, default_value); + sound = notification_sound_from_name(default_value); + } + + const auto result = sound.value_or(NotificationSound::None); + new_config["audio"].as_table()->insert_or_assign(item, notification_sound_name(result)); + if (write_log) { + spdlog::debug("config value audio.{} value: {}", item, notification_sound_name(result)); + } + return result; +} + std::string_view to_string(MissionHudVisibility visibility) { switch (visibility) { @@ -951,6 +970,15 @@ void Config::Load() } } this->installAudioEventHooks = this->trace_audio_events || !this->disabled_audio_events.empty(); + this->alert_victory = + get_notification_sound(config, parsed, "alert_victory", DCA::alert_victory, write_config); + this->alert_defeat = get_notification_sound(config, parsed, "alert_defeat", DCA::alert_defeat, write_config); + this->alert_armada_created = + get_notification_sound(config, parsed, "alert_armada_created", DCA::alert_armada_created, write_config); + this->alert_armada_battle_won = + get_notification_sound(config, parsed, "alert_armada_battle_won", DCA::alert_armada_battle_won, write_config); + this->alert_armada_battle_lost = + get_notification_sound(config, parsed, "alert_armada_battle_lost", DCA::alert_armada_battle_lost, write_config); this->auto_open_bulk_claim_flyout = get_config_or_default(config, parsed, "ui", "auto_open_bulk_claim_flyout", DCU::auto_open_bulk_claim_flyout, write_config); diff --git a/mods/src/config.h b/mods/src/config.h index 43f5c3913..93264d756 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -8,6 +8,8 @@ #include +#include "patches/notification_audio.h" + #if _WIN32 #include #endif @@ -196,6 +198,11 @@ class Config final bool disable_toast_banners; bool trace_audio_events; std::vector disabled_audio_events; + NotificationSound alert_victory; + NotificationSound alert_defeat; + NotificationSound alert_armada_created; + NotificationSound alert_armada_battle_won; + NotificationSound alert_armada_battle_lost; bool auto_open_bulk_claim_flyout; bool auto_confirm_ft_upgrade; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b22cad5b8..efe3530e2 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -11,6 +11,11 @@ namespace Audio { constexpr const char* disabled_events = ""; constexpr bool trace_events = false; + constexpr const char* alert_victory = "none"; + constexpr const char* alert_defeat = "none"; + constexpr const char* alert_armada_created = "none"; + constexpr const char* alert_armada_battle_won = "none"; + constexpr const char* alert_armada_battle_lost = "none"; } // namespace Audio namespace SystemConfig diff --git a/mods/src/notification_audio_mac.mm b/mods/src/notification_audio_mac.mm new file mode 100644 index 000000000..cae47a9eb --- /dev/null +++ b/mods/src/notification_audio_mac.mm @@ -0,0 +1,27 @@ +#ifdef __APPLE__ + +#include "patches/notification_audio_platform.h" + +#import + +bool notification_audio_platform_play(const uint8_t* data, size_t size) +{ + @autoreleasepool { + static NSSound* current_sound = nil; + + NSData* sound_data = [NSData dataWithBytesNoCopy:const_cast(data) length:size freeWhenDone:NO]; + NSSound* sound = [[NSSound alloc] initWithData:sound_data]; + if (!sound) return false; + + [current_sound stop]; +#if __has_feature(objc_arc) + current_sound = sound; +#else + [current_sound release]; + current_sound = sound; +#endif + return [current_sound play]; + } +} + +#endif diff --git a/mods/src/patches/notification_audio.cc b/mods/src/patches/notification_audio.cc new file mode 100644 index 000000000..ebdd87c3c --- /dev/null +++ b/mods/src/patches/notification_audio.cc @@ -0,0 +1,236 @@ +#include "patches/notification_audio.h" + +#include "patches/notification_audio_platform.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if _WIN32 +#include +#include +#endif + +namespace +{ +struct ToneSegment { + double frequency_hz; + int duration_ms; +}; + +constexpr int kSampleRate = 44100; +constexpr double kTwoPi = 6.28318530717958647692; +constexpr double kAmplitude = 0.26; + +constexpr std::array kDefaultPattern{{{740.0, 70}, {0.0, 22}, {880.0, 85}, {0.0, 18}}}; +constexpr std::array kInfoPattern{{{659.0, 80}, {0.0, 24}, {880.0, 110}}}; +constexpr std::array kSuccessPattern{{{587.0, 70}, {0.0, 18}, {740.0, 70}, {0.0, 18}, {988.0, 120}}}; +constexpr std::array kWarningPattern{{{622.0, 90}, {0.0, 36}, {466.0, 110}, {0.0, 28}, {466.0, 90}}}; +constexpr std::array kAlarmPattern{{{880.0, 90}, {0.0, 42}, {880.0, 90}, {0.0, 42}, {698.0, 160}}}; +constexpr std::array kArrivalPattern{{{523.0, 65}, {0.0, 18}, {659.0, 70}, {0.0, 18}, {1046.0, 125}}}; +constexpr std::array kSoftPattern{{{523.0, 90}, {0.0, 26}, {659.0, 110}}}; +constexpr std::array kPingPattern{{{1046.0, 95}}}; +constexpr std::array kRepairPattern{{{440.0, 70}, {0.0, 18}, {554.0, 70}, {0.0, 18}, {740.0, 140}}}; + +std::once_flag s_sound_buffers_once; +std::array, static_cast(NotificationSound::Count)> s_sound_buffers; + +std::string normalize_sound_name(std::string_view value) +{ + std::string normalized; + normalized.reserve(value.size()); + for (const auto ch : value) { + if (ch >= 'A' && ch <= 'Z') { + normalized.push_back(static_cast(ch - 'A' + 'a')); + } else if (ch == '-' || ch == ' ') { + normalized.push_back('_'); + } else { + normalized.push_back(ch); + } + } + return normalized; +} + +std::span sound_pattern(NotificationSound sound) +{ + switch (sound) { + case NotificationSound::Default: + return kDefaultPattern; + case NotificationSound::Info: + return kInfoPattern; + case NotificationSound::Success: + return kSuccessPattern; + case NotificationSound::Warning: + return kWarningPattern; + case NotificationSound::Alarm: + return kAlarmPattern; + case NotificationSound::Arrival: + return kArrivalPattern; + case NotificationSound::Soft: + return kSoftPattern; + case NotificationSound::Ping: + return kPingPattern; + case NotificationSound::Repair: + return kRepairPattern; + default: + return {}; + } +} + +void append_u16(std::vector& buffer, uint16_t value) +{ + buffer.push_back(static_cast(value & 0xff)); + buffer.push_back(static_cast((value >> 8) & 0xff)); +} + +void append_u32(std::vector& buffer, uint32_t value) +{ + buffer.push_back(static_cast(value & 0xff)); + buffer.push_back(static_cast((value >> 8) & 0xff)); + buffer.push_back(static_cast((value >> 16) & 0xff)); + buffer.push_back(static_cast((value >> 24) & 0xff)); +} + +void append_ascii(std::vector& buffer, std::string_view value) +{ buffer.insert(buffer.end(), value.begin(), value.end()); } + +std::vector build_wav(std::span pattern) +{ + uint32_t sample_count = 0; + for (const auto& segment : pattern) { + sample_count += static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); + } + + constexpr uint16_t channels = 1; + constexpr uint16_t bits_per_sample = 16; + const uint32_t data_bytes = sample_count * channels * (bits_per_sample / 8); + + std::vector buffer; + buffer.reserve(44 + data_bytes); + append_ascii(buffer, "RIFF"); + append_u32(buffer, 36 + data_bytes); + append_ascii(buffer, "WAVE"); + append_ascii(buffer, "fmt "); + append_u32(buffer, 16); + append_u16(buffer, 1); + append_u16(buffer, channels); + append_u32(buffer, kSampleRate); + append_u32(buffer, kSampleRate * channels * (bits_per_sample / 8)); + append_u16(buffer, channels * (bits_per_sample / 8)); + append_u16(buffer, bits_per_sample); + append_ascii(buffer, "data"); + append_u32(buffer, data_bytes); + + double phase = 0.0; + for (const auto& segment : pattern) { + const auto segment_samples = static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); + const auto step = segment.frequency_hz > 0.0 ? kTwoPi * segment.frequency_hz / kSampleRate : 0.0; + for (int index = 0; index < segment_samples; ++index) { + const auto fade_in = std::min(1.0, static_cast(index) / 80.0); + const auto fade_out = std::min(1.0, static_cast(segment_samples - index) / 120.0); + const auto sample = segment.frequency_hz > 0.0 ? std::sin(phase) * kAmplitude * fade_in * fade_out : 0.0; + append_u16(buffer, static_cast(static_cast(sample * 32767.0))); + phase += step; + if (phase > kTwoPi) { + phase -= kTwoPi; + } + } + } + return buffer; +} + +void initialize_sound_buffers() +{ + for (size_t index = 0; index < s_sound_buffers.size(); ++index) { + const auto pattern = sound_pattern(static_cast(index)); + if (!pattern.empty()) { + s_sound_buffers[index] = build_wav(pattern); + } + } +} +} // namespace + +const char* notification_sound_name(NotificationSound sound) +{ + switch (sound) { + case NotificationSound::None: + return "none"; + case NotificationSound::Default: + return "default"; + case NotificationSound::Info: + return "info"; + case NotificationSound::Success: + return "success"; + case NotificationSound::Warning: + return "warning"; + case NotificationSound::Alarm: + return "alarm"; + case NotificationSound::Arrival: + return "arrival"; + case NotificationSound::Soft: + return "soft"; + case NotificationSound::Ping: + return "ping"; + case NotificationSound::Repair: + return "repair"; + default: + return "none"; + } +} + +std::optional notification_sound_from_name(std::string_view name) +{ + const auto normalized = normalize_sound_name(name); + if (normalized == "none" || normalized == "off" || normalized == "silent") + return NotificationSound::None; + if (normalized == "default") + return NotificationSound::Default; + if (normalized == "info") + return NotificationSound::Info; + if (normalized == "success" || normalized == "victory") + return NotificationSound::Success; + if (normalized == "warning" || normalized == "warn") + return NotificationSound::Warning; + if (normalized == "alarm" || normalized == "attack") + return NotificationSound::Alarm; + if (normalized == "arrival" || normalized == "arrive") + return NotificationSound::Arrival; + if (normalized == "soft" || normalized == "quiet") + return NotificationSound::Soft; + if (normalized == "ping") + return NotificationSound::Ping; + if (normalized == "repair" || normalized == "repaired") + return NotificationSound::Repair; + return std::nullopt; +} + +void notification_audio_play(NotificationSound sound) +{ + if (sound == NotificationSound::None) + return; + + std::call_once(s_sound_buffers_once, initialize_sound_buffers); + const auto index = static_cast(sound); + if (index >= s_sound_buffers.size() || s_sound_buffers[index].empty()) + return; + + const auto& buffer = s_sound_buffers[index]; + if (!notification_audio_platform_play(buffer.data(), buffer.size())) { + spdlog::warn("[NotifyAudio] Failed to play '{}' cue", notification_sound_name(sound)); + } +} + +#if _WIN32 +bool notification_audio_platform_play(const uint8_t* data, size_t) +{ return PlaySoundW(reinterpret_cast(data), nullptr, SND_ASYNC | SND_MEMORY | SND_NODEFAULT) != FALSE; } +#elif !defined(__APPLE__) +bool notification_audio_platform_play(const uint8_t*, size_t) +{ return false; } +#endif diff --git a/mods/src/patches/notification_audio.h b/mods/src/patches/notification_audio.h new file mode 100644 index 000000000..8afd5347a --- /dev/null +++ b/mods/src/patches/notification_audio.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +enum class NotificationSound : uint8_t { + None = 0, + Default, + Info, + Success, + Warning, + Alarm, + Arrival, + Soft, + Ping, + Repair, + Count, +}; + +[[nodiscard]] const char* notification_sound_name(NotificationSound sound); +[[nodiscard]] std::optional notification_sound_from_name(std::string_view name); +void notification_audio_play(NotificationSound sound); diff --git a/mods/src/patches/notification_audio_platform.h b/mods/src/patches/notification_audio_platform.h new file mode 100644 index 000000000..3ee8a973c --- /dev/null +++ b/mods/src/patches/notification_audio_platform.h @@ -0,0 +1,6 @@ +#pragma once + +#include +#include + +[[nodiscard]] bool notification_audio_platform_play(const uint8_t* data, size_t size); diff --git a/mods/src/patches/notification_service.cc b/mods/src/patches/notification_service.cc index 46109b8c4..189fabe42 100644 --- a/mods/src/patches/notification_service.cc +++ b/mods/src/patches/notification_service.cc @@ -1,5 +1,6 @@ #include "patches/notification_service.h" #include "patches/battle_notify_parser.h" +#include "patches/notification_audio.h" #include "config.h" #include "str_utils.h" @@ -93,6 +94,19 @@ static const char* toast_state_title(int state) } } +static NotificationSound toast_state_sound(int state) +{ + const auto& config = Config::Get(); + switch (state) { + case Victory: return config.alert_victory; + case Defeat: return config.alert_defeat; + case ArmadaCreated: return config.alert_armada_created; + case ArmadaBattleWon: return config.alert_armada_battle_won; + case ArmadaBattleLost: return config.alert_armada_battle_lost; + default: return NotificationSound::None; + } +} + // --------------------------------------------------------------------------- // Platform notification delivery // --------------------------------------------------------------------------- @@ -477,17 +491,23 @@ void notification_init() } catch (...) { spdlog::warn("[Notify] Windows notification service failed (unknown error)"); } +#else +#if __APPLE__ + spdlog::info("[Notify] macOS audio notification service ready"); #else spdlog::info("[Notify] Notification service: platform not supported (no-op)"); #endif +#endif } void notification_handle_toast(Toast* toast) { -#if !_WIN32 - return; // No notification delivery on non-Windows platforms yet -#else + if (!toast) return; + auto state = toast->get_State(); + notification_audio_play(toast_state_sound(state)); + +#if _WIN32 // Check if this toast type is in the user's notify list const auto& notify_types = Config::Get().notify_banner_types; diff --git a/mods/xmake.lua b/mods/xmake.lua index 1a80fc8ab..aee96c5a2 100644 --- a/mods/xmake.lua +++ b/mods/xmake.lua @@ -116,6 +116,7 @@ do if is_plat("windows") then add_cxflags("/bigobj") add_linkdirs("src/il2cpp") + add_syslinks("winmm") elseif is_plat("macosx") then add_cxflags("-fms-extensions") -- Add Objective-C++ source From 05daae5b8624e290fb36c6210b0c39eae7fe7b2a Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sat, 29 Aug 2026 01:55:16 -0500 Subject: [PATCH 2/6] Warn when audio alerts lack toast hooks --- example_community_patch_settings_da.toml | 2 +- example_community_patch_settings_de.toml | 2 +- example_community_patch_settings_en-GB-x-cockney.toml | 2 +- example_community_patch_settings_en-x-minionese.toml | 2 +- example_community_patch_settings_en.toml | 2 +- example_community_patch_settings_es.toml | 2 +- example_community_patch_settings_fr.toml | 2 +- example_community_patch_settings_nl.toml | 2 +- example_community_patch_settings_ru.toml | 2 +- example_community_patch_settings_tlh.toml | 2 +- mods/src/config.cc | 7 +++++++ 11 files changed, 17 insertions(+), 10 deletions(-) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index ff8585a4e..8906a3d17 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[========================================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2a8fc320b..29fd63105 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[===================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index e16874cd2..68196debe 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 20d316196..80171600b 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[=============================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 22eeb26c2..44fbed0d6 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[========================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 6ea0664d8..5e07467e3 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 66441cb22..c80c17447 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[==========================================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 00361a2ae..64be505a7 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[===========================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 120e2c2a1..693344ba4 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[=====================================================================================================================================================================]> [sync.targets.spocksclub] - diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index fbe4b4606..a30e387c6 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -39,6 +39,7 @@ trace_events = false # Optional generated notification cues; "none" disables an alert. # Available sounds: default, info, success, warning, alarm, arrival, soft, ping, repair +# Requires patches.toastbannerhooks = true. alert_victory = "none" alert_defeat = "none" alert_armada_created = "none" @@ -837,4 +838,3 @@ verify_ssl = true # <[======================================================================================================]> [sync.targets.spocksclub] - diff --git a/mods/src/config.cc b/mods/src/config.cc index e11aaca62..424eef469 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -979,6 +979,13 @@ void Config::Load() get_notification_sound(config, parsed, "alert_armada_battle_won", DCA::alert_armada_battle_won, write_config); this->alert_armada_battle_lost = get_notification_sound(config, parsed, "alert_armada_battle_lost", DCA::alert_armada_battle_lost, write_config); + if (!this->installToastBannerHooks + && (this->alert_victory != NotificationSound::None || this->alert_defeat != NotificationSound::None + || this->alert_armada_created != NotificationSound::None + || this->alert_armada_battle_won != NotificationSound::None + || this->alert_armada_battle_lost != NotificationSound::None)) { + spdlog::warn("audio alerts require patches.toastbannerhooks = true"); + } this->auto_open_bulk_claim_flyout = get_config_or_default(config, parsed, "ui", "auto_open_bulk_claim_flyout", DCU::auto_open_bulk_claim_flyout, write_config); From b39cf0ad527a4d044042716d48dae429aa1262c8 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Tue, 1 Sep 2026 21:43:26 -0500 Subject: [PATCH 3/6] Centralize toast audio alert configuration Use one descriptor catalog for parsing, dependency checks, and toast-state lookup while preserving unconditional config normalization. --- mods/src/config.cc | 53 +++++++++++++++++------- mods/src/config.h | 12 +++--- mods/src/patches/notification_service.cc | 15 +------ 3 files changed, 47 insertions(+), 33 deletions(-) diff --git a/mods/src/config.cc b/mods/src/config.cc index 424eef469..5878c90c9 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -30,6 +30,27 @@ namespace DCS = DefaultConfig::Sync; namespace DCSC = DefaultConfig::SystemConfig; namespace DCSH = DefaultConfig::Shortcuts; +namespace +{ +struct ToastAudioAlertConfig { + int toast_state; + std::string_view config_name; + std::string_view default_sound; + NotificationSound Config::* config_member; +}; + +constexpr std::array kToastAudioAlerts{ + ToastAudioAlertConfig{ToastState::Victory, "alert_victory", DCA::alert_victory, &Config::alert_victory}, + ToastAudioAlertConfig{ToastState::Defeat, "alert_defeat", DCA::alert_defeat, &Config::alert_defeat}, + ToastAudioAlertConfig{ToastState::ArmadaCreated, "alert_armada_created", DCA::alert_armada_created, + &Config::alert_armada_created}, + ToastAudioAlertConfig{ToastState::ArmadaBattleWon, "alert_armada_battle_won", DCA::alert_armada_battle_won, + &Config::alert_armada_battle_won}, + ToastAudioAlertConfig{ToastState::ArmadaBattleLost, "alert_armada_battle_lost", DCA::alert_armada_battle_lost, + &Config::alert_armada_battle_lost}, +}; +} // namespace + static const eastl::tuple bannerTypes[] = { {"All", ToastState::All}, {"Standard", ToastState::Standard}, @@ -126,6 +147,19 @@ Config& Config::Get() return config; } +NotificationSound Config::NotificationSoundForToast(int toast_state) const +{ + const auto alert = std::ranges::find(kToastAudioAlerts, toast_state, &ToastAudioAlertConfig::toast_state); + return alert == kToastAudioAlerts.end() ? NotificationSound::None : this->*(alert->config_member); +} + +bool Config::AnyToastAudioAlertConfigured() const +{ + return std::ranges::any_of(kToastAudioAlerts, [this](const auto& alert) { + return this->*(alert.config_member) != NotificationSound::None; + }); +} + MissionHudVisibility Config::MissionHudButtonVisibility(std::string_view button_name) const { const auto it = this->mission_hud_buttons.find(std::string(button_name)); @@ -970,20 +1004,11 @@ void Config::Load() } } this->installAudioEventHooks = this->trace_audio_events || !this->disabled_audio_events.empty(); - this->alert_victory = - get_notification_sound(config, parsed, "alert_victory", DCA::alert_victory, write_config); - this->alert_defeat = get_notification_sound(config, parsed, "alert_defeat", DCA::alert_defeat, write_config); - this->alert_armada_created = - get_notification_sound(config, parsed, "alert_armada_created", DCA::alert_armada_created, write_config); - this->alert_armada_battle_won = - get_notification_sound(config, parsed, "alert_armada_battle_won", DCA::alert_armada_battle_won, write_config); - this->alert_armada_battle_lost = - get_notification_sound(config, parsed, "alert_armada_battle_lost", DCA::alert_armada_battle_lost, write_config); - if (!this->installToastBannerHooks - && (this->alert_victory != NotificationSound::None || this->alert_defeat != NotificationSound::None - || this->alert_armada_created != NotificationSound::None - || this->alert_armada_battle_won != NotificationSound::None - || this->alert_armada_battle_lost != NotificationSound::None)) { + for (const auto& alert : kToastAudioAlerts) { + this->*(alert.config_member) = + get_notification_sound(config, parsed, alert.config_name, alert.default_sound, write_config); + } + if (!this->installToastBannerHooks && AnyToastAudioAlertConfigured()) { spdlog::warn("audio alerts require patches.toastbannerhooks = true"); } this->auto_open_bulk_claim_flyout = get_config_or_default(config, parsed, "ui", "auto_open_bulk_claim_flyout", diff --git a/mods/src/config.h b/mods/src/config.h index 93264d756..0e5a4c46e 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -142,6 +142,8 @@ class Config final [[nodiscard]] MissionHudVisibility MissionHudButtonVisibility(std::string_view button_name) const; [[nodiscard]] bool MissionHudTweaksEnabled() const; + [[nodiscard]] NotificationSound NotificationSoundForToast(int toast_state) const; + [[nodiscard]] bool AnyToastAudioAlertConfigured() const; // Disallow copying/moving to enforce singleton Config(const Config&) = delete; @@ -198,11 +200,11 @@ class Config final bool disable_toast_banners; bool trace_audio_events; std::vector disabled_audio_events; - NotificationSound alert_victory; - NotificationSound alert_defeat; - NotificationSound alert_armada_created; - NotificationSound alert_armada_battle_won; - NotificationSound alert_armada_battle_lost; + NotificationSound alert_victory = NotificationSound::None; + NotificationSound alert_defeat = NotificationSound::None; + NotificationSound alert_armada_created = NotificationSound::None; + NotificationSound alert_armada_battle_won = NotificationSound::None; + NotificationSound alert_armada_battle_lost = NotificationSound::None; bool auto_open_bulk_claim_flyout; bool auto_confirm_ft_upgrade; diff --git a/mods/src/patches/notification_service.cc b/mods/src/patches/notification_service.cc index 189fabe42..45e09b850 100644 --- a/mods/src/patches/notification_service.cc +++ b/mods/src/patches/notification_service.cc @@ -94,19 +94,6 @@ static const char* toast_state_title(int state) } } -static NotificationSound toast_state_sound(int state) -{ - const auto& config = Config::Get(); - switch (state) { - case Victory: return config.alert_victory; - case Defeat: return config.alert_defeat; - case ArmadaCreated: return config.alert_armada_created; - case ArmadaBattleWon: return config.alert_armada_battle_won; - case ArmadaBattleLost: return config.alert_armada_battle_lost; - default: return NotificationSound::None; - } -} - // --------------------------------------------------------------------------- // Platform notification delivery // --------------------------------------------------------------------------- @@ -505,7 +492,7 @@ void notification_handle_toast(Toast* toast) if (!toast) return; auto state = toast->get_State(); - notification_audio_play(toast_state_sound(state)); + notification_audio_play(Config::Get().NotificationSoundForToast(state)); #if _WIN32 From 218c5947e3246d0130aae14865e191f4a23a1f5c Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 3 Sep 2026 00:39:59 -0500 Subject: [PATCH 4/6] Refine notification audio catalog and initialization --- mods/src/config.cc | 34 ++-- mods/src/config.h | 1 - mods/src/patches/notification_audio.cc | 197 +++++++++++++---------- mods/src/patches/notification_audio.h | 2 +- mods/src/patches/notification_service.cc | 13 +- 5 files changed, 135 insertions(+), 112 deletions(-) diff --git a/mods/src/config.cc b/mods/src/config.cc index 5878c90c9..a00f13481 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -39,16 +39,15 @@ struct ToastAudioAlertConfig { NotificationSound Config::* config_member; }; -constexpr std::array kToastAudioAlerts{ - ToastAudioAlertConfig{ToastState::Victory, "alert_victory", DCA::alert_victory, &Config::alert_victory}, - ToastAudioAlertConfig{ToastState::Defeat, "alert_defeat", DCA::alert_defeat, &Config::alert_defeat}, - ToastAudioAlertConfig{ToastState::ArmadaCreated, "alert_armada_created", DCA::alert_armada_created, - &Config::alert_armada_created}, - ToastAudioAlertConfig{ToastState::ArmadaBattleWon, "alert_armada_battle_won", DCA::alert_armada_battle_won, - &Config::alert_armada_battle_won}, - ToastAudioAlertConfig{ToastState::ArmadaBattleLost, "alert_armada_battle_lost", DCA::alert_armada_battle_lost, - &Config::alert_armada_battle_lost}, -}; +constexpr auto kToastAudioAlerts = std::to_array({ + {ToastState::Victory, "alert_victory", DCA::alert_victory, &Config::alert_victory}, + {ToastState::Defeat, "alert_defeat", DCA::alert_defeat, &Config::alert_defeat}, + {ToastState::ArmadaCreated, "alert_armada_created", DCA::alert_armada_created, &Config::alert_armada_created}, + {ToastState::ArmadaBattleWon, "alert_armada_battle_won", DCA::alert_armada_battle_won, + &Config::alert_armada_battle_won}, + {ToastState::ArmadaBattleLost, "alert_armada_battle_lost", DCA::alert_armada_battle_lost, + &Config::alert_armada_battle_lost}, +}); } // namespace static const eastl::tuple bannerTypes[] = { @@ -153,13 +152,6 @@ NotificationSound Config::NotificationSoundForToast(int toast_state) const return alert == kToastAudioAlerts.end() ? NotificationSound::None : this->*(alert->config_member); } -bool Config::AnyToastAudioAlertConfigured() const -{ - return std::ranges::any_of(kToastAudioAlerts, [this](const auto& alert) { - return this->*(alert.config_member) != NotificationSound::None; - }); -} - MissionHudVisibility Config::MissionHudButtonVisibility(std::string_view button_name) const { const auto it = this->mission_hud_buttons.find(std::string(button_name)); @@ -1004,11 +996,13 @@ void Config::Load() } } this->installAudioEventHooks = this->trace_audio_events || !this->disabled_audio_events.empty(); + bool any_toast_audio_alert_configured = false; for (const auto& alert : kToastAudioAlerts) { - this->*(alert.config_member) = - get_notification_sound(config, parsed, alert.config_name, alert.default_sound, write_config); + const auto sound = get_notification_sound(config, parsed, alert.config_name, alert.default_sound, write_config); + this->*(alert.config_member) = sound; + any_toast_audio_alert_configured |= sound != NotificationSound::None; } - if (!this->installToastBannerHooks && AnyToastAudioAlertConfigured()) { + if (!this->installToastBannerHooks && any_toast_audio_alert_configured) { spdlog::warn("audio alerts require patches.toastbannerhooks = true"); } this->auto_open_bulk_claim_flyout = get_config_or_default(config, parsed, "ui", "auto_open_bulk_claim_flyout", diff --git a/mods/src/config.h b/mods/src/config.h index 0e5a4c46e..ffb19d1e7 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -143,7 +143,6 @@ class Config final [[nodiscard]] MissionHudVisibility MissionHudButtonVisibility(std::string_view button_name) const; [[nodiscard]] bool MissionHudTweaksEnabled() const; [[nodiscard]] NotificationSound NotificationSoundForToast(int toast_state) const; - [[nodiscard]] bool AnyToastAudioAlertConfigured() const; // Disallow copying/moving to enforce singleton Config(const Config&) = delete; diff --git a/mods/src/patches/notification_audio.cc b/mods/src/patches/notification_audio.cc index ebdd87c3c..1e2d5f7df 100644 --- a/mods/src/patches/notification_audio.cc +++ b/mods/src/patches/notification_audio.cc @@ -8,7 +8,6 @@ #include #include #include -#include #include #include #include @@ -39,8 +38,92 @@ constexpr std::array kSoftPattern{{{523.0, 90}, {0.0, 26}, {659. constexpr std::array kPingPattern{{{1046.0, 95}}}; constexpr std::array kRepairPattern{{{440.0, 70}, {0.0, 18}, {554.0, 70}, {0.0, 18}, {740.0, 140}}}; -std::once_flag s_sound_buffers_once; -std::array, static_cast(NotificationSound::Count)> s_sound_buffers; +struct SoundDefinition { + NotificationSound sound; + std::string_view name; + std::span pattern; +}; + +constexpr auto kSoundDefinitions = std::to_array({ + {NotificationSound::None, "none", {}}, + {NotificationSound::Default, "default", kDefaultPattern}, + {NotificationSound::Info, "info", kInfoPattern}, + {NotificationSound::Success, "success", kSuccessPattern}, + {NotificationSound::Warning, "warning", kWarningPattern}, + {NotificationSound::Alarm, "alarm", kAlarmPattern}, + {NotificationSound::Arrival, "arrival", kArrivalPattern}, + {NotificationSound::Soft, "soft", kSoftPattern}, + {NotificationSound::Ping, "ping", kPingPattern}, + {NotificationSound::Repair, "repair", kRepairPattern}, +}); + +struct SoundAlias { + std::string_view name; + NotificationSound sound; +}; + +constexpr auto kSoundAliases = std::to_array({ + {"off", NotificationSound::None}, + {"silent", NotificationSound::None}, + {"victory", NotificationSound::Success}, + {"warn", NotificationSound::Warning}, + {"attack", NotificationSound::Alarm}, + {"arrive", NotificationSound::Arrival}, + {"quiet", NotificationSound::Soft}, + {"repaired", NotificationSound::Repair}, +}); + +consteval bool SoundCatalogIsValid() +{ + if (kSoundDefinitions.size() != static_cast(NotificationSound::Count)) { + return false; + } + + for (size_t index = 0; index < kSoundDefinitions.size(); ++index) { + const auto& definition = kSoundDefinitions[index]; + if (static_cast(definition.sound) != index || definition.name.empty() + || (definition.sound == NotificationSound::None) != definition.pattern.empty()) { + return false; + } + for (size_t other = index + 1; other < kSoundDefinitions.size(); ++other) { + if (definition.name == kSoundDefinitions[other].name) { + return false; + } + } + } + + for (size_t index = 0; index < kSoundAliases.size(); ++index) { + const auto& alias = kSoundAliases[index]; + if (alias.name.empty() || static_cast(alias.sound) >= kSoundDefinitions.size()) { + return false; + } + for (const auto& definition : kSoundDefinitions) { + if (alias.name == definition.name) { + return false; + } + } + for (size_t other = index + 1; other < kSoundAliases.size(); ++other) { + if (alias.name == kSoundAliases[other].name) { + return false; + } + } + } + return true; +} + +static_assert(SoundCatalogIsValid()); + +constexpr const SoundDefinition* sound_definition(NotificationSound sound) +{ + const auto index = static_cast(sound); + if (index >= kSoundDefinitions.size() || kSoundDefinitions[index].sound != sound) { + return nullptr; + } + return &kSoundDefinitions[index]; +} + +static_assert(sound_definition(NotificationSound::Success)->name == "success"); +static_assert(sound_definition(NotificationSound::Count) == nullptr); std::string normalize_sound_name(std::string_view value) { @@ -58,32 +141,6 @@ std::string normalize_sound_name(std::string_view value) return normalized; } -std::span sound_pattern(NotificationSound sound) -{ - switch (sound) { - case NotificationSound::Default: - return kDefaultPattern; - case NotificationSound::Info: - return kInfoPattern; - case NotificationSound::Success: - return kSuccessPattern; - case NotificationSound::Warning: - return kWarningPattern; - case NotificationSound::Alarm: - return kAlarmPattern; - case NotificationSound::Arrival: - return kArrivalPattern; - case NotificationSound::Soft: - return kSoftPattern; - case NotificationSound::Ping: - return kPingPattern; - case NotificationSound::Repair: - return kRepairPattern; - default: - return {}; - } -} - void append_u16(std::vector& buffer, uint16_t value) { buffer.push_back(static_cast(value & 0xff)); @@ -146,68 +203,40 @@ std::vector build_wav(std::span pattern) return buffer; } -void initialize_sound_buffers() +using SoundBuffers = std::array, static_cast(NotificationSound::Count)>; + +const SoundBuffers& sound_buffers() { - for (size_t index = 0; index < s_sound_buffers.size(); ++index) { - const auto pattern = sound_pattern(static_cast(index)); - if (!pattern.empty()) { - s_sound_buffers[index] = build_wav(pattern); + static const auto buffers = [] { + SoundBuffers result; + for (const auto& definition : kSoundDefinitions) { + if (!definition.pattern.empty()) { + result[static_cast(definition.sound)] = build_wav(definition.pattern); + } } - } + return result; + }(); + return buffers; } } // namespace -const char* notification_sound_name(NotificationSound sound) +std::string_view notification_sound_name(NotificationSound sound) { - switch (sound) { - case NotificationSound::None: - return "none"; - case NotificationSound::Default: - return "default"; - case NotificationSound::Info: - return "info"; - case NotificationSound::Success: - return "success"; - case NotificationSound::Warning: - return "warning"; - case NotificationSound::Alarm: - return "alarm"; - case NotificationSound::Arrival: - return "arrival"; - case NotificationSound::Soft: - return "soft"; - case NotificationSound::Ping: - return "ping"; - case NotificationSound::Repair: - return "repair"; - default: - return "none"; - } + const auto* definition = sound_definition(sound); + return definition ? definition->name : kSoundDefinitions.front().name; } std::optional notification_sound_from_name(std::string_view name) { const auto normalized = normalize_sound_name(name); - if (normalized == "none" || normalized == "off" || normalized == "silent") - return NotificationSound::None; - if (normalized == "default") - return NotificationSound::Default; - if (normalized == "info") - return NotificationSound::Info; - if (normalized == "success" || normalized == "victory") - return NotificationSound::Success; - if (normalized == "warning" || normalized == "warn") - return NotificationSound::Warning; - if (normalized == "alarm" || normalized == "attack") - return NotificationSound::Alarm; - if (normalized == "arrival" || normalized == "arrive") - return NotificationSound::Arrival; - if (normalized == "soft" || normalized == "quiet") - return NotificationSound::Soft; - if (normalized == "ping") - return NotificationSound::Ping; - if (normalized == "repair" || normalized == "repaired") - return NotificationSound::Repair; + if (const auto definition = std::ranges::find(kSoundDefinitions, normalized, &SoundDefinition::name); + definition != kSoundDefinitions.end()) { + return definition->sound; + } + if (const auto alias = std::ranges::find(kSoundAliases, normalized, &SoundAlias::name); + alias != kSoundAliases.end()) { + return alias->sound; + } return std::nullopt; } @@ -216,12 +245,14 @@ void notification_audio_play(NotificationSound sound) if (sound == NotificationSound::None) return; - std::call_once(s_sound_buffers_once, initialize_sound_buffers); const auto index = static_cast(sound); - if (index >= s_sound_buffers.size() || s_sound_buffers[index].empty()) + if (index >= kSoundDefinitions.size()) + return; + + const auto& buffer = sound_buffers()[index]; + if (buffer.empty()) return; - const auto& buffer = s_sound_buffers[index]; if (!notification_audio_platform_play(buffer.data(), buffer.size())) { spdlog::warn("[NotifyAudio] Failed to play '{}' cue", notification_sound_name(sound)); } diff --git a/mods/src/patches/notification_audio.h b/mods/src/patches/notification_audio.h index 8afd5347a..345fa9840 100644 --- a/mods/src/patches/notification_audio.h +++ b/mods/src/patches/notification_audio.h @@ -18,6 +18,6 @@ enum class NotificationSound : uint8_t { Count, }; -[[nodiscard]] const char* notification_sound_name(NotificationSound sound); +[[nodiscard]] std::string_view notification_sound_name(NotificationSound sound); [[nodiscard]] std::optional notification_sound_from_name(std::string_view name); void notification_audio_play(NotificationSound sound); diff --git a/mods/src/patches/notification_service.cc b/mods/src/patches/notification_service.cc index f2f8d43c4..293424f4c 100644 --- a/mods/src/patches/notification_service.cc +++ b/mods/src/patches/notification_service.cc @@ -450,6 +450,7 @@ static std::string strip_unity_rich_text(const std::string& s) void notification_init() { +#if _WIN32 // Resolve LanguageManager::Localize(out string, LocaleTextContext) — the // 2-parameter overload that takes an LTC and returns a localized string. auto lm_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.Localization", "LanguageManager"); @@ -500,7 +501,6 @@ void notification_init() spdlog::warn("[Notify] Could not resolve Object::ToString — placeholder formatting may be incomplete"); } -#if _WIN32 try { winrt::init_apartment(winrt::apartment_type::single_threaded); spdlog::info("[Notify] Windows notification service initialized"); @@ -510,26 +510,25 @@ void notification_init() } catch (...) { spdlog::warn("[Notify] Windows notification service failed (unknown error)"); } -#else -#if __APPLE__ +#elif __APPLE__ spdlog::info("[Notify] macOS audio notification service ready"); #else spdlog::info("[Notify] Notification service: platform not supported (no-op)"); #endif -#endif } void notification_handle_toast(Toast* toast) { if (!toast) return; - auto state = toast->get_State(); - notification_audio_play(Config::Get().NotificationSoundForToast(state)); + const auto& config = Config::Get(); + const auto state = toast->get_State(); + notification_audio_play(config.NotificationSoundForToast(state)); #if _WIN32 // Check if this toast type is in the user's notify list - const auto& notify_types = Config::Get().notify_banner_types; + const auto& notify_types = config.notify_banner_types; if (std::ranges::find(notify_types, state) == notify_types.end()) { return; } From 5ecf29323d1a22a5e55c6beea6549a32dedc83c2 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 22:09:20 -0500 Subject: [PATCH 5/6] Install fleet observers independently and read current notification preferences --- example_community_patch_settings_en.toml | 2 + mods/src/config.cc | 7 +-- mods/src/defaultconfig.h | 1 + mods/src/notification_desktop_mac.mm | 3 + mods/src/patches/fleet_watch.cc | 6 ++ mods/src/patches/notification_service.cc | 8 ++- mods/src/patches/parts/fleet_notifications.cc | 60 ++++++++++--------- 7 files changed, 51 insertions(+), 36 deletions(-) diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 5d1c72e56..fed836ccd 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -282,6 +282,8 @@ loadingtiphooks = true doubleclickassignshiphooks = true forbiddentechconfirmhooks = true audioeventhooks = true +# Install fleet observers independently of the desktop/audio selections. +fleetnotificationhooks = true freeresizehooks = true game_version = true giftsbulkclaimhooks = true diff --git a/mods/src/config.cc b/mods/src/config.cc index 501b83809..7f8921efb 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -968,6 +968,8 @@ void Config::Load() get_config_or_default(config, parsed, "patches", "doubleclickassignshiphooks", DCP::doubleclickassignshiphooks, write_config); this->installForbiddenTechConfirmationHooks = get_config_or_default(config, parsed, "patches", "forbiddentechconfirmhooks", DCP::forbiddentechconfirmhooks, write_config); + this->installFleetNotificationHooks = + get_config_or_default(config, parsed, "patches", "fleetnotificationhooks", DCP::fleetnotificationhooks, write_config); this->installAudioEventHooks = get_config_or_default(config, parsed, "patches", "audioeventhooks", DCP::audioeventhooks, write_config); this->installInstantCargoCounterHooks = @@ -1347,11 +1349,6 @@ void Config::Load() spdlog::debug("Final fleet notification events: {}", fleet_events_string); parsed["ui"].as_table()->insert_or_assign("notify_fleet_events", fleet_events_string); -#if _WIN32 || __APPLE__ - this->installFleetNotificationHooks = (this->notify_fleet_events | this->audio_fleet_events) != 0; -#else - this->installFleetNotificationHooks = false; -#endif spdlog::debug(""); diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 3b427ba20..263930300 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -100,6 +100,7 @@ namespace Patches constexpr bool doubleclickassignshiphooks = true; constexpr bool forbiddentechconfirmhooks = true; constexpr bool audioeventhooks = true; + constexpr bool fleetnotificationhooks = true; constexpr bool instantcargocounterhooks = true; constexpr bool cargoformathooks = true; // on by default: cargo number precision override constexpr bool officersorthooks = true; // restore Below Deck Ability sort option diff --git a/mods/src/notification_desktop_mac.mm b/mods/src/notification_desktop_mac.mm index 63f8e3514..019f5afe8 100644 --- a/mods/src/notification_desktop_mac.mm +++ b/mods/src/notification_desktop_mac.mm @@ -3,6 +3,7 @@ #import #import #include +#include namespace { @@ -22,6 +23,8 @@ void notification_desktop_mac_init() { + static std::atomic_bool requested{false}; + if (requested.exchange(true)) return; dispatch_async(dispatch_get_main_queue(), ^{ @autoreleasepool { UNUserNotificationCenter* center = Center(); diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index fbda37e57..7af1de8b0 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -429,6 +429,12 @@ void Tick() if (!slot.occupied || slot.fast_poll_started_ms == 0) { continue; } + // A subscriber preference can change without a fleet state transition. + if (!needs_fast_poll(slot.state)) { + slot.fast_poll_started_ms = 0; + --s_fast_poll_count; + continue; + } const auto lifetime = now_ms - slot.fast_poll_started_ms; if (lifetime >= kFastPollLifetimeMs) { slot.fast_poll_started_ms = 0; diff --git a/mods/src/patches/notification_service.cc b/mods/src/patches/notification_service.cc index dfece590b..11b355b2c 100644 --- a/mods/src/patches/notification_service.cc +++ b/mods/src/patches/notification_service.cc @@ -461,6 +461,11 @@ ScopedToastNotificationSuppression::~ScopedToastNotificationSuppression() void notification_init() { +#if __APPLE__ + // Desktop enablement can follow audio-only/all-off initialization. + if (!Config::Get().notify_banner_types.empty() || Config::Get().notify_fleet_events != 0) + notification_desktop_mac_init(); +#endif if (s_initialized) { return; } @@ -529,8 +534,6 @@ void notification_init() spdlog::warn("[Notify] Windows notification service failed (unknown error)"); } #elif __APPLE__ - if (!Config::Get().notify_banner_types.empty() || Config::Get().notify_fleet_events != 0) - notification_desktop_mac_init(); spdlog::info("[Notify] macOS notification service ready"); #else spdlog::info("[Notify] Notification service: platform not supported (no-op)"); @@ -544,6 +547,7 @@ void notification_emit(std::string_view title, std::string_view body) const std::string owned_body{body}; show_system_notification(owned_title.c_str(), owned_body.c_str()); #elif __APPLE__ + notification_desktop_mac_init(); notification_desktop_mac_emit(title, body); #else (void)title; diff --git a/mods/src/patches/parts/fleet_notifications.cc b/mods/src/patches/parts/fleet_notifications.cc index cd357eefb..8f4f5bab5 100644 --- a/mods/src/patches/parts/fleet_notifications.cc +++ b/mods/src/patches/parts/fleet_notifications.cc @@ -98,14 +98,21 @@ static_assert(docked(FleetState::Impulsing, FleetState::Docked)); static_assert(!docked(FleetState::Repairing, FleetState::Docked)); static_assert(repair_complete(FleetState::Repairing, FleetState::Docked)); -FleetNotificationMask s_enabled_notifications = 0; -FleetNotificationMask s_enabled_events = 0; - bool notification_enabled(FleetNotificationKind kind) -{ return (s_enabled_notifications & fleet_notification_bit(kind)) != 0; } +{ return (Config::Get().notify_fleet_events & fleet_notification_bit(kind)) != 0; } + +FleetNotificationMask enabled_events() +{ + const auto& config = Config::Get(); + auto enabled = config.notify_fleet_events; + for (const auto& entry : kFleetNotificationCatalog) + if (config.alert_fleet_events[static_cast(entry.kind)] != NotificationSound::None) + enabled |= fleet_notification_bit(entry.kind); + return enabled; +} bool event_enabled(FleetNotificationKind kind) -{ return (s_enabled_events & fleet_notification_bit(kind)) != 0; } +{ return (enabled_events() & fleet_notification_bit(kind)) != 0; } void play_event_audio(FleetNotificationKind kind) { @@ -136,13 +143,14 @@ std::array s_miner_opc{}; void observe_miner_opc(const fleet_watch::Snapshot& snapshot, FleetPlayerData* fleet, bool publish) { - if (!event_enabled(FleetNotificationKind::MinerOpc) || snapshot.slot < 0 || snapshot.slot >= kFleetSlotCount) { + if (snapshot.slot < 0 || snapshot.slot >= kFleetSlotCount) { return; } const bool mining = snapshot.state == FleetState::Mining; const auto cargo = mining ? read_fleet_opc_sample(fleet, snapshot.slot, snapshot.fleet_id, snapshot.state) : FleetOpcCargo{}; - if (!s_miner_opc[snapshot.slot].Observe(snapshot.fleet_id, mining, cargo.known, cargo.opc, publish)) { + if (!s_miner_opc[snapshot.slot].Observe(snapshot.fleet_id, mining, cargo.known, cargo.opc, + publish && event_enabled(FleetNotificationKind::MinerOpc))) { return; } spdlog::debug("[FleetNotifications] event=MinerOPC slot={} fleet={}", snapshot.slot, snapshot.fleet_id); @@ -167,12 +175,13 @@ std::array s_arrivals{}; void emit_transition(const fleet_watch::Transition& transition) { const auto slot = transition.after.slot; - if (event_enabled(FleetNotificationKind::ArrivedInSystem) && slot >= 0 && slot < kFleetSlotCount) { + if (slot >= 0 && slot < kFleetSlotCount) { const bool native_warp = transition.after.state == FleetState::Impulsing && transition.fleet && transition.fleet->PreviousState == FleetState::Warping; if (s_arrivals[slot].Observe(transition.after.fleet_id, transition.observation_epoch, arrival_phase(transition.before.state), arrival_phase(transition.after.state), - native_warp)) { + native_warp) + && event_enabled(FleetNotificationKind::ArrivedInSystem)) { play_event_audio(FleetNotificationKind::ArrivedInSystem); if (notification_enabled(FleetNotificationKind::ArrivedInSystem)) notification_emit("Fleet Arrived", "Your " + fleet_subject(transition.fleet) + " has arrived in-system"); @@ -223,7 +232,12 @@ constexpr bool needs_fast_poll(FleetNotificationMask enabled, FleetState state) } bool needs_enabled_fast_poll(FleetState state) -{ return needs_fast_poll(s_enabled_events, state); } +{ + // Recheck desktop authorization independently of one-time metadata setup. + // This existing observation path notices later desktop enablement without a new poller. + notification_init(); + return needs_fast_poll(enabled_events(), state); +} static_assert(needs_fast_poll(fleet_notification_bit(FleetNotificationKind::ArrivedInSystem), FleetState::Warping)); static_assert(!needs_fast_poll(fleet_notification_bit(FleetNotificationKind::ArrivedInSystem), FleetState::Impulsing)); @@ -280,6 +294,8 @@ void ToastFleetObserver_HandleMiningDepleted_Hook(auto original, void* self, int } else { original(self, fleet_id); } + if (!event_enabled(FleetNotificationKind::NodeDepleted)) + return; const auto id = static_cast(fleet_id); if (!allow_node_depletion(id)) { return; @@ -303,14 +319,10 @@ bool install_node_depletion_hook() ErrorMsg::MissingHelper("HUD", "ToastFleetObserver"); return false; } -#if __APPLE__ const auto* metadata = method_contract::Resolve(helper.get_cls(), "HandleMiningDepleted", false, "System.Void", {"System.Int64"}); auto* method = method_contract::Pointer(metadata); -#else - auto* method = helper.GetMethod("HandleMiningDepleted", 1); -#endif - if (!method) { + if (!method || metadata->has_full_generic_sharing_signature) { ErrorMsg::MissingMethod("ToastFleetObserver", "HandleMiningDepleted"); return false; } @@ -321,22 +333,12 @@ bool install_node_depletion_hook() void InstallFleetNotificationHooks() { #if _WIN32 || __APPLE__ - s_enabled_notifications = Config::Get().notify_fleet_events; -#endif -#if _WIN32 || __APPLE__ - s_enabled_events = s_enabled_notifications | Config::Get().audio_fleet_events; notification_init(); - - // OPC uses the existing round-robin observations (~2.5s per slot); no additional mining poll or detour. - constexpr auto observed_events = - kAllFleetNotifications & ~fleet_notification_bit(FleetNotificationKind::NodeDepleted); - if ((s_enabled_events & observed_events) != 0 - && !fleet_watch::Subscribe({emit_transition, needs_enabled_fast_poll, - event_enabled(FleetNotificationKind::MinerOpc) ? observe_miner_opc : nullptr})) { + // Continuous quiet observation prevents replay when a feature is enabled later. + // OPC keeps the existing round-robin cadence; no additional mining poll or detour. + if (!fleet_watch::Subscribe({emit_transition, needs_enabled_fast_poll, observe_miner_opc})) spdlog::warn("[FleetNotifications] Fleet Watch subscription failed"); - } - if (event_enabled(FleetNotificationKind::NodeDepleted) && !install_node_depletion_hook()) { + if (!install_node_depletion_hook()) spdlog::warn("[FleetNotifications] node-depletion hook installation failed"); - } #endif } From 4b0d57ef3fa2a3685edede62efc86360e9249e86 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 22:18:16 -0500 Subject: [PATCH 6/6] Match fleet event tokens by ASCII bytes and remove stale audio mask cache --- mods/src/config.cc | 9 ++------- mods/src/config.h | 1 - mods/src/patches/fleet_notification_types.h | 15 +++++++++++++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/mods/src/config.cc b/mods/src/config.cc index 7f8921efb..b552cd6f3 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1091,14 +1091,10 @@ void Config::Load() if (!this->installToastBannerHooks && any_toast_audio_alert_configured) { spdlog::warn("audio alerts require patches.toastbannerhooks = true"); } - this->audio_fleet_events = 0; for (const auto& entry : kFleetNotificationCatalog) { const auto sound = get_notification_sound(config, parsed, entry.audio_config_name, DCA::alert_fleet_default, write_config); this->alert_fleet_events[static_cast(entry.kind)] = sound; - if (sound != NotificationSound::None) { - this->audio_fleet_events |= fleet_notification_bit(entry.kind); - } } this->auto_open_bulk_claim_flyout = get_config_or_default(config, parsed, "ui", "auto_open_bulk_claim_flyout", DCU::auto_open_bulk_claim_flyout, write_config); @@ -1321,13 +1317,12 @@ void Config::Load() if (trimmed.empty()) { continue; } - const auto normalized = AsciiStrToUpper(trimmed); - if (normalized == "ALL") { + if (fleet_notification_name_matches(trimmed, "All")) { this->notify_fleet_events = kAllFleetNotifications; break; } const auto match = std::ranges::find_if(kFleetNotificationCatalog, [&](const auto& entry) { - return normalized == AsciiStrToUpper(entry.config_name); + return fleet_notification_name_matches(trimmed, entry.config_name); }); if (match == kFleetNotificationCatalog.end()) { spdlog::warn("Unknown fleet notification event '{}'; ignoring it", trimmed); diff --git a/mods/src/config.h b/mods/src/config.h index 6d4c021f0..44e0597c4 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -203,7 +203,6 @@ class Config final std::vector disabled_banner_types; std::vector notify_banner_types; FleetNotificationMask notify_fleet_events = 0; - FleetNotificationMask audio_fleet_events = 0; std::array alert_fleet_events{}; int extend_chest_purchase_max; diff --git a/mods/src/patches/fleet_notification_types.h b/mods/src/patches/fleet_notification_types.h index 192f1d56b..8e43d492d 100644 --- a/mods/src/patches/fleet_notification_types.h +++ b/mods/src/patches/fleet_notification_types.h @@ -36,6 +36,21 @@ inline constexpr std::array kFleetNotificationCatalog{ static_assert(kFleetNotificationCatalog.size() == static_cast(FleetNotificationKind::Count)); +// Match ASCII configuration tokens without locale, signed ctype input or NUL truncation. +constexpr bool fleet_notification_name_matches(std::string_view value, std::string_view expected) +{ + if (value.size() != expected.size()) return false; + const auto fold = [](unsigned char c) { return c >= 'a' && c <= 'z' ? c - ('a' - 'A') : c; }; + for (std::size_t i = 0; i < value.size(); ++i) + if (fold(static_cast(value[i])) != fold(static_cast(expected[i]))) + return false; + return true; +} +static_assert(fleet_notification_name_matches("mInErOpc", "MinerOPC")); +static_assert(fleet_notification_name_matches(std::string_view{"AllSuffix", 3}, "All")); +static_assert(!fleet_notification_name_matches(std::string_view{"All\0suffix", 10}, "All")); +static_assert(!fleet_notification_name_matches("\xC3\xA9", "All")); + constexpr FleetNotificationMask fleet_notification_bit(FleetNotificationKind kind) { return FleetNotificationMask{1} << static_cast(kind); }