diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index be825d06..530cae1e 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 5d5f74b8..83f135c6 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 b3e12851..462efa43 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 aa3b27dc..d089e3c5 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 f865d278..4e590f7c 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 a27dbe01..be44db80 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -1,14 +1,18 @@ #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 #include #include +#include #include #include #include +#include #include #include #include @@ -37,12 +41,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -891,6 +897,23 @@ 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}; + +// 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 { @@ -915,6 +938,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); @@ -1712,6 +1739,14 @@ 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()}}; + fleets::request_reread(job.repairfleetparams().fleetid()); + } break; default: continue; } @@ -2342,6 +2377,175 @@ 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)); + fleets::request_reread(fleet_id); + } + } + + // 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) { + if (response.fleets().contains(entry.first)) { + return false; + } + fleets::request_reread(entry.first); + return true; + }); + } + + 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) @@ -2802,6 +3006,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); @@ -2895,6 +3106,284 @@ 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(); +} + +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, FleetState state) +{ + const auto is_mining = fleet->IsMining(); + const auto is_repairing = fleet->IsRepairing(); + + 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", 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()) { + 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())); + } + } + + 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 +// 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", "travel_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) +{ + auto* hull = fleet->Hull; + // 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; + + 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, 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; +} + +// 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"); +} + +// 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; + } + 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 +3536,8 @@ void InstallSyncPatches() } } + fleets::install(); + std::thread(workers::ship_sync_data).detach(); std::thread(workers::ship_combat_log_data).detach(); } diff --git a/mods/src/prime/FleetLiveData.h b/mods/src/prime/FleetLiveData.h new file mode 100644 index 00000000..b527610b --- /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 c26249db..05a02968 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,101 @@ 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()); + } + // 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; diff --git a/mods/src/prime/ManagedRead.h b/mods/src/prime/ManagedRead.h new file mode 100644 index 00000000..ae4da9a6 --- /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 diff --git a/mods/src/prime/proto/stfc.sync.proto b/mods/src/prime/proto/stfc.sync.proto new file mode 100644 index 00000000..bf05b005 --- /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; +}