From f523eb4b73e8a44938c70548aa7bda147d67eaf4 Mon Sep 17 00:00:00 2001 From: ProgenyAlpha Date: Tue, 13 Oct 2026 01:28:34 -0400 Subject: [PATCH 1/8] Sync: add opt-in live fleet state ("fleet" records, config key "fleets") Subscribe to fleet_watch from the sync patches when any target enables `fleets`, and queue one record per FleetState transition: {"type":"fleet","fid":,"slot":<0-9>,"hull_id":, "level":,"state":,"state_name":"Mining", "prev_state":,"t":} A full snapshot of all occupied slots is queued as a first sync (X-PRIME-SYNC: 2) whenever fleet_watch establishes a baseline (login, fleet topology change) and whenever another category's first sync is queued. Callbacks run on the game thread and only build JSON and queue it; HTTP stays on the existing sync worker. fleet_watch gains an optional Subscription::on_baseline callback. Default off; documented in every example config. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DNMkDr9v6waKrZ538gutCK --- example_community_patch_settings_en.toml | 3 + mods/src/config.h | 3 + mods/src/defaultconfig.h | 1 + mods/src/patches/fleet_watch.cc | 20 ++++ mods/src/patches/fleet_watch.h | 5 + mods/src/patches/parts/sync.cc | 139 +++++++++++++++++++++++ 6 files changed, 171 insertions(+) diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index be825d061..530cae1e5 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -928,6 +928,9 @@ buildings = true # Do you want to sync Haven data? haven = true +# Do you want to sync live fleet state (mining, docked, warping...)? Opt-in, off by default +fleets = false + # Do you want to sync inventory data? inventory = true diff --git a/mods/src/config.h b/mods/src/config.h index 5d5f74b80..83f135c6d 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -23,6 +23,7 @@ class SyncConfig Buffs, Buildings, EmeraldChain, + Fleets, Haven, Inventory, Jobs, @@ -51,6 +52,7 @@ class SyncConfig bool battlelogs = false; bool buffs = false; bool buildings = true; + bool fleets = false; bool haven = true; bool inventory = false; bool jobs = false; @@ -74,6 +76,7 @@ constexpr std::array SyncOptions{ SyncConfig::Option{SyncConfig::Type::Buffs, "buff", "buffs", &SyncConfig::buffs}, SyncConfig::Option{SyncConfig::Type::Buildings, "module", "buildings", &SyncConfig::buildings}, SyncConfig::Option{SyncConfig::Type::EmeraldChain, "emerald_chain", "buffs", &SyncConfig::buffs}, + SyncConfig::Option{SyncConfig::Type::Fleets, "fleet", "fleets", &SyncConfig::fleets}, SyncConfig::Option{SyncConfig::Type::Haven, "haven", "haven", &SyncConfig::haven}, SyncConfig::Option{SyncConfig::Type::Inventory, "inventory", "inventory", &SyncConfig::inventory}, SyncConfig::Option{SyncConfig::Type::Jobs, "job", "jobs", &SyncConfig::jobs}, diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b3e128516..462efa432 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -240,6 +240,7 @@ namespace Sync constexpr bool battlelogs = false; constexpr bool buffs = true; constexpr bool buildings = true; + constexpr bool fleets = false; // opt-in: live fleet state from fleet_watch constexpr bool haven = true; constexpr bool inventory = true; constexpr bool jobs = true; diff --git a/mods/src/patches/fleet_watch.cc b/mods/src/patches/fleet_watch.cc index aa3b27dc2..d089e3c5a 100644 --- a/mods/src/patches/fleet_watch.cc +++ b/mods/src/patches/fleet_watch.cc @@ -165,6 +165,23 @@ void dispatch_transition(int slot, FleetPlayerData* fleet, FleetState before, Fl } } +void dispatch_baseline() +{ + CallbackScope callback_scope; + for (const auto& subscription : s_subscriptions) { + if (!subscription.on_baseline) { + continue; + } + try { + subscription.on_baseline(); + } catch (const std::exception& error) { + spdlog::warn("[FleetWatch] baseline callback failed: {}", error.what()); + } catch (...) { + spdlog::warn("[FleetWatch] baseline callback failed with an unknown exception"); + } + } +} + void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool publish) { if (!fleet || requested_slot < 0 || requested_slot >= kFleetSlotCount) { @@ -360,6 +377,9 @@ void Tick() spdlog::info("[FleetWatchProbe] established stable baseline for {} fleet slots", baseline_count); } #endif + if (final_scan.manager_available) { + dispatch_baseline(); + } } } } else if (lifetime >= kSeedLifetimeMs) { diff --git a/mods/src/patches/fleet_watch.h b/mods/src/patches/fleet_watch.h index f865d278c..4e590f7c1 100644 --- a/mods/src/patches/fleet_watch.h +++ b/mods/src/patches/fleet_watch.h @@ -18,10 +18,15 @@ struct Transition { using TransitionCallback = void (*)(const Transition& transition); using FastPollPredicate = bool (*)(FleetState state); +using BaselineCallback = void (*)(); struct Subscription { TransitionCallback on_transition = nullptr; FastPollPredicate needs_fast_poll = nullptr; + // Optional. Runs on the game thread each time a stable baseline is established while the fleet service is available + // (after login, and again after any fleet-slot topology change). Transitions are only dispatched after a baseline, so + // a consumer that needs the complete fleet list reads FleetsManager here. + BaselineCallback on_baseline = nullptr; }; // Registers a process-lifetime fleet-state observer. The first subscription joins the shared screen-update dispatcher; diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index a27dbe01c..62fbc5024 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -1,6 +1,8 @@ #include "config.h" #include "errormsg.h" #include "file.h" +#include "patches/fleet_watch.h" +#include "patches/screen_update_hook.h" #include "str_utils.h" #include @@ -9,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -891,6 +894,12 @@ static void save_previously_sent_logs() } } // namespace trackers +namespace fleets +{ +// Set from any thread when another category's first sync is queued; the game thread answers with a full fleet snapshot. +static std::atomic_bool snapshot_requested{false}; +} // namespace fleets + namespace workers { @@ -915,6 +924,10 @@ static void queue_data(SyncConfig::Type type, const std::string& data, bool is_f static void queue_data(SyncConfig::Type type, const nlohmann::json& data, bool is_first_sync = false) { + if (is_first_sync && type != SyncConfig::Type::Fleets) { + fleets::snapshot_requested.store(true, std::memory_order_release); + } + { std::scoped_lock lk(sync_data_mtx); sync_data_queue.emplace(type, data.dump(), is_first_sync); @@ -2895,6 +2908,130 @@ static void HandlePlatformResponse(ServiceResponse* service_response) } } +namespace fleets +{ +constexpr int kFleetSlotCount = 10; + +static const char* state_name(FleetState state) +{ + switch (state) { + case FleetState::IdleInSpace: + return "IdleInSpace"; + case FleetState::Docked: + return "Docked"; + case FleetState::Mining: + return "Mining"; + case FleetState::Destroyed: + return "Destroyed"; + case FleetState::TieringUp: + return "TieringUp"; + case FleetState::Repairing: + return "Repairing"; + case FleetState::Battling: + return "Battling"; + case FleetState::WarpCharging: + return "WarpCharging"; + case FleetState::Warping: + return "Warping"; + case FleetState::Impulsing: + return "Impulsing"; + case FleetState::Capturing: + return "Capturing"; + case FleetState::AutoHunting: + return "AutoHunting"; + case FleetState::Outposting: + return "Outposting"; + default: + return "Unknown"; + } +} + +static int64_t unix_seconds() +{ + return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); +} + +// Game thread only: reads IL2CPP properties of a FleetPlayerData that is valid for the current frame. +static nlohmann::json record(int slot, FleetPlayerData* fleet, FleetState state, FleetState previous) +{ + auto* hull = fleet->Hull; + const int64_t hull_id = hull ? static_cast(hull->Id) : 0; + + return {{"type", SyncConfig::Type::Fleets}, + {"fid", fleet->Id}, + {"slot", slot}, + {"hull_id", hull_id}, + {"level", fleet->Level}, + {"state", static_cast(state)}, + {"state_name", state_name(state)}, + {"prev_state", static_cast(previous)}, + {"t", unix_seconds()}}; +} + +// Game thread only. A snapshot is the complete list of occupied slots, so it is sent as a first sync. +static void queue_snapshot(const char* reason) +{ + auto* manager = FleetsManager::Instance(); + if (!manager || !manager->HasFleetService()) { + return; + } + + auto fleet_array = nlohmann::json::array(); + for (int slot = 0; slot < kFleetSlotCount; ++slot) { + if (auto* fleet = manager->GetFleetPlayerData(slot); fleet) { + fleet_array.push_back(record(slot, fleet, fleet->CurrentState, fleet->PreviousState)); + } + } + + if (!fleet_array.empty()) { + http::logging::debug("PROCESS", "fleets", STR_FORMAT("Fleet snapshot ({}): {} fleets", reason, fleet_array.size())); + workers::queue_data(SyncConfig::Type::Fleets, fleet_array, true); + } +} + +static void on_transition(const fleet_watch::Transition& transition) +{ + if (!transition.fleet) { + return; + } + + auto fleet_array = nlohmann::json::array(); + fleet_array.push_back( + record(transition.after.slot, transition.fleet, transition.after.state, transition.before.state)); + workers::queue_data(SyncConfig::Type::Fleets, fleet_array); +} + +static void on_baseline() +{ + snapshot_requested.store(false, std::memory_order_relaxed); + queue_snapshot("baseline"); +} + +static void tick() +{ + if (!snapshot_requested.load(std::memory_order_relaxed) + || !snapshot_requested.exchange(false, std::memory_order_acq_rel)) { + return; + } + queue_snapshot("first sync"); +} + +static void install() +{ + if (!Config::Get().sync_options.fleets) { + return; + } + + if (!fleet_watch::Subscribe({.on_transition = on_transition, .on_baseline = on_baseline})) { + spdlog::warn("Sync: fleet state subscriber could not be installed; fleet sync disabled"); + return; + } + + register_screen_manager_update_callback(tick); + spdlog::info("Sync: fleet state sync enabled"); +} +} // namespace fleets + namespace hooks { // IL2CPP value type: keep this parameter by value so following arguments retain their platform ABI positions. @@ -3047,6 +3184,8 @@ void InstallSyncPatches() } } + fleets::install(); + std::thread(workers::ship_sync_data).detach(); std::thread(workers::ship_combat_log_data).detach(); } From b9f3dc89ef6e6fb02529c4ea6a41ecaba4bb337c Mon Sep 17 00:00:00 2001 From: ProgenyAlpha Date: Tue, 13 Oct 2026 03:15:26 -0400 Subject: [PATCH 2/8] Sync: widen hull ids as unsigned in fleet records HullSpec::Id is read as a 32-bit long and hull ids exceed 2^31 (Kumari is 3005922948), so widening the signed value put negative ids in the records and name lookups failed for those hulls. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DNMkDr9v6waKrZ538gutCK --- mods/src/patches/parts/sync.cc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 62fbc5024..c67c273d9 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -2955,7 +2955,9 @@ static int64_t unix_seconds() static nlohmann::json record(int slot, FleetPlayerData* fleet, FleetState state, FleetState previous) { auto* hull = fleet->Hull; - const int64_t hull_id = hull ? static_cast(hull->Id) : 0; + // HullSpec::Id is read as a 32-bit long; hull ids run past 2^31 (e.g. Kumari 3005922948), so take + // the value as unsigned before widening or those come out negative in the record. + const int64_t hull_id = hull ? static_cast(static_cast(hull->Id)) : 0; return {{"type", SyncConfig::Type::Fleets}, {"fid", fleet->Id}, From 3603892b1618e12a3bc9152dd03a68bfa415c16a Mon Sep 17 00:00:00 2001 From: ProgenyAlpha Date: Thu, 22 Oct 2026 23:06:45 -0400 Subject: [PATCH 3/8] Sync: include prev_state_name in fleet records --- mods/src/patches/parts/sync.cc | 1 + 1 file changed, 1 insertion(+) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index c67c273d9..3425dd51a 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -2967,6 +2967,7 @@ static nlohmann::json record(int slot, FleetPlayerData* fleet, FleetState state, {"state", static_cast(state)}, {"state_name", state_name(state)}, {"prev_state", static_cast(previous)}, + {"prev_state_name", state_name(previous)}, {"t", unix_seconds()}}; } From 41f0e1d3e97e1c99d0f6cb07eec8b7f3bc50a86e Mon Sep 17 00:00:00 2001 From: ProgenyAlpha Date: Thu, 22 Oct 2026 23:35:50 -0400 Subject: [PATCH 4/8] Sync: add live cargo and mining state to client fleet records Client records now carry the home/mining/repairing flags, cargo, and while mining the node, amount mined and the game's own due time (mining_due). Repairing fleets carry repair_due. Missing values are left out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DNMkDr9v6waKrZ538gutCK --- mods/src/patches/parts/sync.cc | 84 +++++++++++++++++++--- mods/src/prime/FleetLiveData.h | 120 +++++++++++++++++++++++++++++++ mods/src/prime/FleetPlayerData.h | 71 ++++++++++++++++++ mods/src/prime/ManagedRead.h | 84 ++++++++++++++++++++++ 4 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 mods/src/prime/FleetLiveData.h create mode 100644 mods/src/prime/ManagedRead.h diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 3425dd51a..938731880 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -46,6 +46,7 @@ #include #include #include +#include #include #include #include @@ -2951,6 +2952,62 @@ static int64_t unix_seconds() return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); } +template static void put(nlohmann::json& out, const char* key, const std::optional& value) +{ + if (value) { + out[key] = *value; + } +} + +// Game thread only. Missing members are left out. +static void add_live_state(nlohmann::json& out, FleetPlayerData* fleet) +{ + const auto is_mining = fleet->IsMining(); + const auto is_repairing = fleet->IsRepairing(); + + put(out, "is_home", fleet->IsHome()); + put(out, "is_deployed", fleet->IsDeployed()); + put(out, "is_mining", is_mining); + put(out, "is_repairing", is_repairing); + put(out, "is_damaged", fleet->IsDamaged()); + put(out, "is_destroyed", fleet->IsDestroyed()); + put(out, "drydock", fleet->DrydockID()); + put(out, "cargo_fill", fleet->CargoResourceFillLevel()); + + if (auto* hold = fleet->GetCargoHoldData()) { + if (auto* current = hold->CurrentCargo()) { + put(out, "cargo", current->CurrentValue()); + put(out, "cargo_max", current->MaxValue()); + } + if (auto* protected_cargo = hold->ProtectedCargoProgress()) { + put(out, "protected_cargo", protected_cargo->CurrentValue()); + } + } + + if (is_mining.value_or(false)) { + if (auto* slot = fleet->MiningData()) { + put(out, "node_id", slot->ParentId()); + put(out, "node_resource", slot->ResourceId()); + put(out, "amount_mined", slot->AmountMined()); + put(out, "occupied_since", ManagedRead::DateTimeToUnix(slot->OccupyTimeUtc())); + if (auto* node = slot->PointData()) { + put(out, "node_amount", node->Amount()); + } + } + put(out, "mining_rate", fleet->GetMiningRate()); + + if (auto* timer = fleet->MiningSlotTimer()) { + put(out, "mining_due", ManagedRead::DateTimeToUnix(timer->DueTime())); + } + } + + if (is_repairing.value_or(false)) { + if (auto* timer = fleet->Timer()) { + put(out, "repair_due", ManagedRead::DateTimeToUnix(timer->DueTime())); + } + } +} + // Game thread only: reads IL2CPP properties of a FleetPlayerData that is valid for the current frame. static nlohmann::json record(int slot, FleetPlayerData* fleet, FleetState state, FleetState previous) { @@ -2959,16 +3016,23 @@ static nlohmann::json record(int slot, FleetPlayerData* fleet, FleetState state, // the value as unsigned before widening or those come out negative in the record. const int64_t hull_id = hull ? static_cast(static_cast(hull->Id)) : 0; - return {{"type", SyncConfig::Type::Fleets}, - {"fid", fleet->Id}, - {"slot", slot}, - {"hull_id", hull_id}, - {"level", fleet->Level}, - {"state", static_cast(state)}, - {"state_name", state_name(state)}, - {"prev_state", static_cast(previous)}, - {"prev_state_name", state_name(previous)}, - {"t", unix_seconds()}}; + nlohmann::json out = {{"type", SyncConfig::Type::Fleets}, + {"fid", fleet->Id}, + {"slot", slot}, + {"hull_id", hull_id}, + {"level", fleet->Level}, + {"state", static_cast(state)}, + {"state_name", state_name(state)}, + {"prev_state", static_cast(previous)}, + {"prev_state_name", state_name(previous)}, + {"t", unix_seconds()}, + {"src", "client"}}; + try { + add_live_state(out, fleet); + } catch (const std::exception& e) { + spdlog::warn("Sync: fleet live state read failed for {}: {}", fleet->Id, e.what()); + } + return out; } // Game thread only. A snapshot is the complete list of occupied slots, so it is sent as a first sync. diff --git a/mods/src/prime/FleetLiveData.h b/mods/src/prime/FleetLiveData.h new file mode 100644 index 000000000..b527610b8 --- /dev/null +++ b/mods/src/prime/FleetLiveData.h @@ -0,0 +1,120 @@ +#pragma once + +#include "ManagedRead.h" + +#include + +#include +#include + +// Cargo, mining slot and timers off FleetPlayerData. Interface values go through the interface getter because +// FleetMiningSlot implements IMiningSlot explicitly. All accessors are null-safe. + +namespace fleet_live_detail +{ +inline Il2CppClass* models_class(const char* name) +{ + return il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", name).get_cls(); +} + +// interface getter, else the runtime class's +inline const MethodInfo* getter(Il2CppClass* iface, const char* property, void* obj) +{ + if (auto* g = ManagedRead::Getter(iface, property)) { + return g; + } + return ManagedRead::RuntimeGetter(obj, property); +} +} // namespace fleet_live_detail + +// Digit.PrimeServer.Models.IProgressData (concrete: ProgressData, CargoProgressData) +struct IProgressData { + static Il2CppClass* get_class() + { + static auto cls = fleet_live_detail::models_class("IProgressData"); + return cls; + } + + std::optional CurrentValue() + { + return ManagedRead::Value(fleet_live_detail::getter(get_class(), "CurrentValue", this), this); + } + std::optional MaxValue() + { + return ManagedRead::Value(fleet_live_detail::getter(get_class(), "MaxValue", this), this); + } +}; + +// Digit.PrimeServer.Models.CargoHoldData +struct CargoHoldData { + static IL2CppClassHelper& get_class_helper() + { + static auto class_helper = + il2cpp_get_class_helper("Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "CargoHoldData"); + return class_helper; + } + + IProgressData* CurrentCargo() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "CurrentCargo"); + return (IProgressData*)ManagedRead::Object(getter, this); + } + IProgressData* ProtectedCargoProgress() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "ProtectedCargoProgress"); + return (IProgressData*)ManagedRead::Object(getter, this); + } +}; + +// Digit.PrimeServer.Models.IMiningNodeParams. Amount isn't on the interface, so it's read off the runtime class. +struct IMiningNodeParams { + std::optional Amount() + { + return ManagedRead::Value(ManagedRead::RuntimeGetter(this, "Amount"), this); + } +}; + +// Digit.PrimeServer.Models.IMiningSlot (concrete: FleetMiningSlot, MiningSlot) +struct IMiningSlot { + static Il2CppClass* get_class() + { + static auto cls = fleet_live_detail::models_class("IMiningSlot"); + return cls; + } + + std::optional ParentId() + { + return ManagedRead::Value(fleet_live_detail::getter(get_class(), "ParentId", this), this); + } + std::optional ResourceId() + { + return ManagedRead::Value(fleet_live_detail::getter(get_class(), "ResourceId", this), this); + } + std::optional AmountMined() + { + return ManagedRead::Value(fleet_live_detail::getter(get_class(), "AmountMined", this), this); + } + std::optional OccupyTimeUtc() + { + return ManagedRead::Value(fleet_live_detail::getter(get_class(), "OccupyTimeUtc", this), this); + } + IMiningNodeParams* PointData() + { + return (IMiningNodeParams*)ManagedRead::Object(fleet_live_detail::getter(get_class(), "PointData", this), this); + } +}; + +// Digit.PrimeServer.Models.ITimerData (concrete: MiningSlotTimer and others) +struct ITimerData { + static Il2CppClass* get_class() + { + static auto cls = fleet_live_detail::models_class("ITimerData"); + return cls; + } + + // DateTime _dateData + std::optional DueTime() + { + return ManagedRead::Value(fleet_live_detail::getter(get_class(), "DueTime", this), this); + } +}; diff --git a/mods/src/prime/FleetPlayerData.h b/mods/src/prime/FleetPlayerData.h index c26249db5..e1f96104c 100644 --- a/mods/src/prime/FleetPlayerData.h +++ b/mods/src/prime/FleetPlayerData.h @@ -6,8 +6,11 @@ #include "HullSpec.h" #include "RecallRequirement.h" #include "CanRepairRequirement.h" +#include "FleetLiveData.h" +#include "ManagedRead.h" #include +#include struct Ship; @@ -123,6 +126,74 @@ struct FleetPlayerData { return property.GetRaw(this); } + // live state, empty when missing + std::optional IsHome() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "IsHome"); + return ManagedRead::Value(getter, this); + } + std::optional IsDeployed() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "IsDeployed"); + return ManagedRead::Value(getter, this); + } + std::optional IsMining() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "IsMining"); + return ManagedRead::Value(getter, this); + } + std::optional IsRepairing() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "IsRepairing"); + return ManagedRead::Value(getter, this); + } + std::optional IsDamaged() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "IsDamaged"); + return ManagedRead::Value(getter, this); + } + std::optional IsDestroyed() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "IsDestroyed"); + return ManagedRead::Value(getter, this); + } + std::optional DrydockID() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "DrydockID"); + return ManagedRead::Value(getter, this); + } + std::optional CargoResourceFillLevel() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "CargoResourceFillLevel"); + return ManagedRead::Value(getter, this); + } + CargoHoldData* GetCargoHoldData() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "CargoHoldData"); + return (CargoHoldData*)ManagedRead::Object(getter, this); + } + IMiningSlot* MiningData() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "MiningData"); + return (IMiningSlot*)ManagedRead::Object(getter, this); + } + ITimerData* Timer() + { + static auto getter = ManagedRead::Getter(get_class_helper().get_cls(), "Timer"); + return (ITimerData*)ManagedRead::Object(getter, this); + } + // no property for the mining timer, so read the private field + ITimerData* MiningSlotTimer() + { + static auto field = get_class_helper().GetField("_miningSlotTimer"); + return (ITimerData*)ManagedRead::FieldObject(this, field.get_info()); + } + std::optional GetMiningRate() + { + static auto method = get_class_helper().GetMethodInfo("GetMiningRate", 0); + return ManagedRead::Value(method, this); + } + int64_t GetLocaId() { static auto GetLocaIdWarn = true; diff --git a/mods/src/prime/ManagedRead.h b/mods/src/prime/ManagedRead.h new file mode 100644 index 000000000..ae4da9a6f --- /dev/null +++ b/mods/src/prime/ManagedRead.h @@ -0,0 +1,84 @@ +#pragma once + +#include + +#include +#include + +// Null-safe IL2CPP reads: an empty optional or nullptr instead of 0 when anything is missing. +namespace ManagedRead +{ +// property getter on a class or interface +inline const MethodInfo* Getter(Il2CppClass* cls, const char* property) +{ + if (!cls) { + return nullptr; + } + const PropertyInfo* prop = il2cpp_class_get_property_from_name(cls, property); + return prop ? il2cpp_property_get_get_method(const_cast(prop)) : nullptr; +} + +// getter on the object's runtime class, for members only some implementations have +inline const MethodInfo* RuntimeGetter(void* obj, const char* property) +{ + return obj ? Getter(((Il2CppObject*)obj)->klass, property) : nullptr; +} + +// instance call, virtual and interface dispatch included +inline Il2CppObject* Invoke(const MethodInfo* method, void* obj) +{ + if (!method || !obj) { + return nullptr; + } + auto* object = (Il2CppObject*)obj; + if (method->klass && !il2cpp_class_is_assignable_from(method->klass, object->klass)) { + return nullptr; + } + const MethodInfo* target = il2cpp_object_get_virtual_method(object, method); + if (!target) { + return nullptr; + } + Il2CppException* exception = nullptr; + Il2CppObject* result = il2cpp_runtime_invoke(target, obj, nullptr, &exception); + return exception ? nullptr : result; +} + +// reference result +inline void* Object(const MethodInfo* getter, void* obj) +{ + return Invoke(getter, obj); +} + +// boxed value result; T must match the managed size (uint64_t for DateTime) +template std::optional Value(const MethodInfo* getter, void* obj) +{ + Il2CppObject* boxed = Invoke(getter, obj); + if (!boxed) { + return std::nullopt; + } + auto* data = static_cast(il2cpp_object_unbox(boxed)); + return data ? std::optional(*data) : std::nullopt; +} + +// object stored in an instance field +inline void* FieldObject(void* obj, FieldInfo* field) +{ + if (!obj || !field) { + return nullptr; + } + return *reinterpret_cast(reinterpret_cast(obj) + field->offset); +} + +// DateTime -> unix seconds. The top two bits are the Kind; MinValue (unset) gives nullopt. +inline std::optional DateTimeToUnix(std::optional date_data) +{ + if (!date_data) { + return std::nullopt; + } + const int64_t ticks = static_cast(*date_data & 0x3FFFFFFFFFFFFFFFull); + if (ticks <= 0) { + return std::nullopt; + } + return (ticks - 621355968000000000LL) / 10000000LL; +} +} // namespace ManagedRead From f6e74fd3a0cb2d96888c769583b873b93f091493 Mon Sep 17 00:00:00 2001 From: ProgenyAlpha Date: Thu, 22 Oct 2026 23:36:48 -0400 Subject: [PATCH 5/8] Sync: send the server's deployed-fleet data as fleet records MyDeployedFleets is parsed into src=server fleet records: system, node, hull/shield, cargo cap, warp range, strength and pursued_by. No other players' ids. Only fleets that changed are sent. MyDeployedFleetsResponse lives in its own stfc.sync.proto because the game doesn't declare it, and regenerating the Digit protos would drop it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DNMkDr9v6waKrZ538gutCK --- mods/src/patches/parts/sync.cc | 171 +++++++++++++++++++++++++++ mods/src/prime/proto/stfc.sync.proto | 14 +++ 2 files changed, 185 insertions(+) create mode 100644 mods/src/prime/proto/stfc.sync.proto diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 938731880..97ca2abe4 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -2356,6 +2358,168 @@ static void store_offers(std::unique_ptr&& bytes) } } +namespace deployed_fleets +{ + using Digit::PrimeServer::Models::ClientModifierType; + using Digit::PrimeServer::Models::DeployedFleetState; + + static const char* state_name(DeployedFleetState state) + { + switch (state) { + case Digit::PrimeServer::Models::DEPLOYEDFLEETSTATE_IDLE: + return "Idle"; + case Digit::PrimeServer::Models::DEPLOYEDFLEETSTATE_MOVING: + return "Moving"; + case Digit::PrimeServer::Models::DEPLOYEDFLEETSTATE_WARPING: + return "Warping"; + case Digit::PrimeServer::Models::DEPLOYEDFLEETSTATE_BATTLING: + return "Battling"; + case Digit::PrimeServer::Models::DEPLOYEDFLEETSTATE_RECALL: + return "Recall"; + // docked at a mining node, not the station + case Digit::PrimeServer::Models::DEPLOYEDFLEETSTATE_DOCKED: + return "OnNode"; + case Digit::PrimeServer::Models::DEPLOYEDFLEETSTATE_PREBATTLE: + return "PreBattle"; + default: + return "Unknown"; + } + } + + // 1234 not 1234.0, and 14.364 not 14.36400032043457 + static nlohmann::json number(float value) + { + if (std::trunc(value) == value && std::abs(value) < 9.0e15f) { + return static_cast(value); + } + + char buffer[32]; + double widened = value; + if (const auto [end, ec] = std::to_chars(std::begin(buffer), std::end(buffer), value); ec == std::errc{}) { + std::from_chars(std::begin(buffer), end, widened); + } + return widened; + } + + using FloatMap = google::protobuf::Map; + + static std::optional lookup(const FloatMap& map, ClientModifierType key) + { + if (const auto it = map.find(key); it != map.end()) { + return it->second; + } + return std::nullopt; + } + + // leave missing stats out instead of sending 0 + static void put(nlohmann::json& record, const char* name, const FloatMap& map, ClientModifierType key) + { + if (const auto value = lookup(map, key); value && std::isfinite(*value)) { + record[name] = number(*value); + } + } + + // summed over the fleet's ships: shipStats has the max, shipAttributes the damage taken + static void put_points(nlohmann::json& record, const char* current_name, const char* max_name, + const Digit::PrimeServer::Models::DeployedFleet& fleet, ClientModifierType max_key, + ClientModifierType damage_key) + { + double total_max = 0; + double total_damage = 0; + bool found = false; + + for (const auto& [ship_id, stats] : fleet.shipstats()) { + const auto max = lookup(stats.shipattributes_(), max_key); + if (!max) { + continue; + } + + found = true; + total_max += *max; + if (const auto it = fleet.shipattributes().find(ship_id); it != fleet.shipattributes().end()) { + total_damage += lookup(it->second.shipattributes_(), damage_key).value_or(0.0f); + } + } + + if (found) { + record[current_name] = std::llround(std::max(0.0, total_max - total_damage)); + record[max_name] = std::llround(total_max); + } + } +} // namespace deployed_fleets + +static void my_deployed_fleets(std::unique_ptr&& bytes) +{ + using json = nlohmann::json; + using namespace Digit::PrimeServer::Models; + using deployed_fleets::put; + + auto response = MyDeployedFleetsResponse(); + if (!response.ParseFromString(*bytes)) { + spdlog::error("Failed to parse deployed fleets"); + return; + } + + http::logging::trace("PROCESS", "deployed fleets", STR_FORMAT("Processing {} fleets", response.fleets_size())); + + // the server resends every fleet on each update, so only send the ones that changed + static std::unordered_map last_sent; + static std::mutex last_sent_mtx; + + const int64_t now = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + auto fleet_array = json::array(); + + for (const auto& [fleet_id, fleet] : response.fleets()) { + json record = {{"type", SyncConfig::Type::Fleets}, + {"fid", fleet_id}, + {"src", "server"}, + {"server_state", static_cast(fleet.state())}, + {"server_state_name", deployed_fleets::state_name(fleet.state())}, + {"node_id", fleet.nodeid()}}; + + if (fleet.hullids_size() > 0) { + // widen through uint32 like the client records + record["hull_id"] = static_cast(static_cast(fleet.hullids(0))); + } + + if (fleet.has_nodeaddress()) { + record["system_id"] = fleet.nodeaddress().system(); + } + + deployed_fleets::put_points(record, "hull_hp", "hull_max", fleet, CLIENTMODIFIERTYPE_MODHULLHPMAX, + CLIENTMODIFIERTYPE_SHIPHULLDAMAGE); + deployed_fleets::put_points(record, "shield_hp", "shield_max", fleet, CLIENTMODIFIERTYPE_MODSHIELDHPMAX, + CLIENTMODIFIERTYPE_SHIPSHIELDDAMAGE); + + const auto& stats = fleet.stats(); + put(record, "cargo_cap", stats, CLIENTMODIFIERTYPE_MODCARGOCAPACITY); + put(record, "warp_range", stats, CLIENTMODIFIERTYPE_MODWARPDISTANCE); + put(record, "strength", fleet.attributes(), CLIENTMODIFIERTYPE_FLEETSTRENGTH); + + // a count only, no other players' ids + record["pursued_by"] = fleet.pursuedbynum(); + + const auto state_value = std::hash{}(record); + std::scoped_lock lk(last_sent_mtx); + if (auto& last = last_sent[fleet_id]; last != state_value) { + last = state_value; + record["t"] = now; + fleet_array.push_back(std::move(record)); + } + } + + // forget fleets that left, so they're sent again when they're back + { + std::scoped_lock lk(last_sent_mtx); + std::erase_if(last_sent, [&](const auto& entry) { return !response.fleets().contains(entry.first); }); + } + + if (!fleet_array.empty()) { + workers::queue_data(SyncConfig::Type::Fleets, fleet_array, /*is_first_sync=*/false); + } +} + namespace json { static void battle_result_headers(const nlohmann::json& section) @@ -2816,6 +2980,13 @@ static void HandleEntityGroup(EntityGroup* entity_group) } break; + // fleets + case EntityGroup::Type::MyDeployedFleets: + if (sync_options.fleets) { + submit_async(processors::my_deployed_fleets); + } + break; + // misc case EntityGroup::Type::Json: submit_async(processors::json::parse); diff --git a/mods/src/prime/proto/stfc.sync.proto b/mods/src/prime/proto/stfc.sync.proto new file mode 100644 index 000000000..bf05b0054 --- /dev/null +++ b/mods/src/prime/proto/stfc.sync.proto @@ -0,0 +1,14 @@ +syntax="proto3"; + +option optimize_for = LITE_RUNTIME; + +// Messages the game doesn't declare. Kept out of the generated Digit.*.proto so regenerating doesn't drop them. + +import "Digit.PrimeServer.Models.proto"; + +package Digit.PrimeServer.Models; + +// MyDeployedFleets payload, keyed by fleet id +message MyDeployedFleetsResponse { + map fleets = 1; +} From b80681bb52d0d2722d3cc56938582d37421fc388 Mon Sep 17 00:00:00 2001 From: ProgenyAlpha Date: Thu, 22 Oct 2026 23:37:43 -0400 Subject: [PATCH 6/8] Sync: link repair jobs to their fleet Repair jobs carry the fleet id. They're only sent with fleets on, so jobs-only receivers see nothing new. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DNMkDr9v6waKrZ538gutCK --- example_community_patch_settings_en.toml | 5 ++++- mods/src/patches/parts/sync.cc | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 530cae1e5..8883acba3 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -928,7 +928,10 @@ buildings = true # Do you want to sync Haven data? haven = true -# Do you want to sync live fleet state (mining, docked, warping...)? Opt-in, off by default +# Do you want to sync live fleet state? Opt-in, off by default. Sends "fleet" records keyed by fid: +# src=client on each state change of a docked or deployed fleet (slot, state, cargo, mining node and due time), +# src=server when the server's deployed-fleet data changes (system, hull/shield, strength, battle status). +# Also adds repair jobs (with the fleet id) to the jobs feed when jobs is on. fleets = false # Do you want to sync inventory data? diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 97ca2abe4..4c9318cff 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -1728,6 +1728,13 @@ static void jobs(std::unique_ptr&& bytes) const auto& away_assignment = job.awayassignmentparams(); job_params = {{"aid", away_assignment.awayassignmentinstanceid()}}; } break; + case Digit::PrimeServer::Models::JOBTYPE_REPAIRFLEET: { + // part of fleet sync + if (!Config::Get().sync_options.fleets) { + continue; + } + job_params = {{"fid", job.repairfleetparams().fleetid()}}; + } break; default: continue; } From 22b56ef60930d874fcf84432501693d704073489 Mon Sep 17 00:00:00 2001 From: ProgenyAlpha Date: Thu, 22 Oct 2026 23:53:16 -0400 Subject: [PATCH 7/8] Sync: send hull damage and re-read a fleet when the server updates it Client records carry hull_damage while the fleet is docked or destroyed; the client doesn't keep it current in space, where the server's hull points are the live value. The client fills in some values (mining and travel timers, damage) from server data that arrives after the state change. When the server sends new data for a fleet, or drops it from the deployed list, or a repair job arrives for it, that fleet is re-read a second later and resent if those values changed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DNMkDr9v6waKrZ538gutCK --- example_community_patch_settings_en.toml | 5 +- mods/src/patches/parts/sync.cc | 108 ++++++++++++++++++++++- mods/src/prime/FleetPlayerData.h | 27 ++++++ 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 8883acba3..530cae1e5 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -928,10 +928,7 @@ buildings = true # Do you want to sync Haven data? haven = true -# Do you want to sync live fleet state? Opt-in, off by default. Sends "fleet" records keyed by fid: -# src=client on each state change of a docked or deployed fleet (slot, state, cargo, mining node and due time), -# src=server when the server's deployed-fleet data changes (system, hull/shield, strength, battle status). -# Also adds repair jobs (with the fleet id) to the jobs feed when jobs is on. +# Do you want to sync live fleet state (mining, docked, warping...)? Opt-in, off by default fleets = false # Do you want to sync inventory data? diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 4c9318cff..2843f0741 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -901,6 +901,17 @@ namespace fleets { // Set from any thread when another category's first sync is queued; the game thread answers with a full fleet snapshot. static std::atomic_bool snapshot_requested{false}; + +// Fleets the server just sent new data for. The client fills in some values (mining and travel timers, damage) from +// that data, so the game thread re-reads these fleets shortly after. +static std::mutex reread_mtx; +static std::unordered_map reread_at; + +static void request_reread(uint64_t fid) +{ + std::scoped_lock lk(reread_mtx); + reread_at.try_emplace(fid, std::chrono::steady_clock::now() + std::chrono::seconds(1)); +} } // namespace fleets namespace workers @@ -1734,6 +1745,7 @@ static void jobs(std::unique_ptr&& bytes) continue; } job_params = {{"fid", job.repairfleetparams().fleetid()}}; + fleets::request_reread(job.repairfleetparams().fleetid()); } break; default: continue; @@ -2513,13 +2525,20 @@ static void my_deployed_fleets(std::unique_ptr&& bytes) last = state_value; record["t"] = now; fleet_array.push_back(std::move(record)); + fleets::request_reread(fleet_id); } } - // forget fleets that left, so they're sent again when they're back + // forget fleets that left (docked, destroyed), so they're sent again when they're back { std::scoped_lock lk(last_sent_mtx); - std::erase_if(last_sent, [&](const auto& entry) { return !response.fleets().contains(entry.first); }); + std::erase_if(last_sent, [&](const auto& entry) { + if (response.fleets().contains(entry.first)) { + return false; + } + fleets::request_reread(entry.first); + return true; + }); } if (!fleet_array.empty()) { @@ -3143,13 +3162,22 @@ static void add_live_state(nlohmann::json& out, FleetPlayerData* fleet) const auto is_mining = fleet->IsMining(); const auto is_repairing = fleet->IsRepairing(); - put(out, "is_home", fleet->IsHome()); + const auto is_home = fleet->IsHome(); + const auto is_destroyed = fleet->IsDestroyed(); + + put(out, "is_home", is_home); put(out, "is_deployed", fleet->IsDeployed()); put(out, "is_mining", is_mining); put(out, "is_repairing", is_repairing); put(out, "is_damaged", fleet->IsDamaged()); - put(out, "is_destroyed", fleet->IsDestroyed()); + put(out, "is_destroyed", is_destroyed); put(out, "drydock", fleet->DrydockID()); + // the client only updates this at the station; in space the server's hull points are the live value + if (is_home.value_or(false) || is_destroyed.value_or(false)) { + if (const auto damage = fleet->HullDamage()) { + out["hull_damage"] = std::llround(*damage); + } + } put(out, "cargo_fill", fleet->CargoResourceFillLevel()); if (auto* hold = fleet->GetCargoHoldData()) { @@ -3186,6 +3214,25 @@ static void add_live_state(nlohmann::json& out, FleetPlayerData* fleet) } } +// Game thread only. The values the client fills in late, as last sent per fleet; a re-read is only sent when they +// changed. Values that move on their own are left out: cargo while mining, hull while repairing. +static std::unordered_map sent_late; + +static size_t late_fields(const nlohmann::json& record) +{ + auto fields = nlohmann::json::object(); + for (const auto* key : + {"is_damaged", "is_destroyed", "is_repairing", "repair_due", "hull_damage", "mining_due", "node_id", "cargo_max"}) { + if (const auto it = record.find(key); it != record.end()) { + fields[key] = *it; + } + } + if (record.value("is_repairing", false)) { + fields.erase("hull_damage"); + } + return std::hash{}(fields); +} + // Game thread only: reads IL2CPP properties of a FleetPlayerData that is valid for the current frame. static nlohmann::json record(int slot, FleetPlayerData* fleet, FleetState state, FleetState previous) { @@ -3210,6 +3257,8 @@ static nlohmann::json record(int slot, FleetPlayerData* fleet, FleetState state, } catch (const std::exception& e) { spdlog::warn("Sync: fleet live state read failed for {}: {}", fleet->Id, e.what()); } + + sent_late[fleet->Id] = late_fields(out); return out; } @@ -3252,8 +3301,59 @@ static void on_baseline() queue_snapshot("baseline"); } +// Game thread only. +static void reread_requested() +{ + std::vector due; + { + std::scoped_lock lk(reread_mtx); + if (reread_at.empty()) { + return; + } + const auto now = std::chrono::steady_clock::now(); + std::erase_if(reread_at, [&](const auto& entry) { + if (entry.second > now) { + return false; + } + due.push_back(entry.first); + return true; + }); + } + if (due.empty()) { + return; + } + + auto* manager = FleetsManager::Instance(); + if (!manager || !manager->HasFleetService()) { + return; + } + + auto fleet_array = nlohmann::json::array(); + for (int slot = 0; slot < kFleetSlotCount; ++slot) { + try { + auto* fleet = manager->GetFleetPlayerData(slot); + if (!fleet || std::ranges::find(due, fleet->Id) == due.end()) { + continue; + } + const auto before = sent_late[fleet->Id]; + auto entry = record(slot, fleet, fleet->CurrentState, fleet->PreviousState); + if (late_fields(entry) != before) { + fleet_array.push_back(std::move(entry)); + } + } catch (const std::exception& e) { + spdlog::warn("Sync: fleet re-read failed for slot {}: {}", slot, e.what()); + } + } + + if (!fleet_array.empty()) { + workers::queue_data(SyncConfig::Type::Fleets, fleet_array); + } +} + static void tick() { + reread_requested(); + if (!snapshot_requested.load(std::memory_order_relaxed) || !snapshot_requested.exchange(false, std::memory_order_acq_rel)) { return; diff --git a/mods/src/prime/FleetPlayerData.h b/mods/src/prime/FleetPlayerData.h index e1f96104c..05a029681 100644 --- a/mods/src/prime/FleetPlayerData.h +++ b/mods/src/prime/FleetPlayerData.h @@ -188,12 +188,39 @@ struct FleetPlayerData { static auto field = get_class_helper().GetField("_miningSlotTimer"); return (ITimerData*)ManagedRead::FieldObject(this, field.get_info()); } + // damage on the fleet's ship (BaseShip), so a docked ship's hull is known without the server + std::optional HullDamage() + { + static ShipGetter getter{"HullDamage"}; + return getter.Read(this->Ship); + } std::optional GetMiningRate() { static auto method = get_class_helper().GetMethodInfo("GetMiningRate", 0); return ManagedRead::Value(method, this); } +private: + // the ship's concrete class is only known at runtime; look the getter up once per class + struct ShipGetter { + const char* property; + Il2CppClass* cls = nullptr; + const MethodInfo* getter = nullptr; + + std::optional Read(void* ship) + { + if (!ship) { + return std::nullopt; + } + if (auto* klass = ((Il2CppObject*)ship)->klass; klass != cls) { + cls = klass; + getter = ManagedRead::Getter(klass, property); + } + return ManagedRead::Value(getter, ship); + } + }; + +public: int64_t GetLocaId() { static auto GetLocaIdWarn = true; From 64e97ea5e12c6c54a8a684ca18e8713e4dfc4455 Mon Sep 17 00:00:00 2001 From: ProgenyAlpha Date: Fri, 23 Oct 2026 01:11:12 -0400 Subject: [PATCH 8/8] Sync: send the travel timer with fleet records While a fleet charges, warps or moves on impulse, client records carry travel_due, the arrival time from the fleet's timer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DNMkDr9v6waKrZ538gutCK --- mods/src/patches/parts/sync.cc | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 2843f0741..be44db801 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -3157,7 +3157,7 @@ template static void put(nlohmann::json& out, const char* key, cons } // Game thread only. Missing members are left out. -static void add_live_state(nlohmann::json& out, FleetPlayerData* fleet) +static void add_live_state(nlohmann::json& out, FleetPlayerData* fleet, FleetState state) { const auto is_mining = fleet->IsMining(); const auto is_repairing = fleet->IsRepairing(); @@ -3212,6 +3212,12 @@ static void add_live_state(nlohmann::json& out, FleetPlayerData* fleet) put(out, "repair_due", ManagedRead::DateTimeToUnix(timer->DueTime())); } } + + if (state == FleetState::WarpCharging || state == FleetState::Warping || state == FleetState::Impulsing) { + if (auto* timer = fleet->Timer()) { + put(out, "travel_due", ManagedRead::DateTimeToUnix(timer->DueTime())); + } + } } // Game thread only. The values the client fills in late, as last sent per fleet; a re-read is only sent when they @@ -3222,7 +3228,8 @@ static size_t late_fields(const nlohmann::json& record) { auto fields = nlohmann::json::object(); for (const auto* key : - {"is_damaged", "is_destroyed", "is_repairing", "repair_due", "hull_damage", "mining_due", "node_id", "cargo_max"}) { + {"is_damaged", "is_destroyed", "is_repairing", "repair_due", "travel_due", "hull_damage", "mining_due", "node_id", + "cargo_max"}) { if (const auto it = record.find(key); it != record.end()) { fields[key] = *it; } @@ -3253,7 +3260,7 @@ static nlohmann::json record(int slot, FleetPlayerData* fleet, FleetState state, {"t", unix_seconds()}, {"src", "client"}}; try { - add_live_state(out, fleet); + add_live_state(out, fleet, state); } catch (const std::exception& e) { spdlog::warn("Sync: fleet live state read failed for {}: {}", fleet->Id, e.what()); }