From 90027944f916e450719229ead709206f34be2edb Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 05:46:06 -0500 Subject: [PATCH 1/6] Add reusable fleet observation service --- mods/src/patches/fleet_watch.cc | 407 +++++++++++++++++++++++++ mods/src/patches/fleet_watch.h | 43 +++ mods/src/patches/parts/hotkeys.cc | 36 ++- mods/src/patches/screen_update_hook.cc | 28 ++ mods/src/patches/screen_update_hook.h | 12 + mods/src/prime/FleetPlayerData.h | 53 ++-- mods/src/prime/FleetsManager.h | 36 +++ 7 files changed, 584 insertions(+), 31 deletions(-) create mode 100644 mods/src/patches/fleet_watch.cc create mode 100644 mods/src/patches/fleet_watch.h create mode 100644 mods/src/patches/screen_update_hook.cc create mode 100644 mods/src/patches/screen_update_hook.h diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc new file mode 100644 index 000000000..dc8f78dd5 --- /dev/null +++ b/mods/src/patches/fleet_watch.cc @@ -0,0 +1,407 @@ +#include "patches/fleet_watch.h" + +#include "errormsg.h" +#include "patches/screen_update_hook.h" + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int kFleetSlotCount = 10; +constexpr int64_t kManagerProbeIntervalMs = 250; +constexpr int64_t kFastPollIntervalMs = 250; +constexpr int64_t kFastPollBackoffMs = 5'000; +constexpr int64_t kFastPollPeriodMs = 30'000; +constexpr int64_t kFastPollLifetimeMs = 24LL * 60 * 60 * 1'000; +constexpr int64_t kSeedIntervalMs = 1'000; +constexpr int64_t kSeedBackoffIntervalMs = 5'000; +constexpr int64_t kSeedFastPeriodMs = 30'000; +constexpr int64_t kSeedLifetimeMs = 120'000; +constexpr int64_t kSeedStabilizationMs = 5'000; +constexpr int64_t kSeedMaxStabilizationMs = 30'000; + +struct ObservedFleet { + uint64_t fleet_id = 0; + FleetState state = FleetState::Unknown; + bool occupied = false; + int64_t fast_poll_started_ms = 0; +}; + +std::array s_slots{}; +std::vector s_subscriptions; +bool s_hooks_installed = false; +bool s_seed_pending = false; +bool s_seed_has_observation = false; +bool s_seed_manager_backed = false; +int64_t s_seed_started_ms = 0; +int64_t s_seed_first_observed_ms = 0; +int64_t s_seed_last_change_ms = 0; +int64_t s_last_seed_attempt_ms = 0; +int64_t s_last_manager_probe_ms = 0; +int64_t s_last_fast_poll_ms = 0; +int s_manager_probe_slot = 0; +int s_fast_poll_count = 0; + +int64_t now_milliseconds() +{ + return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +bool needs_fast_poll(FleetState state) +{ + return std::any_of(s_subscriptions.begin(), s_subscriptions.end(), [state](const auto& subscription) { + return subscription.needs_fast_poll && subscription.needs_fast_poll(state); + }); +} + +void reset_observation() +{ + s_slots = {}; + s_seed_pending = true; + s_seed_has_observation = false; + s_seed_manager_backed = false; + s_seed_started_ms = now_milliseconds(); + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_last_seed_attempt_ms = 0; + s_last_manager_probe_ms = 0; + s_last_fast_poll_ms = 0; + s_manager_probe_slot = 0; + s_fast_poll_count = 0; +} + +void restart_seed(int slot, uint64_t fleet_id) +{ + spdlog::debug("[FleetWatch] fleet topology changed; restarting baseline slot={} fleet={}", slot, fleet_id); + reset_observation(); +} + +void clear_slot(int slot) +{ + if (s_slots[slot].occupied && s_slots[slot].fast_poll_started_ms != 0) { + --s_fast_poll_count; + } + s_slots[slot] = {}; +} + +int resolve_slot(FleetPlayerData* fleet, int requested_slot) +{ + if (!fleet) { + return -1; + } + if (requested_slot >= 0 && requested_slot < kFleetSlotCount) { + return requested_slot; + } + for (int slot = 0; slot < kFleetSlotCount; ++slot) { + if (s_slots[slot].occupied && s_slots[slot].fleet_id == fleet->Id) { + return slot; + } + } + const auto model_slot = fleet->Index; + return model_slot >= 0 && model_slot < kFleetSlotCount ? model_slot : -1; +} + +int occupied_slot_count() +{ + return static_cast( + std::count_if(s_slots.begin(), s_slots.end(), [](const auto& slot) { return slot.occupied; })); +} + +void dispatch_transition(int slot, FleetPlayerData* fleet, FleetState before, FleetState after, + fleet_watch::Source source) +{ + const fleet_watch::Transition transition{ + .before = fleet_watch::Snapshot{slot, fleet->Id, before}, + .after = fleet_watch::Snapshot{slot, fleet->Id, after}, + .fleet = fleet, + .source = source, + }; + for (const auto& subscription : s_subscriptions) { + subscription.on_transition(transition); + } +} + +void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool publish, fleet_watch::Source source) +{ + const auto slot = resolve_slot(fleet, requested_slot); + if (!fleet || slot < 0) { + return; + } + + auto& previous = s_slots[slot]; + const auto fleet_id = fleet->Id; + const auto state = fleet->CurrentState; + const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; + if (!same_fleet && previous.occupied && !s_seed_pending) { + restart_seed(slot, fleet_id); + } + + const bool changed = !same_fleet || previous.state != state; + if (same_fleet && publish && !s_seed_pending && previous.state != state) { + dispatch_transition(slot, fleet, previous.state, state, source); + } + + const bool was_fast_polling = previous.occupied && previous.fast_poll_started_ms != 0; + int64_t fast_started_ms = 0; + if (needs_fast_poll(state)) { + fast_started_ms = same_fleet && was_fast_polling ? previous.fast_poll_started_ms : now_milliseconds(); + } + previous = ObservedFleet{fleet_id, state, true, fast_started_ms}; + + const bool is_fast_polling = fast_started_ms != 0; + if (was_fast_polling != is_fast_polling) { + s_fast_poll_count += is_fast_polling ? 1 : -1; + if (is_fast_polling && s_fast_poll_count == 1) { + s_last_fast_poll_ms = 0; + } + } + if (s_seed_pending && changed) { + const auto now_ms = now_milliseconds(); + if (!s_seed_has_observation) { + s_seed_has_observation = true; + s_seed_first_observed_ms = now_ms; + } + s_seed_last_change_ms = now_ms; + } +} + +struct ScanResult { + int observed_count = 0; + bool manager_available = false; + bool topology_changed = false; + int changed_slot = -1; + uint64_t changed_fleet = 0; +}; + +ScanResult scan_manager(bool publish, bool only_fast_polling) +{ + ScanResult result; + auto* manager = FleetsManager::Instance(); + if (!manager || !manager->HasFleetService()) { + return result; + } + result.manager_available = true; + + std::array fleets{}; + for (int slot = 0; slot < kFleetSlotCount; ++slot) { + fleets[slot] = manager->GetFleetPlayerData(slot); + if (fleets[slot]) { + ++result.observed_count; + } + if (only_fast_polling) { + continue; + } + const bool occupied = fleets[slot] != nullptr; + const auto fleet_id = occupied ? fleets[slot]->Id : 0; + if (s_slots[slot].occupied != occupied || (occupied && s_slots[slot].fleet_id != fleet_id)) { + result.topology_changed = true; + if (result.changed_slot < 0) { + result.changed_slot = slot; + result.changed_fleet = fleet_id; + } + } + } + + if (result.topology_changed && publish && !s_seed_pending) { + restart_seed(result.changed_slot, result.changed_fleet); + publish = false; + } else if (result.topology_changed && s_seed_pending && s_seed_has_observation) { + s_seed_last_change_ms = now_milliseconds(); + } + + for (int slot = 0; slot < kFleetSlotCount; ++slot) { + if (only_fast_polling && (!s_slots[slot].occupied || s_slots[slot].fast_poll_started_ms == 0)) { + continue; + } + if (fleets[slot]) { + observe_fleet(fleets[slot], slot, publish, fleet_watch::Source::FleetManager); + } else if (!only_fast_polling) { + clear_slot(slot); + } + } + s_seed_manager_backed = true; + return result; +} + +void observe_widget(FleetPlayerData* fleet) +{ + if (!fleet || s_subscriptions.empty()) { + return; + } + const auto slot = resolve_slot(fleet, -1); + if (!s_seed_pending && s_seed_manager_backed && slot >= 0) { + auto* manager = FleetsManager::Instance(); + auto* current = manager && manager->HasFleetService() ? manager->GetFleetPlayerData(slot) : nullptr; + if (!current || current->Id != fleet->Id) { + restart_seed(slot, fleet->Id); + } + } + observe_fleet(fleet, slot, true, fleet_watch::Source::FleetStateWidget); +} + +FleetPlayerData* fleet_widget_context(void* self) +{ + if (!self) { + return nullptr; + } + static auto get_context = + IL2CppClassHelper{reinterpret_cast(self)->klass}.GetMethod("get_Context", + 0); + return get_context ? get_context(self) : nullptr; +} + +void FleetStateWidget_SetWidgetData_Hook(auto original, void* self) +{ + original(self); + observe_widget(fleet_widget_context(self)); +} + +bool install_hooks() +{ + if (!register_screen_manager_update_callback(fleet_watch::Tick)) { + return false; + } + install_screen_manager_update_hook(); + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "FleetStateWidget"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("HUD", "FleetStateWidget"); + return false; + } + auto method = helper.GetMethod("SetWidgetData"); + if (!method) { + ErrorMsg::MissingMethod("FleetStateWidget", "SetWidgetData"); + return false; + } + SPUD_STATIC_DETOUR(method, FleetStateWidget_SetWidgetData_Hook); + return true; +} +} // namespace + +namespace fleet_watch +{ +bool Subscribe(Subscription subscription) +{ + if (!subscription.on_transition) { + return false; + } + const auto duplicate = std::find_if(s_subscriptions.begin(), s_subscriptions.end(), [&](const auto& existing) { + return existing.on_transition == subscription.on_transition; + }); + if (duplicate != s_subscriptions.end()) { + return false; + } + + if (!s_hooks_installed) { + if (!install_hooks()) { + return false; + } + s_hooks_installed = true; + reset_observation(); + } + s_subscriptions.push_back(subscription); + return true; +} + +void Tick() +{ + if (s_subscriptions.empty()) { + return; + } + + const auto now_ms = now_milliseconds(); + if (s_seed_pending) { + if (s_seed_started_ms == 0) { + s_seed_started_ms = now_ms; + } + const auto lifetime = now_ms - s_seed_started_ms; + const auto interval = lifetime < kSeedFastPeriodMs ? kSeedIntervalMs : kSeedBackoffIntervalMs; + if (s_last_seed_attempt_ms == 0 || now_ms - s_last_seed_attempt_ms >= interval) { + s_last_seed_attempt_ms = now_ms; + const auto scan = scan_manager(false, false); + if (s_seed_manager_backed && !scan.manager_available) { + reset_observation(); + return; + } + if (scan.manager_available && !s_seed_has_observation) { + s_seed_has_observation = true; + s_seed_first_observed_ms = now_ms; + s_seed_last_change_ms = now_ms; + } + } + + if (s_seed_has_observation) { + const bool quiet = now_ms - s_seed_last_change_ms >= kSeedStabilizationMs; + const bool forced = now_ms - s_seed_first_observed_ms >= kSeedMaxStabilizationMs; + if (quiet || forced) { + const auto final_scan = scan_manager(false, false); + if (final_scan.manager_available || !s_seed_manager_backed) { + const auto baseline_count = final_scan.observed_count > 0 ? final_scan.observed_count : occupied_slot_count(); + s_seed_pending = false; + s_last_manager_probe_ms = now_ms; + s_last_fast_poll_ms = s_fast_poll_count > 0 ? now_ms : 0; + spdlog::debug("[FleetWatch] established stable baseline for {} fleet slots", baseline_count); + } + } + } else if (lifetime >= kSeedLifetimeMs) { + s_seed_pending = false; + spdlog::warn("[FleetWatch] baseline stopped after {}ms without an available fleet service", kSeedLifetimeMs); + } + return; + } + + if (s_seed_manager_backed + && (s_last_manager_probe_ms == 0 || now_ms - s_last_manager_probe_ms >= kManagerProbeIntervalMs)) { + s_last_manager_probe_ms = now_ms; + auto* manager = FleetsManager::Instance(); + if (!manager || !manager->HasFleetService()) { + restart_seed(-1, 0); + return; + } + const int slot = s_manager_probe_slot; + s_manager_probe_slot = (slot + 1) % kFleetSlotCount; + auto* fleet = manager->GetFleetPlayerData(slot); + const bool occupied = fleet != nullptr; + const auto fleet_id = occupied ? fleet->Id : 0; + if (s_slots[slot].occupied != occupied || (occupied && s_slots[slot].fleet_id != fleet_id)) { + restart_seed(slot, fleet_id); + return; + } + if (fleet) { + observe_fleet(fleet, slot, true, Source::FleetManager); + } + } + + if (s_fast_poll_count <= 0) { + return; + } + bool fast_period = false; + for (auto& slot : s_slots) { + if (!slot.occupied || slot.fast_poll_started_ms == 0) { + continue; + } + const auto lifetime = now_ms - slot.fast_poll_started_ms; + if (lifetime >= kFastPollLifetimeMs) { + slot.fast_poll_started_ms = 0; + --s_fast_poll_count; + } else if (lifetime < kFastPollPeriodMs) { + fast_period = true; + } + } + const auto interval = fast_period ? kFastPollIntervalMs : kFastPollBackoffMs; + if (s_fast_poll_count > 0 && (s_last_fast_poll_ms == 0 || now_ms - s_last_fast_poll_ms >= interval)) { + s_last_fast_poll_ms = now_ms; + scan_manager(true, true); + } +} +} // namespace fleet_watch diff --git a/mods/src/patches/fleet_watch.h b/mods/src/patches/fleet_watch.h new file mode 100644 index 000000000..ef2d85149 --- /dev/null +++ b/mods/src/patches/fleet_watch.h @@ -0,0 +1,43 @@ +#pragma once + +#include + +#include + +namespace fleet_watch +{ +enum class Source : uint8_t { + FleetManager, + FleetStateWidget, +}; + +struct Snapshot { + int slot = -1; + uint64_t fleet_id = 0; + FleetState state = FleetState::Unknown; +}; + +struct Transition { + Snapshot before; + Snapshot after; + FleetPlayerData* fleet = nullptr; + Source source = Source::FleetManager; +}; + +using TransitionCallback = void (*)(const Transition& transition); +using FastPollPredicate = bool (*)(FleetState state); + +struct Subscription { + TransitionCallback on_transition = nullptr; + FastPollPredicate needs_fast_poll = nullptr; +}; + +// Registers a process-lifetime fleet-state observer. The first subscription installs the shared observation hooks; +// with no subscriptions, Fleet Watch performs no hooks, scans, or IL2CPP work. +// Register during patch installation. Callbacks run synchronously on the game thread, and Transition::fleet is valid +// only for the duration of the callback. +bool Subscribe(Subscription subscription); + +// Called by the shared ScreenManager.Update detour. Consumers should use Subscribe rather than calling this directly. +void Tick(); +} // namespace fleet_watch diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index f04ddafb1..148db3d45 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -39,6 +39,7 @@ #include "patches/mapkey.h" #include "patches/parts/daily_faction_bulk_claim.h" #include "patches/parts/focus_search.h" +#include "patches/screen_update_hook.h" #include @@ -188,6 +189,11 @@ bool MoveShipSelectionInDock(bool goLeft) void ScreenManager_Update_Hook(auto original, ScreenManager* _this) { + dispatch_screen_manager_update_callbacks(); + if (!Config::Get().installHotkeyHooks) { + return original(_this); + } + // This function is called every frame to update the screen manager. // Create a global clock to detect time elapsed static std::chrono::time_point select_clock = std::chrono::steady_clock::now(); @@ -1027,6 +1033,24 @@ void ShowWithFleet_Hook(auto original, PreScanTargetWidget* _this, void* a1) } } +void install_screen_manager_update_hook() +{ + static bool installed = false; + if (installed) { + return; + } + + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "ScreenManager"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("UI", "ScreenManager"); + } else if (auto update = helper.GetMethod("Update"); update) { + SPUD_STATIC_DETOUR(update, ScreenManager_Update_Hook); + installed = true; + } else { + ErrorMsg::MissingMethod("ScreenManager", "Update"); + } +} + void InstallHotkeyHooks() { auto shortcuts_manager_helper = @@ -1042,17 +1066,7 @@ void InstallHotkeyHooks() } } - auto screen_manager_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "ScreenManager"); - if (!screen_manager_helper.isValidHelper()) { - ErrorMsg::MissingHelper("UI", "ScreenManager"); - } else { - auto ptr_update = screen_manager_helper.GetMethod("Update"); - if (ptr_update == nullptr) { - ErrorMsg::MissingMethod("ScreenManager", "Update"); - } else { - SPUD_STATIC_DETOUR(ptr_update, ScreenManager_Update_Hook); - } - } + install_screen_manager_update_hook(); static auto rewards_button_widget = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Combat", "RewardsButtonWidget"); diff --git a/mods/src/patches/screen_update_hook.cc b/mods/src/patches/screen_update_hook.cc new file mode 100644 index 000000000..e794b62fc --- /dev/null +++ b/mods/src/patches/screen_update_hook.cc @@ -0,0 +1,28 @@ +#include "patches/screen_update_hook.h" + +#include +#include + +namespace +{ +std::vector s_callbacks; +} + +bool register_screen_manager_update_callback(ScreenManagerUpdateCallback callback) +{ + if (!callback) { + return false; + } + if (std::find(s_callbacks.begin(), s_callbacks.end(), callback) != s_callbacks.end()) { + return true; + } + s_callbacks.push_back(callback); + return true; +} + +void dispatch_screen_manager_update_callbacks() +{ + for (const auto callback : s_callbacks) { + callback(); + } +} diff --git a/mods/src/patches/screen_update_hook.h b/mods/src/patches/screen_update_hook.h new file mode 100644 index 000000000..f230b12dd --- /dev/null +++ b/mods/src/patches/screen_update_hook.h @@ -0,0 +1,12 @@ +#pragma once + +// ScreenManager.Update has one detour owner shared by HotkeyHooks and opt-in frame services. +// Installation is idempotent so either owner can request the dispatcher. +void install_screen_manager_update_hook(); + +using ScreenManagerUpdateCallback = void (*)(); + +// Registers a process-lifetime callback that runs before the game's ScreenManager.Update handling. +// Register during patch installation. Re-registering the same callback is harmless. +bool register_screen_manager_update_callback(ScreenManagerUpdateCallback callback); +void dispatch_screen_manager_update_callbacks(); diff --git a/mods/src/prime/FleetPlayerData.h b/mods/src/prime/FleetPlayerData.h index 05c5c0fa0..a7facd1fb 100644 --- a/mods/src/prime/FleetPlayerData.h +++ b/mods/src/prime/FleetPlayerData.h @@ -8,26 +8,32 @@ #include enum class FleetState { - Unknown = 0, - IdleInSpace = 1, - Docked = 2, - Mining = 4, - Destroyed = 8, - TieringUp = 16, - Repairing = 32, - CannotLaunch = 56, - Battling = 64, - WarpCharging = 128, - Warping = 256, - CanRemove = 384, - CannotMove = 504, - Impulsing = 512, - CanManage = 899, - Capturing = 1024, - CanRecall = 1541, - CanEngage = 1543, - Deployed = 1989, - CanLocate = 1991 + Unknown = 0, + IdleInSpace = 1, + Docked = 2, + Mining = 4, + Destroyed = 8, + TieringUp = 16, + CanReplaceOfficers = 18, + Repairing = 32, + CannotLaunch = 56, + Battling = 64, + WarpCharging = 128, + Warping = 256, + CanRemove = 384, + Impulsing = 512, + CanActivateAbility = 513, + CanDisco = 515, + Capturing = 1024, + AutoHunting = 2048, + CannotMove = 2552, + CanManage = 2947, + CanBeTargetedByAbility = 3589, + CanEngage = 3591, + Outposting = 4096, + CanRecall = 5637, + Deployed = 8133, + CanLocate = 8135 }; struct FleetPlayerData { @@ -36,6 +42,7 @@ struct FleetPlayerData { __declspec(property(get = __get_PreviousState)) FleetState PreviousState; __declspec(property(get = __get_Id)) uint64_t Id; __declspec(property(get = __get_Hull)) HullSpec* Hull; + __declspec(property(get = __get_Index)) int Index; __declspec(property(get = __get_Address)) void* Address; __declspec(property(get = __get_Level)) int64_t Level; __declspec(property(get = __get_HasShip)) bool HasShip; @@ -54,6 +61,12 @@ 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; + } void* __get_Address() { static auto field = get_class_helper().GetProperty("Address"); diff --git a/mods/src/prime/FleetsManager.h b/mods/src/prime/FleetsManager.h index df4649868..b10d324fc 100644 --- a/mods/src/prime/FleetsManager.h +++ b/mods/src/prime/FleetsManager.h @@ -104,6 +104,42 @@ struct FleetsManager : MonoSingleton { return nullptr; } + bool HasFleetService() + { + static auto* fleet_service_cache = + il2cpp_class_get_field_from_name(get_class_helper().get_cls(), "_fleetServiceCache"); + static auto FleetServiceCacheWarn = true; + if (!fleet_service_cache) { + if (FleetServiceCacheWarn) { + FleetServiceCacheWarn = false; + spdlog::error("Unable to find field 'FleetsManager->_fleetServiceCache'"); + } + return false; + } + + auto* cache = reinterpret_cast(reinterpret_cast(this) + fleet_service_cache->offset); + if (*cache) { + return true; + } + + static auto* HasServiceMethod = [] { + auto* cache_class = il2cpp_class_from_type(il2cpp_field_get_type(fleet_service_cache)); + return cache_class ? il2cpp_class_get_method_from_name(cache_class, "get_HasService", 0) : nullptr; + }(); + static auto HasServiceWarn = true; + if (!HasServiceMethod) { + if (HasServiceWarn) { + HasServiceWarn = false; + ErrorMsg::MissingMethod("CachedService", "get_HasService"); + } + return false; + } + + Il2CppException* exception = nullptr; + auto* result = il2cpp_runtime_invoke(HasServiceMethod, cache, nullptr, &exception); + return !exception && result && *static_cast(il2cpp_object_unbox(result)); + } + FleetDeployedData* __get_TargetFleetData() { static auto field = get_class_helper().GetField("_targetFleetData").offset(); From 78514e6dbcefb279dd3becca9dcd2cae2531bfa8 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 05:59:23 -0500 Subject: [PATCH 2/6] Harden fleet observer lifecycle --- mods/src/patches/fleet_watch.cc | 225 ++++++++++++++++++------- mods/src/patches/fleet_watch.h | 5 + mods/src/patches/parts/hotkeys.cc | 13 +- mods/src/patches/screen_update_hook.cc | 24 ++- mods/src/patches/screen_update_hook.h | 2 +- 5 files changed, 205 insertions(+), 64 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index dc8f78dd5..e5211ab3e 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -6,51 +6,70 @@ #include #include +#include #include #include #include #include #include +#include +#include #include #include namespace { -constexpr int kFleetSlotCount = 10; -constexpr int64_t kManagerProbeIntervalMs = 250; -constexpr int64_t kFastPollIntervalMs = 250; -constexpr int64_t kFastPollBackoffMs = 5'000; -constexpr int64_t kFastPollPeriodMs = 30'000; -constexpr int64_t kFastPollLifetimeMs = 24LL * 60 * 60 * 1'000; -constexpr int64_t kSeedIntervalMs = 1'000; -constexpr int64_t kSeedBackoffIntervalMs = 5'000; -constexpr int64_t kSeedFastPeriodMs = 30'000; -constexpr int64_t kSeedLifetimeMs = 120'000; -constexpr int64_t kSeedStabilizationMs = 5'000; -constexpr int64_t kSeedMaxStabilizationMs = 30'000; +constexpr int kFleetSlotCount = 10; +constexpr int64_t kManagerProbeIntervalMs = 250; +constexpr int64_t kManagerDiscoveryIntervalMs = 5'000; +constexpr int64_t kFastPollIntervalMs = 250; +constexpr int64_t kFastPollBackoffMs = 5'000; +constexpr int64_t kFastPollPeriodMs = 30'000; +constexpr int64_t kFastPollLifetimeMs = 24LL * 60 * 60 * 1'000; +constexpr int64_t kSeedIntervalMs = 1'000; +constexpr int64_t kSeedBackoffIntervalMs = 5'000; +constexpr int64_t kSeedFastPeriodMs = 30'000; +constexpr int64_t kSeedLifetimeMs = 120'000; +constexpr int64_t kSeedStabilizationMs = 5'000; +constexpr int64_t kSeedMaxStabilizationMs = 30'000; struct ObservedFleet { uint64_t fleet_id = 0; FleetState state = FleetState::Unknown; bool occupied = false; int64_t fast_poll_started_ms = 0; + bool fast_poll_exhausted = false; }; std::array s_slots{}; std::vector s_subscriptions; -bool s_hooks_installed = false; -bool s_seed_pending = false; -bool s_seed_has_observation = false; -bool s_seed_manager_backed = false; -int64_t s_seed_started_ms = 0; -int64_t s_seed_first_observed_ms = 0; -int64_t s_seed_last_change_ms = 0; -int64_t s_last_seed_attempt_ms = 0; -int64_t s_last_manager_probe_ms = 0; -int64_t s_last_fast_poll_ms = 0; -int s_manager_probe_slot = 0; -int s_fast_poll_count = 0; +bool s_hooks_installed = false; +bool s_seed_pending = false; +bool s_seed_has_observation = false; +bool s_seed_manager_backed = false; +bool s_seed_finalize_candidate = false; +int64_t s_seed_started_ms = 0; +int64_t s_seed_first_observed_ms = 0; +int64_t s_seed_last_change_ms = 0; +int64_t s_last_seed_attempt_ms = 0; +int64_t s_last_manager_probe_ms = 0; +int64_t s_last_manager_discovery_ms = 0; +int64_t s_last_fast_poll_ms = 0; +int s_manager_probe_slot = 0; +int s_fast_poll_count = 0; +int s_dispatch_depth = 0; +bool s_evaluating_fast_poll = false; + +class CallbackScope +{ +public: + CallbackScope() + { ++s_dispatch_depth; } + + ~CallbackScope() + { --s_dispatch_depth; } +}; int64_t now_milliseconds() { @@ -60,25 +79,50 @@ int64_t now_milliseconds() bool needs_fast_poll(FleetState state) { - return std::any_of(s_subscriptions.begin(), s_subscriptions.end(), [state](const auto& subscription) { - return subscription.needs_fast_poll && subscription.needs_fast_poll(state); - }); + if (s_evaluating_fast_poll) { + spdlog::warn("[FleetWatch] suppressed recursive fast-poll predicate evaluation"); + return false; + } + s_evaluating_fast_poll = true; + struct PredicateScope { + ~PredicateScope() + { s_evaluating_fast_poll = false; } + } predicate_scope; + CallbackScope callback_scope; + + for (const auto& subscription : s_subscriptions) { + if (!subscription.needs_fast_poll) { + continue; + } + try { + if (subscription.needs_fast_poll(state)) { + return true; + } + } catch (const std::exception& error) { + spdlog::warn("[FleetWatch] fast-poll predicate failed: {}", error.what()); + } catch (...) { + spdlog::warn("[FleetWatch] fast-poll predicate failed with an unknown exception"); + } + } + return false; } void reset_observation() { - s_slots = {}; - s_seed_pending = true; - s_seed_has_observation = false; - s_seed_manager_backed = false; - s_seed_started_ms = now_milliseconds(); - s_seed_first_observed_ms = 0; - s_seed_last_change_ms = 0; - s_last_seed_attempt_ms = 0; - s_last_manager_probe_ms = 0; - s_last_fast_poll_ms = 0; - s_manager_probe_slot = 0; - s_fast_poll_count = 0; + s_slots = {}; + s_seed_pending = true; + s_seed_has_observation = false; + s_seed_manager_backed = false; + s_seed_finalize_candidate = false; + s_seed_started_ms = now_milliseconds(); + s_seed_first_observed_ms = 0; + s_seed_last_change_ms = 0; + s_last_seed_attempt_ms = 0; + s_last_manager_probe_ms = 0; + s_last_manager_discovery_ms = 0; + s_last_fast_poll_ms = 0; + s_manager_probe_slot = 0; + s_fast_poll_count = 0; } void restart_seed(int slot, uint64_t fleet_id) @@ -127,8 +171,15 @@ void dispatch_transition(int slot, FleetPlayerData* fleet, FleetState before, Fl .fleet = fleet, .source = source, }; + CallbackScope callback_scope; for (const auto& subscription : s_subscriptions) { - subscription.on_transition(transition); + try { + subscription.on_transition(transition); + } catch (const std::exception& error) { + spdlog::warn("[FleetWatch] transition callback failed: {}", error.what()); + } catch (...) { + spdlog::warn("[FleetWatch] transition callback failed with an unknown exception"); + } } } @@ -143,21 +194,21 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool publish, fle const auto fleet_id = fleet->Id; const auto state = fleet->CurrentState; const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; - if (!same_fleet && previous.occupied && !s_seed_pending) { + const bool same_state = same_fleet && previous.state == state; + if (!same_fleet && !s_seed_pending) { restart_seed(slot, fleet_id); } - const bool changed = !same_fleet || previous.state != state; - if (same_fleet && publish && !s_seed_pending && previous.state != state) { - dispatch_transition(slot, fleet, previous.state, state, source); - } + const bool changed = !same_state; const bool was_fast_polling = previous.occupied && previous.fast_poll_started_ms != 0; int64_t fast_started_ms = 0; - if (needs_fast_poll(state)) { + const bool fast_exhausted = same_state && previous.fast_poll_exhausted; + if (!fast_exhausted && needs_fast_poll(state)) { fast_started_ms = same_fleet && was_fast_polling ? previous.fast_poll_started_ms : now_milliseconds(); } - previous = ObservedFleet{fleet_id, state, true, fast_started_ms}; + const auto previous_state = previous.state; + previous = ObservedFleet{fleet_id, state, true, fast_started_ms, fast_exhausted}; const bool is_fast_polling = fast_started_ms != 0; if (was_fast_polling != is_fast_polling) { @@ -172,7 +223,11 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool publish, fle s_seed_has_observation = true; s_seed_first_observed_ms = now_ms; } - s_seed_last_change_ms = now_ms; + s_seed_last_change_ms = now_ms; + s_seed_finalize_candidate = false; + } + if (same_fleet && publish && !s_seed_pending && previous_state != state) { + dispatch_transition(slot, fleet, previous_state, state, source); } } @@ -195,6 +250,9 @@ ScanResult scan_manager(bool publish, bool only_fast_polling) std::array fleets{}; for (int slot = 0; slot < kFleetSlotCount; ++slot) { + if (only_fast_polling && (!s_slots[slot].occupied || s_slots[slot].fast_poll_started_ms == 0)) { + continue; + } fleets[slot] = manager->GetFleetPlayerData(slot); if (fleets[slot]) { ++result.observed_count; @@ -217,7 +275,8 @@ ScanResult scan_manager(bool publish, bool only_fast_polling) restart_seed(result.changed_slot, result.changed_fleet); publish = false; } else if (result.topology_changed && s_seed_pending && s_seed_has_observation) { - s_seed_last_change_ms = now_milliseconds(); + s_seed_last_change_ms = now_milliseconds(); + s_seed_finalize_candidate = false; } for (int slot = 0; slot < kFleetSlotCount; ++slot) { @@ -269,10 +328,6 @@ void FleetStateWidget_SetWidgetData_Hook(auto original, void* self) bool install_hooks() { - if (!register_screen_manager_update_callback(fleet_watch::Tick)) { - return false; - } - install_screen_manager_update_hook(); auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "FleetStateWidget"); if (!helper.isValidHelper()) { ErrorMsg::MissingHelper("HUD", "FleetStateWidget"); @@ -283,8 +338,11 @@ bool install_hooks() ErrorMsg::MissingMethod("FleetStateWidget", "SetWidgetData"); return false; } + if (!install_screen_manager_update_hook()) { + return false; + } SPUD_STATIC_DETOUR(method, FleetStateWidget_SetWidgetData_Hook); - return true; + return register_screen_manager_update_callback(fleet_watch::Tick); } } // namespace @@ -292,7 +350,7 @@ namespace fleet_watch { bool Subscribe(Subscription subscription) { - if (!subscription.on_transition) { + if (!subscription.on_transition || s_dispatch_depth != 0) { return false; } const auto duplicate = std::find_if(s_subscriptions.begin(), s_subscriptions.end(), [&](const auto& existing) { @@ -344,22 +402,41 @@ void Tick() const bool quiet = now_ms - s_seed_last_change_ms >= kSeedStabilizationMs; const bool forced = now_ms - s_seed_first_observed_ms >= kSeedMaxStabilizationMs; if (quiet || forced) { + if (!s_seed_finalize_candidate) { + s_seed_finalize_candidate = true; + return; + } const auto final_scan = scan_manager(false, false); + if (!forced && now_ms - s_seed_last_change_ms < kSeedStabilizationMs) { + s_seed_finalize_candidate = false; + return; + } if (final_scan.manager_available || !s_seed_manager_backed) { const auto baseline_count = final_scan.observed_count > 0 ? final_scan.observed_count : occupied_slot_count(); - s_seed_pending = false; - s_last_manager_probe_ms = now_ms; - s_last_fast_poll_ms = s_fast_poll_count > 0 ? now_ms : 0; + s_seed_pending = false; + s_last_manager_probe_ms = now_ms; + s_last_fast_poll_ms = s_fast_poll_count > 0 ? now_ms : 0; spdlog::debug("[FleetWatch] established stable baseline for {} fleet slots", baseline_count); } } } else if (lifetime >= kSeedLifetimeMs) { - s_seed_pending = false; + s_seed_pending = false; + s_last_manager_discovery_ms = now_ms; spdlog::warn("[FleetWatch] baseline stopped after {}ms without an available fleet service", kSeedLifetimeMs); } return; } + if (!s_seed_manager_backed + && (s_last_manager_discovery_ms == 0 || now_ms - s_last_manager_discovery_ms >= kManagerDiscoveryIntervalMs)) { + s_last_manager_discovery_ms = now_ms; + auto* manager = FleetsManager::Instance(); + if (manager && manager->HasFleetService()) { + restart_seed(-1, 0); + return; + } + } + if (s_seed_manager_backed && (s_last_manager_probe_ms == 0 || now_ms - s_last_manager_probe_ms >= kManagerProbeIntervalMs)) { s_last_manager_probe_ms = now_ms; @@ -393,6 +470,7 @@ void Tick() const auto lifetime = now_ms - slot.fast_poll_started_ms; if (lifetime >= kFastPollLifetimeMs) { slot.fast_poll_started_ms = 0; + slot.fast_poll_exhausted = true; --s_fast_poll_count; } else if (lifetime < kFastPollPeriodMs) { fast_period = true; @@ -404,4 +482,33 @@ void Tick() scan_manager(true, true); } } + +#ifdef _MODDBG +namespace +{ + void runtime_probe_transition(const Transition& transition) + { + spdlog::info("[FleetWatchProbe] source={} slot={} fleet={} oldState={} newState={}", + transition.source == Source::FleetManager ? "fleet-manager" : "fleet-state-widget", + transition.after.slot, transition.after.fleet_id, static_cast(transition.before.state), + static_cast(transition.after.state)); + } + + bool runtime_probe_fast_poll(FleetState state) + { return state != FleetState::Unknown; } +} // namespace + +void InstallRuntimeProbe() +{ + const auto* enabled = std::getenv("STFC_MOD_FLEET_WATCH_PROBE"); + if (!enabled || std::string_view{enabled} != "1") { + return; + } + if (Subscribe({runtime_probe_transition, runtime_probe_fast_poll})) { + spdlog::info("[FleetWatchProbe] runtime subscriber installed"); + } else { + spdlog::warn("[FleetWatchProbe] runtime subscriber installation failed"); + } +} +#endif } // namespace fleet_watch diff --git a/mods/src/patches/fleet_watch.h b/mods/src/patches/fleet_watch.h index ef2d85149..58440aa17 100644 --- a/mods/src/patches/fleet_watch.h +++ b/mods/src/patches/fleet_watch.h @@ -40,4 +40,9 @@ bool Subscribe(Subscription subscription); // Called by the shared ScreenManager.Update detour. Consumers should use Subscribe rather than calling this directly. void Tick(); + +#ifdef _MODDBG +// Enables a raw transition logger when STFC_MOD_FLEET_WATCH_PROBE=1 for exact-artifact runtime validation. +void InstallRuntimeProbe(); +#endif } // namespace fleet_watch diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index 148db3d45..5a18809ec 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -41,6 +41,10 @@ #include "patches/parts/focus_search.h" #include "patches/screen_update_hook.h" +#ifdef _MODDBG +#include "patches/fleet_watch.h" +#endif + #include #include @@ -1033,11 +1037,11 @@ void ShowWithFleet_Hook(auto original, PreScanTargetWidget* _this, void* a1) } } -void install_screen_manager_update_hook() +bool install_screen_manager_update_hook() { static bool installed = false; if (installed) { - return; + return true; } auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "ScreenManager"); @@ -1046,9 +1050,11 @@ void install_screen_manager_update_hook() } else if (auto update = helper.GetMethod("Update"); update) { SPUD_STATIC_DETOUR(update, ScreenManager_Update_Hook); installed = true; + return true; } else { ErrorMsg::MissingMethod("ScreenManager", "Update"); } + return false; } void InstallHotkeyHooks() @@ -1067,6 +1073,9 @@ void InstallHotkeyHooks() } install_screen_manager_update_hook(); +#ifdef _MODDBG + fleet_watch::InstallRuntimeProbe(); +#endif static auto rewards_button_widget = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Combat", "RewardsButtonWidget"); diff --git a/mods/src/patches/screen_update_hook.cc b/mods/src/patches/screen_update_hook.cc index e794b62fc..494fe77b0 100644 --- a/mods/src/patches/screen_update_hook.cc +++ b/mods/src/patches/screen_update_hook.cc @@ -1,18 +1,26 @@ #include "patches/screen_update_hook.h" +#include + #include +#include #include namespace { std::vector s_callbacks; -} +bool s_dispatching = false; +} // namespace bool register_screen_manager_update_callback(ScreenManagerUpdateCallback callback) { if (!callback) { return false; } + if (s_dispatching) { + spdlog::warn("[ScreenUpdate] rejected callback registration during dispatch"); + return false; + } if (std::find(s_callbacks.begin(), s_callbacks.end(), callback) != s_callbacks.end()) { return true; } @@ -22,7 +30,19 @@ bool register_screen_manager_update_callback(ScreenManagerUpdateCallback callbac void dispatch_screen_manager_update_callbacks() { + if (s_dispatching) { + spdlog::warn("[ScreenUpdate] suppressed recursive callback dispatch"); + return; + } + s_dispatching = true; for (const auto callback : s_callbacks) { - callback(); + try { + callback(); + } catch (const std::exception& error) { + spdlog::warn("[ScreenUpdate] callback failed: {}", error.what()); + } catch (...) { + spdlog::warn("[ScreenUpdate] callback failed with an unknown exception"); + } } + s_dispatching = false; } diff --git a/mods/src/patches/screen_update_hook.h b/mods/src/patches/screen_update_hook.h index f230b12dd..613d5edee 100644 --- a/mods/src/patches/screen_update_hook.h +++ b/mods/src/patches/screen_update_hook.h @@ -2,7 +2,7 @@ // ScreenManager.Update has one detour owner shared by HotkeyHooks and opt-in frame services. // Installation is idempotent so either owner can request the dispatcher. -void install_screen_manager_update_hook(); +bool install_screen_manager_update_hook(); using ScreenManagerUpdateCallback = void (*)(); From 6a85d77e556e76bdcf0f4c67a5569b67fcc6c463 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 06:03:34 -0500 Subject: [PATCH 3/6] Expose fleet observer runtime baseline evidence --- mods/src/patches/fleet_watch.cc | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index e5211ab3e..42531fb6a 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -60,6 +60,9 @@ int s_manager_probe_slot = 0; int s_fast_poll_count = 0; int s_dispatch_depth = 0; bool s_evaluating_fast_poll = false; +#ifdef _MODDBG +bool s_runtime_probe_enabled = false; +#endif class CallbackScope { @@ -417,6 +420,11 @@ void Tick() s_last_manager_probe_ms = now_ms; s_last_fast_poll_ms = s_fast_poll_count > 0 ? now_ms : 0; spdlog::debug("[FleetWatch] established stable baseline for {} fleet slots", baseline_count); +#ifdef _MODDBG + if (s_runtime_probe_enabled) { + spdlog::info("[FleetWatchProbe] established stable baseline for {} fleet slots", baseline_count); + } +#endif } } } else if (lifetime >= kSeedLifetimeMs) { @@ -504,9 +512,11 @@ void InstallRuntimeProbe() if (!enabled || std::string_view{enabled} != "1") { return; } + s_runtime_probe_enabled = true; if (Subscribe({runtime_probe_transition, runtime_probe_fast_poll})) { spdlog::info("[FleetWatchProbe] runtime subscriber installed"); } else { + s_runtime_probe_enabled = false; spdlog::warn("[FleetWatchProbe] runtime subscriber installation failed"); } } From 09335b6e24fa78c1ce304f9f5a0d7ff32596f66b Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 06:12:21 -0500 Subject: [PATCH 4/6] Use shared update polling for fleet observation --- mods/src/patches/fleet_watch.cc | 73 ++++++--------------------------- mods/src/patches/fleet_watch.h | 14 ++----- 2 files changed, 15 insertions(+), 72 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index 42531fb6a..2d182d32d 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -3,11 +3,9 @@ #include "errormsg.h" #include "patches/screen_update_hook.h" -#include #include #include -#include #include #include @@ -44,7 +42,7 @@ struct ObservedFleet { std::array s_slots{}; std::vector s_subscriptions; -bool s_hooks_installed = false; +bool s_observer_installed = false; bool s_seed_pending = false; bool s_seed_has_observation = false; bool s_seed_manager_backed = false; @@ -165,14 +163,12 @@ int occupied_slot_count() std::count_if(s_slots.begin(), s_slots.end(), [](const auto& slot) { return slot.occupied; })); } -void dispatch_transition(int slot, FleetPlayerData* fleet, FleetState before, FleetState after, - fleet_watch::Source source) +void dispatch_transition(int slot, FleetPlayerData* fleet, FleetState before, FleetState after) { const fleet_watch::Transition transition{ .before = fleet_watch::Snapshot{slot, fleet->Id, before}, .after = fleet_watch::Snapshot{slot, fleet->Id, after}, .fleet = fleet, - .source = source, }; CallbackScope callback_scope; for (const auto& subscription : s_subscriptions) { @@ -186,7 +182,7 @@ void dispatch_transition(int slot, FleetPlayerData* fleet, FleetState before, Fl } } -void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool publish, fleet_watch::Source source) +void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool publish) { const auto slot = resolve_slot(fleet, requested_slot); if (!fleet || slot < 0) { @@ -230,7 +226,7 @@ void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool publish, fle s_seed_finalize_candidate = false; } if (same_fleet && publish && !s_seed_pending && previous_state != state) { - dispatch_transition(slot, fleet, previous_state, state, source); + dispatch_transition(slot, fleet, previous_state, state); } } @@ -287,7 +283,7 @@ ScanResult scan_manager(bool publish, bool only_fast_polling) continue; } if (fleets[slot]) { - observe_fleet(fleets[slot], slot, publish, fleet_watch::Source::FleetManager); + observe_fleet(fleets[slot], slot, publish); } else if (!only_fast_polling) { clear_slot(slot); } @@ -296,55 +292,11 @@ ScanResult scan_manager(bool publish, bool only_fast_polling) return result; } -void observe_widget(FleetPlayerData* fleet) +bool install_observer() { - if (!fleet || s_subscriptions.empty()) { - return; - } - const auto slot = resolve_slot(fleet, -1); - if (!s_seed_pending && s_seed_manager_backed && slot >= 0) { - auto* manager = FleetsManager::Instance(); - auto* current = manager && manager->HasFleetService() ? manager->GetFleetPlayerData(slot) : nullptr; - if (!current || current->Id != fleet->Id) { - restart_seed(slot, fleet->Id); - } - } - observe_fleet(fleet, slot, true, fleet_watch::Source::FleetStateWidget); -} - -FleetPlayerData* fleet_widget_context(void* self) -{ - if (!self) { - return nullptr; - } - static auto get_context = - IL2CppClassHelper{reinterpret_cast(self)->klass}.GetMethod("get_Context", - 0); - return get_context ? get_context(self) : nullptr; -} - -void FleetStateWidget_SetWidgetData_Hook(auto original, void* self) -{ - original(self); - observe_widget(fleet_widget_context(self)); -} - -bool install_hooks() -{ - auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.HUD", "FleetStateWidget"); - if (!helper.isValidHelper()) { - ErrorMsg::MissingHelper("HUD", "FleetStateWidget"); - return false; - } - auto method = helper.GetMethod("SetWidgetData"); - if (!method) { - ErrorMsg::MissingMethod("FleetStateWidget", "SetWidgetData"); - return false; - } if (!install_screen_manager_update_hook()) { return false; } - SPUD_STATIC_DETOUR(method, FleetStateWidget_SetWidgetData_Hook); return register_screen_manager_update_callback(fleet_watch::Tick); } } // namespace @@ -363,11 +315,11 @@ bool Subscribe(Subscription subscription) return false; } - if (!s_hooks_installed) { - if (!install_hooks()) { + if (!s_observer_installed) { + if (!install_observer()) { return false; } - s_hooks_installed = true; + s_observer_installed = true; reset_observation(); } s_subscriptions.push_back(subscription); @@ -463,7 +415,7 @@ void Tick() return; } if (fleet) { - observe_fleet(fleet, slot, true, Source::FleetManager); + observe_fleet(fleet, slot, true); } } @@ -496,9 +448,8 @@ namespace { void runtime_probe_transition(const Transition& transition) { - spdlog::info("[FleetWatchProbe] source={} slot={} fleet={} oldState={} newState={}", - transition.source == Source::FleetManager ? "fleet-manager" : "fleet-state-widget", - transition.after.slot, transition.after.fleet_id, static_cast(transition.before.state), + spdlog::info("[FleetWatchProbe] slot={} fleet={} oldState={} newState={}", transition.after.slot, + transition.after.fleet_id, static_cast(transition.before.state), static_cast(transition.after.state)); } diff --git a/mods/src/patches/fleet_watch.h b/mods/src/patches/fleet_watch.h index 58440aa17..f865d278c 100644 --- a/mods/src/patches/fleet_watch.h +++ b/mods/src/patches/fleet_watch.h @@ -2,15 +2,8 @@ #include -#include - namespace fleet_watch { -enum class Source : uint8_t { - FleetManager, - FleetStateWidget, -}; - struct Snapshot { int slot = -1; uint64_t fleet_id = 0; @@ -20,8 +13,7 @@ struct Snapshot { struct Transition { Snapshot before; Snapshot after; - FleetPlayerData* fleet = nullptr; - Source source = Source::FleetManager; + FleetPlayerData* fleet = nullptr; }; using TransitionCallback = void (*)(const Transition& transition); @@ -32,8 +24,8 @@ struct Subscription { FastPollPredicate needs_fast_poll = nullptr; }; -// Registers a process-lifetime fleet-state observer. The first subscription installs the shared observation hooks; -// with no subscriptions, Fleet Watch performs no hooks, scans, or IL2CPP work. +// Registers a process-lifetime fleet-state observer. The first subscription joins the shared screen-update dispatcher; +// with no subscriptions, Fleet Watch performs no scans or IL2CPP work. // Register during patch installation. Callbacks run synchronously on the game thread, and Transition::fleet is valid // only for the duration of the callback. bool Subscribe(Subscription subscription); From 53860e9acee13e94052b3d32adab6a892bd3c130 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 06:15:48 -0500 Subject: [PATCH 5/6] Trim manager-only observer surface --- mods/src/patches/fleet_watch.cc | 21 ++----------------- mods/src/prime/FleetPlayerData.h | 35 +++++++++++++------------------- 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index 2d182d32d..aa3b27dc2 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -140,23 +140,6 @@ void clear_slot(int slot) s_slots[slot] = {}; } -int resolve_slot(FleetPlayerData* fleet, int requested_slot) -{ - if (!fleet) { - return -1; - } - if (requested_slot >= 0 && requested_slot < kFleetSlotCount) { - return requested_slot; - } - for (int slot = 0; slot < kFleetSlotCount; ++slot) { - if (s_slots[slot].occupied && s_slots[slot].fleet_id == fleet->Id) { - return slot; - } - } - const auto model_slot = fleet->Index; - return model_slot >= 0 && model_slot < kFleetSlotCount ? model_slot : -1; -} - int occupied_slot_count() { return static_cast( @@ -184,10 +167,10 @@ void dispatch_transition(int slot, FleetPlayerData* fleet, FleetState before, Fl void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool publish) { - const auto slot = resolve_slot(fleet, requested_slot); - if (!fleet || slot < 0) { + if (!fleet || requested_slot < 0 || requested_slot >= kFleetSlotCount) { return; } + const auto slot = requested_slot; auto& previous = s_slots[slot]; const auto fleet_id = fleet->Id; diff --git a/mods/src/prime/FleetPlayerData.h b/mods/src/prime/FleetPlayerData.h index a7facd1fb..ce1820304 100644 --- a/mods/src/prime/FleetPlayerData.h +++ b/mods/src/prime/FleetPlayerData.h @@ -1,9 +1,9 @@ #pragma once #include "BattleTargetData.h" +#include "CanRepairRequirement.h" #include "HullSpec.h" #include "RecallRequirement.h" -#include "CanRepairRequirement.h" #include @@ -35,17 +35,16 @@ enum class FleetState { Deployed = 8133, CanLocate = 8135 }; - + struct FleetPlayerData { public: - __declspec(property(get = __get_CurrentState)) FleetState CurrentState; + __declspec(property(get = __get_CurrentState)) FleetState CurrentState; __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_Address)) void* Address; - __declspec(property(get = __get_Level)) int64_t Level; - __declspec(property(get = __get_HasShip)) bool HasShip; + __declspec(property(get = __get_Id)) uint64_t Id; + __declspec(property(get = __get_Hull)) HullSpec* Hull; + __declspec(property(get = __get_Address)) void* Address; + __declspec(property(get = __get_Level)) int64_t Level; + __declspec(property(get = __get_HasShip)) bool HasShip; private: static IL2CppClassHelper& get_class_helper() @@ -61,12 +60,6 @@ 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; - } void* __get_Address() { static auto field = get_class_helper().GetProperty("Address"); @@ -75,33 +68,33 @@ struct FleetPlayerData { FleetState __get_CurrentState() { static auto field = get_class_helper().GetProperty("CurrentState"); - auto* value = field.Get(this); + auto* value = field.Get(this); return value ? *value : FleetState::Unknown; } FleetState __get_PreviousState() { static auto field = get_class_helper().GetProperty("PreviousState"); - auto* value = field.Get(this); + auto* value = field.Get(this); return value ? *value : FleetState::Unknown; } - + uint64_t __get_Id() { static auto field = get_class_helper().GetProperty("Id"); - auto* value = field.Get(this); + auto* value = field.Get(this); return value ? *value : 0; } int64_t __get_Level() { static auto field = get_class_helper().GetProperty("Level"); - auto* value = field.Get(this); + auto* value = field.Get(this); return value ? *value : 0; } bool __get_HasShip() { static auto field = get_class_helper().GetProperty("HasShip"); - auto* value = field.Get(this); + auto* value = field.Get(this); return value ? *value : false; } }; From 2586be15ad42b2b39500f79ed9772d6cc21f6897 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 06:17:47 -0500 Subject: [PATCH 6/6] Keep fleet state refresh focused --- mods/src/prime/FleetPlayerData.h | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/mods/src/prime/FleetPlayerData.h b/mods/src/prime/FleetPlayerData.h index ce1820304..d565f7e70 100644 --- a/mods/src/prime/FleetPlayerData.h +++ b/mods/src/prime/FleetPlayerData.h @@ -1,9 +1,9 @@ #pragma once #include "BattleTargetData.h" -#include "CanRepairRequirement.h" #include "HullSpec.h" #include "RecallRequirement.h" +#include "CanRepairRequirement.h" #include @@ -35,16 +35,16 @@ enum class FleetState { Deployed = 8133, CanLocate = 8135 }; - + struct FleetPlayerData { public: - __declspec(property(get = __get_CurrentState)) FleetState CurrentState; + __declspec(property(get = __get_CurrentState)) FleetState CurrentState; __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_Address)) void* Address; - __declspec(property(get = __get_Level)) int64_t Level; - __declspec(property(get = __get_HasShip)) bool HasShip; + __declspec(property(get = __get_Id)) uint64_t Id; + __declspec(property(get = __get_Hull)) HullSpec* Hull; + __declspec(property(get = __get_Address)) void* Address; + __declspec(property(get = __get_Level)) int64_t Level; + __declspec(property(get = __get_HasShip)) bool HasShip; private: static IL2CppClassHelper& get_class_helper() @@ -68,33 +68,33 @@ struct FleetPlayerData { FleetState __get_CurrentState() { static auto field = get_class_helper().GetProperty("CurrentState"); - auto* value = field.Get(this); + auto* value = field.Get(this); return value ? *value : FleetState::Unknown; } FleetState __get_PreviousState() { static auto field = get_class_helper().GetProperty("PreviousState"); - auto* value = field.Get(this); + auto* value = field.Get(this); return value ? *value : FleetState::Unknown; } - + uint64_t __get_Id() { static auto field = get_class_helper().GetProperty("Id"); - auto* value = field.Get(this); + auto* value = field.Get(this); return value ? *value : 0; } int64_t __get_Level() { static auto field = get_class_helper().GetProperty("Level"); - auto* value = field.Get(this); + auto* value = field.Get(this); return value ? *value : 0; } bool __get_HasShip() { static auto field = get_class_helper().GetProperty("HasShip"); - auto* value = field.Get(this); + auto* value = field.Get(this); return value ? *value : false; } };