diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 07f09dd78..31afa8663 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: | @@ -523,6 +530,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 55e29aed0..c5907e8ee 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -46,6 +46,16 @@ 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" + # <[======================================================================================================]> # # ***** * * * @@ -668,6 +678,12 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# Windows/macOS: highlight deployed fleet-bar portraits in amber when cargo exceeds protected cargo +highlight_opc_fleets = false + +# Windows/macOS: show estimated time to over-protected cargo below mining fleet status +fleet_hud_opc_eta = false + # Forhindr "Escape" i at spørge, om spillet skal afsluttes disable_escape_exit = true @@ -713,6 +729,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 b00e903dc..27def08c7 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -46,6 +46,16 @@ 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" + # <[=======================================]> # # **** ** ** @@ -668,6 +678,12 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# Windows/macOS: highlight deployed fleet-bar portraits in amber when cargo exceeds protected cargo +highlight_opc_fleets = false + +# Windows/macOS: show estimated time to over-protected cargo below mining fleet status +fleet_hud_opc_eta = false + # Verhindert, dass die Escape-Taste zum Beenden des Spiels auffordert disable_escape_exit = true @@ -713,6 +729,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 53d73320e..7a1c8418e 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -46,6 +46,16 @@ 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" + # <[=======================================]> # # **** ** ** @@ -668,6 +678,12 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# Windows/macOS: highlight deployed fleet-bar portraits in amber when cargo exceeds protected cargo +highlight_opc_fleets = false + +# Windows/macOS: show estimated time to over-protected cargo below mining fleet status +fleet_hud_opc_eta = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true @@ -713,6 +729,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 cad31a7ec..d252b6c52 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -46,6 +46,16 @@ 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" + # <[=======================================]> # # **** ** ** @@ -668,6 +678,12 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# Windows/macOS: highlight deployed fleet-bar portraits in amber when cargo exceeds protected cargo +highlight_opc_fleets = false + +# Windows/macOS: show estimated time to over-protected cargo below mining fleet status +fleet_hud_opc_eta = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true @@ -713,6 +729,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 ec9b3ad8a..c7fc148d1 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -46,6 +46,16 @@ 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" + # <[=======================================]> # # **** ** ** @@ -270,6 +280,10 @@ chatpatches = true dailyfactionbulkclaimhooks = true doubleclickassignshiphooks = true forbiddentechconfirmhooks = true +# Install fleet observers independently of the desktop/audio selections. +fleetnotificationhooks = true +# Install OPC indicator hooks independently of highlight and ETA preferences. +opcindicatorhooks = true freeresizehooks = true galacticanomalytimerhooks = true game_version = true @@ -672,6 +686,12 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# Windows/macOS: highlight deployed fleet-bar portraits in amber when cargo exceeds protected cargo +highlight_opc_fleets = false + +# Windows/macOS: show estimated time to over-protected cargo below mining fleet status +fleet_hud_opc_eta = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true @@ -717,6 +737,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 535cffdb6..19fa77ff0 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -46,6 +46,16 @@ 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" + # <[=====================================================]> # # * * * @@ -668,6 +678,12 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# Windows/macOS: highlight deployed fleet-bar portraits in amber when cargo exceeds protected cargo +highlight_opc_fleets = false + +# Windows/macOS: show estimated time to over-protected cargo below mining fleet status +fleet_hud_opc_eta = false + # Impide que "Escape" pregunte si deseas salir del juego disable_escape_exit = true @@ -713,6 +729,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 2364d54ff..89318e540 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -46,6 +46,16 @@ 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" + # <[=======================================]> # # **** ** ** @@ -668,6 +678,12 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# Windows/macOS: highlight deployed fleet-bar portraits in amber when cargo exceeds protected cargo +highlight_opc_fleets = false + +# Windows/macOS: show estimated time to over-protected cargo below mining fleet status +fleet_hud_opc_eta = false + # Empêcher "échap" de proposer de quitter le jeu disable_escape_exit = true @@ -713,6 +729,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 b601977e0..435207d94 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -46,6 +46,16 @@ 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" + # <[=======================================]> # # **** ** ** @@ -668,6 +678,12 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# Windows/macOS: highlight deployed fleet-bar portraits in amber when cargo exceeds protected cargo +highlight_opc_fleets = false + +# Windows/macOS: show estimated time to over-protected cargo below mining fleet status +fleet_hud_opc_eta = false + # Zorgt ervoor dat het gebruiken van "escape" het spel niet sluit disable_escape_exit = true @@ -713,6 +729,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 fa66b9da2..356bac96b 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -46,6 +46,16 @@ 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" + # <[============================================================]> # # @@ -668,6 +678,12 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# Windows/macOS: highlight deployed fleet-bar portraits in amber when cargo exceeds protected cargo +highlight_opc_fleets = false + +# Windows/macOS: show estimated time to over-protected cargo below mining fleet status +fleet_hud_opc_eta = false + # Не показывать запрос выхода из игры при нажатии Escape disable_escape_exit = true @@ -713,6 +729,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 a74a10736..1f7ff6135 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -46,6 +46,16 @@ 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" + # <[=====================================================]> # # * * **** * * @@ -668,6 +678,12 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# Windows/macOS: highlight deployed fleet-bar portraits in amber when cargo exceeds protected cargo +highlight_opc_fleets = false + +# Windows/macOS: show estimated time to over-protected cargo below mining fleet status +fleet_hud_opc_eta = false + # Prevent "escape" vo' prompting Daq exit game disable_escape_exit = true @@ -713,6 +729,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 da3198c50..ffde490f6 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -43,7 +43,7 @@ constexpr bool is_all_audio_wildcard(std::string_view value) static_assert(is_all_audio_wildcard("aLl")); static_assert(!is_all_audio_wildcard(std::string_view{"All\0suffix", 10})); static_assert(!is_all_audio_wildcard(std::string_view{"\xffll", 3})); - + struct ToastAudioAlertConfig { int toast_state; std::string_view config_name; @@ -993,6 +993,10 @@ 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->installOpcIndicatorHooks = + get_config_or_default(config, parsed, "patches", "opcindicatorhooks", DCP::opcindicatorhooks, write_config); + this->installFleetNotificationHooks = + get_config_or_default(config, parsed, "patches", "fleetnotificationhooks", DCP::fleetnotificationhooks, write_config); this->installArtifactExchangeHooks = get_config_or_default(config, parsed, "patches", "artifactexchangehooks", DCP::artifactexchangehooks, write_config); this->installAudioEventHooks = @@ -1121,8 +1125,17 @@ void Config::Load() 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); + DCU::auto_open_bulk_claim_flyout, write_config); + this->highlight_opc_fleets = get_config_or_default(config, parsed, "ui", "highlight_opc_fleets", + DCU::highlight_opc_fleets, write_config); + this->fleet_hud_opc_eta = get_config_or_default(config, parsed, "ui", "fleet_hud_opc_eta", + DCU::fleet_hud_opc_eta, write_config); read_daily_bulk_claim_factions(config, parsed, this->daily_bulk_claim_factions, DCU::daily_bulk_claim_factions, write_config); @@ -1345,6 +1358,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 66788c1b7..6dab9f3da 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -8,6 +8,7 @@ #include +#include "patches/fleet_notification_types.h" #include "patches/notification_audio.h" #if _WIN32 @@ -205,6 +206,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; @@ -228,6 +231,8 @@ class Config final NotificationSound alert_armada_battle_lost = NotificationSound::None; bool auto_open_bulk_claim_flyout; bool auto_confirm_ft_upgrade; + bool highlight_opc_fleets; + bool fleet_hud_opc_eta; std::vector daily_bulk_claim_factions; @@ -273,6 +278,7 @@ class Config final bool installZoomHooks; bool installBuffFixHooks; bool installToastBannerHooks; + bool installFleetNotificationHooks; bool installPanHooks; bool installHotkeyHooks; bool installFreeResizeHooks; @@ -293,6 +299,7 @@ class Config final bool installForbiddenTechConfirmationHooks; bool installInstantWarpConfirmationHooks; bool installAudioEventHooks; + bool installOpcIndicatorHooks; bool installArtifactExchangeHooks; std::string config_settings_url; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 40b31ae57..b735ce31c 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -16,6 +16,7 @@ namespace Audio 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 @@ -99,6 +100,8 @@ namespace Patches constexpr bool doubleclickassignshiphooks = true; constexpr bool forbiddentechconfirmhooks = true; constexpr bool audioeventhooks = true; + constexpr bool fleetnotificationhooks = true; + constexpr bool opcindicatorhooks = true; constexpr bool artifactexchangehooks = true; constexpr bool instantcargocounterhooks = true; constexpr bool cargoformathooks = true; // on by default: cargo number precision override @@ -255,6 +258,8 @@ namespace UI constexpr bool auto_confirm_ft_upgrade = false; constexpr bool auto_open_bulk_claim_flyout = false; constexpr const char* daily_bulk_claim_factions = ""; + constexpr bool highlight_opc_fleets = false; + constexpr bool fleet_hud_opc_eta = false; constexpr bool daily_bulk_claim_toggle_default_on = false; constexpr bool disable_escape_exit = true; // Maximum gap between Escape presses that opens the exit prompt. @@ -285,6 +290,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/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_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 293424f4c..11b355b2c 100644 --- a/mods/src/patches/notification_service.cc +++ b/mods/src/patches/notification_service.cc @@ -1,6 +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" @@ -25,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 @@ -448,9 +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 _WIN32 +#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"); @@ -501,6 +522,8 @@ 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); spdlog::info("[Notify] Windows notification service initialized"); @@ -511,12 +534,27 @@ void notification_init() spdlog::warn("[Notify] Windows notification service failed (unknown error)"); } #elif __APPLE__ - spdlog::info("[Notify] macOS audio notification service ready"); + spdlog::info("[Notify] macOS notification service ready"); #else spdlog::info("[Notify] Notification service: platform not supported (no-op)"); #endif } +void notification_emit(std::string_view title, std::string_view body) +{ +#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 + (void)title; + (void)body; +#endif +} + void notification_handle_toast(Toast* toast) { if (!toast) return; @@ -525,7 +563,10 @@ void notification_handle_toast(Toast* toast) const auto state = toast->get_State(); notification_audio_play(config.NotificationSoundForToast(state)); -#if _WIN32 +#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.notify_banner_types; @@ -558,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/parts/opc_indicators.cc b/mods/src/patches/parts/opc_indicators.cc new file mode 100644 index 000000000..5a8433ca7 --- /dev/null +++ b/mods/src/patches/parts/opc_indicators.cc @@ -0,0 +1,1536 @@ +#include "il2cpp/method_contract.h" +#include +#include "config.h" +#include "errormsg.h" +#include "patches/fleet_opc_sample.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr const char* kOpcHighlightName = "CommunityOPCHighlight"; +constexpr const char* kOpcEtaLabelName = "CommunityOpcEtaLabel"; +constexpr const char* kOpcEtaBackgroundName = "CommunityOpcEtaBackground"; +constexpr const char* kOpcCardLabelName = "CommunityOpcCardEtaLabel"; +constexpr int kFleetSlotCount = 10; +constexpr int64_t kOpcEtaRefreshMs = 1'000; +constexpr int64_t kUiRetryInitialMs = 1'000; +constexpr int64_t kUiRetryMaximumMs = 30'000; + +struct OpcEtaLogState { + uint64_t fleet_id = 0; + std::string display; +}; + +struct OpcEtaRenderState { + uint64_t fleet_id = 0; + std::string computed_display; + std::string computed_card_display; + std::string rendered_display; + FleetState fleet_state = FleetState::Unknown; + bool selected = false; + bool computed_safe = false; + bool rendered_safe_on_node = false; + bool fleet_state_known = false; + bool layout_initialized = false; + uint8_t setup_failures = 0; + int64_t setup_retry_at_ms = 0; +}; + +struct OpcCardRenderState { + uint64_t fleet_id = 0; + uintptr_t timer_anchor_id = 0; + std::string display; + bool layout_initialized = false; + uint8_t setup_failures = 0; + int64_t setup_retry_at_ms = 0; +}; + +struct UiVector2 { + float x; + float y; +}; + +std::array s_last_opc_eta_log_states{}; +std::array s_opc_eta_render_states{}; +std::array s_last_opc_eta_refresh_fleet_ids{}; +std::array s_last_opc_eta_refresh_ms{}; +std::array s_opc_highlight_setup_failures{}; +std::array s_opc_highlight_retry_at_ms{}; +std::array s_opc_highlight_retry_fleet_ids{}; +std::array s_opc_highlight_retry_anchor_ids{}; +bool s_highlight_ready = false; +bool s_eta_ready = false; +OpcCardRenderState s_opc_card_render_state{}; +FieldInfo* s_state_context_field = nullptr; +FieldInfo* s_flag_context_field = nullptr; +const MethodInfo* s_local_get_fleet = nullptr; +const MethodInfo* s_is_index_selected = nullptr; + +struct Color { + float r; + float g; + float b; + float a; +}; + +struct FleetOpcStatus { + bool mining = false; + bool cargo_known = false; + bool rate_known = false; + bool node_known = false; + bool opc = false; + bool safe_on_node = false; + double current_cargo = 0.0; + double protected_limit = 0.0; + double rate_per_second = 0.0; + double node_remaining = 0.0; + int64_t eta_seconds = -1; + int64_t node_eta_seconds = -1; +}; + +constexpr bool cargo_is_opc(double current_value, double protected_limit) +{ return current_value > protected_limit; } + +constexpr bool node_stays_protected(double current_value, double protected_limit, double node_remaining) +{ return !cargo_is_opc(current_value, protected_limit) && node_remaining <= protected_limit - current_value; } + +static_assert(!cargo_is_opc(99.0, 100.0)); +static_assert(!cargo_is_opc(100.0, 100.0)); +static_assert(cargo_is_opc(101.0, 100.0)); +static_assert(node_stays_protected(99.0, 100.0, 1.0)); +static_assert(!node_stays_protected(99.0, 100.0, 2.0)); + +int64_t steady_now_milliseconds() +{ + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +void schedule_ui_retry(uint8_t& failure_count, int64_t& retry_at_ms) +{ + if (failure_count < 6) { + ++failure_count; + } + auto delay_ms = kUiRetryInitialMs << (failure_count - 1); + if (delay_ms > kUiRetryMaximumMs) { + delay_ms = kUiRetryMaximumMs; + } + retry_at_ms = steady_now_milliseconds() + delay_ms; +} + +void clear_ui_retry(uint8_t& failure_count, int64_t& retry_at_ms) +{ + failure_count = 0; + retry_at_ms = 0; +} + +FleetOpcStatus read_opc_status(FleetPlayerData* fleet, int slot, uint64_t fleet_id, FleetState state) +{ + FleetOpcStatus status; + if (!fleet || state != FleetState::Mining) { + return status; + } + + status.mining = true; + const auto cargo = read_fleet_opc_sample(fleet, slot, fleet_id, state); + status.current_cargo = cargo.current; + status.protected_limit = cargo.protected_limit; + status.cargo_known = cargo.known; + status.opc = cargo.opc; + + auto* mining_data = fleet->MiningData; + status.rate_per_second = mining_data ? mining_data->MiningSpeed : 0.0; + status.rate_known = std::isfinite(status.rate_per_second) && status.rate_per_second > 0.0; + + if (mining_data) { + const auto node_current = mining_data->AmountMined; + const auto node_max = static_cast(mining_data->Amount); + status.node_known = + std::isfinite(node_current) && std::isfinite(node_max) && node_current >= 0.0 && node_max >= node_current; + if (status.node_known) { + status.node_remaining = node_max - node_current; + } + } + + if (status.rate_known && status.node_known) { + const auto node_seconds = std::ceil(status.node_remaining / status.rate_per_second); + if (std::isfinite(node_seconds) && node_seconds <= static_cast(std::numeric_limits::max())) { + status.node_eta_seconds = static_cast(node_seconds); + } + } + + if (!status.cargo_known || status.opc || !status.rate_known) { + status.eta_seconds = status.opc ? 0 : -1; + return status; + } + + const auto remaining = std::max(0.0, status.protected_limit - status.current_cargo); + const auto seconds = std::ceil(remaining / status.rate_per_second); + if (std::isfinite(seconds) && seconds <= static_cast(std::numeric_limits::max())) { + status.eta_seconds = static_cast(seconds); + } + if (status.node_eta_seconds >= 0) { + status.safe_on_node = node_stays_protected(status.current_cargo, status.protected_limit, status.node_remaining); + } + return status; +} + +std::string format_duration(int64_t seconds) +{ + if (seconds < 0) { + return {}; + } + if (seconds < 60) { + return "<1m"; + } + + const auto minutes = seconds / 60 + (seconds % 60 != 0); + if (minutes < 60) { + return std::to_string(minutes) + "m"; + } + + const auto hours = minutes / 60; + const auto remaining_minutes = minutes % 60; + if (hours < 24) { + auto result = std::to_string(hours) + "h"; + if (remaining_minutes != 0) { + result += " " + std::to_string(remaining_minutes) + "m"; + } + return result; + } + + const auto days = hours / 24; + const auto remaining_hours = hours % 24; + auto result = std::to_string(days) + "d"; + if (remaining_hours != 0) { + result += " " + std::to_string(remaining_hours) + "h"; + } + return result; +} + +std::string format_opc_eta(const FleetOpcStatus& status) +{ + if (!status.mining || !status.cargo_known) { + return {}; + } + if (status.opc) { + return "OPC"; + } + if (!status.rate_known || status.eta_seconds < 0) { + return {}; + } + return status.safe_on_node ? "SAFE" : format_duration(status.eta_seconds); +} + +std::string format_opc_card_display(const FleetOpcStatus& status) +{ + if (!status.mining || !status.cargo_known) { + return {}; + } + + const auto opc = status.opc ? "Now" : status.eta_seconds >= 0 ? format_duration(status.eta_seconds) : "Unknown"; + return "OPC: " + opc; +} + +Il2CppObject* invoke(const MethodInfo* method, void* target, void** args, const char* operation) +{ + if (!method) { + return nullptr; + } + + Il2CppObject* result = nullptr; + if (!Il2CppRuntime::TryInvoke(method, target, args, &result)) { + spdlog::warn("[OpcIndicators] {} failed", operation); + return nullptr; + } + return result; +} + +bool invoke_void(const MethodInfo* method, void* target, void** args, const char* operation) +{ + if (!method) { + return false; + } + + if (!Il2CppRuntime::TryInvoke(method, target, args)) { + spdlog::warn("[OpcIndicators] {} failed", operation); + return false; + } + return true; +} + +const MethodInfo* resolve_instance_void(IL2CppClassHelper& helper, const char* name, + std::initializer_list parameters = {}) +{ + const auto* method = method_contract::Resolve(helper.get_cls(), name, false, "System.Void", parameters); + if (!method || method->has_full_generic_sharing_signature) { + std::string signature; + for (const auto* parameter : parameters) { + if (!signature.empty()) signature += ", "; + signature += parameter; + } + spdlog::warn("[OpcIndicators] {}.{}({}) -> Void: {}", helper.get_cls() ? helper.get_cls()->name : "", + name, signature, method ? "unsupported generic sharing" : "instance method unavailable"); + return nullptr; + } + return method; +} + +void destroy_game_object(GameObject* game_object) +{ + if (!game_object) { + return; + } + + game_object->SetActive(false); + static auto object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Object"); + static auto destroy = object_helper.GetMethodInfo("Destroy", 1); + void* args[1] = {game_object}; + invoke_void(destroy, nullptr, args, "Object.Destroy"); +} + +Transform* component_transform(void* component) +{ + if (!component) { + return nullptr; + } + + static auto component_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Component"); + static auto get_transform = component_helper.GetMethodInfo("get_transform"); + return reinterpret_cast(invoke(get_transform, component, nullptr, "Component.get_transform")); +} + +Transform* direct_child_named(Transform* parent, const char* name) +{ + if (!parent) { + return nullptr; + } + + for (int32_t index = 0; index < parent->childCount; ++index) { + auto* child = parent->GetChild(index); + auto* child_object = child ? child->gameObject : nullptr; + if (child_object && child_object->Name() == name) { + return child; + } + } + return nullptr; +} + +Transform* opc_anchor_from_tile(Transform* tile_transform) +{ return direct_child_named(tile_transform, "BodyContainer"); } + +void* component_in_parent(void* component, IL2CppClassHelper& target_helper) +{ + auto* transform = component_transform(component); + auto* game_object = transform ? transform->gameObject : nullptr; + if (!game_object) { + return nullptr; + } + + static auto game_object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "GameObject"); + static auto get_component_in_parent = game_object_helper.GetMethodInfo("GetComponentInParent", 2); + if (!target_helper.isValidHelper() || !get_component_in_parent) { + return nullptr; + } + + void* target_type = target_helper.GetType(); + bool include_inactive = true; + void* args[2] = {target_type, &include_inactive}; + return invoke(get_component_in_parent, game_object, args, "GameObject.GetComponentInParent"); +} + +Transform* opc_anchor_from_component(void* component) +{ + static auto fleet_tile_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Ships", "FleetLocalViewController"); + auto* fleet_tile = component_in_parent(component, fleet_tile_helper); + return opc_anchor_from_tile(component_transform(fleet_tile)); +} + +bool is_instance_class_field(const FieldInfo* field, std::string_view expected_namespace, + std::string_view expected_name) +{ + if (!field || !field->type || field->type->byref || field->type->type != IL2CPP_TYPE_CLASS + || (il2cpp_field_get_flags(const_cast(field)) & FIELD_ATTRIBUTE_STATIC) != 0 + || field->offset < static_cast(sizeof(Il2CppObject))) { + return false; + } + + auto* field_class = il2cpp_class_from_type(field->type); + return field_class && field_class->namespaze && field_class->name && field_class->namespaze == expected_namespace + && field_class->name == expected_name; +} + +void* fleet_panel_controller(void* component) +{ + if (!component) { + return nullptr; + } + + static auto fleet_bar_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "FleetBarViewController"); + static auto* panel_field = fleet_bar_helper.get_cls() + ? il2cpp_class_get_field_from_name(fleet_bar_helper.get_cls(), "_fleetPanelController") + : nullptr; + auto* fleet_bar = component_in_parent(component, fleet_bar_helper); + if (!fleet_bar || !is_instance_class_field(panel_field, "Digit.Prime.Ships", "FleetLocalViewController")) { + return nullptr; + } + Il2CppObject* panel = nullptr; + il2cpp_field_get_value(static_cast(fleet_bar), panel_field, &panel); + return panel; +} + +Transform* fleet_panel_timer_anchor(void* component) +{ + auto* fleet_panel = fleet_panel_controller(component); + if (!fleet_panel) { + return nullptr; + } + + static auto fleet_local_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Ships", "FleetLocalViewController"); + static auto* timer_widget_field = fleet_local_helper.get_cls() + ? il2cpp_class_get_field_from_name(fleet_local_helper.get_cls(), "_timerWidget") + : nullptr; + if (!is_instance_class_field(timer_widget_field, "Digit.Client.UI", "TimerWidget")) { + return nullptr; + } + Il2CppObject* timer_widget = nullptr; + il2cpp_field_get_value(static_cast(fleet_panel), timer_widget_field, &timer_widget); + return component_transform(timer_widget); +} + +Transform* opc_anchor_from_fleetbar_flag(void* fleetbar_flag_widget) +{ return opc_anchor_from_component(fleetbar_flag_widget); } + +GameObject* find_opc_highlight(Transform* body_transform) +{ + auto* transform = direct_child_named(body_transform, kOpcHighlightName); + return transform ? transform->gameObject : nullptr; +} + +bool configure_opc_frame(Transform* transform) +{ + if (!transform) { + return false; + } + + static auto game_object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "GameObject"); + static auto image_helper = il2cpp_get_class_helper("UnityEngine.UI", "UnityEngine.UI", "Image"); + static auto get_component = game_object_helper.GetMethodInfo("GetComponent", 1); + static auto set_color = image_helper.GetMethodInfo("set_color"); + static auto set_raycast_target = image_helper.GetMethodInfo("set_raycastTarget"); + if (!image_helper.isValidHelper() || !get_component || !set_color || !set_raycast_target) { + return false; + } + + bool tinted_background = false; + auto* game_object = transform->gameObject; + void* image_type = image_helper.GetType(); + void* component_args[1] = {image_type}; + if (auto* image = + game_object ? invoke(get_component, game_object, component_args, "GameObject.GetComponent") : nullptr; + image) { + Color color{1.0f, 0.66f, 0.12f, 1.0f}; + void* color_args[1] = {&color}; + bool raycast = false; + void* raycast_args[1] = {&raycast}; + tinted_background = invoke_void(set_color, image, color_args, "Image.set_color") + && invoke_void(set_raycast_target, image, raycast_args, "Graphic.set_raycastTarget"); + } + + for (int32_t index = 0; index < transform->childCount; ++index) { + if (auto* child = transform->GetChild(index); child && child->gameObject) { + child->gameObject->SetActive(false); + } + } + return tinted_background; +} + +bool configure_opc_inner(Transform* highlight_transform, Transform* body_transform) +{ + auto* inner_transform = direct_child_named(highlight_transform, "WaveDefenseBackground"); + auto* source_transform = direct_child_named(body_transform, "MiningContainer"); + auto* inner_object = inner_transform ? inner_transform->gameObject : nullptr; + auto* source_object = source_transform ? source_transform->gameObject : nullptr; + if (!inner_object || !source_object) { + return false; + } + + static auto game_object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "GameObject"); + static auto image_helper = il2cpp_get_class_helper("UnityEngine.UI", "UnityEngine.UI", "Image"); + static auto get_component = game_object_helper.GetMethodInfo("GetComponent", 1); + static auto get_sprite = image_helper.GetMethodInfo("get_sprite"); + static auto set_sprite = image_helper.GetMethodInfo("set_sprite"); + static auto set_type = image_helper.GetMethodInfo("set_type"); + static auto set_fill_amount = image_helper.GetMethodInfo("set_fillAmount"); + static auto set_color = image_helper.GetMethodInfo("set_color"); + static auto set_raycast_target = image_helper.GetMethodInfo("set_raycastTarget"); + if (!image_helper.isValidHelper() || !get_component || !get_sprite || !set_sprite || !set_type || !set_fill_amount + || !set_color || !set_raycast_target) { + return false; + } + + void* image_type = image_helper.GetType(); + void* component_args[1] = {image_type}; + auto* inner_image = invoke(get_component, inner_object, component_args, "GameObject.GetComponent"); + auto* source_image = invoke(get_component, source_object, component_args, "GameObject.GetComponent"); + auto* source_sprite = source_image ? invoke(get_sprite, source_image, nullptr, "Image.get_sprite") : nullptr; + if (!inner_image || !source_sprite) { + return false; + } + + void* sprite_args[1] = {source_sprite}; + int32_t filled = 3; + void* type_args[1] = {&filled}; + float fill_amount = 1.0f; + void* fill_args[1] = {&fill_amount}; + Color color{0.78f, 0.40f, 0.06f, 0.62f}; + void* color_args[1] = {&color}; + bool raycast = false; + void* raycast_args[1] = {&raycast}; + const bool configured = invoke_void(set_sprite, inner_image, sprite_args, "Image.set_sprite") + && invoke_void(set_type, inner_image, type_args, "Image.set_type") + && invoke_void(set_fill_amount, inner_image, fill_args, "Image.set_fillAmount") + && invoke_void(set_color, inner_image, color_args, "Image.set_color") + && invoke_void(set_raycast_target, inner_image, raycast_args, "Graphic.set_raycastTarget"); + inner_object->SetActive(configured); + return configured; +} + +GameObject* create_opc_highlight(Transform* body_transform) +{ + auto* source_transform = direct_child_named(body_transform, "Background"); + auto* source_object = source_transform ? source_transform->gameObject : nullptr; + if (!source_object) { + return nullptr; + } + + static auto object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Object"); + static auto game_object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "GameObject"); + static auto transform_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Transform"); + static auto instantiate = object_helper.GetMethodInfoSpecial("Instantiate", [](int count, const Il2CppType** params) { + return count == 3 && params[0]->type == IL2CPP_TYPE_CLASS && params[1]->type == IL2CPP_TYPE_CLASS + && params[2]->type == IL2CPP_TYPE_BOOLEAN; + }); + static auto get_transform = game_object_helper.GetMethodInfo("get_transform"); + static auto set_name = object_helper.GetMethodInfo("set_name"); + static auto set_sibling_index = transform_helper.GetMethodInfo("SetSiblingIndex"); + + if (!object_helper.isValidHelper() || !game_object_helper.isValidHelper() || !transform_helper.isValidHelper() + || !instantiate || !get_transform || !set_name || !set_sibling_index) { + spdlog::warn("[OpcIndicators] required Unity methods are unavailable; OPC highlight was not created"); + return nullptr; + } + + bool world_position_stays = false; + void* instantiate_args[3] = {source_object, body_transform, &world_position_stays}; + auto* highlight = reinterpret_cast(invoke(instantiate, nullptr, instantiate_args, "Object.Instantiate")); + if (!highlight) { + return nullptr; + } + + const auto highlight_handle = il2cpp_gchandle_new(reinterpret_cast(highlight), false); + if (!highlight_handle) { + destroy_game_object(highlight); + return nullptr; + } + + highlight->SetActive(false); + auto* highlight_transform = + reinterpret_cast(invoke(get_transform, highlight, nullptr, "GameObject.get_transform")); + if (!configure_opc_frame(highlight_transform) || !configure_opc_inner(highlight_transform, body_transform)) { + destroy_game_object(highlight); + il2cpp_gchandle_free(highlight_handle); + return nullptr; + } + + int32_t sibling_index = 0; + for (; sibling_index < body_transform->childCount; ++sibling_index) { + if (body_transform->GetChild(sibling_index) == source_transform) { + break; + } + } + void* sibling_args[1] = {&sibling_index}; + void* name_args[1] = {il2cpp_string_new(kOpcHighlightName)}; + // Publish the discoverable name only after setup succeeds; Unity destruction is deferred until frame end. + if (sibling_index >= body_transform->childCount + || !invoke_void(set_sibling_index, highlight_transform, sibling_args, "Transform.SetSiblingIndex") + || !invoke_void(set_name, highlight, name_args, "Object.set_name")) { + destroy_game_object(highlight); + il2cpp_gchandle_free(highlight_handle); + return nullptr; + } + + auto* result = reinterpret_cast(il2cpp_gchandle_get_target(highlight_handle)); + il2cpp_gchandle_free(highlight_handle); + spdlog::debug("[OpcIndicators] created fleet-bar OPC highlight"); + return result; +} + +constexpr int32_t kCurrentDeployedStateMask = 8133; + +constexpr bool is_deployed(FleetState state) +{ return (static_cast(state) & kCurrentDeployedStateMask) != 0; } + +static_assert(is_deployed(FleetState::Mining)); +static_assert(is_deployed(FleetState::Warping)); +static_assert(!is_deployed(FleetState::Docked)); +static_assert(!is_deployed(FleetState::Destroyed)); + +void update_opc_highlight(Transform* body_transform, FleetPlayerData* fleet) +{ + if (!body_transform) { + return; + } + + auto* highlight = find_opc_highlight(body_transform); + const auto slot = fleet ? fleet->Index : -1; + if (!Config::Get().highlight_opc_fleets) { + if (highlight) highlight->SetActive(false); + if (slot >= 0 && slot < kFleetSlotCount) { + s_opc_highlight_retry_fleet_ids[slot] = 0; + s_opc_highlight_retry_anchor_ids[slot] = 0; + clear_ui_retry(s_opc_highlight_setup_failures[slot], s_opc_highlight_retry_at_ms[slot]); + } + return; + } + if (!fleet || slot < 0 || slot >= kFleetSlotCount) { + if (highlight) { + highlight->SetActive(false); + } + return; + } + + const auto fleet_id = fleet->Id; + // The address is an identity token only; retaining it never implies that the Unity object is still live. + const auto anchor_id = reinterpret_cast(body_transform); + if (s_opc_highlight_retry_fleet_ids[slot] != fleet_id || s_opc_highlight_retry_anchor_ids[slot] != anchor_id) { + s_opc_highlight_retry_fleet_ids[slot] = fleet_id; + s_opc_highlight_retry_anchor_ids[slot] = anchor_id; + clear_ui_retry(s_opc_highlight_setup_failures[slot], s_opc_highlight_retry_at_ms[slot]); + } + const auto state = fleet->CurrentState; + const bool show = fleet->HasShip && is_deployed(state) + && read_fleet_opc_sample(fleet, slot, fleet_id, state).opc; + if (!show) { + clear_ui_retry(s_opc_highlight_setup_failures[slot], s_opc_highlight_retry_at_ms[slot]); + if (highlight) { + highlight->SetActive(false); + } + return; + } + + if (s_opc_highlight_retry_at_ms[slot] > steady_now_milliseconds()) { + if (highlight) { + highlight->SetActive(false); + } + return; + } + + if (!highlight) { + highlight = create_opc_highlight(body_transform); + if (!highlight) { + schedule_ui_retry(s_opc_highlight_setup_failures[slot], s_opc_highlight_retry_at_ms[slot]); + return; + } + clear_ui_retry(s_opc_highlight_setup_failures[slot], s_opc_highlight_retry_at_ms[slot]); + } + highlight->SetActive(true); +} + +Transform* fleet_state_widget_label_anchor(void* fleet_state_widget) +{ return opc_anchor_from_component(fleet_state_widget); } + +bool fleet_tile_is_selected(void* fleet_state_widget, FleetPlayerData* fleet) +{ + if (!fleet_state_widget || !fleet) { + return false; + } + + static auto fleet_bar_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "FleetBarViewController"); + auto* fleet_bar = component_in_parent(fleet_state_widget, fleet_bar_helper); + auto index = fleet->Index; + void* args[]{&index}; + auto* result = fleet_bar ? invoke(s_is_index_selected, fleet_bar, args, "FleetBar.IsIndexSelected") : nullptr; + return result && *static_cast(il2cpp_object_unbox(result)); +} + +bool configure_opc_eta_label(void* label, Transform* transform, bool selected, bool safe_on_node) +{ + if (!label || !transform) { + return false; + } + + static auto rect_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "RectTransform"); + static auto anchor_min = rect_helper.GetMethodInfo("set_anchorMin"); + static auto anchor_max = rect_helper.GetMethodInfo("set_anchorMax"); + static auto pivot_method = rect_helper.GetMethodInfo("set_pivot"); + static auto size_delta = rect_helper.GetMethodInfo("set_sizeDelta"); + static auto position = rect_helper.GetMethodInfo("set_anchoredPosition"); + static auto transform_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Transform"); + static auto last_sibling = transform_helper.GetMethodInfo("SetAsLastSibling"); + static auto text_helper = il2cpp_get_class_helper("Unity.TextMeshPro", "TMPro", "TMP_Text"); + static auto set_font_size = text_helper.GetMethodInfo("set_fontSize", 1); + static auto set_alignment = text_helper.GetMethodInfo("set_alignment", 1); + static auto graphic_helper = il2cpp_get_class_helper("UnityEngine.UI", "UnityEngine.UI", "Graphic"); + static auto set_raycast = graphic_helper.GetMethodInfo("set_raycastTarget", 1); + static auto set_color = graphic_helper.GetMethodInfo("set_color", 1); + if (!anchor_min || !anchor_max || !pivot_method || !size_delta || !position || !last_sibling || !set_font_size + || !set_alignment || !set_raycast || !set_color) { + return false; + } + + UiVector2 top_center{0.5f, 1.0f}; + UiVector2 bottom_center{0.5f, 0.0f}; + UiVector2 size{148.0f, 36.0f}; + UiVector2 offset{0.0f, selected ? 24.0f : 14.0f}; + float font_size = 18.0f; + int32_t alignment = 514; // TMPro.TextAlignmentOptions.Center + bool raycast_target = false; + Color text_color = safe_on_node ? Color{0.45f, 0.86f, 1.0f, 1.0f} : Color{1.0f, 0.69f, 0.13f, 1.0f}; + void* top_args[1] = {&top_center}; + void* bottom_args[1] = {&bottom_center}; + void* size_args[1] = {&size}; + void* offset_args[1] = {&offset}; + void* font_args[1] = {&font_size}; + void* alignment_args[1] = {&alignment}; + void* raycast_args[1] = {&raycast_target}; + void* color_args[1] = {&text_color}; + return invoke_void(anchor_min, transform, top_args, "RectTransform.set_anchorMin") + && invoke_void(anchor_max, transform, top_args, "RectTransform.set_anchorMax") + && invoke_void(pivot_method, transform, bottom_args, "RectTransform.set_pivot") + && invoke_void(size_delta, transform, size_args, "RectTransform.set_sizeDelta") + && invoke_void(position, transform, offset_args, "RectTransform.set_anchoredPosition") + && invoke_void(last_sibling, transform, nullptr, "Transform.SetAsLastSibling") + && invoke_void(set_font_size, label, font_args, "TMP_Text.set_fontSize") + && invoke_void(set_alignment, label, alignment_args, "TMP_Text.set_alignment") + && invoke_void(set_raycast, label, raycast_args, "Graphic.set_raycastTarget") + && invoke_void(set_color, label, color_args, "Graphic.set_color"); +} + +bool configure_opc_card_label(void* label, Transform* transform) +{ + if (!label || !transform) { + return false; + } + + static auto rect_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "RectTransform"); + static auto anchor_min = rect_helper.GetMethodInfo("set_anchorMin"); + static auto anchor_max = rect_helper.GetMethodInfo("set_anchorMax"); + static auto pivot_method = rect_helper.GetMethodInfo("set_pivot"); + static auto size_delta = rect_helper.GetMethodInfo("set_sizeDelta"); + static auto position = rect_helper.GetMethodInfo("set_anchoredPosition"); + static auto transform_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Transform"); + static auto last_sibling = transform_helper.GetMethodInfo("SetAsLastSibling"); + static auto text_helper = il2cpp_get_class_helper("Unity.TextMeshPro", "TMPro", "TMP_Text"); + static auto set_font_size = text_helper.GetMethodInfo("set_fontSize", 1); + static auto set_alignment = text_helper.GetMethodInfo("set_alignment", 1); + static auto graphic_helper = il2cpp_get_class_helper("UnityEngine.UI", "UnityEngine.UI", "Graphic"); + static auto set_raycast = graphic_helper.GetMethodInfo("set_raycastTarget", 1); + static auto set_color = graphic_helper.GetMethodInfo("set_color", 1); + if (!anchor_min || !anchor_max || !pivot_method || !size_delta || !position || !last_sibling || !set_font_size + || !set_alignment || !set_raycast || !set_color) { + return false; + } + + // Runtime probe: the expanded panel's TimerContainer is 280x49.8 and its native Timer is centered at x=7.6. + // Place this single line just beneath the native 43.4-high timer without changing the panel's own layout. + UiVector2 center{0.5f, 0.5f}; + UiVector2 size{190.0f, 26.0f}; + UiVector2 offset{7.6f, -39.0f}; + float font_size = 18.0f; + int32_t alignment = 514; // TMPro.TextAlignmentOptions.Center + bool raycast_target = false; + Color text_color{0.82f, 0.91f, 0.96f, 1.0f}; + void* anchor_args[1] = {¢er}; + void* pivot_args[1] = {¢er}; + void* size_args[1] = {&size}; + void* offset_args[1] = {&offset}; + void* font_args[1] = {&font_size}; + void* alignment_args[1] = {&alignment}; + void* raycast_args[1] = {&raycast_target}; + void* color_args[1] = {&text_color}; + return invoke_void(anchor_min, transform, anchor_args, "RectTransform.set_anchorMin") + && invoke_void(anchor_max, transform, anchor_args, "RectTransform.set_anchorMax") + && invoke_void(pivot_method, transform, pivot_args, "RectTransform.set_pivot") + && invoke_void(size_delta, transform, size_args, "RectTransform.set_sizeDelta") + && invoke_void(position, transform, offset_args, "RectTransform.set_anchoredPosition") + && invoke_void(last_sibling, transform, nullptr, "Transform.SetAsLastSibling") + && invoke_void(set_font_size, label, font_args, "TMP_Text.set_fontSize") + && invoke_void(set_alignment, label, alignment_args, "TMP_Text.set_alignment") + && invoke_void(set_raycast, label, raycast_args, "Graphic.set_raycastTarget") + && invoke_void(set_color, label, color_args, "Graphic.set_color"); +} + +void* find_opc_eta_label(Transform* label_anchor) +{ + auto* label_transform = direct_child_named(label_anchor, kOpcEtaLabelName); + auto* label_object = label_transform ? label_transform->gameObject : nullptr; + if (!label_object) { + return nullptr; + } + + static auto game_object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "GameObject"); + static auto text_helper = il2cpp_get_class_helper("Unity.TextMeshPro", "TMPro", "TextMeshProUGUI"); + static auto get_component = game_object_helper.GetMethodInfo("GetComponent", 1); + void* text_type = text_helper.GetType(); + void* args[1] = {text_type}; + return invoke(get_component, label_object, args, "GameObject.GetComponent"); +} + +void* create_ui_component(const char* name, Transform* parent, IL2CppClassHelper& component_helper, + const char* operation) +{ + if (!parent || !component_helper.isValidHelper()) { + return nullptr; + } + + static auto game_object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "GameObject"); + static auto transform_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Transform"); + static auto constructor = game_object_helper.GetMethodInfoSpecial(".ctor", [](int count, const Il2CppType** params) { + return count == 1 && params[0]->type == IL2CPP_TYPE_STRING; + }); + static auto get_transform = game_object_helper.GetMethodInfo("get_transform"); + static auto set_parent = + transform_helper.GetMethodInfoSpecial("SetParent", [](int count, const Il2CppType**) { return count == 2; }); + static auto add_component = game_object_helper.GetMethodInfo("AddComponent", 1); + if (!constructor || !get_transform || !set_parent || !add_component) { + spdlog::warn("[OpcIndicators] {} Unity methods are unavailable", operation); + return nullptr; + } + + auto* game_object = reinterpret_cast(il2cpp_object_new(game_object_helper.get_cls())); + if (!game_object) { + spdlog::warn("[OpcIndicators] {} could not allocate a GameObject", operation); + return nullptr; + } + const auto handle = il2cpp_gchandle_new(reinterpret_cast(game_object), false); + if (!handle) { + return nullptr; + } + + void* name_args[1] = {il2cpp_string_new(name)}; + if (!invoke_void(constructor, game_object, name_args, "GameObject.ctor")) { + il2cpp_gchandle_free(handle); + return nullptr; + } + + auto* transform = reinterpret_cast(invoke(get_transform, game_object, nullptr, "get_transform")); + bool world_position_stays = false; + void* parent_args[2] = {parent, &world_position_stays}; + if (!transform || !invoke_void(set_parent, transform, parent_args, "Transform.SetParent")) { + destroy_game_object(game_object); + il2cpp_gchandle_free(handle); + return nullptr; + } + + void* component_type = component_helper.GetType(); + if (!component_type) { + destroy_game_object(game_object); + il2cpp_gchandle_free(handle); + return nullptr; + } + void* component_args[1] = {component_type}; + auto* component = invoke(add_component, game_object, component_args, operation); + if (!component) { + destroy_game_object(game_object); + il2cpp_gchandle_free(handle); + return nullptr; + } + + game_object->SetActive(true); + il2cpp_gchandle_free(handle); + return component; +} + +GameObject* find_opc_eta_background(Transform* label_anchor) +{ + auto* background_transform = direct_child_named(label_anchor, kOpcEtaBackgroundName); + return background_transform ? background_transform->gameObject : nullptr; +} + +void* opc_eta_background_image(GameObject* background) +{ + if (!background) { + return nullptr; + } + + static auto game_object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "GameObject"); + static auto image_helper = il2cpp_get_class_helper("UnityEngine.UI", "UnityEngine.UI", "Image"); + static auto get_component = game_object_helper.GetMethodInfo("GetComponent", 1); + void* image_type = image_helper.GetType(); + void* args[1] = {image_type}; + return invoke(get_component, background, args, "GameObject.GetComponent"); +} + +bool configure_opc_eta_background(void* image, Transform* transform, bool selected) +{ + if (!image || !transform) { + return false; + } + + static auto rect_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "RectTransform"); + static auto anchor_min = rect_helper.GetMethodInfo("set_anchorMin"); + static auto anchor_max = rect_helper.GetMethodInfo("set_anchorMax"); + static auto pivot_method = rect_helper.GetMethodInfo("set_pivot"); + static auto size_delta = rect_helper.GetMethodInfo("set_sizeDelta"); + static auto position = rect_helper.GetMethodInfo("set_anchoredPosition"); + static auto transform_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "Transform"); + static auto last_sibling = transform_helper.GetMethodInfo("SetAsLastSibling"); + static auto image_helper = il2cpp_get_class_helper("UnityEngine.UI", "UnityEngine.UI", "Image"); + static auto set_color = image_helper.GetMethodInfo("set_color", 1); + static auto set_raycast = image_helper.GetMethodInfo("set_raycastTarget", 1); + if (!anchor_min || !anchor_max || !pivot_method || !size_delta || !position || !last_sibling || !set_color + || !set_raycast) { + return false; + } + + UiVector2 top_center{0.5f, 1.0f}; + UiVector2 bottom_center{0.5f, 0.0f}; + UiVector2 size{108.0f, 30.0f}; + UiVector2 offset{0.0f, selected ? 25.0f : 15.0f}; + Color background{0.035f, 0.075f, 0.10f, 0.86f}; + bool raycast_target = false; + void* top_args[1] = {&top_center}; + void* bottom_args[1] = {&bottom_center}; + void* size_args[1] = {&size}; + void* offset_args[1] = {&offset}; + void* color_args[1] = {&background}; + void* raycast_args[1] = {&raycast_target}; + return invoke_void(anchor_min, transform, top_args, "RectTransform.set_anchorMin") + && invoke_void(anchor_max, transform, top_args, "RectTransform.set_anchorMax") + && invoke_void(pivot_method, transform, bottom_args, "RectTransform.set_pivot") + && invoke_void(size_delta, transform, size_args, "RectTransform.set_sizeDelta") + && invoke_void(position, transform, offset_args, "RectTransform.set_anchoredPosition") + && invoke_void(last_sibling, transform, nullptr, "Transform.SetAsLastSibling") + && invoke_void(set_color, image, color_args, "Image.set_color") + && invoke_void(set_raycast, image, raycast_args, "Image.set_raycastTarget"); +} + +GameObject* create_opc_eta_background(Transform* label_anchor, bool selected) +{ + if (auto* existing = find_opc_eta_background(label_anchor); existing) { + if (opc_eta_background_image(existing)) { + return existing; + } + destroy_game_object(existing); + return nullptr; + } + + static auto image_helper = il2cpp_get_class_helper("UnityEngine.UI", "UnityEngine.UI", "Image"); + auto* image = + create_ui_component(kOpcEtaBackgroundName, label_anchor, image_helper, "GameObject.AddComponent"); + auto* transform = component_transform(image); + auto* game_object = transform ? transform->gameObject : nullptr; + if (!game_object || !configure_opc_eta_background(image, transform, selected)) { + destroy_game_object(game_object); + return nullptr; + } + return game_object; +} + +void* create_opc_eta_label(Transform* label_anchor, bool selected, bool safe_on_node) +{ + if (!label_anchor) { + return nullptr; + } + if (auto* child = direct_child_named(label_anchor, kOpcEtaLabelName); child) { + if (auto* existing = find_opc_eta_label(label_anchor); existing && component_transform(existing)) { + return existing; + } + destroy_game_object(child->gameObject); + return nullptr; + } + + static auto text_helper = il2cpp_get_class_helper("Unity.TextMeshPro", "TMPro", "TextMeshProUGUI"); + auto* label = + create_ui_component(kOpcEtaLabelName, label_anchor, text_helper, "GameObject.AddComponent"); + auto* transform = component_transform(label); + auto* game_object = transform ? transform->gameObject : nullptr; + if (!game_object || !configure_opc_eta_label(label, transform, selected, safe_on_node)) { + destroy_game_object(game_object); + return nullptr; + } + + spdlog::info("[OpcIndicators] created fleet timer OPC ETA label"); + return label; +} + +void* find_opc_card_label(Transform* timer_anchor) +{ + auto* label_transform = direct_child_named(timer_anchor, kOpcCardLabelName); + auto* label_object = label_transform ? label_transform->gameObject : nullptr; + if (!label_object) { + return nullptr; + } + + static auto game_object_helper = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "GameObject"); + static auto text_helper = il2cpp_get_class_helper("Unity.TextMeshPro", "TMPro", "TextMeshProUGUI"); + static auto get_component = game_object_helper.GetMethodInfo("GetComponent", 1); + void* text_type = text_helper.GetType(); + void* args[1] = {text_type}; + return invoke(get_component, label_object, args, "GameObject.GetComponent"); +} + +void* create_opc_card_label(Transform* timer_anchor) +{ + if (!timer_anchor) { + return nullptr; + } + if (auto* child = direct_child_named(timer_anchor, kOpcCardLabelName); child) { + if (auto* existing = find_opc_card_label(timer_anchor); existing && component_transform(existing)) { + return existing; + } + destroy_game_object(child->gameObject); + return nullptr; + } + + static auto text_helper = il2cpp_get_class_helper("Unity.TextMeshPro", "TMPro", "TextMeshProUGUI"); + auto* label = create_ui_component(kOpcCardLabelName, timer_anchor, text_helper, + "GameObject.AddComponent"); + auto* transform = component_transform(label); + auto* game_object = transform ? transform->gameObject : nullptr; + if (!game_object || !configure_opc_card_label(label, transform)) { + destroy_game_object(game_object); + return nullptr; + } + + spdlog::info("[OpcIndicators] created selected-fleet OPC card label"); + return label; +} + +void hide_opc_card_label(Transform* timer_anchor) +{ + if (auto* transform = direct_child_named(timer_anchor, kOpcCardLabelName); transform && transform->gameObject) { + transform->gameObject->SetActive(false); + } +} + +void destroy_opc_card_label(Transform* timer_anchor) +{ + auto* label = direct_child_named(timer_anchor, kOpcCardLabelName); + destroy_game_object(label ? label->gameObject : nullptr); +} + +void update_opc_card_label(void* ui_component, FleetPlayerData* fleet, const std::string& display) +{ + auto* timer_anchor = fleet_panel_timer_anchor(ui_component); + const auto timer_anchor_id = reinterpret_cast(timer_anchor); + auto& state = s_opc_card_render_state; + if (!fleet || display.empty()) { + hide_opc_card_label(timer_anchor); + state = {}; + return; + } + + if (state.fleet_id != fleet->Id || state.timer_anchor_id != timer_anchor_id) { + state = {}; + state.fleet_id = fleet->Id; + state.timer_anchor_id = timer_anchor_id; + } + if (state.setup_retry_at_ms > steady_now_milliseconds()) { + return; + } + + auto* label = find_opc_card_label(timer_anchor); + bool created = false; + if (!label) { + label = create_opc_card_label(timer_anchor); + created = label != nullptr; + } + auto* transform = component_transform(label); + auto* game_object = transform ? transform->gameObject : nullptr; + if (!label || !transform || !game_object + || ((!state.layout_initialized || created) && !configure_opc_card_label(label, transform))) { + destroy_opc_card_label(timer_anchor); + state.layout_initialized = false; + schedule_ui_retry(state.setup_failures, state.setup_retry_at_ms); + return; + } + + if (created || state.display != display) { + static auto tmp_helper = il2cpp_get_class_helper("Unity.TextMeshPro", "TMPro", "TMP_Text"); + static auto set_text = tmp_helper.GetMethodInfo("set_text", 1); + void* args[1] = {il2cpp_string_new(display.c_str())}; + if (!set_text || !invoke_void(set_text, label, args, "TMP_Text.set_text")) { + destroy_opc_card_label(timer_anchor); + state.layout_initialized = false; + schedule_ui_retry(state.setup_failures, state.setup_retry_at_ms); + return; + } + } + + state.display = display; + state.layout_initialized = true; + clear_ui_retry(state.setup_failures, state.setup_retry_at_ms); + game_object->SetActive(true); +} + +void log_opc_eta(FleetPlayerData* fleet, const FleetOpcStatus& status, const std::string& display) +{ + const auto slot = fleet ? fleet->Index : -1; + if (slot < 0 || slot >= kFleetSlotCount) { + return; + } + + const auto fleet_id = fleet ? fleet->Id : 0; + auto& previous = s_last_opc_eta_log_states[slot]; + if (previous.fleet_id == fleet_id && previous.display == display) { + return; + } + + const bool previously_visible = !previous.display.empty(); + previous = {fleet_id, display}; + if (display.empty()) { + if (previously_visible) { + spdlog::debug("[OpcIndicators] slot={} fleet={} opcEta=hidden", slot, fleet_id); + } + return; + } + + spdlog::debug("[OpcIndicators] slot={} fleet={} cargo={:.0f} protected={:.0f} ratePerSecond={:.3f} opc={} " + "etaSeconds={} nodeEtaSeconds={} safeOnNode={} label='{}'", + slot, fleet_id, status.current_cargo, status.protected_limit, status.rate_per_second, status.opc, + status.eta_seconds, status.node_eta_seconds, status.safe_on_node, display); +} + +void hide_opc_eta(Transform* label_anchor) +{ + if (!label_anchor) { + return; + } + if (auto* transform = direct_child_named(label_anchor, kOpcEtaLabelName); transform && transform->gameObject) { + transform->gameObject->SetActive(false); + } + if (auto* transform = direct_child_named(label_anchor, kOpcEtaBackgroundName); transform && transform->gameObject) { + transform->gameObject->SetActive(false); + } +} + +void destroy_opc_eta(Transform* label_anchor) +{ + if (!label_anchor) { + return; + } + auto* label = direct_child_named(label_anchor, kOpcEtaLabelName); + auto* background = direct_child_named(label_anchor, kOpcEtaBackgroundName); + destroy_game_object(label ? label->gameObject : nullptr); + destroy_game_object(background ? background->gameObject : nullptr); +} + +void reset_opc_eta_slot(int slot) +{ + if (slot < 0 || slot >= kFleetSlotCount) { + return; + } + s_opc_eta_render_states[slot] = {}; + s_last_opc_eta_log_states[slot] = {}; + s_last_opc_eta_refresh_fleet_ids[slot] = 0; + s_last_opc_eta_refresh_ms[slot] = 0; +} + +bool opc_eta_refresh_due(FleetPlayerData* fleet, bool force) +{ + const auto slot = fleet ? fleet->Index : -1; + if (slot < 0 || slot >= kFleetSlotCount) { + return true; + } + + const auto fleet_id = fleet->Id; + const auto now_ms = steady_now_milliseconds(); + if (!force && s_last_opc_eta_refresh_fleet_ids[slot] == fleet_id && s_last_opc_eta_refresh_ms[slot] != 0 + && now_ms - s_last_opc_eta_refresh_ms[slot] < kOpcEtaRefreshMs) { + return false; + } + + s_last_opc_eta_refresh_fleet_ids[slot] = fleet_id; + s_last_opc_eta_refresh_ms[slot] = now_ms; + return true; +} + +void update_opc_eta_label(void* ui_component, FleetPlayerData* fleet, Transform* known_label_anchor = nullptr) +{ + auto* label_anchor = known_label_anchor ? known_label_anchor : fleet_state_widget_label_anchor(ui_component); + const bool panel_component = ui_component && fleet_panel_controller(ui_component) == ui_component; + if (!Config::Get().fleet_hud_opc_eta) { + hide_opc_eta(label_anchor); + update_opc_card_label(ui_component, nullptr, {}); + reset_opc_eta_slot(fleet ? fleet->Index : -1); + return; + } + if (!ui_component || !fleet) { + hide_opc_eta(label_anchor); + if (panel_component) { + update_opc_card_label(ui_component, nullptr, {}); + } + return; + } + const auto slot = fleet->Index; + if (slot < 0 || slot >= kFleetSlotCount) { + hide_opc_eta(label_anchor); + if (panel_component) { + update_opc_card_label(ui_component, nullptr, {}); + } + return; + } + const bool selected = panel_component || fleet_tile_is_selected(ui_component, fleet); + auto& render = s_opc_eta_render_states[slot]; + const bool fleet_changed = render.fleet_id != fleet->Id; + if (fleet_changed) { + render = {}; + render.fleet_id = fleet->Id; + } + + const auto fleet_state = fleet->CurrentState; + const bool state_changed = !render.fleet_state_known || render.fleet_state != fleet_state; + render.fleet_state = fleet_state; + render.fleet_state_known = true; + + const bool refresh_due = opc_eta_refresh_due(fleet, fleet_changed || state_changed); + auto display = render.computed_display; + auto card_display = render.computed_card_display; + auto safe_on_node = render.computed_safe; + if (refresh_due) { + const auto status = read_opc_status(fleet, slot, render.fleet_id, fleet_state); + display = format_opc_eta(status); + card_display = display.empty() ? std::string{} : format_opc_card_display(status); + safe_on_node = status.safe_on_node; + log_opc_eta(fleet, status, display); + } + + if (display.empty()) { + hide_opc_eta(label_anchor); + if (selected) { + update_opc_card_label(ui_component, fleet, {}); + } + render.computed_display.clear(); + render.computed_card_display.clear(); + render.rendered_display.clear(); + render.computed_safe = false; + render.rendered_safe_on_node = false; + render.layout_initialized = false; + clear_ui_retry(render.setup_failures, render.setup_retry_at_ms); + return; + } + + if (selected) { + update_opc_card_label(ui_component, fleet, card_display); + } + if (!label_anchor) { + render.computed_display = display; + render.computed_card_display = card_display; + render.computed_safe = safe_on_node; + return; + } + + if (render.setup_retry_at_ms > steady_now_milliseconds()) { + render.computed_display = display; + render.computed_card_display = card_display; + render.computed_safe = safe_on_node; + return; + } + + auto* background = find_opc_eta_background(label_anchor); + auto* label = find_opc_eta_label(label_anchor); + + bool created = false; + if (!background) { + background = create_opc_eta_background(label_anchor, selected); + created = background != nullptr; + } + if (!label) { + label = create_opc_eta_label(label_anchor, selected, safe_on_node); + created = created || label != nullptr; + } + if (created) { + render.layout_initialized = false; + } + auto* label_transform = component_transform(label); + auto* label_object = label_transform ? label_transform->gameObject : nullptr; + auto* background_image = opc_eta_background_image(background); + auto* background_transform = component_transform(background_image); + if (!background || !background_image || !background_transform || !label || !label_transform || !label_object) { + destroy_opc_eta(label_anchor); + render.computed_display = display; + render.computed_card_display = card_display; + render.computed_safe = safe_on_node; + render.layout_initialized = false; + schedule_ui_retry(render.setup_failures, render.setup_retry_at_ms); + return; + } + + if (!render.layout_initialized || render.selected != selected || render.rendered_safe_on_node != safe_on_node) { + const bool background_configured = configure_opc_eta_background(background_image, background_transform, selected); + const bool label_configured = configure_opc_eta_label(label, label_transform, selected, safe_on_node); + render.layout_initialized = label_configured && background_configured; + render.selected = selected; + if (!render.layout_initialized) { + destroy_opc_eta(label_anchor); + render.computed_display = display; + render.computed_card_display = card_display; + render.computed_safe = safe_on_node; + schedule_ui_retry(render.setup_failures, render.setup_retry_at_ms); + return; + } + } + + if (created || render.rendered_display != display) { + static auto tmp_helper = il2cpp_get_class_helper("Unity.TextMeshPro", "TMPro", "TMP_Text"); + static auto set_text = tmp_helper.GetMethodInfo("set_text", 1); + const auto desired = "" + display + ""; + void* args[1] = {il2cpp_string_new(desired.c_str())}; + if (!set_text || !invoke_void(set_text, label, args, "TMP_Text.set_text")) { + destroy_opc_eta(label_anchor); + render.computed_display = display; + render.computed_card_display = card_display; + render.computed_safe = safe_on_node; + render.layout_initialized = false; + schedule_ui_retry(render.setup_failures, render.setup_retry_at_ms); + return; + } + } + + render.computed_display = display; + render.computed_card_display = card_display; + render.computed_safe = safe_on_node; + render.rendered_display = display; + render.rendered_safe_on_node = safe_on_node; + clear_ui_retry(render.setup_failures, render.setup_retry_at_ms); + background->SetActive(true); + label_object->SetActive(true); +} + +FleetPlayerData* widget_context(void* self, FieldInfo* field) +{ + FleetPlayerData* fleet = nullptr; + if (self && field) + il2cpp_field_get_value(static_cast(self), field, &fleet); + return fleet; +} +FleetPlayerData* fleet_state_widget_context(void* self) +{ return widget_context(self, s_state_context_field); } +FleetPlayerData* fleetbar_flag_widget_context(void* self) +{ return widget_context(self, s_flag_context_field); } +FleetPlayerData* fleet_local_view_fleet(void* self) +{ return self ? reinterpret_cast(invoke(s_local_get_fleet, self, nullptr, "FleetLocal.get_fleet")) : nullptr; } + +void FleetStateWidget_SetWidgetData_Hook(auto original, void* self) +{ + original(self); + if (s_eta_ready) + update_opc_eta_label(self, fleet_state_widget_context(self)); +} + +void FleetStateWidget_ClearWidgetData_Hook(auto original, void* self) +{ + if (!s_eta_ready) { + original(self); + return; + } + auto* fleet = fleet_state_widget_context(self); + auto* label_anchor = fleet_state_widget_label_anchor(self); + const auto slot = fleet ? fleet->Index : -1; + original(self); + if (label_anchor) { + hide_opc_eta(label_anchor); + reset_opc_eta_slot(slot); + } + if (fleet && s_opc_card_render_state.fleet_id == fleet->Id) { + auto* panel_fleet = fleet_local_view_fleet(fleet_panel_controller(self)); + if (!panel_fleet || panel_fleet->Id != fleet->Id) { + update_opc_card_label(self, nullptr, {}); + } + } +} + +void FleetbarFlagWidget_SetWidgetData_Hook(auto original, void* self) +{ + original(self); + if (s_highlight_ready) + update_opc_highlight(opc_anchor_from_fleetbar_flag(self), fleetbar_flag_widget_context(self)); +} + +void FleetbarFlagWidget_ClearWidgetData_Hook(auto original, void* self) +{ + if (!s_highlight_ready) { + original(self); + return; + } + auto* fleet = fleetbar_flag_widget_context(self); + auto* anchor = opc_anchor_from_fleetbar_flag(self); + const auto slot = fleet ? fleet->Index : -1; + original(self); + if (anchor) { + if (auto* highlight = find_opc_highlight(anchor); highlight) { + highlight->SetActive(false); + } + if (slot >= 0 && slot < kFleetSlotCount) { + s_opc_highlight_retry_fleet_ids[slot] = 0; + s_opc_highlight_retry_anchor_ids[slot] = 0; + clear_ui_retry(s_opc_highlight_setup_failures[slot], s_opc_highlight_retry_at_ms[slot]); + } + } +} + +void FleetLocalViewController_BindDataContext_Hook(auto original, void* self, void* provider, void* data_context) +{ + original(self, provider, data_context); + if (!s_eta_ready && !s_highlight_ready) return; + + auto* tile_transform = component_transform(self); + auto* label_anchor = opc_anchor_from_tile(tile_transform); + auto* fleet = fleet_local_view_fleet(self); + if (s_highlight_ready) { + update_opc_highlight(label_anchor, fleet); + } + const bool panel_component = fleet_panel_controller(self) == self; + if (s_eta_ready && (label_anchor || panel_component)) { + reset_opc_eta_slot(fleet ? fleet->Index : -1); + update_opc_eta_label(self, fleet, label_anchor); + } +} + +void FleetLocalViewController_OnCurrentCargoReactiveEvent_Hook(auto original, void* self, int32_t dirty_flags) +{ + original(self, dirty_flags); + if (!s_eta_ready && !s_highlight_ready) return; + auto* tile_transform = component_transform(self); + auto* fleet = fleet_local_view_fleet(self); + invalidate_fleet_opc_sample(fleet ? fleet->Index : -1); + if (s_highlight_ready) { + update_opc_highlight(opc_anchor_from_tile(tile_transform), fleet); + } + if (s_eta_ready) { + update_opc_eta_label(self, fleet, opc_anchor_from_tile(tile_transform)); + } +} + +} // namespace + +void InstallOpcIndicatorHooks() +{ +#if !defined(_WIN32) && !defined(__APPLE__) + spdlog::warn("[OpcIndicators] unavailable: unsupported platform"); + return; +#endif + auto local = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Ships", "FleetLocalViewController"); + auto state = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "FleetStateWidget"); + auto flag = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "FleetbarFlagWidget"); + auto bar = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "FleetBarViewController"); + for (const auto& [helper, name] : std::array{ + std::pair{&local, "Digit.Prime.Ships.FleetLocalViewController"}, + std::pair{&state, "Digit.Prime.HUD.FleetStateWidget"}, + std::pair{&flag, "Digit.Prime.HUD.FleetbarFlagWidget"}, + std::pair{&bar, "Digit.Prime.HUD.FleetBarViewController"}}) + if (!helper->isValidHelper()) spdlog::warn("[OpcIndicators] missing class {}", name); + const auto* bind = resolve_instance_void(local, "BindDataContext", + {"Digit.Client.UI.IDataContextProvider", "System.Object"}); + const auto* cargo = resolve_instance_void(local, "OnCurrentCargoReactiveEvent", {"System.Int32"}); + const auto* state_set = resolve_instance_void(state, "SetWidgetData"); + const auto* state_clear = resolve_instance_void(state, "ClearWidgetData"); + const auto* flag_set = resolve_instance_void(flag, "SetWidgetData"); + const auto* flag_clear = resolve_instance_void(flag, "ClearWidgetData"); + s_local_get_fleet = method_contract::Resolve(local.get_cls(), "get_fleet", false, + "Digit.PrimeServer.Models.FleetPlayerData", {}); + s_is_index_selected = method_contract::Resolve(bar.get_cls(), "IsIndexSelected", false, + "System.Boolean", {"System.Int32"}); + s_state_context_field = state.get_cls() ? il2cpp_class_get_field_from_name(state.get_cls(), "m_context") : nullptr; + s_flag_context_field = flag.get_cls() ? il2cpp_class_get_field_from_name(flag.get_cls(), "m_context") : nullptr; + const bool local_ready = bind && cargo && s_local_get_fleet && !s_local_get_fleet->has_full_generic_sharing_signature; + const bool eta_ready = local_ready && state_set && state_clear && s_is_index_selected + && !s_is_index_selected->has_full_generic_sharing_signature + && is_instance_class_field(s_state_context_field, "Digit.PrimeServer.Models", "FleetPlayerData"); + const bool highlight_ready = local_ready && flag_set && flag_clear + && is_instance_class_field(s_flag_context_field, "Digit.PrimeServer.Models", "FleetPlayerData"); + if (!s_local_get_fleet || s_local_get_fleet->has_full_generic_sharing_signature) + spdlog::warn("[OpcIndicators] both families unavailable: FleetLocalViewController.get_fleet() -> FleetPlayerData: {}", + s_local_get_fleet ? "unsupported generic sharing" : "instance method unavailable"); + if (!s_is_index_selected || s_is_index_selected->has_full_generic_sharing_signature) + spdlog::warn("[OpcIndicators] ETA unavailable: FleetBarViewController.IsIndexSelected(Int32) -> Boolean: {}", + s_is_index_selected ? "unsupported generic sharing" : "instance method unavailable"); + for (const auto& [field, owner] : std::array{ + std::pair{s_state_context_field, "ETA: FleetStateWidget"}, + std::pair{s_flag_context_field, "highlight: FleetbarFlagWidget"}}) { + auto* type = field && field->type && field->type->type == IL2CPP_TYPE_CLASS + ? il2cpp_class_from_type(field->type) : nullptr; + if (!is_instance_class_field(field, "Digit.PrimeServer.Models", "FleetPlayerData")) + spdlog::warn("[OpcIndicators] {}.m_context: expected instance FleetPlayerData class field, offset >= {}; " + "actual offset={} type={} class={} static={} byref={}", + owner, sizeof(Il2CppObject), field ? field->offset : -1, + field && field->type ? static_cast(field->type->type) : -1, + type ? type->name : "", + field && field->type && bool(field->type->attrs & FIELD_ATTRIBUTE_STATIC), + field && field->type && bool(field->type->byref)); + } + if (!eta_ready) spdlog::warn("[OpcIndicators] ETA unavailable; retaining native ETA labels"); + if (!highlight_ready) spdlog::warn("[OpcIndicators] highlight unavailable; retaining native fleet flags"); + if (!eta_ready && !highlight_ready) { + spdlog::warn("[OpcIndicators] unavailable: required method/context contracts"); + return; + } + std::vector targets{bind, cargo}; + std::vector names{"FleetLocalViewController.BindDataContext", "FleetLocalViewController.OnCurrentCargoReactiveEvent"}; + if (eta_ready) { + targets.push_back(state_set); targets.push_back(state_clear); + names.push_back("FleetStateWidget.SetWidgetData"); names.push_back("FleetStateWidget.ClearWidgetData"); + } + if (highlight_ready) { + targets.push_back(flag_set); targets.push_back(flag_clear); + names.push_back("FleetbarFlagWidget.SetWidgetData"); names.push_back("FleetbarFlagWidget.ClearWidgetData"); + } + for (size_t i = 0; i < targets.size(); ++i) + for (size_t j = 0; j < i; ++j) + if (targets[i]->methodPointer == targets[j]->methodPointer) { + spdlog::warn("[OpcIndicators] unavailable: {} and {} share a native hook target", names[i], names[j]); + return; + } + bool installed = true; + if (eta_ready) { + const bool a = SPUD_STATIC_DETOUR(state_clear->methodPointer, FleetStateWidget_ClearWidgetData_Hook) != nullptr; + const bool b = SPUD_STATIC_DETOUR(state_set->methodPointer, FleetStateWidget_SetWidgetData_Hook) != nullptr; + if (!a) spdlog::warn("[OpcIndicators] hook installation failed: FleetStateWidget.ClearWidgetData; both families retain native behavior"); + if (!b) spdlog::warn("[OpcIndicators] hook installation failed: FleetStateWidget.SetWidgetData; both families retain native behavior"); + installed = a && b; + } + if (installed && highlight_ready) { + const bool a = SPUD_STATIC_DETOUR(flag_clear->methodPointer, FleetbarFlagWidget_ClearWidgetData_Hook) != nullptr; + const bool b = SPUD_STATIC_DETOUR(flag_set->methodPointer, FleetbarFlagWidget_SetWidgetData_Hook) != nullptr; + if (!a) spdlog::warn("[OpcIndicators] hook installation failed: FleetbarFlagWidget.ClearWidgetData; both families retain native behavior"); + if (!b) spdlog::warn("[OpcIndicators] hook installation failed: FleetbarFlagWidget.SetWidgetData; both families retain native behavior"); + installed = a && b; + } + if (installed) { + const bool a = SPUD_STATIC_DETOUR(bind->methodPointer, FleetLocalViewController_BindDataContext_Hook) != nullptr; + const bool b = SPUD_STATIC_DETOUR(cargo->methodPointer, FleetLocalViewController_OnCurrentCargoReactiveEvent_Hook) != nullptr; + if (!a) spdlog::warn("[OpcIndicators] hook installation failed: FleetLocalViewController.BindDataContext; both families retain native behavior"); + if (!b) spdlog::warn("[OpcIndicators] hook installation failed: FleetLocalViewController.OnCurrentCargoReactiveEvent; both families retain native behavior"); + installed = a && b; + } + // A partial installation remains native; do not retry the same targets. + s_eta_ready = installed && eta_ready; + s_highlight_ready = installed && highlight_ready; + spdlog::info("[OpcIndicators] eta_ready={} highlight_ready={}", s_eta_ready, s_highlight_ready); +} diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index ad04cc499..3d73925d9 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(); @@ -50,6 +51,7 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); +void InstallOpcIndicatorHooks(); void InstallHavenHistoryHooks(); __int64 il2cpp_init_hook(auto original, const char* domain_name) @@ -128,6 +130,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}}, @@ -155,6 +158,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.installForbiddenTechConfirmationHooks}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, + {"OpcIndicators", {InstallOpcIndicatorHooks, &cfg.installOpcIndicatorHooks}}, {"HavenHistory", {InstallHavenHistoryHooks, &cfg.installHavenHistoryHooks}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); 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..4c511dee5 100644 --- a/mods/src/prime/FleetPlayerData.h +++ b/mods/src/prime/FleetPlayerData.h @@ -3,7 +3,9 @@ #include "errormsg.h" #include "BattleTargetData.h" +#include "CargoHoldData.h" #include "HullSpec.h" +#include "MiningSlot.h" #include "RecallRequirement.h" #include "CanRepairRequirement.h" @@ -44,6 +46,9 @@ 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_Index)) int Index; + __declspec(property(get = __get_MiningData)) MiningSlot* MiningData; + __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 +67,22 @@ struct FleetPlayerData { static auto field = get_class_helper().GetProperty("Hull"); return field.GetRaw(this); } + int __get_Index() + { + static auto property = get_class_helper().GetProperty("Index"); + auto* value = property.Get(this); + return value ? *value : -1; + } + MiningSlot* __get_MiningData() + { + static auto property = get_class_helper().GetProperty("MiningData"); + return property.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/MiningSlot.h b/mods/src/prime/MiningSlot.h new file mode 100644 index 000000000..0a880619d --- /dev/null +++ b/mods/src/prime/MiningSlot.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include +#include +#include + +// FleetPlayerData.MiningData is IMiningSlot; resolve its actual implementation. +// Both MiningSlot and FleetMiningSlot use the same validated numeric contracts. +struct MiningSlot { + __declspec(property(get = __get_ResourceId)) int64_t ResourceId; + __declspec(property(get = __get_MiningSpeed)) float MiningSpeed; + __declspec(property(get = __get_AmountMined)) double AmountMined; + __declspec(property(get = __get_Amount)) int64_t Amount; + +private: + template T Read(const char* name, const char* explicit_name, const char* type, T unavailable) + { + auto* object = reinterpret_cast(this); + auto* cls = il2cpp_object_get_class(object); + static auto slot_interface = il2cpp_get_class_helper( + "Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "IMiningSlot"); + if (!cls || !slot_interface.get_cls() || !il2cpp_class_is_assignable_from(slot_interface.get_cls(), cls)) + return unavailable; + const MethodInfo* method = nullptr; + for (auto* owner = cls; owner && !method; owner = il2cpp_class_get_parent(owner)) { + const auto* public_getter = method_contract::Resolve(owner, name, false, type, {}); + const auto* explicit_getter = method_contract::Resolve(owner, explicit_name, false, type, {}); + if (public_getter && explicit_getter) return unavailable; + method = public_getter ? public_getter : explicit_getter; + } + Il2CppObject* value = nullptr; + if (!method || method->has_full_generic_sharing_signature + || !Il2CppRuntime::TryInvoke(method, object, nullptr, &value) || !value + || il2cpp_object_get_class(value) != il2cpp_class_from_type(method->return_type)) + return unavailable; + return *static_cast(il2cpp_object_unbox(value)); + } + +public: + int64_t __get_ResourceId() + { return Read("get_ResourceId", "Digit.PrimeServer.Models.IMiningSlot.get_ResourceId", "System.Int64", 0); } + float __get_MiningSpeed() + { return Read("get_MiningSpeed", "Digit.PrimeServer.Models.IMiningSlot.get_MiningSpeed", "System.Single", 0.0f); } + double __get_AmountMined() + { return Read("get_AmountMined", "Digit.PrimeServer.Models.IMiningSlot.get_AmountMined", "System.Double", + std::numeric_limits::quiet_NaN()); } + int64_t __get_Amount() + { return Read("get_Amount", "Digit.PrimeServer.Models.IMiningSlot.get_Amount", "System.Int64", -1); } +}; 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 aee96c5a2..b0fe52325 100644 --- a/mods/xmake.lua +++ b/mods/xmake.lua @@ -122,7 +122,7 @@ do -- 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")