From fac18943b6aee00d6fc9901086a7d8923d9198b4 Mon Sep 17 00:00:00 2001 From: blevouschurverantunt <35274597+blevouschurverantunt@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:33:14 -0500 Subject: [PATCH 1/7] feat(sync): add away_assignments sync option Add SyncConfig::Type::AwayAssignments as the first enumerator (mirrors Jobs/Missions/Traits naming), a corresponding away_assignments member (default false, matching every other sync data flag), and its SyncOptions row so TOML loading, per-target overrides, runtime.vars write-back and JSON type serialization all pick it up automatically. config.cc needs no changes since everything iterates SyncOptions. Co-Authored-By: Claude Fable 5.1 --- mods/src/config.h | 32 ++++++++++++++++++-------------- mods/src/defaultconfig.h | 1 + 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/mods/src/config.h b/mods/src/config.h index cd9d4de6d..b037b2a69 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -16,6 +16,7 @@ class SyncConfig { public: enum class Type { + AwayAssignments, Battles, Buffs, Buildings, @@ -41,25 +42,28 @@ class SyncConfig std::string proxy; - bool verify_ssl = true; - bool battlelogs = false; - bool buffs = false; - bool buildings = false; - bool inventory = false; - bool jobs = false; - bool missions = false; - bool officer = false; - bool research = false; - bool resources = false; - bool ships = false; - bool slots = false; - bool tech = false; - bool traits = false; + bool verify_ssl = true; + bool away_assignments = false; + bool battlelogs = false; + bool buffs = false; + bool buildings = false; + bool inventory = false; + bool jobs = false; + bool missions = false; + bool officer = false; + bool research = false; + bool resources = false; + bool ships = false; + bool slots = false; + bool tech = false; + bool traits = false; [[nodiscard]] bool enabled(Type type) const; }; constexpr std::array SyncOptions{ + SyncConfig::Option{SyncConfig::Type::AwayAssignments, "away_assignment", "away_assignments", + &SyncConfig::away_assignments}, SyncConfig::Option{SyncConfig::Type::Battles, "battlelog", "battlelogs", &SyncConfig::battlelogs}, SyncConfig::Option{SyncConfig::Type::Buffs, "buff", "buffs", &SyncConfig::buffs}, SyncConfig::Option{SyncConfig::Type::Buildings, "module", "buildings", &SyncConfig::buildings}, diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 222e87d0d..bdb2691d6 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -203,6 +203,7 @@ namespace Shortcuts namespace Sync { + constexpr bool away_assignments = true; constexpr bool battlelogs = true; constexpr bool buffs = true; constexpr bool buildings = true; From 564dc98d096886662e51bffbaea65e79956a66ea Mon Sep 17 00:00:00 2001 From: blevouschurverantunt <35274597+blevouschurverantunt@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:37:20 -0500 Subject: [PATCH 2/7] feat(sync): forward away team assignments Add processors::away_assignments_list (EntityGroup 111, AwayAssignmentUserListResponse) and processors::away_assignment_instance (EntityGroup 113, bare AwayAssignmentInstance), following the same two-processors-plus-shared-helper split used for entity_slots / entity_slots_data + single_slot_locked, since no entity group carries a single-instance wrapper here. away_assignment_event() builds the sync event for one instance and only emits it when a hash of its observable fields changes, tracked in the new trackers::away_assignment_states map (mirrors slot_states). The list processor also prunes ids that drop out of the response and emits a collected_away_assignment event for each, mirroring how jobs() prunes completed jobs. Wire both entity groups into HandleEntityGroup's dispatcher as the first (alphabetical) block, gated by the new away_assignments option. Co-Authored-By: Claude Fable 5.1 --- mods/src/patches/parts/sync.cc | 114 +++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 146d60b4a..fa7f642d4 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -796,6 +796,9 @@ static std::mutex resource_states_alliance_mtx; static std::unordered_map slot_states; static std::mutex slot_states_mtx; +static std::unordered_map away_assignment_states; +static std::mutex away_assignment_states_mtx; + static eastl::ring_buffer previously_sent_battlelogs; static std::mutex previously_sent_battlelogs_mtx; @@ -1092,6 +1095,105 @@ static void ship_combat_log_data() namespace processors { +// Builds the sync event for one away team assignment instance and appends it to `out_array` only if the +// instance's observable state changed since the last time it was emitted. Lock is not held because the +// caller already does (mirrors single_slot_locked()). +static void away_assignment_event(const Digit::PrimeServer::Models::AwayAssignmentInstance& instance, + nlohmann::json& out_array) +{ + using json = nlohmann::json; + using trackers::away_assignment_states; + + auto officer_ids = json::array(); + for (const auto& officer_id : instance.officerids() | std::views::values) { + if (officer_id != 0) { + officer_ids.push_back(officer_id); + } + } + + json event = {{"type", SyncConfig::Type::AwayAssignments}, + {"aid", instance.id()}, + {"template_id", instance.awayassignmenttemplateid()}, + {"state", instance.state()}, + {"officer_ids", officer_ids}, + {"job_uuid", instance.jobuuid()}, + {"duration", instance.parameters().duration()}, + {"rarity", instance.rarity()}}; + + const auto state_value = static_cast(std::hash{}(event)); + + if (const auto& it = away_assignment_states.find(instance.id()); + it == away_assignment_states.end() || it->second != state_value) { + away_assignment_states[instance.id()] = state_value; + out_array.push_back(std::move(event)); + } +} + +static void away_assignments_list(std::unique_ptr&& bytes) +{ + using json = nlohmann::json; + static std::atomic_bool is_first_sync{true}; + + if (auto response = Digit::PrimeServer::Models::AwayAssignmentUserListResponse(); response.ParseFromString(*bytes)) { + + http::logging::trace("PROCESS", "away assignments", + STR_FORMAT("Processing {} away assignments", response.instances_size())); + + std::unordered_set ids_in_response; + ids_in_response.reserve(static_cast(response.instances_size())); + auto assignment_array = json::array(); + + { + std::scoped_lock lk(trackers::away_assignment_states_mtx); + + for (const auto& instance : response.instances()) { + ids_in_response.insert(instance.id()); + away_assignment_event(instance, assignment_array); + } + + // Prune entries that are no longer present to prevent unbounded growth + for (auto it = trackers::away_assignment_states.begin(); it != trackers::away_assignment_states.end();) { + if (!ids_in_response.contains(it->first)) { + assignment_array.push_back({{"type", "collected_" + SyncConfig::Type::AwayAssignments}, {"aid", it->first}}); + it = trackers::away_assignment_states.erase(it); + } else { + ++it; + } + } + } + + if (!assignment_array.empty()) { + const bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); + workers::queue_data(SyncConfig::Type::AwayAssignments, assignment_array, first_sync); + } + } else { + spdlog::error("Failed to parse away assignments"); + } +} + +static void away_assignment_instance(std::unique_ptr&& bytes) +{ + using json = nlohmann::json; + + if (auto instance = Digit::PrimeServer::Models::AwayAssignmentInstance(); instance.ParseFromString(*bytes)) { + + http::logging::trace("PROCESS", "away assignment instance", + STR_FORMAT("Processing away assignment {}", instance.id())); + + auto assignment_array = json::array(); + { + std::scoped_lock lk(trackers::away_assignment_states_mtx); + away_assignment_event(instance, assignment_array); + } + + if (!assignment_array.empty()) { + workers::queue_data(SyncConfig::Type::AwayAssignments, assignment_array); + } + } else { + spdlog::error("Failed to parse away assignment instance"); + } +} + static void battle_result_headers(std::unique_ptr&& bytes) { // TODO: Placeholder for future client support; currently unused by the game client. @@ -2166,6 +2268,18 @@ static void HandleEntityGroup(EntityGroup* entity_group) const auto& sync_options = Config::Get().sync_options; switch (entity_group->Type_) { + // away assignments + case EntityGroup::Type::AwayAssignmentsList: + if (sync_options.away_assignments) { + submit_async(processors::away_assignments_list); + } + break; + case EntityGroup::Type::AwayAssignmentsInstance: + if (sync_options.away_assignments) { + submit_async(processors::away_assignment_instance); + } + break; + // battlelogs case EntityGroup::Type::BattleResultHeaders: if (sync_options.battlelogs) { From 68ab45314f74a16bd091b8ad1e14e99133da9196 Mon Sep 17 00:00:00 2001 From: blevouschurverantunt <35274597+blevouschurverantunt@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:38:03 -0500 Subject: [PATCH 3/7] feat(sync): include away assignment jobs and officer away status jobs(): add JOBTYPE_AWAYASSIGNMENT so away-team jobs flow through the existing job/completed_job events with an "aid" pointing at the instance, in the same style as the other job-type cases. officers(): surface Officer.awayAssignmentInstanceId as "away_assignment_id" on the officer event. The officer de-dup state is a struct local to officers() (kept separate from the shared RankLevelShardsState used by forbidden_tech) so this extra field doesn't change forbidden tech's comparison at all, while still causing an officer to re-emit if only their away assignment changes. Co-Authored-By: Claude Fable 5.1 --- mods/src/patches/parts/sync.cc | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index fa7f642d4..6b60afb12 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -1416,6 +1416,10 @@ static void jobs(std::unique_ptr&& bytes) const auto& scrap = job.scrapyardparams(); job_params = {{"psid", scrap.shipid()}, {"hull_id", scrap.hullid()}, {"level", scrap.level()}}; } break; + case Digit::PrimeServer::Models::JOBTYPE_AWAYASSIGNMENT: { + const auto& away_assignment = job.awayassignmentparams(); + job_params = {{"aid", away_assignment.awayassignmentinstanceid()}}; + } break; default: continue; } @@ -1534,10 +1538,24 @@ static void completed_missions(std::unique_ptr&& bytes) static void officers(std::unique_ptr&& bytes) { using json = nlohmann::json; - using trackers::types::RankLevelShardsState; - static std::unordered_map officer_states; - static std::mutex officer_states_mtx; + // Local to officers(): like RankLevelShardsState, plus the away assignment instance id so a + // change there alone (rank/level/shards unchanged) still re-emits the officer. + struct OfficerState { + int32_t rank = -1; + int32_t level = -1; + int32_t shard_count = -1; + int64_t away_assignment_instance_id = 0; + + bool operator==(const OfficerState& other) const + { + return this->rank == other.rank && this->level == other.level && this->shard_count == other.shard_count + && this->away_assignment_instance_id == other.away_assignment_instance_id; + } + }; + + static std::unordered_map officer_states; + static std::mutex officer_states_mtx; if (auto response = Digit::PrimeServer::Models::OfficersResponse(); response.ParseFromString(*bytes)) { @@ -1548,7 +1566,8 @@ static void officers(std::unique_ptr&& bytes) std::scoped_lock lk(officer_states_mtx); for (const auto& officer : response.officers()) { - const RankLevelShardsState officer_state{officer.rankindex(), officer.level(), officer.shardcount()}; + const OfficerState officer_state{officer.rankindex(), officer.level(), officer.shardcount(), + officer.awayassignmentinstanceid()}; if (const auto& it = officer_states.find(officer.id()); it == officer_states.end() || it->second != officer_state) { @@ -1557,7 +1576,8 @@ static void officers(std::unique_ptr&& bytes) {"oid", officer.id()}, {"rank", officer.rankindex()}, {"level", officer.level()}, - {"shard_count", officer.shardcount()}}); + {"shard_count", officer.shardcount()}, + {"away_assignment_id", officer.awayassignmentinstanceid()}}); } } } From 114d03e7a732f07412133da881f413682df9e535 Mon Sep 17 00:00:00 2001 From: blevouschurverantunt <35274597+blevouschurverantunt@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:39:13 -0500 Subject: [PATCH 4/7] docs(config): add away_assignments to example settings Add the away_assignments key as the first entry of the [sync] "Subgroup: Data" block in all 10 localized example config files, matching the existing comment/blank-line rhythm of the other data keys. da/de/es/fr/nl/ru translations follow each file's own missions line pattern and are best-effort; tlh follows the file's established "Do you want Daq sync X De'?" template. The unsuffixed pointer file and [sync.targets.*] example blocks are intentionally left untouched, matching how every other sync data key (e.g. traits) is handled there. Co-Authored-By: Claude Fable 5.1 --- example_community_patch_settings_da.toml | 3 +++ example_community_patch_settings_de.toml | 3 +++ example_community_patch_settings_en-GB-x-cockney.toml | 3 +++ example_community_patch_settings_en-x-minionese.toml | 3 +++ example_community_patch_settings_en.toml | 3 +++ example_community_patch_settings_es.toml | 3 +++ example_community_patch_settings_fr.toml | 3 +++ example_community_patch_settings_nl.toml | 3 +++ example_community_patch_settings_ru.toml | 3 +++ example_community_patch_settings_tlh.toml | 3 +++ 10 files changed, 30 insertions(+) diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index bd067d2a8..6a1f44368 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -802,6 +802,9 @@ show_station_cargo = true # Subgroup: Data # -------------- +# Vil du synkronisere landgangsholdsdata? +away_assignments = true + # Vil du synkronisere kamplogge? battlelogs = true diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 04b7f970c..bd904a362 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -802,6 +802,9 @@ show_station_cargo = true # Subgroup: Daten # --------------- +# Außenteam-Einsatzdaten synchronisieren? +away_assignments = true + # Kampfberichte synchronisieren? battlelogs = true diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 72b77cb88..8791cc2e8 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -802,6 +802,9 @@ show_station_cargo = true # Subgroup: Data # -------------- +# Do you want to sync away team assignment data? +away_assignments = true + # Do you want to sync battlelogs? battlelogs = true diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index ca5a5850f..8edd76274 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -802,6 +802,9 @@ show_station_cargo = true # Subgroup: Data # -------------- +# Do you want to sync away team assignment data? +away_assignments = true + # Do you want to sync battlelogs? battlelogs = true diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 20855aed0..3850383c4 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -802,6 +802,9 @@ show_station_cargo = true # Subgroup: Data # -------------- +# Do you want to sync away team assignment data? +away_assignments = true + # Do you want to sync battlelogs? battlelogs = true diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index cd1fb5c77..4de016dec 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -802,6 +802,9 @@ show_station_cargo = true # Subgroup: Data # -------------- +# ¿Quieres sincronizar los datos de asignaciones de equipos de exploración? +away_assignments = true + # ¿Quieres sincronizar los registros de batalla? battlelogs = true diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index 6fad76481..6e4d2b2a5 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -802,6 +802,9 @@ show_station_cargo = true # Subgroup: Data # -------------- +# Voulez-vous synchroniser les données des missions d'équipe d'intervention ? +away_assignments = true + # Voulez-vous synchroniser les journaux de combat ? battlelogs = true diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index 59649e9f8..16d09a919 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -802,6 +802,9 @@ show_station_cargo = true # Subgroup: Data # -------------- +# Wil je data van landingsteam-opdrachten syncen? +away_assignments = true + # Wil je Gevechtslogboeken syncen? battlelogs = true diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index eaf83d806..a4d545e66 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -802,6 +802,9 @@ show_station_cargo = true # Subgroup: Data # -------------- +# Синхронизировать данные заданий десантной группы? +away_assignments = true + # Синхронизировать журналы боёв? battlelogs = true diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index 0ebcbb1b5..802596445 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -802,6 +802,9 @@ show_station_cargo = true # Subgroup: Data # -------------- +# Do you want Daq sync away team assignment De'? +away_assignments = true + # Do you want Daq sync battlelogs? battlelogs = true From 44a8e9e4c8f213da6d40e61199ab1570541204ff Mon Sep 17 00:00:00 2001 From: blevouschurverantunt <35274597+blevouschurverantunt@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:28:35 -0500 Subject: [PATCH 5/7] fix(sync): stable officer order, drop unverified instance group officerIds is a protobuf map, whose iteration order does not match slot order (observed e.g. 2=.., 0=.., 1=..). Sort by slot index before emitting officer_ids so the array, and the de-dup hash built from it, are stable run to run. Remove the AwayAssignmentsInstance (group 113) processor and dispatch case: it was never observed on the wire during probing (not at login, not on opening the screen, not on collect), so its payload shape is unverified and we should not ship unverified parsing. AwayAssignmentsList (group 111) is the only observed source of away assignment sync data and remains the single caller of away_assignment_event(), whose comment now states plainly that the caller must hold trackers::away_assignment_states_mtx. Also runs clang-format over the touched line ranges in config.h and sync.cc to keep the branch's own added/changed lines clean. Co-Authored-By: Claude Fable 5.1 --- mods/src/config.h | 2 +- mods/src/patches/parts/sync.cc | 41 ++++++++-------------------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/mods/src/config.h b/mods/src/config.h index b037b2a69..95394a5cd 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -63,7 +63,7 @@ class SyncConfig constexpr std::array SyncOptions{ SyncConfig::Option{SyncConfig::Type::AwayAssignments, "away_assignment", "away_assignments", - &SyncConfig::away_assignments}, + &SyncConfig::away_assignments}, SyncConfig::Option{SyncConfig::Type::Battles, "battlelog", "battlelogs", &SyncConfig::battlelogs}, SyncConfig::Option{SyncConfig::Type::Buffs, "buff", "buffs", &SyncConfig::buffs}, SyncConfig::Option{SyncConfig::Type::Buildings, "module", "buildings", &SyncConfig::buildings}, diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 6b60afb12..fa2c2a46e 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -1096,16 +1096,21 @@ namespace processors { // Builds the sync event for one away team assignment instance and appends it to `out_array` only if the -// instance's observable state changed since the last time it was emitted. Lock is not held because the -// caller already does (mirrors single_slot_locked()). +// instance's observable state changed since the last time it was emitted. Caller must hold +// trackers::away_assignment_states_mtx. static void away_assignment_event(const Digit::PrimeServer::Models::AwayAssignmentInstance& instance, - nlohmann::json& out_array) + nlohmann::json& out_array) { using json = nlohmann::json; using trackers::away_assignment_states; + // Protobuf map iteration order is not guaranteed to match slot order, so sort by slot index + // before emitting officer_ids to keep the array (and therefore the de-dup hash) stable. + std::vector> officer_slots(instance.officerids().begin(), instance.officerids().end()); + std::ranges::sort(officer_slots, {}, &std::pair::first); + auto officer_ids = json::array(); - for (const auto& officer_id : instance.officerids() | std::views::values) { + for (const auto& [slot, officer_id] : officer_slots) { if (officer_id != 0) { officer_ids.push_back(officer_id); } @@ -1171,29 +1176,6 @@ static void away_assignments_list(std::unique_ptr&& bytes) } } -static void away_assignment_instance(std::unique_ptr&& bytes) -{ - using json = nlohmann::json; - - if (auto instance = Digit::PrimeServer::Models::AwayAssignmentInstance(); instance.ParseFromString(*bytes)) { - - http::logging::trace("PROCESS", "away assignment instance", - STR_FORMAT("Processing away assignment {}", instance.id())); - - auto assignment_array = json::array(); - { - std::scoped_lock lk(trackers::away_assignment_states_mtx); - away_assignment_event(instance, assignment_array); - } - - if (!assignment_array.empty()) { - workers::queue_data(SyncConfig::Type::AwayAssignments, assignment_array); - } - } else { - spdlog::error("Failed to parse away assignment instance"); - } -} - static void battle_result_headers(std::unique_ptr&& bytes) { // TODO: Placeholder for future client support; currently unused by the game client. @@ -2294,11 +2276,6 @@ static void HandleEntityGroup(EntityGroup* entity_group) submit_async(processors::away_assignments_list); } break; - case EntityGroup::Type::AwayAssignmentsInstance: - if (sync_options.away_assignments) { - submit_async(processors::away_assignment_instance); - } - break; // battlelogs case EntityGroup::Type::BattleResultHeaders: From 6bd1893e3b131d0e3db9d578787d51fee52e11ba Mon Sep 17 00:00:00 2001 From: blevouschurverantunt <35274597+blevouschurverantunt@users.noreply.github.com> Date: Sun, 20 Sep 2026 11:02:16 -0500 Subject: [PATCH 6/7] fix(sync): emit away assignments on start, drop officer change Address sync maintainer review feedback on PR #295: - Revert processors::officers() to be byte-identical to dev. Away assignment status stays off officer events; slot order isn't available in that hook, so instead of losing it we emit assignment starts through a dedicated event (see below). - Drop a redundant static_cast in away_assignment_event(): std::hash already returns size_t, matching away_assignment_states's value type. - Restore processors::away_assignment_instance for EntityGroup 113 (AwayAssignmentsInstance), parsed as a bare AwayAssignmentInstance and routed through the existing away_assignment_event()/ away_assignment_states_mtx so list and instance updates share one de-dup state and the slot-ordered officer_ids. Live capture against the Windows client confirms group 113 fires when an assignment is started: first at state=1 (READYTOSTART) with officerIds already populated and an empty jobUuid, then ~1.5s later at state=2 (INPROGRESS) with jobUuid set. Wired into HandleEntityGroup's dispatcher right after the existing AwayAssignmentsList case, gated by the same away_assignments option. Co-Authored-By: Claude Fable 5.1 --- mods/src/patches/parts/sync.cc | 56 +++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index fa2c2a46e..6b9fe501e 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -1125,7 +1125,7 @@ static void away_assignment_event(const Digit::PrimeServer::Models::AwayAssignme {"duration", instance.parameters().duration()}, {"rarity", instance.rarity()}}; - const auto state_value = static_cast(std::hash{}(event)); + const auto state_value = std::hash{}(event); if (const auto& it = away_assignment_states.find(instance.id()); it == away_assignment_states.end() || it->second != state_value) { @@ -1176,6 +1176,29 @@ static void away_assignments_list(std::unique_ptr&& bytes) } } +static void away_assignment_instance(std::unique_ptr&& bytes) +{ + using json = nlohmann::json; + + if (auto instance = Digit::PrimeServer::Models::AwayAssignmentInstance(); instance.ParseFromString(*bytes)) { + + http::logging::trace("PROCESS", "away assignment instance", + STR_FORMAT("Processing away assignment {}", instance.id())); + + auto assignment_array = json::array(); + { + std::scoped_lock lk(trackers::away_assignment_states_mtx); + away_assignment_event(instance, assignment_array); + } + + if (!assignment_array.empty()) { + workers::queue_data(SyncConfig::Type::AwayAssignments, assignment_array); + } + } else { + spdlog::error("Failed to parse away assignment instance"); + } +} + static void battle_result_headers(std::unique_ptr&& bytes) { // TODO: Placeholder for future client support; currently unused by the game client. @@ -1520,24 +1543,10 @@ static void completed_missions(std::unique_ptr&& bytes) static void officers(std::unique_ptr&& bytes) { using json = nlohmann::json; + using trackers::types::RankLevelShardsState; - // Local to officers(): like RankLevelShardsState, plus the away assignment instance id so a - // change there alone (rank/level/shards unchanged) still re-emits the officer. - struct OfficerState { - int32_t rank = -1; - int32_t level = -1; - int32_t shard_count = -1; - int64_t away_assignment_instance_id = 0; - - bool operator==(const OfficerState& other) const - { - return this->rank == other.rank && this->level == other.level && this->shard_count == other.shard_count - && this->away_assignment_instance_id == other.away_assignment_instance_id; - } - }; - - static std::unordered_map officer_states; - static std::mutex officer_states_mtx; + static std::unordered_map officer_states; + static std::mutex officer_states_mtx; if (auto response = Digit::PrimeServer::Models::OfficersResponse(); response.ParseFromString(*bytes)) { @@ -1548,8 +1557,7 @@ static void officers(std::unique_ptr&& bytes) std::scoped_lock lk(officer_states_mtx); for (const auto& officer : response.officers()) { - const OfficerState officer_state{officer.rankindex(), officer.level(), officer.shardcount(), - officer.awayassignmentinstanceid()}; + const RankLevelShardsState officer_state{officer.rankindex(), officer.level(), officer.shardcount()}; if (const auto& it = officer_states.find(officer.id()); it == officer_states.end() || it->second != officer_state) { @@ -1558,8 +1566,7 @@ static void officers(std::unique_ptr&& bytes) {"oid", officer.id()}, {"rank", officer.rankindex()}, {"level", officer.level()}, - {"shard_count", officer.shardcount()}, - {"away_assignment_id", officer.awayassignmentinstanceid()}}); + {"shard_count", officer.shardcount()}}); } } } @@ -2276,6 +2283,11 @@ static void HandleEntityGroup(EntityGroup* entity_group) submit_async(processors::away_assignments_list); } break; + case EntityGroup::Type::AwayAssignmentsInstance: + if (sync_options.away_assignments) { + submit_async(processors::away_assignment_instance); + } + break; // battlelogs case EntityGroup::Type::BattleResultHeaders: From 94c21d2ea41b7ebc43d90859eb8f0d05058116ad Mon Sep 17 00:00:00 2001 From: blevouschurverantunt <35274597+blevouschurverantunt@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:07:49 -0500 Subject: [PATCH 7/7] Restore battle log and ship sync for the current client (build 267) Client build 267 (2026-09-29) delivers battle result headers as entity group 219 (BattleResultHeadersResponse) and owned ships as group 251 (PlayerShipsResponse) instead of the Json group. Implement the 219 processor and add a 251 processor, sharing helpers with the Json handlers. Add the client's EntityGroup values 242-252, and declare only the proto fields that are read. Co-Authored-By: Claude Fable 5.1 --- mods/src/patches/parts/sync.cc | 191 ++++++++++++------ mods/src/prime/EntityGroup.h | 12 ++ .../proto/Digit.PrimeServer.Models.proto | 32 +++ 3 files changed, 170 insertions(+), 65 deletions(-) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index fe1035cfc..6d96a8768 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -1095,10 +1095,120 @@ static void ship_combat_log_data() namespace processors { +static void queue_battle_ids(const std::vector& battle_ids) +{ + std::vector to_enqueue; + { + using trackers::previously_sent_battlelogs; + using trackers::previously_sent_battlelogs_mtx; + + std::scoped_lock lk(previously_sent_battlelogs_mtx); + + for (const auto id : battle_ids | std::views::reverse) { + if (eastl::find(previously_sent_battlelogs.begin(), previously_sent_battlelogs.end(), id) + == previously_sent_battlelogs.end()) { + previously_sent_battlelogs.push_back(id); + to_enqueue.push_back(id); + } + } + } + + if (!to_enqueue.empty()) { + http::logging::debug("QUEUE", "battle headers", + STR_FORMAT("Queuing {} battles for background processing", to_enqueue.size())); + + { + std::scoped_lock lk(workers::combat_log_data_mtx); + for (const auto id : to_enqueue) { + workers::combat_log_data_queue.push(id); + } + } + + trackers::save_previously_sent_logs(); + workers::combat_log_data_cv.notify_all(); + } +} + static void battle_result_headers(std::unique_ptr&& bytes) { - // TODO: Placeholder for future client support; currently unused by the game client. - spdlog::debug("process_battle_result_headers() was called"); + if (auto response = Digit::PrimeServer::Models::BattleResultHeadersResponse(); response.ParseFromString(*bytes)) { + http::logging::trace("PROCESS", "battle headers", + STR_FORMAT("Processing {} battle headers", response.headers_size())); + + std::vector battle_ids; + battle_ids.reserve(static_cast(response.headers_size())); + + for (const auto& header : response.headers()) { + battle_ids.push_back(static_cast(header.id())); + } + + queue_battle_ids(battle_ids); + } else { + spdlog::error("Failed to parse battle result headers"); + } +} + +struct ShipRecord { + int64_t id; + int64_t hull_id; + int32_t tier; + int32_t level; + double level_percentage; + std::vector components; +}; + +static void sync_ships(const std::vector& ships) +{ + using json = nlohmann::json; + using trackers::types::ShipState; + + static std::unordered_map ship_states; + static std::mutex ship_states_mtx; + static std::atomic_bool is_first_sync{true}; + + auto ship_array = json::array(); + { + std::scoped_lock lk(ship_states_mtx); + + for (const auto& ship : ships) { + const ShipState state{ship.tier, ship.level, ship.level_percentage, ship.components}; + + if (const auto& it = ship_states.find(ship.id); it == ship_states.end() || it->second != state) { + ship_states[ship.id] = state; + ship_array.push_back({{"type", SyncConfig::Type::Ships}, + {"psid", ship.id}, + {"level", ship.level}, + {"level_percentage", ship.level_percentage}, + {"tier", ship.tier}, + {"hull_id", ship.hull_id}, + {"components", ship.components}}); + } + } + } + + if (!ship_array.empty()) { + const bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); + workers::queue_data(SyncConfig::Type::Ships, ship_array, first_sync); + } +} + +static void player_ships(std::unique_ptr&& bytes) +{ + if (auto response = Digit::PrimeServer::Models::PlayerShipsResponse(); response.ParseFromString(*bytes)) { + http::logging::trace("PROCESS", "ships", STR_FORMAT("Processing {} ships", response.ships_size())); + + std::vector ships; + ships.reserve(static_cast(response.ships_size())); + + for (const auto& ship : response.ships() | std::views::values) { + ships.push_back({ship.id(), ship.hullid(), ship.tier(), ship.level(), ship.levelpercentage(), + std::vector(ship.components().begin(), ship.components().end())}); + } + + sync_ships(ships); + } else { + spdlog::error("Failed to parse player ships"); + } } static void battle_report(std::unique_ptr&& bytes) @@ -1927,36 +2037,7 @@ namespace json battle_ids.push_back(id); } - std::vector to_enqueue; - { - using trackers::previously_sent_battlelogs; - using trackers::previously_sent_battlelogs_mtx; - - std::scoped_lock lk(previously_sent_battlelogs_mtx); - - for (const auto id : battle_ids | std::views::reverse) { - if (eastl::find(previously_sent_battlelogs.begin(), previously_sent_battlelogs.end(), id) - == previously_sent_battlelogs.end()) { - previously_sent_battlelogs.push_back(id); - to_enqueue.push_back(id); - } - } - } - - if (!to_enqueue.empty()) { - http::logging::debug("QUEUE", "battle headers", - STR_FORMAT("Queuing {} battles for background processing", to_enqueue.size())); - - { - std::scoped_lock lk(workers::combat_log_data_mtx); - for (const auto id : to_enqueue) { - workers::combat_log_data_queue.push(id); - } - } - - trackers::save_previously_sent_logs(); - workers::combat_log_data_cv.notify_all(); - } + queue_battle_ids(battle_ids); } static void resources(const nlohmann::json& section) @@ -2020,43 +2101,19 @@ namespace json static void ships(const nlohmann::json& section) { using json = nlohmann::json; - using trackers::types::ShipState; - - static std::unordered_map ship_states; - static std::mutex ship_states_mtx; - static std::atomic_bool is_first_sync{true}; http::logging::trace("PROCESS", "ships", STR_FORMAT("Processing {} ships (JSON)", section.size())); - auto ship_array = json::array(); - { - std::scoped_lock lk(ship_states_mtx); - - for (const auto& ship : section.get() | std::views::values) { - const auto id = ship["id"].get(); - const auto tier = ship["tier"].get(); - const auto level = ship["level"].get(); - const auto level_percentage = ship["level_percentage"].get(); - const auto components = ship["components"].get>(); - const ShipState state{tier, level, level_percentage, components}; - - if (const auto& it = ship_states.find(id); it == ship_states.end() || it->second != state) { - ship_states[id] = state; - ship_array.push_back({{"type", SyncConfig::Type::Ships}, - {"psid", id}, - {"level", level}, - {"level_percentage", level_percentage}, - {"tier", tier}, - {"hull_id", ship["hull_id"].get()}, - {"components", components}}); - } - } - } + std::vector records; + records.reserve(section.size()); - if (!ship_array.empty()) { - const bool first_sync = is_first_sync.exchange(false, std::memory_order_acq_rel); - workers::queue_data(SyncConfig::Type::Ships, ship_array, first_sync); + for (const auto& ship : section.get() | std::views::values) { + records.push_back({ship["id"].get(), ship["hull_id"].get(), ship["tier"].get(), + ship["level"].get(), ship["level_percentage"].get(), + ship["components"].get>()}); } + + sync_ships(records); } static void parse(std::unique_ptr&& bytes) @@ -2382,7 +2439,11 @@ static void HandleEntityGroup(EntityGroup* entity_group) break; // ships - // TODO: currently still part of JSON, likely to change in the future + case EntityGroup::Type::Ships: + if (sync_options.ships) { + submit_async(processors::player_ships); + } + break; // slots case EntityGroup::Type::EntitySlots: diff --git a/mods/src/prime/EntityGroup.h b/mods/src/prime/EntityGroup.h index 5214470d9..2356a1890 100644 --- a/mods/src/prime/EntityGroup.h +++ b/mods/src/prime/EntityGroup.h @@ -252,6 +252,18 @@ struct EntityGroup { ResourceProducers = 240, // 0x000000F0 Starbase = 241, // 0x000000F1 + NodeSystem = 242, // 0x000000F2 + DockingPoints = 243, // 0x000000F3 + PlayerContainer = 244, // 0x000000F4 + MarauderQuickScanData = 245, // 0x000000F5 + AllianceContainer = 246, // 0x000000F6 + QuickScanResults = 247, // 0x000000F7 + DeployedFleets = 248, // 0x000000F8 + MyDeployedFleets = 249, // 0x000000F9 + Fleets = 250, // 0x000000FA + Ships = 251, // 0x000000FB + ResourceHarvesters = 252, // 0x000000FC + PlanetaryBaseData = 1213, // 0x000004BD PlanetaryResourceProducers = 1214, // 0x000004BE PlanetaryMapData = 1215, // 0x000004BF diff --git a/mods/src/prime/proto/Digit.PrimeServer.Models.proto b/mods/src/prime/proto/Digit.PrimeServer.Models.proto index e3cb44dd6..39392b81b 100644 --- a/mods/src/prime/proto/Digit.PrimeServer.Models.proto +++ b/mods/src/prime/proto/Digit.PrimeServer.Models.proto @@ -3577,6 +3577,17 @@ enum Type { TYPE_STARBASEMODULES = 239; TYPE_RESOURCEPRODUCERS = 240; TYPE_STARBASE = 241; + TYPE_NODESYSTEM = 242; + TYPE_DOCKINGPOINTS = 243; + TYPE_PLAYERCONTAINER = 244; + TYPE_MARAUDERQUICKSCANDATA = 245; + TYPE_ALLIANCECONTAINER = 246; + TYPE_QUICKSCANRESULTS = 247; + TYPE_DEPLOYEDFLEETS = 248; + TYPE_MYDEPLOYEDFLEETS = 249; + TYPE_FLEETS = 250; + TYPE_SHIPS = 251; + TYPE_RESOURCEHARVESTERS = 252; TYPE_OUTPOSTSTATICDATA = 8401; TYPE_OUTPOSTUPGRADERESPONSE = 8402; TYPE_OUTPOSTSYNC = 8403; @@ -3900,6 +3911,19 @@ message PlayerPartyResponse { PartyResponse response = 1; } +message PlayerShipData { + int64 id = 1; + int32 tier = 2; + int64 hullId = 3; + repeated int64 components = 4; + int32 level = 5; + float levelPercentage = 6; +} + +message PlayerShipsResponse { + map ships = 1; +} + message PlanetaryBaseSpec { int64 id = 1; string buildingName = 2; @@ -5583,3 +5607,11 @@ message WaveDefenseChallengeDataResponse { message ActiveWormholesResponse { repeated Wormhole wormholes = 1; } + +message JournalHeaderProto { + int64 id = 1; +} + +message BattleResultHeadersResponse { + repeated JournalHeaderProto headers = 1; +}