diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6f1268bc1..83d66e569 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -206,6 +206,13 @@ jobs: - name: Build run: xmake build -y stfc-community-mod + - name: Test fleet observation trackers + run: | + xmake build -y miner-opc-tests + xmake build -y fleet-arrival-tests + xmake run miner-opc-tests + xmake run fleet-arrival-tests + - name: Test x64 trampoline relocation shell: pwsh run: | @@ -521,6 +528,13 @@ jobs: shell: bash run: sccache --show-stats + - name: Test fleet observation trackers + run: | + xmake build -y miner-opc-tests + xmake build -y fleet-arrival-tests + xmake run miner-opc-tests + xmake run fleet-arrival-tests + - name: Report Swift module cache shell: bash run: | diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 2069b388a..e6c08c638 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -37,6 +37,25 @@ 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 +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Fleet cues are independent of desktop notifications and toastbannerhooks. +# Use the sound names above; "none" disables a cue. Windows and macOS. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[======================================================================================================]> # # ***** * * * @@ -689,6 +708,10 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet events that should trigger Windows/macOS notifications (comma separated, or "All") +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, Docked, RepairComplete, MinerOPC +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index dc626072b..eaa7e77a8 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -37,6 +37,25 @@ 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 +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Fleet cues are independent of desktop notifications and toastbannerhooks. +# Use the sound names above; "none" disables a cue. Windows and macOS. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -689,6 +708,10 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet events that should trigger Windows/macOS notifications (comma separated, or "All") +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, Docked, RepairComplete, MinerOPC +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index e1aed8205..e455299c7 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -37,6 +37,25 @@ 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 +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Fleet cues are independent of desktop notifications and toastbannerhooks. +# Use the sound names above; "none" disables a cue. Windows and macOS. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -689,6 +708,10 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet events that should trigger Windows/macOS notifications (comma separated, or "All") +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, Docked, RepairComplete, MinerOPC +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 562af2608..5a07f5589 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -37,6 +37,25 @@ 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 +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Fleet cues are independent of desktop notifications and toastbannerhooks. +# Use the sound names above; "none" disables a cue. Windows and macOS. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -689,6 +708,10 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet events that should trigger Windows/macOS notifications (comma separated, or "All") +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, Docked, RepairComplete, MinerOPC +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 8dcc031a6..fed836ccd 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -37,6 +37,25 @@ 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 +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Fleet cues are independent of desktop notifications and toastbannerhooks. +# Use the sound names above; "none" disables a cue. Windows and macOS. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -263,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 @@ -700,6 +721,10 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet events that should trigger Windows/macOS notifications (comma separated, or "All") +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, Docked, RepairComplete, MinerOPC +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index 795c8f1c8..0bd0e918a 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -37,6 +37,25 @@ 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 +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Fleet cues are independent of desktop notifications and toastbannerhooks. +# Use the sound names above; "none" disables a cue. Windows and macOS. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=====================================================]> # # * * * @@ -689,6 +708,10 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet events that should trigger Windows/macOS notifications (comma separated, or "All") +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, Docked, RepairComplete, MinerOPC +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 8bf856ea3..f9c396726 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -37,6 +37,25 @@ 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 +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Fleet cues are independent of desktop notifications and toastbannerhooks. +# Use the sound names above; "none" disables a cue. Windows and macOS. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -689,6 +708,10 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet events that should trigger Windows/macOS notifications (comma separated, or "All") +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, Docked, RepairComplete, MinerOPC +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 719b54a1d..ae6a85569 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -37,6 +37,25 @@ 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 +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Fleet cues are independent of desktop notifications and toastbannerhooks. +# Use the sound names above; "none" disables a cue. Windows and macOS. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=======================================]> # # **** ** ** @@ -689,6 +708,10 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet events that should trigger Windows/macOS notifications (comma separated, or "All") +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, Docked, RepairComplete, MinerOPC +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index 6e1d0ca2a..80880e1d4 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -37,6 +37,25 @@ 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 +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Fleet cues are independent of desktop notifications and toastbannerhooks. +# Use the sound names above; "none" disables a cue. Windows and macOS. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[============================================================]> # # @@ -689,6 +708,10 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet events that should trigger Windows/macOS notifications (comma separated, or "All") +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, Docked, RepairComplete, MinerOPC +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index ca88ac0b1..341441100 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -37,6 +37,25 @@ 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 +# Requires patches.toastbannerhooks = true. +alert_victory = "none" +alert_defeat = "none" +alert_armada_created = "none" +alert_armada_battle_won = "none" +alert_armada_battle_lost = "none" + +# Fleet cues are independent of desktop notifications and toastbannerhooks. +# Use the sound names above; "none" disables a cue. Windows and macOS. +alert_fleet_arrived_in_system = "none" +alert_fleet_arrived_at_destination = "none" +alert_fleet_started_mining = "none" +alert_fleet_node_depleted = "none" +alert_fleet_docked = "none" +alert_fleet_repair_complete = "none" +alert_fleet_miner_opc = "none" + # <[=====================================================]> # # * * **** * * @@ -689,6 +708,10 @@ disabled_banner_types = "" # Specifies banner types that should trigger notifications (comma separated) notify_banner_types = "" +# Fleet events that should trigger Windows/macOS notifications (comma separated, or "All") +# Options: ArrivedInSystem, ArrivedAtDestination, StartedMining, NodeDepleted, Docked, RepairComplete, MinerOPC +notify_fleet_events = "" + # Subgroup: Chat # -------------- diff --git a/mods/src/config.cc b/mods/src/config.cc index b69377fbb..b552cd6f3 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -32,6 +32,26 @@ 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 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[] = { {"All", ToastState::All}, {"Standard", ToastState::Standard}, @@ -128,6 +148,12 @@ 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); +} + MissionHudVisibility Config::MissionHudButtonVisibility(std::string_view button_name) const { const auto it = this->mission_hud_buttons.find(std::string(button_name)); @@ -324,6 +350,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) { @@ -923,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 = @@ -1035,6 +1082,20 @@ void Config::Load() this->disabled_audio_events.emplace_back(stripped); } } + bool any_toast_audio_alert_configured = false; + for (const auto& alert : kToastAudioAlerts) { + 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 && any_toast_audio_alert_configured) { + spdlog::warn("audio alerts require patches.toastbannerhooks = true"); + } + 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; + } 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); @@ -1248,6 +1309,42 @@ void Config::Load() spdlog::debug("Final notify banner types: {}", notifyString); parsed["ui"].as_table()->insert_or_assign("notify_banner_types", notifyString); + auto notify_fleet_events_str = get_config_or_default(config, parsed, "ui", "notify_fleet_events", + DCU::notify_fleet_events, write_log); + this->notify_fleet_events = 0; + for (const auto& event : StrSplit(notify_fleet_events_str, ',')) { + const std::string trimmed{StripAsciiWhitespace(event)}; + if (trimmed.empty()) { + continue; + } + 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 fleet_notification_name_matches(trimmed, entry.config_name); + }); + if (match == kFleetNotificationCatalog.end()) { + spdlog::warn("Unknown fleet notification event '{}'; ignoring it", trimmed); + continue; + } + this->notify_fleet_events |= fleet_notification_bit(match->kind); + } + + std::string fleet_events_string; + for (const auto& entry : kFleetNotificationCatalog) { + if ((this->notify_fleet_events & fleet_notification_bit(entry.kind)) == 0) { + continue; + } + if (!fleet_events_string.empty()) { + fleet_events_string.append(", "); + } + fleet_events_string.append(entry.config_name); + } + spdlog::debug("Final fleet notification events: {}", fleet_events_string); + parsed["ui"].as_table()->insert_or_assign("notify_fleet_events", fleet_events_string); + + spdlog::debug(""); parse_config_shortcut(config, parsed, "move_up", GameFunction::MoveUp, DCSH::move_up); diff --git a/mods/src/config.h b/mods/src/config.h index 2ab320ce8..44e0597c4 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -8,6 +8,9 @@ #include +#include "patches/fleet_notification_types.h" +#include "patches/notification_audio.h" + #if _WIN32 #include #endif @@ -154,6 +157,7 @@ class Config final void AdjustUiViewerScale(bool scaleUp); [[nodiscard]] MissionHudVisibility MissionHudButtonVisibility(std::string_view button_name) const; + [[nodiscard]] NotificationSound NotificationSoundForToast(int toast_state) const; // Disallow copying/moving to enforce singleton Config(const Config&) = delete; @@ -198,6 +202,8 @@ class Config final bool borderless_fullscreen; std::vector disabled_banner_types; std::vector notify_banner_types; + FleetNotificationMask notify_fleet_events = 0; + std::array alert_fleet_events{}; int extend_chest_purchase_max; int extend_donation_max; @@ -213,8 +219,13 @@ class Config final bool disable_toast_banners; bool trace_audio_events; std::vector disabled_audio_events; - bool auto_open_bulk_claim_flyout; - bool auto_confirm_ft_upgrade; + 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; std::vector daily_bulk_claim_factions; @@ -259,6 +270,7 @@ class Config final bool installZoomHooks; bool installBuffFixHooks; bool installToastBannerHooks; + bool installFleetNotificationHooks; bool installPanHooks; bool installHotkeyHooks; bool installFreeResizeHooks; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 10764b99f..263930300 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -11,6 +11,12 @@ 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"; + constexpr const char* alert_fleet_default = "none"; } // namespace Audio namespace SystemConfig @@ -94,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 @@ -275,6 +282,7 @@ namespace UI constexpr const char* instant_warp_always_ask = ""; constexpr const char* pinned_ships = ""; constexpr const char* notify_banner_types = ""; + constexpr const char* notify_fleet_events = ""; constexpr auto extend_chest_purchase_max = 160; constexpr auto extend_donation_max = 80; constexpr bool extend_donation_slider = true; diff --git a/mods/src/il2cpp/method_contract.h b/mods/src/il2cpp/method_contract.h new file mode 100644 index 000000000..d7d1ccdc5 --- /dev/null +++ b/mods/src/il2cpp/method_contract.h @@ -0,0 +1,44 @@ +#pragma once + +#include "il2cpp-functions.h" +#include +#include +#include + +namespace method_contract +{ +inline bool Type(const Il2CppType* type, const char* name) +{ + if (!type || type->byref) return false; + auto* actual = il2cpp_type_get_name(type); + const bool matches = actual && std::strcmp(actual, name) == 0; + il2cpp_free(actual); + return matches; +} + +// Resolve the entire managed signature, including static/instance dispatch. +// A renamed, ambiguous or generic method is not a compatible callback. +inline const MethodInfo* Resolve(Il2CppClass* cls, const char* name, bool is_static, + const char* result, std::initializer_list parameters) +{ + if (!cls) return nullptr; + const MethodInfo* found = nullptr; + void* iterator = nullptr; + while (auto* method = il2cpp_class_get_methods(cls, &iterator)) { + if (std::strcmp(method->name, name) != 0 || !method->methodPointer || method->is_generic || method->is_inflated + || bool(method->flags & METHOD_ATTRIBUTE_STATIC) != is_static + || method->parameters_count != parameters.size() || !Type(method->return_type, result)) continue; + bool matches = true; + unsigned i = 0; + for (auto* parameter : parameters) + matches = Type(method->parameters[i++], parameter) && matches; + if (!matches) continue; + if (found) return nullptr; + found = method; + } + return found; +} + +inline void* Pointer(const MethodInfo* method) +{ return method ? reinterpret_cast(method->methodPointer) : nullptr; } +} // namespace method_contract 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/notification_desktop_mac.mm b/mods/src/notification_desktop_mac.mm new file mode 100644 index 000000000..019f5afe8 --- /dev/null +++ b/mods/src/notification_desktop_mac.mm @@ -0,0 +1,84 @@ +#if __APPLE__ +#include "patches/notification_desktop_mac.h" +#import +#import +#include +#include + +namespace +{ +UNUserNotificationCenter* Center() +{ + // The injected library uses the game's bundle identity. A command-line host + // without an application bundle cannot register a notification center. + if (![NSBundle mainBundle].bundleIdentifier.length) return nil; + @try { + return [UNUserNotificationCenter currentNotificationCenter]; + } @catch (NSException* exception) { + spdlog::warn("[Notify] macOS notification center unavailable: {}", exception.name.UTF8String); + return nil; + } +} +} + +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(); + if (!center) { + spdlog::warn("[Notify] macOS desktop notifications require the game application bundle"); + return; + } + // Do not replace the game's delegate or add a second sound. Audio cues + // have their own config and backend. macOS controls background banners. + [center requestAuthorizationWithOptions:UNAuthorizationOptionAlert completionHandler:^(BOOL granted, NSError* error) { + if (error) + spdlog::warn("[Notify] macOS authorization failed: {}", error.localizedDescription.UTF8String); + else + spdlog::info("[Notify] macOS desktop notifications authorized={}", bool(granted)); + }]; + } + }); +} + +void notification_desktop_mac_emit(std::string_view title, std::string_view body) +{ + @autoreleasepool { + NSString* ownedTitle = [[NSString alloc] initWithBytes:title.data() length:title.size() encoding:NSUTF8StringEncoding]; + NSString* ownedBody = [[NSString alloc] initWithBytes:body.data() length:body.size() encoding:NSUTF8StringEncoding]; + if (ownedTitle && ownedBody) { + dispatch_async(dispatch_get_main_queue(), ^{ + @autoreleasepool { + UNUserNotificationCenter* center = Center(); + if (!center) return; + [center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* settings) { + @autoreleasepool { + if (settings.authorizationStatus != UNAuthorizationStatusAuthorized + && settings.authorizationStatus != UNAuthorizationStatusProvisional) return; + UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init]; + content.title = ownedTitle; + content.body = ownedBody; + UNNotificationRequest* request = [UNNotificationRequest requestWithIdentifier:[NSUUID UUID].UUIDString + content:content trigger:nil]; + [center addNotificationRequest:request withCompletionHandler:^(NSError* error) { + if (error) + spdlog::warn("[Notify] macOS delivery failed: {}", error.localizedDescription.UTF8String); + }]; +#if !__has_feature(objc_arc) + [content release]; +#endif + } + }]; + } + }); + } +#if !__has_feature(objc_arc) + [ownedTitle release]; + [ownedBody release]; +#endif + } +} +#endif diff --git a/mods/src/patches/fleet_arrival_tracker.h b/mods/src/patches/fleet_arrival_tracker.h new file mode 100644 index 000000000..5f859e824 --- /dev/null +++ b/mods/src/patches/fleet_arrival_tracker.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +// Only the phases relevant to identifying one arrival per observed journey. +enum class FleetArrivalPhase { Other, Charging, Warping, Impulsing }; + +struct FleetArrivalTracker { + uint64_t fleet_id = 0; + uint64_t epoch = 0; + bool journey_observed = false; + + bool Observe(uint64_t id, uint64_t observation_epoch, FleetArrivalPhase before, + FleetArrivalPhase after, bool native_previous_warping) + { + if (fleet_id != id || epoch != observation_epoch) { + fleet_id = id; + epoch = observation_epoch; + journey_observed = false; + } + if (before == FleetArrivalPhase::Warping || before == FleetArrivalPhase::Charging + || after == FleetArrivalPhase::Charging + || after == FleetArrivalPhase::Warping) + journey_observed = true; + + // A sampled intermediate state can hide Warping -> Impulsing even though + // native history still records it. Require an observed journey; native + // history alone can be stale after startup or baseline reseeding. + const bool arrival = after == FleetArrivalPhase::Impulsing + && (before == FleetArrivalPhase::Warping + || native_previous_warping); + if (!arrival || !journey_observed) + return false; + journey_observed = false; + return true; + } +}; diff --git a/mods/src/patches/fleet_notification_types.h b/mods/src/patches/fleet_notification_types.h new file mode 100644 index 000000000..8e43d492d --- /dev/null +++ b/mods/src/patches/fleet_notification_types.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include + +enum class FleetNotificationKind : uint8_t { + ArrivedInSystem = 0, + ArrivedAtDestination, + StartedMining, + NodeDepleted, + Docked, + RepairComplete, + MinerOpc, + Count, +}; + +using FleetNotificationMask = uint32_t; + +struct FleetNotificationCatalogEntry { + FleetNotificationKind kind; + std::string_view config_name; + std::string_view audio_config_name; +}; + +inline constexpr std::array kFleetNotificationCatalog{ + FleetNotificationCatalogEntry{FleetNotificationKind::ArrivedInSystem, "ArrivedInSystem", "alert_fleet_arrived_in_system"}, + FleetNotificationCatalogEntry{FleetNotificationKind::ArrivedAtDestination, "ArrivedAtDestination", "alert_fleet_arrived_at_destination"}, + FleetNotificationCatalogEntry{FleetNotificationKind::StartedMining, "StartedMining", "alert_fleet_started_mining"}, + FleetNotificationCatalogEntry{FleetNotificationKind::NodeDepleted, "NodeDepleted", "alert_fleet_node_depleted"}, + FleetNotificationCatalogEntry{FleetNotificationKind::Docked, "Docked", "alert_fleet_docked"}, + FleetNotificationCatalogEntry{FleetNotificationKind::RepairComplete, "RepairComplete", "alert_fleet_repair_complete"}, + FleetNotificationCatalogEntry{FleetNotificationKind::MinerOpc, "MinerOPC", "alert_fleet_miner_opc"}, +}; + +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); } + +constexpr std::string_view fleet_notification_name(FleetNotificationKind kind) +{ + for (const auto& entry : kFleetNotificationCatalog) { + if (entry.kind == kind) { + return entry.config_name; + } + } + return "Unknown"; +} + +inline constexpr FleetNotificationMask kAllFleetNotifications = [] { + FleetNotificationMask result = 0; + for (const auto& entry : kFleetNotificationCatalog) { + result |= fleet_notification_bit(entry.kind); + } + return result; +}(); diff --git a/mods/src/patches/fleet_opc_sample.cc b/mods/src/patches/fleet_opc_sample.cc new file mode 100644 index 000000000..4690de1ea --- /dev/null +++ b/mods/src/patches/fleet_opc_sample.cc @@ -0,0 +1,35 @@ +#include "patches/fleet_opc_sample.h" + +#include + +#include +#include + +namespace +{ +FleetOpcSampleCache s_cache; +} + +void invalidate_fleet_opc_sample(int slot) +{ s_cache.Invalidate(slot); } + +FleetOpcCargo read_fleet_opc_sample(FleetPlayerData* fleet, int slot, uint64_t fleet_id, FleetState state) +{ + if (!fleet) + return {}; + const auto now_ms = + std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) + .count(); + return s_cache.Read(slot, fleet_id, reinterpret_cast(fleet), static_cast(state), now_ms, [fleet] { + FleetOpcCargo sample; + auto* cargo = fleet->CargoHoldData; + auto* progress = cargo ? cargo->UnprotectedCargoProgress : nullptr; + if (progress) { + sample.current = progress->CurrentValue; + sample.protected_limit = progress->MinValue; + sample.known = std::isfinite(sample.current) && std::isfinite(sample.protected_limit); + sample.opc = sample.known && sample.current > sample.protected_limit; + } + return sample; + }); +} diff --git a/mods/src/patches/fleet_opc_sample.h b/mods/src/patches/fleet_opc_sample.h new file mode 100644 index 000000000..f3414887d --- /dev/null +++ b/mods/src/patches/fleet_opc_sample.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include + +struct FleetPlayerData; +enum class FleetState; + +struct FleetOpcCargo { + bool known = false; + bool opc = false; + double current = 0.0; + double protected_limit = 0.0; +}; + +// Game-thread-only, value-only cache shared by the OPC timer, highlight, and alert. +// Object identity is only a cache key, never a retained managed pointer to dereference. +class FleetOpcSampleCache +{ +public: + void Invalidate(int slot) + { + if (slot >= 0 && slot < static_cast(entries.size())) { + entries[slot].valid = false; + } + } + + template + FleetOpcCargo Read(int slot, uint64_t fleet_id, uintptr_t object_id, int state, int64_t now_ms, Reader read) + { + if (slot < 0 || slot >= static_cast(entries.size())) + return {}; + auto& entry = entries[slot]; + if (entry.valid && entry.fleet_id == fleet_id && entry.object_id == object_id && entry.state == state + && now_ms >= entry.sampled_ms && now_ms - entry.sampled_ms < 1'000) { + return entry.cargo; + } + const auto cargo = read(); + entry = {true, fleet_id, object_id, state, now_ms, cargo}; + return cargo; + } + +private: + struct Entry { + bool valid = false; + uint64_t fleet_id = 0; + uintptr_t object_id = 0; + int state = 0; + int64_t sampled_ms = 0; + FleetOpcCargo cargo; + }; + std::array entries{}; +}; + +FleetOpcCargo read_fleet_opc_sample(FleetPlayerData* fleet, int slot, uint64_t fleet_id, FleetState state); + +// Call on the existing cargo-change callback before rendering from the shared sample. +void invalidate_fleet_opc_sample(int slot); diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index aa3b27dc2..7af1de8b0 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -41,6 +41,7 @@ struct ObservedFleet { }; std::array s_slots{}; +uint64_t s_observation_epoch = 0; std::vector s_subscriptions; bool s_observer_installed = false; bool s_seed_pending = false; @@ -110,6 +111,7 @@ bool needs_fast_poll(FleetState state) void reset_observation() { + ++s_observation_epoch; s_slots = {}; s_seed_pending = true; s_seed_has_observation = false; @@ -152,6 +154,7 @@ void dispatch_transition(int slot, FleetPlayerData* fleet, FleetState before, Fl .before = fleet_watch::Snapshot{slot, fleet->Id, before}, .after = fleet_watch::Snapshot{slot, fleet->Id, after}, .fleet = fleet, + .observation_epoch = s_observation_epoch, }; CallbackScope callback_scope; for (const auto& subscription : s_subscriptions) { @@ -208,6 +211,22 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool publish) s_seed_last_change_ms = now_ms; s_seed_finalize_candidate = false; } + { + CallbackScope callback_scope; + const fleet_watch::Snapshot snapshot{slot, fleet_id, state}; + for (const auto& subscription : s_subscriptions) { + if (!subscription.on_observation) { + continue; + } + try { + subscription.on_observation(snapshot, fleet, same_fleet && publish && !s_seed_pending); + } catch (const std::exception& error) { + spdlog::warn("[FleetWatch] observation callback failed: {}", error.what()); + } catch (...) { + spdlog::warn("[FleetWatch] observation callback failed with an unknown exception"); + } + } + } if (same_fleet && publish && !s_seed_pending && previous_state != state) { dispatch_transition(slot, fleet, previous_state, state); } @@ -410,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/fleet_watch.h b/mods/src/patches/fleet_watch.h index f865d278c..fd175d49c 100644 --- a/mods/src/patches/fleet_watch.h +++ b/mods/src/patches/fleet_watch.h @@ -14,14 +14,20 @@ struct Transition { Snapshot before; Snapshot after; FleetPlayerData* fleet = nullptr; + // Changes whenever the observer resets its baseline; consumers must discard journey history. + uint64_t observation_epoch = 0; }; using TransitionCallback = void (*)(const Transition& transition); using FastPollPredicate = bool (*)(FleetState state); +// Every observation, including unchanged states and quiet baseline samples. Never retain fleet. +// allow_notifications is false during baseline/reseeding or for a newly observed fleet. +using ObservationCallback = void (*)(const Snapshot& snapshot, FleetPlayerData* fleet, bool allow_notifications); struct Subscription { - TransitionCallback on_transition = nullptr; - FastPollPredicate needs_fast_poll = nullptr; + TransitionCallback on_transition = nullptr; + FastPollPredicate needs_fast_poll = nullptr; + ObservationCallback on_observation = nullptr; }; // Registers a process-lifetime fleet-state observer. The first subscription joins the shared screen-update dispatcher; diff --git a/mods/src/patches/miner_opc_tracker.h b/mods/src/patches/miner_opc_tracker.h new file mode 100644 index 000000000..9bae6cf4d --- /dev/null +++ b/mods/src/patches/miner_opc_tracker.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +// Value-only state: unavailable cargo breaks continuity instead of inventing a crossing. +struct MinerOpcTracker { + uint64_t fleet_id = 0; + bool mining = false; + bool known = false; + bool opc = false; + + constexpr bool Observe(uint64_t id, bool is_mining, bool cargo_known, bool is_opc, bool publish) + { + const bool crossed = publish && fleet_id == id && mining && is_mining && known && cargo_known && !opc && is_opc; + fleet_id = id; + mining = is_mining; + known = is_mining && cargo_known; + opc = known && is_opc; + return crossed; + } +}; diff --git a/mods/src/patches/notification_audio.cc b/mods/src/patches/notification_audio.cc new file mode 100644 index 000000000..1e2d5f7df --- /dev/null +++ b/mods/src/patches/notification_audio.cc @@ -0,0 +1,267 @@ +#include "patches/notification_audio.h" + +#include "patches/notification_audio_platform.h" + +#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}}}; + +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) +{ + 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; +} + +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; +} + +using SoundBuffers = std::array, static_cast(NotificationSound::Count)>; + +const SoundBuffers& sound_buffers() +{ + 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 + +std::string_view notification_sound_name(NotificationSound sound) +{ + 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 (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; +} + +void notification_audio_play(NotificationSound sound) +{ + if (sound == NotificationSound::None) + return; + + const auto index = static_cast(sound); + if (index >= kSoundDefinitions.size()) + return; + + const auto& buffer = sound_buffers()[index]; + if (buffer.empty()) + return; + + 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..345fa9840 --- /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]] 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_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_desktop_mac.h b/mods/src/patches/notification_desktop_mac.h new file mode 100644 index 000000000..d491b1302 --- /dev/null +++ b/mods/src/patches/notification_desktop_mac.h @@ -0,0 +1,5 @@ +#pragma once +#include + +void notification_desktop_mac_init(); +void notification_desktop_mac_emit(std::string_view title, std::string_view body); diff --git a/mods/src/patches/notification_service.cc b/mods/src/patches/notification_service.cc index 54edf2b39..11b355b2c 100644 --- a/mods/src/patches/notification_service.cc +++ b/mods/src/patches/notification_service.cc @@ -1,5 +1,9 @@ #include "patches/notification_service.h" #include "patches/battle_notify_parser.h" +#include "patches/notification_audio.h" +#if __APPLE__ +#include "patches/notification_desktop_mac.h" +#endif #include "config.h" #include "str_utils.h" @@ -24,6 +28,8 @@ static const MethodInfo* s_localize_ltc = nullptr; // LanguageManager.Localize(out string, LocaleTextContext) — instance static const MethodInfo* s_locale_utils_localize = nullptr; // LocaleUtilities.Localize(LocaleTextContext, bool, bool) — static static const MethodInfo* s_object_tostring = nullptr; +static bool s_initialized = false; +static thread_local int s_toast_notification_suppression_depth = 0; // --------------------------------------------------------------------------- // Toast state → human-readable title @@ -447,8 +453,25 @@ static std::string strip_unity_rich_text(const std::string& s) // Public API // --------------------------------------------------------------------------- +ScopedToastNotificationSuppression::ScopedToastNotificationSuppression() +{ ++s_toast_notification_suppression_depth; } + +ScopedToastNotificationSuppression::~ScopedToastNotificationSuppression() +{ --s_toast_notification_suppression_depth; } + 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; + } + s_initialized = true; + +#if _WIN32 || __APPLE__ // 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"); @@ -499,6 +522,7 @@ void notification_init() spdlog::warn("[Notify] Could not resolve Object::ToString — placeholder formatting may be incomplete"); } +#endif #if _WIN32 try { winrt::init_apartment(winrt::apartment_type::single_threaded); @@ -509,20 +533,43 @@ void notification_init() } catch (...) { spdlog::warn("[Notify] Windows notification service failed (unknown error)"); } +#elif __APPLE__ + spdlog::info("[Notify] macOS notification service ready"); #else spdlog::info("[Notify] Notification service: platform not supported (no-op)"); #endif } -void notification_handle_toast(Toast* toast) +void notification_emit(std::string_view title, std::string_view body) { -#if !_WIN32 - return; // No notification delivery on non-Windows platforms yet +#if _WIN32 + const std::string owned_title{title}; + 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 - auto state = toast->get_State(); + (void)title; + (void)body; +#endif +} + +void notification_handle_toast(Toast* toast) +{ + if (!toast) return; + + const auto& config = Config::Get(); + const auto state = toast->get_State(); + notification_audio_play(config.NotificationSoundForToast(state)); + +#if _WIN32 || __APPLE__ + if (s_toast_notification_suppression_depth > 0) { + return; + } // 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; } @@ -552,6 +599,6 @@ void notification_handle_toast(Toast* toast) } } - show_system_notification(title, body.c_str()); + notification_emit(title, body); #endif } diff --git a/mods/src/patches/notification_service.h b/mods/src/patches/notification_service.h index 2ebad087a..7a8776695 100644 --- a/mods/src/patches/notification_service.h +++ b/mods/src/patches/notification_service.h @@ -1,10 +1,27 @@ #pragma once +#include + struct Toast; +// Temporarily prevent game toasts from being forwarded as system notifications. +// The in-game toast pipeline is unaffected, and suppression is local to the calling thread. +class ScopedToastNotificationSuppression +{ +public: + ScopedToastNotificationSuppression(); + ~ScopedToastNotificationSuppression(); + + ScopedToastNotificationSuppression(const ScopedToastNotificationSuppression&) = delete; + ScopedToastNotificationSuppression& operator=(const ScopedToastNotificationSuppression&) = delete; +}; + // Initialize the notification service (resolve IL2CPP methods, init platform). -// Call once during InstallToastBannerHooks(). +// Idempotent; call from each feature installer that emits notifications. void notification_init(); +// Deliver a notification created by a mod feature rather than a Scopely toast. +void notification_emit(std::string_view title, std::string_view body); + // Called from toast hooks — checks config, formats, and delivers notification. void notification_handle_toast(Toast* toast); diff --git a/mods/src/patches/parts/fleet_notifications.cc b/mods/src/patches/parts/fleet_notifications.cc new file mode 100644 index 000000000..8f4f5bab5 --- /dev/null +++ b/mods/src/patches/parts/fleet_notifications.cc @@ -0,0 +1,344 @@ +#include "patches/fleet_notification_types.h" +#include "patches/fleet_arrival_tracker.h" + +#include "config.h" +#include "il2cpp/method_contract.h" +#include "errormsg.h" +#include "patches/fleet_opc_sample.h" +#include "patches/fleet_watch.h" +#include "patches/miner_opc_tracker.h" +#include "patches/notification_service.h" +#include "str_utils.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include + +namespace +{ +constexpr int kFleetSlotCount = 10; + +using TransitionPredicate = bool (*)(FleetState before, FleetState after); + +struct TransitionRule { + FleetNotificationKind kind; + TransitionPredicate matches; + std::string_view title; + std::string_view message; +}; + +constexpr bool state_is_destination(FleetState state) +{ + switch (state) { + case FleetState::IdleInSpace: + case FleetState::CanRecall: + case FleetState::CanEngage: + case FleetState::CanLocate: + case FleetState::Deployed: + case FleetState::Capturing: + return true; + default: + return false; + } +} + +constexpr bool state_can_dock_from_space(FleetState state) +{ + switch (state) { + case FleetState::IdleInSpace: + case FleetState::Mining: + case FleetState::Battling: + case FleetState::WarpCharging: + case FleetState::Warping: + case FleetState::Impulsing: + case FleetState::Capturing: + case FleetState::CanRecall: + case FleetState::CanEngage: + case FleetState::CanLocate: + case FleetState::Deployed: + return true; + default: + return false; + } +} + +constexpr bool arrived_at_destination(FleetState before, FleetState after) +{ return before == FleetState::Impulsing && state_is_destination(after); } + +constexpr bool started_mining(FleetState before, FleetState after) +{ return before != FleetState::Mining && after == FleetState::Mining; } + +constexpr bool docked(FleetState before, FleetState after) +{ return before != FleetState::Repairing && after == FleetState::Docked && state_can_dock_from_space(before); } + +constexpr bool repair_complete(FleetState before, FleetState after) +{ return before == FleetState::Repairing && after == FleetState::Docked; } + +constexpr std::array kTransitionRules{ + TransitionRule{FleetNotificationKind::ArrivedAtDestination, arrived_at_destination, "Fleet Arrived", + "has reached its destination"}, + TransitionRule{FleetNotificationKind::StartedMining, started_mining, "Fleet Mining", "started mining"}, + TransitionRule{FleetNotificationKind::Docked, docked, "Fleet Docked", "docked"}, + TransitionRule{FleetNotificationKind::RepairComplete, repair_complete, "Repair Complete", "finished repairs"}, +}; + +static_assert(arrived_at_destination(FleetState::Impulsing, FleetState::IdleInSpace)); +static_assert(started_mining(FleetState::IdleInSpace, FleetState::Mining)); +static_assert(docked(FleetState::Impulsing, FleetState::Docked)); +static_assert(!docked(FleetState::Repairing, FleetState::Docked)); +static_assert(repair_complete(FleetState::Repairing, FleetState::Docked)); + +bool notification_enabled(FleetNotificationKind kind) +{ 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 (enabled_events() & fleet_notification_bit(kind)) != 0; } + +void play_event_audio(FleetNotificationKind kind) +{ + notification_audio_play(Config::Get().alert_fleet_events[static_cast(kind)]); +} + +std::string normalize_name(std::string name) +{ + constexpr std::string_view live_suffix = "_LIVE"; + if (name.ends_with(live_suffix)) { + name.erase(name.size() - live_suffix.size()); + } + for (auto& character : name) { + if (character == '_') { + character = ' '; + } + } + return name; +} + +std::string fleet_subject(FleetPlayerData* fleet) +{ + auto* hull = fleet ? fleet->Hull : nullptr; + return hull && hull->Name ? normalize_name(to_string(hull->Name)) : "fleet"; +} + +std::array s_miner_opc{}; + +void observe_miner_opc(const fleet_watch::Snapshot& snapshot, FleetPlayerData* fleet, bool publish) +{ + 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 && event_enabled(FleetNotificationKind::MinerOpc))) { + return; + } + spdlog::debug("[FleetNotifications] event=MinerOPC slot={} fleet={}", snapshot.slot, snapshot.fleet_id); + play_event_audio(FleetNotificationKind::MinerOpc); + if (notification_enabled(FleetNotificationKind::MinerOpc)) { + notification_emit("Miner Is OPC", "Your " + fleet_subject(fleet) + " is now over protected cargo."); + } +} + +FleetArrivalPhase arrival_phase(FleetState state) +{ + switch (state) { + case FleetState::WarpCharging: return FleetArrivalPhase::Charging; + case FleetState::Warping: return FleetArrivalPhase::Warping; + case FleetState::Impulsing: return FleetArrivalPhase::Impulsing; + default: return FleetArrivalPhase::Other; + } +} + +std::array s_arrivals{}; + +void emit_transition(const fleet_watch::Transition& transition) +{ + const auto slot = transition.after.slot; + 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) + && 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"); + } + } + for (const auto& rule : kTransitionRules) { + if (!event_enabled(rule.kind) || !rule.matches(transition.before.state, transition.after.state)) { + continue; + } +#ifdef _MODDBG + spdlog::info("[FleetNotificationsProbe] event={} fleet={} oldState={} newState={}", + fleet_notification_name(rule.kind), transition.after.fleet_id, + static_cast(transition.before.state), static_cast(transition.after.state)); +#endif + play_event_audio(rule.kind); + if (notification_enabled(rule.kind)) { + notification_emit(rule.title, "Your " + fleet_subject(transition.fleet) + " " + std::string{rule.message}); + } + } +} + +constexpr bool needs_fast_poll(FleetNotificationMask enabled, FleetState state) +{ + constexpr auto arrival_events = fleet_notification_bit(FleetNotificationKind::ArrivedInSystem) + | fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination); + constexpr auto post_warp_events = arrival_events | fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked); + constexpr auto post_impulse_events = fleet_notification_bit(FleetNotificationKind::ArrivedAtDestination) + | fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked); + constexpr auto post_activity_events = fleet_notification_bit(FleetNotificationKind::StartedMining) + | fleet_notification_bit(FleetNotificationKind::Docked); + + switch (state) { + case FleetState::WarpCharging: + case FleetState::Warping: + return (enabled & post_warp_events) != 0; + case FleetState::Impulsing: + return (enabled & post_impulse_events) != 0; + case FleetState::Battling: + case FleetState::Capturing: + return (enabled & post_activity_events) != 0; + case FleetState::Repairing: + return (enabled & fleet_notification_bit(FleetNotificationKind::RepairComplete)) != 0; + default: + return false; + } +} + +bool needs_enabled_fast_poll(FleetState 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)); +static_assert(needs_fast_poll(fleet_notification_bit(FleetNotificationKind::StartedMining), FleetState::Battling)); +static_assert(needs_fast_poll(fleet_notification_bit(FleetNotificationKind::RepairComplete), FleetState::Repairing)); +static_assert(!needs_fast_poll(0, FleetState::Warping)); +// Audio-only subscriptions use the same event mask and fast-poll path as desktop alerts. +static_assert(needs_fast_poll(0 | fleet_notification_bit(FleetNotificationKind::ArrivedInSystem), FleetState::Warping)); +static_assert(!needs_fast_poll(fleet_notification_bit(FleetNotificationKind::NodeDepleted), FleetState::Mining)); + +struct RecentNodeDepletion { + uint64_t fleet_id = 0; + std::chrono::steady_clock::time_point emitted_at{}; +}; + +std::array s_recent_node_depletions{}; +std::size_t s_next_node_depletion = 0; + +bool allow_node_depletion(uint64_t fleet_id) +{ + constexpr auto duplicate_window = std::chrono::seconds{2}; + const auto now = std::chrono::steady_clock::now(); + for (const auto& recent : s_recent_node_depletions) { + if (recent.emitted_at != std::chrono::steady_clock::time_point{} && recent.fleet_id == fleet_id + && now - recent.emitted_at < duplicate_window) { + return false; + } + } + s_recent_node_depletions[s_next_node_depletion] = {fleet_id, now}; + s_next_node_depletion = (s_next_node_depletion + 1) % s_recent_node_depletions.size(); + return true; +} + +FleetPlayerData* find_fleet(uint64_t fleet_id) +{ + auto* manager = FleetsManager::Instance(); + if (!manager || !manager->HasFleetService()) { + return nullptr; + } + for (int slot = 0; slot < kFleetSlotCount; ++slot) { + auto* fleet = manager->GetFleetPlayerData(slot); + if (fleet && fleet->Id == fleet_id) { + return fleet; + } + } + return nullptr; +} + +void ToastFleetObserver_HandleMiningDepleted_Hook(auto original, void* self, int64_t fleet_id) +{ + if (notification_enabled(FleetNotificationKind::NodeDepleted)) { + ScopedToastNotificationSuppression suppress_duplicate_forwarding; + original(self, fleet_id); + } else { + original(self, fleet_id); + } + if (!event_enabled(FleetNotificationKind::NodeDepleted)) + return; + const auto id = static_cast(fleet_id); + if (!allow_node_depletion(id)) { + return; + } +#ifdef _MODDBG + spdlog::info("[FleetNotificationsProbe] event={} fleet={}", + fleet_notification_name(FleetNotificationKind::NodeDepleted), id); +#endif + play_event_audio(FleetNotificationKind::NodeDepleted); + if (notification_enabled(FleetNotificationKind::NodeDepleted)) { + auto* fleet = find_fleet(id); + notification_emit("Node Depleted", fleet ? "Your " + fleet_subject(fleet) + " depleted its node." + : "A mining fleet depleted its node."); + } +} + +bool install_node_depletion_hook() +{ + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "ToastFleetObserver"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("HUD", "ToastFleetObserver"); + return false; + } + const auto* metadata = method_contract::Resolve(helper.get_cls(), "HandleMiningDepleted", false, + "System.Void", {"System.Int64"}); + auto* method = method_contract::Pointer(metadata); + if (!method || metadata->has_full_generic_sharing_signature) { + ErrorMsg::MissingMethod("ToastFleetObserver", "HandleMiningDepleted"); + return false; + } + return SPUD_STATIC_DETOUR(method, ToastFleetObserver_HandleMiningDepleted_Hook) != nullptr; +} +} // namespace + +void InstallFleetNotificationHooks() +{ +#if _WIN32 || __APPLE__ + notification_init(); + // 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 (!install_node_depletion_hook()) + spdlog::warn("[FleetNotifications] node-depletion hook installation failed"); +#endif +} diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index d4e50a9d9..1605b8449 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -25,6 +25,7 @@ void InstallBuffFixHooks(); void InstallFreeResizeHooks(); #endif void InstallToastBannerHooks(); +void InstallFleetNotificationHooks(); void InstallPanHooks(); void InstallHotkeyHooks(); void InstallGiftsBulkClaimHooks(); @@ -126,6 +127,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"ZoomHooks", {InstallZoomHooks, &cfg.installZoomHooks}}, {"BuffFixHooks", {InstallBuffFixHooks, &cfg.installBuffFixHooks}}, {"ToastBannerHooks", {InstallToastBannerHooks, &cfg.installToastBannerHooks}}, + {"FleetNotifications", {InstallFleetNotificationHooks, &cfg.installFleetNotificationHooks}}, {"PanHooks", {InstallPanHooks, &cfg.installPanHooks}}, {"HotkeyHooks", {InstallHotkeyHooks, &cfg.installHotkeyHooks}}, {"GiftsBulkClaimHooks", {InstallGiftsBulkClaimHooks, &cfg.installGiftsBulkClaimHooks}}, diff --git a/mods/src/prime/CargoHoldData.h b/mods/src/prime/CargoHoldData.h new file mode 100644 index 000000000..a43486d1d --- /dev/null +++ b/mods/src/prime/CargoHoldData.h @@ -0,0 +1,25 @@ +#pragma once + +#include "ProgressData.h" + +#include + +struct CargoHoldData { +public: + __declspec(property(get = __get_UnprotectedCargoProgress)) ProgressData* UnprotectedCargoProgress; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "CargoHoldData"); + return class_helper; + } + +public: + ProgressData* __get_UnprotectedCargoProgress() + { + static auto property = get_class_helper().GetProperty("UnprotectedCargoProgress"); + return property.GetRaw(this); + } +}; diff --git a/mods/src/prime/FleetPlayerData.h b/mods/src/prime/FleetPlayerData.h index c969aa9ff..a6bce13df 100644 --- a/mods/src/prime/FleetPlayerData.h +++ b/mods/src/prime/FleetPlayerData.h @@ -3,6 +3,7 @@ #include "errormsg.h" #include "BattleTargetData.h" +#include "CargoHoldData.h" #include "HullSpec.h" #include "RecallRequirement.h" #include "CanRepairRequirement.h" @@ -44,6 +45,7 @@ struct FleetPlayerData { __declspec(property(get = __get_PreviousState)) FleetState PreviousState; __declspec(property(get = __get_Id)) uint64_t Id; __declspec(property(get = __get_Hull)) HullSpec* Hull; + __declspec(property(get = __get_CargoHoldData)) ::CargoHoldData* CargoHoldData; __declspec(property(get = __get_Address)) void* Address; __declspec(property(get = __get_Level)) int64_t Level; __declspec(property(get = __get_HasShip)) bool HasShip; @@ -62,6 +64,11 @@ struct FleetPlayerData { static auto field = get_class_helper().GetProperty("Hull"); return field.GetRaw(this); } + ::CargoHoldData* __get_CargoHoldData() + { + static auto property = get_class_helper().GetProperty("CargoHoldData"); + return property.GetRaw(this); + } void* __get_Address() { static auto field = get_class_helper().GetProperty("Address"); diff --git a/mods/src/prime/ProgressData.h b/mods/src/prime/ProgressData.h new file mode 100644 index 000000000..94049700d --- /dev/null +++ b/mods/src/prime/ProgressData.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include + +struct ProgressData { +public: + __declspec(property(get = __get_CurrentValue)) double CurrentValue; + __declspec(property(get = __get_MinValue)) double MinValue; + +private: + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "ProgressData"); + return class_helper; + } + +public: + double __get_CurrentValue() + { + static auto property = get_class_helper().GetProperty("CurrentValue"); + auto* value = property.Get(this); + return value ? *value : std::numeric_limits::quiet_NaN(); + } + double __get_MinValue() + { + static auto property = get_class_helper().GetProperty("MinValue"); + auto* value = property.Get(this); + return value ? *value : std::numeric_limits::quiet_NaN(); + } +}; diff --git a/mods/xmake.lua b/mods/xmake.lua index 1a80fc8ab..b0fe52325 100644 --- a/mods/xmake.lua +++ b/mods/xmake.lua @@ -116,12 +116,13 @@ 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 add_files("src/*.mm") -- Link Cocoa framework - add_frameworks("Cocoa") + add_frameworks("Cocoa", "UserNotifications", {public = true}) end set_policy("build.optimization.lto", true) diff --git a/tests/fleet_arrival_tracker.cc b/tests/fleet_arrival_tracker.cc new file mode 100644 index 000000000..10eb7851e --- /dev/null +++ b/tests/fleet_arrival_tracker.cc @@ -0,0 +1,55 @@ +#include "patches/fleet_arrival_tracker.h" +#ifdef NDEBUG +#undef NDEBUG +#endif +#include +#include + +int main() +{ + using P = FleetArrivalPhase; + FleetArrivalTracker t; + auto step = [&](P before, P after, bool native = false, uint64_t epoch = 1, uint64_t fleet = 10) { + return t.Observe(fleet, epoch, before, after, native); + }; + // Normal arrival, followed by the same native history resurfacing: only once. + assert(!step(P::Other, P::Charging)); + assert(!step(P::Charging, P::Warping)); + assert(step(P::Warping, P::Impulsing, true)); + assert(!step(P::Impulsing, P::Other)); + assert(!step(P::Other, P::Impulsing, true)); + // Captured fleet A recall: native previous remains Warping through transient Docked. + assert(!step(P::Other, P::Charging)); + assert(!step(P::Charging, P::Warping)); + assert(!step(P::Warping, P::Other)); + assert(step(P::Other, P::Impulsing, true)); + // Vindicator sequence: polling skipped Warping. Native history is required. + assert(!step(P::Other, P::Charging)); + assert(!step(P::Charging, P::Other)); + assert(!step(P::Other, P::Impulsing, false)); + assert(step(P::Other, P::Impulsing, true)); + // Any intermediate state is accepted, not just the captured Docked value. + assert(!step(P::Other, P::Charging)); + assert(!step(P::Charging, P::Other)); + assert(step(P::Other, P::Impulsing, true)); + // Captured outbound fleet A: Warping was skipped, then arrival resurfaced. + assert(!step(P::Other, P::Charging)); + assert(!step(P::Charging, P::Other)); + assert(!step(P::Other, P::Charging)); + assert(step(P::Charging, P::Impulsing, true)); + assert(!step(P::Impulsing, P::Other)); + assert(!step(P::Other, P::Impulsing, true)); + // Startup/ordinary undock cannot replay a completed journey. + assert(!step(P::Other, P::Impulsing, true)); + // Reseeding and fleet replacement discard prior journey observations. + assert(!step(P::Other, P::Charging)); + assert(!step(P::Other, P::Impulsing, true, 2)); + assert(!step(P::Other, P::Charging, false, 2)); + assert(!step(P::Other, P::Impulsing, true, 2, 11)); + // Baseline already in warp: its eventual normal arrival still fires. + assert(step(P::Warping, P::Impulsing, true, 3, 12)); + // Baseline in charging also establishes an in-flight journey, even if warp is skipped. + assert(step(P::Charging, P::Impulsing, true, 4, 13)); + assert(!step(P::Charging, P::Impulsing, false, 5, 14)); + std::cout << "Fleet arrival regression cases passed\n"; +} diff --git a/tests/miner_opc_tests.cc b/tests/miner_opc_tests.cc new file mode 100644 index 000000000..3fc19e524 --- /dev/null +++ b/tests/miner_opc_tests.cc @@ -0,0 +1,99 @@ +#include "patches/fleet_opc_sample.h" +#include "patches/miner_opc_tracker.h" + +#ifdef NDEBUG +#undef NDEBUG +#endif +#include + +// Each scenario models a reachable observation sequence; compile-time checks also run in release builds. +constexpr bool crossing_and_rearm() +{ + MinerOpcTracker tracker; + if (tracker.Observe(1, true, true, false, false)) + return false; + if (!tracker.Observe(1, true, true, true, true)) + return false; + if (tracker.Observe(1, true, true, true, true)) + return false; + if (tracker.Observe(1, true, true, false, true)) + return false; + return tracker.Observe(1, true, true, true, true); +} +constexpr bool quiet_baselines() +{ + MinerOpcTracker tracker; + if (tracker.Observe(1, true, true, true, true)) + return false; // Already OPC at first sight. + tracker.Observe(1, true, true, false, true); + if (tracker.Observe(1, true, true, true, false)) + return false; // Reseeding. + if (tracker.Observe(1, true, true, true, true)) + return false; + tracker.Observe(1, true, true, false, true); + return !tracker.Observe(2, true, true, true, true); // Slot replacement. +} +constexpr bool interrupted_mining() +{ + MinerOpcTracker tracker; + tracker.Observe(1, true, true, false, true); + tracker.Observe(1, false, false, false, true); + if (tracker.Observe(1, true, true, true, true)) + return false; + tracker.Observe(1, true, true, false, true); + tracker.Observe(1, true, false, false, true); // Missing/non-finite cargo breaks continuity. + return !tracker.Observe(1, true, true, true, true); +} +static_assert(crossing_and_rearm()); +static_assert(quiet_baselines()); +static_assert(interrupted_mining()); +int main() +{ + // A cargo event within the TTL must not redraw the old protected state. + FleetOpcSampleCache event_cache; + int event_reads = 0; + bool source_opc = false; + auto event_read = [&] { + ++event_reads; + return FleetOpcCargo{true, source_opc, source_opc ? 2.0 : 0.0, 1.0}; + }; + assert(!event_cache.Read(0, 1, 100, 4, 1000, event_read).opc); + source_opc = true; + event_cache.Invalidate(0); + assert(event_cache.Read(0, 1, 100, 4, 1500, event_read).opc); // Cargo-event highlight sees fresh value. + assert(event_cache.Read(0, 1, 100, 4, 1501, event_read).opc); // Timer/alert reuse the new value. + assert(event_reads == 2); + event_cache.Invalidate(-1); + event_cache.Invalidate(10); + assert(event_cache.Read(0, 1, 100, 4, 1502, event_read).opc); + assert(event_reads == 2); + + FleetOpcSampleCache cache; + int reads = 0; + auto read = [&] { + ++reads; + return FleetOpcCargo{true, reads > 1, static_cast(reads), 1.0}; + }; + assert(!cache.Read(0, 1, 100, 4, 1000, read).opc); // Timer populates sample. + assert(!cache.Read(0, 1, 100, 4, 1200, read).opc); // Highlight reuses it. + assert(!cache.Read(0, 1, 100, 4, 1999, read).opc); // Alert reuses it. + assert(reads == 1); + assert(cache.Read(0, 1, 100, 4, 2000, read).opc); // Hidden overlay: alert refreshes expired sample. + assert(reads == 2); + cache.Read(0, 2, 100, 4, 2001, read); // New fleet. + cache.Read(0, 2, 200, 4, 2002, read); // New managed object. + cache.Read(0, 2, 200, 1, 2003, read); // New state. + assert(reads == 5); + cache.Read(1, 2, 200, 1, 2003, read); // Independent slot. + assert(reads == 6); + assert(!cache.Read(-1, 2, 200, 1, 2003, read).known); + assert(!cache.Read(10, 2, 200, 1, 2003, read).known); + assert(reads == 6); + auto unknown = [&] { + ++reads; + return FleetOpcCargo{}; + }; + assert(!cache.Read(0, 2, 200, 1, 3003, unknown).known); + assert(!cache.Read(0, 2, 200, 1, 3004, read).known); // Failed reads share the same bounded retry interval. + assert(reads == 7); +} diff --git a/tests/xmake.lua b/tests/xmake.lua new file mode 100644 index 000000000..239f24f5a --- /dev/null +++ b/tests/xmake.lua @@ -0,0 +1,16 @@ + +target("miner-opc-tests") +do + set_kind("binary") + set_default(false) + add_files("miner_opc_tests.cc") + add_includedirs("../mods/src") +end + +target("fleet-arrival-tests") +do + set_kind("binary") + set_default(false) + add_files("fleet_arrival_tracker.cc") + add_includedirs("../mods/src") +end diff --git a/xmake.lua b/xmake.lua index 099d26406..ab98245bf 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,6 +26,7 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") +includes("tests") target("il2cpp-checked-tests") set_kind("binary")