From 582830bb779cc345d20b9bda08cfc541812c9698 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 01/10] Move notification sound into its feature module Notification cues and native playback move into the feature file that uses them, with one sound API name across the settings reader, the toast hook and the platform code. Toasts format typed parameters in one pass over the template. A value containing another placeholder is no longer read as a placeholder itself, and each cue generates only the requested sound instead of nine buffers. --- libs/il2cpp/include/il2cpp/il2cpp.h | 16 -- mods/src/config.cc | 28 +- mods/src/config.h | 18 +- mods/src/patches/notification_audio.cc | 267 ------------------ mods/src/patches/notification_audio.h | 23 -- .../src/patches/notification_audio_platform.h | 6 - mods/src/patches/parts/audio_events.cc | 34 ++- mods/src/patches/parts/notification_audio.cc | 163 +++++++++++ mods/src/patches/parts/notification_audio.h | 26 ++ .../parts}/notification_audio_mac.mm | 8 +- mods/src/patches/parts/toast_text.h | 53 ++-- mods/src/patches/parts/toasts.cc | 64 ++--- tests/mod_logic.cc | 58 ++++ xmake.lua | 8 +- 14 files changed, 351 insertions(+), 421 deletions(-) delete mode 100644 mods/src/patches/notification_audio.cc delete mode 100644 mods/src/patches/notification_audio.h delete mode 100644 mods/src/patches/notification_audio_platform.h create mode 100644 mods/src/patches/parts/notification_audio.cc create mode 100644 mods/src/patches/parts/notification_audio.h rename mods/src/{ => patches/parts}/notification_audio_mac.mm (76%) diff --git a/libs/il2cpp/include/il2cpp/il2cpp.h b/libs/il2cpp/include/il2cpp/il2cpp.h index a2fc9517c..fe89cbfaa 100644 --- a/libs/il2cpp/include/il2cpp/il2cpp.h +++ b/libs/il2cpp/include/il2cpp/il2cpp.h @@ -551,22 +551,6 @@ inline Il2CppString* ToString(Object* object) return object->Call<"ToString", Il2CppString*>(); } -// Instance fields of the object's class and its bases, most derived first. For reading game data whose field -// names drift between builds; bindings should name their fields. -template void ForEachInstanceField(Il2CppObject* object, Visit visit) -{ - for (auto* klass = object ? object->klass : nullptr; klass; klass = il2cpp_class_get_parent(klass)) { - void* iter = nullptr; - while (auto* field = il2cpp_class_get_fields(klass, &iter)) - if (!(field->type->attrs & FIELD_ATTRIBUTE_STATIC)) - visit(field); - } -} - -// A field found by ForEachInstanceField. T must match the field's layout; nothing checks it here. -template T ReadField(Il2CppObject* object, FieldInfo* field) -{ return detail::ReadField(field, object); } - // Runs the method a delegate is bound to on its target directly, skipping the delegate's invoke stub. Unlike // Call, a managed exception is not caught here, so the caller can fall back: catch Il2CppExceptionWrapper. template R InvokeBound(Il2CppDelegate* delegate, A... args) diff --git a/mods/src/config.cc b/mods/src/config.cc index 154869b6c..fea8cba54 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -603,8 +603,6 @@ void Config::Load(const SettingsFile& file, bool first) r.Read("buffs", "use_out_of_dock_power", buffs.use_out_of_dock_power); r.Read("audio", "trace_events", audio.trace_events); - audio.disable_all_events = false; - audio.disabled_events.clear(); for (const auto& event : StrSplit(r.Get("audio", "disabled_events", ""), ',')) { if (AsciiStrToLower(event) == "all") audio.disable_all_events = true; @@ -619,12 +617,12 @@ void Config::Load(const SettingsFile& file, bool first) {"alert_armada_battle_won", &audio.alert_armada_battle_won}, {"alert_armada_battle_lost", &audio.alert_armada_battle_lost}}) { const auto name = r.Get("audio", key, "none"); - const auto sound = notification_sound_from_name(StripAsciiWhitespace(name)); + const auto sound = ParseNotificationSound(StripAsciiWhitespace(name)); if (!sound) spdlog::warn("invalid config value audio.{}: '{}'; using none", key, name); - *value = sound.value_or(NotificationSound::None); - r.Record("audio", key, notification_sound_name(*value)); - has_audio_alert |= *value != NotificationSound::None; + *value = sound.value_or(NotificationSound::kNone); + r.Record("audio", key, NotificationSoundName(*value)); + has_audio_alert |= *value != NotificationSound::kNone; } if (has_audio_alert && !patches.toastbannerhooks) spdlog::warn("audio alerts require patches.toastbannerhooks = true"); @@ -726,21 +724,3 @@ void Config::Load(const SettingsFile& file, bool first) << "Loaded release version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION << "\n\n"; #endif } - -NotificationSound Config::NotificationSoundForToast(int toast_state) const -{ - switch (toast_state) { - case static_cast(ToastState::Victory): - return audio.alert_victory; - case static_cast(ToastState::Defeat): - return audio.alert_defeat; - case static_cast(ToastState::ArmadaCreated): - return audio.alert_armada_created; - case static_cast(ToastState::ArmadaBattleWon): - return audio.alert_armada_battle_won; - case static_cast(ToastState::ArmadaBattleLost): - return audio.alert_armada_battle_lost; - default: - return NotificationSound::None; - } -} diff --git a/mods/src/config.h b/mods/src/config.h index 80944075a..72f8cb027 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -1,12 +1,12 @@ #pragma once -#include "patches/notification_audio.h" - // User settings from the settings TOML. Each struct is a TOML section and each member is named after its key, // with its default as the initializer: Config::Get().graphics.ui_scale is [graphics] ui_scale. They are read at // startup and again whenever the file changes; a few are also toggled at runtime by hotkeys. Everything runs on // the game thread except sync, which only reads. +#include "patches/parts/notification_audio.h" + #include #include #include @@ -145,7 +145,6 @@ struct SettingsFile; class Config final { public: - [[nodiscard]] NotificationSound NotificationSoundForToast(int toast_state) const; // The current settings. Call it again rather than keeping the reference: a reload replaces them. [[nodiscard]] static Config& Get(); // Re-reads the settings file and makes it current, rebinding shortcuts. A file that does not parse keeps the @@ -263,15 +262,14 @@ class Config final } buffs; struct Audio { - bool disable_all_events = false; - NotificationSound alert_victory = NotificationSound::None; - NotificationSound alert_defeat = NotificationSound::None; - NotificationSound alert_armada_created = NotificationSound::None; - NotificationSound alert_armada_battle_won = NotificationSound::None; - NotificationSound alert_armada_battle_lost = NotificationSound::None; - bool trace_events = false; std::vector disabled_events; + bool disable_all_events = false; + NotificationSound alert_victory = NotificationSound::kNone; + NotificationSound alert_defeat = NotificationSound::kNone; + NotificationSound alert_armada_created = NotificationSound::kNone; + NotificationSound alert_armada_battle_won = NotificationSound::kNone; + NotificationSound alert_armada_battle_lost = NotificationSound::kNone; } audio; struct Ui { diff --git a/mods/src/patches/notification_audio.cc b/mods/src/patches/notification_audio.cc deleted file mode 100644 index 1e2d5f7df..000000000 --- a/mods/src/patches/notification_audio.cc +++ /dev/null @@ -1,267 +0,0 @@ -#include "patches/notification_audio.h" - -#include "patches/notification_audio_platform.h" - -#include - -#include -#include -#include -#include -#include -#include -#include - -#if _WIN32 -#include -#include -#endif - -namespace -{ -struct ToneSegment { - double frequency_hz; - int duration_ms; -}; - -constexpr int kSampleRate = 44100; -constexpr double kTwoPi = 6.28318530717958647692; -constexpr double kAmplitude = 0.26; - -constexpr std::array kDefaultPattern{{{740.0, 70}, {0.0, 22}, {880.0, 85}, {0.0, 18}}}; -constexpr std::array kInfoPattern{{{659.0, 80}, {0.0, 24}, {880.0, 110}}}; -constexpr std::array kSuccessPattern{{{587.0, 70}, {0.0, 18}, {740.0, 70}, {0.0, 18}, {988.0, 120}}}; -constexpr std::array kWarningPattern{{{622.0, 90}, {0.0, 36}, {466.0, 110}, {0.0, 28}, {466.0, 90}}}; -constexpr std::array kAlarmPattern{{{880.0, 90}, {0.0, 42}, {880.0, 90}, {0.0, 42}, {698.0, 160}}}; -constexpr std::array kArrivalPattern{{{523.0, 65}, {0.0, 18}, {659.0, 70}, {0.0, 18}, {1046.0, 125}}}; -constexpr std::array kSoftPattern{{{523.0, 90}, {0.0, 26}, {659.0, 110}}}; -constexpr std::array kPingPattern{{{1046.0, 95}}}; -constexpr std::array kRepairPattern{{{440.0, 70}, {0.0, 18}, {554.0, 70}, {0.0, 18}, {740.0, 140}}}; - -struct SoundDefinition { - NotificationSound sound; - std::string_view name; - std::span pattern; -}; - -constexpr auto kSoundDefinitions = std::to_array({ - {NotificationSound::None, "none", {}}, - {NotificationSound::Default, "default", kDefaultPattern}, - {NotificationSound::Info, "info", kInfoPattern}, - {NotificationSound::Success, "success", kSuccessPattern}, - {NotificationSound::Warning, "warning", kWarningPattern}, - {NotificationSound::Alarm, "alarm", kAlarmPattern}, - {NotificationSound::Arrival, "arrival", kArrivalPattern}, - {NotificationSound::Soft, "soft", kSoftPattern}, - {NotificationSound::Ping, "ping", kPingPattern}, - {NotificationSound::Repair, "repair", kRepairPattern}, -}); - -struct SoundAlias { - std::string_view name; - NotificationSound sound; -}; - -constexpr auto kSoundAliases = std::to_array({ - {"off", NotificationSound::None}, - {"silent", NotificationSound::None}, - {"victory", NotificationSound::Success}, - {"warn", NotificationSound::Warning}, - {"attack", NotificationSound::Alarm}, - {"arrive", NotificationSound::Arrival}, - {"quiet", NotificationSound::Soft}, - {"repaired", NotificationSound::Repair}, -}); - -consteval bool SoundCatalogIsValid() -{ - if (kSoundDefinitions.size() != static_cast(NotificationSound::Count)) { - return false; - } - - for (size_t index = 0; index < kSoundDefinitions.size(); ++index) { - const auto& definition = kSoundDefinitions[index]; - if (static_cast(definition.sound) != index || definition.name.empty() - || (definition.sound == NotificationSound::None) != definition.pattern.empty()) { - return false; - } - for (size_t other = index + 1; other < kSoundDefinitions.size(); ++other) { - if (definition.name == kSoundDefinitions[other].name) { - return false; - } - } - } - - for (size_t index = 0; index < kSoundAliases.size(); ++index) { - const auto& alias = kSoundAliases[index]; - if (alias.name.empty() || static_cast(alias.sound) >= kSoundDefinitions.size()) { - return false; - } - for (const auto& definition : kSoundDefinitions) { - if (alias.name == definition.name) { - return false; - } - } - for (size_t other = index + 1; other < kSoundAliases.size(); ++other) { - if (alias.name == kSoundAliases[other].name) { - return false; - } - } - } - return true; -} - -static_assert(SoundCatalogIsValid()); - -constexpr const SoundDefinition* sound_definition(NotificationSound sound) -{ - const auto index = static_cast(sound); - if (index >= kSoundDefinitions.size() || kSoundDefinitions[index].sound != sound) { - return nullptr; - } - return &kSoundDefinitions[index]; -} - -static_assert(sound_definition(NotificationSound::Success)->name == "success"); -static_assert(sound_definition(NotificationSound::Count) == nullptr); - -std::string normalize_sound_name(std::string_view value) -{ - std::string normalized; - normalized.reserve(value.size()); - for (const auto ch : value) { - if (ch >= 'A' && ch <= 'Z') { - normalized.push_back(static_cast(ch - 'A' + 'a')); - } else if (ch == '-' || ch == ' ') { - normalized.push_back('_'); - } else { - normalized.push_back(ch); - } - } - return normalized; -} - -void append_u16(std::vector& buffer, uint16_t value) -{ - buffer.push_back(static_cast(value & 0xff)); - buffer.push_back(static_cast((value >> 8) & 0xff)); -} - -void append_u32(std::vector& buffer, uint32_t value) -{ - buffer.push_back(static_cast(value & 0xff)); - buffer.push_back(static_cast((value >> 8) & 0xff)); - buffer.push_back(static_cast((value >> 16) & 0xff)); - buffer.push_back(static_cast((value >> 24) & 0xff)); -} - -void append_ascii(std::vector& buffer, std::string_view value) -{ buffer.insert(buffer.end(), value.begin(), value.end()); } - -std::vector build_wav(std::span pattern) -{ - uint32_t sample_count = 0; - for (const auto& segment : pattern) { - sample_count += static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); - } - - constexpr uint16_t channels = 1; - constexpr uint16_t bits_per_sample = 16; - const uint32_t data_bytes = sample_count * channels * (bits_per_sample / 8); - - std::vector buffer; - buffer.reserve(44 + data_bytes); - append_ascii(buffer, "RIFF"); - append_u32(buffer, 36 + data_bytes); - append_ascii(buffer, "WAVE"); - append_ascii(buffer, "fmt "); - append_u32(buffer, 16); - append_u16(buffer, 1); - append_u16(buffer, channels); - append_u32(buffer, kSampleRate); - append_u32(buffer, kSampleRate * channels * (bits_per_sample / 8)); - append_u16(buffer, channels * (bits_per_sample / 8)); - append_u16(buffer, bits_per_sample); - append_ascii(buffer, "data"); - append_u32(buffer, data_bytes); - - double phase = 0.0; - for (const auto& segment : pattern) { - const auto segment_samples = static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); - const auto step = segment.frequency_hz > 0.0 ? kTwoPi * segment.frequency_hz / kSampleRate : 0.0; - for (int index = 0; index < segment_samples; ++index) { - const auto fade_in = std::min(1.0, static_cast(index) / 80.0); - const auto fade_out = std::min(1.0, static_cast(segment_samples - index) / 120.0); - const auto sample = segment.frequency_hz > 0.0 ? std::sin(phase) * kAmplitude * fade_in * fade_out : 0.0; - append_u16(buffer, static_cast(static_cast(sample * 32767.0))); - phase += step; - if (phase > kTwoPi) { - phase -= kTwoPi; - } - } - } - return buffer; -} - -using SoundBuffers = std::array, static_cast(NotificationSound::Count)>; - -const SoundBuffers& sound_buffers() -{ - static const auto buffers = [] { - SoundBuffers result; - for (const auto& definition : kSoundDefinitions) { - if (!definition.pattern.empty()) { - result[static_cast(definition.sound)] = build_wav(definition.pattern); - } - } - return result; - }(); - return buffers; -} -} // namespace - -std::string_view notification_sound_name(NotificationSound sound) -{ - const auto* definition = sound_definition(sound); - return definition ? definition->name : kSoundDefinitions.front().name; -} - -std::optional notification_sound_from_name(std::string_view name) -{ - const auto normalized = normalize_sound_name(name); - if (const auto definition = std::ranges::find(kSoundDefinitions, normalized, &SoundDefinition::name); - definition != kSoundDefinitions.end()) { - return definition->sound; - } - if (const auto alias = std::ranges::find(kSoundAliases, normalized, &SoundAlias::name); - alias != kSoundAliases.end()) { - return alias->sound; - } - return std::nullopt; -} - -void notification_audio_play(NotificationSound sound) -{ - if (sound == NotificationSound::None) - return; - - const auto index = static_cast(sound); - if (index >= kSoundDefinitions.size()) - return; - - const auto& buffer = sound_buffers()[index]; - if (buffer.empty()) - return; - - if (!notification_audio_platform_play(buffer.data(), buffer.size())) { - spdlog::warn("[NotifyAudio] Failed to play '{}' cue", notification_sound_name(sound)); - } -} - -#if _WIN32 -bool notification_audio_platform_play(const uint8_t* data, size_t) -{ return PlaySoundW(reinterpret_cast(data), nullptr, SND_ASYNC | SND_MEMORY | SND_NODEFAULT) != FALSE; } -#elif !defined(__APPLE__) -bool notification_audio_platform_play(const uint8_t*, size_t) -{ return false; } -#endif diff --git a/mods/src/patches/notification_audio.h b/mods/src/patches/notification_audio.h deleted file mode 100644 index 345fa9840..000000000 --- a/mods/src/patches/notification_audio.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include -#include -#include - -enum class NotificationSound : uint8_t { - None = 0, - Default, - Info, - Success, - Warning, - Alarm, - Arrival, - Soft, - Ping, - Repair, - Count, -}; - -[[nodiscard]] std::string_view notification_sound_name(NotificationSound sound); -[[nodiscard]] std::optional notification_sound_from_name(std::string_view name); -void notification_audio_play(NotificationSound sound); diff --git a/mods/src/patches/notification_audio_platform.h b/mods/src/patches/notification_audio_platform.h deleted file mode 100644 index 3ee8a973c..000000000 --- a/mods/src/patches/notification_audio_platform.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -#include -#include - -[[nodiscard]] bool notification_audio_platform_play(const uint8_t* data, size_t size); diff --git a/mods/src/patches/parts/audio_events.cc b/mods/src/patches/parts/audio_events.cc index 7cd523687..14a12a26f 100644 --- a/mods/src/patches/parts/audio_events.cc +++ b/mods/src/patches/parts/audio_events.cc @@ -15,19 +15,33 @@ namespace { +// PlaySound=0, UnpauseSound=3, AdvanceSequence=16, PlayScheduled=33. Cleanup still runs with All. +constexpr bool SuppressEvent(bool all, bool named, int32_t action) +{ return all ? action == 0 || action == 3 || action == 16 || action == 33 : named; } +static_assert(SuppressEvent(true, false, 0)); +static_assert(!SuppressEvent(true, true, 1)); +static_assert(!SuppressEvent(true, true, 23)); +static_assert(SuppressEvent(false, true, 21)); + // Fabric.EventManager.PostEvent(string, EventAction, object, GameObject, InitialiseParameters, bool, OnEventNotify) bool AudioEventManager_PostEvent_Hook(auto original, Il2CppObject* _this, Il2CppString* event_name, int event_action, Il2CppObject* parameter, Il2CppObject* parent_game_object, Il2CppObject* initialise_parameters, bool add_to_queue, Il2CppObject* on_event_notify) { - if (const auto& audio = Config::Get().audio; event_name && (audio.trace_events || audio.disable_all_events || !audio.disabled_events.empty())) { - const auto event = il2cpp::ToUtf8(event_name); - if (audio.trace_events) - spdlog::info("Audio event: {}", event); - if ((audio.disable_all_events ? event_action == 0 || event_action == 3 || event_action == 16 || event_action == 33 : std::ranges::find(audio.disabled_events, event) != audio.disabled_events.end())) { - spdlog::debug("Suppressed audio event: {}", event); - return false; + if (const auto& audio = Config::Get().audio; + event_name && (audio.trace_events || audio.disable_all_events || !audio.disabled_events.empty())) { + try { + const auto event = il2cpp::ToUtf8(event_name); + if (audio.trace_events) + spdlog::info("Audio event: {}", event); + if (SuppressEvent(audio.disable_all_events, + std::ranges::find(audio.disabled_events, event) != audio.disabled_events.end(), event_action)) { + spdlog::debug("Suppressed audio event: {}", event); + return false; + } + } catch (...) { + // A filter failure must leave the native event path available. } } return original(_this, event_name, event_action, parameter, parent_game_object, initialise_parameters, add_to_queue, @@ -35,7 +49,11 @@ bool AudioEventManager_PostEvent_Hook(auto original, Il2CppObject* _this, Il2Cpp } void InstallAudio() -{ IL2CPP_HOOK(AudioEventManager::Method("PostEvent", 7), AudioEventManager_PostEvent_Hook); } +{ + IL2CPP_HOOK(AudioEventManager::Method("PostEvent"), + AudioEventManager_PostEvent_Hook); +} } // namespace const Feature kAudio{"Audio", &Config::Patches::audioeventhooks, InstallAudio}; diff --git a/mods/src/patches/parts/notification_audio.cc b/mods/src/patches/parts/notification_audio.cc new file mode 100644 index 000000000..9141272af --- /dev/null +++ b/mods/src/patches/parts/notification_audio.cc @@ -0,0 +1,163 @@ +// Named notification cues and their generated mono PCM WAV data. +#include "patches/parts/notification_audio.h" +#include "str_utils.h" + +#include + +#include +#include +#include +#include + +#if _WIN32 +#include +#include +#endif + +bool PlayNotificationBuffer(const uint8_t* data, size_t size); + +namespace +{ +struct ToneSegment { + double frequency_hz; + int duration_ms; +}; +struct SoundDefinition { + std::string_view name; + std::array pattern; +}; + +// Entries follow NotificationSound; unused segments have zero duration. +constexpr SoundDefinition kSounds[] = { + {"none", {}}, + {"default", {{{740, 70}, {0, 22}, {880, 85}, {0, 18}}}}, + {"info", {{{659, 80}, {0, 24}, {880, 110}}}}, + {"success", {{{587, 70}, {0, 18}, {740, 70}, {0, 18}, {988, 120}}}}, + {"warning", {{{622, 90}, {0, 36}, {466, 110}, {0, 28}, {466, 90}}}}, + {"alarm", {{{880, 90}, {0, 42}, {880, 90}, {0, 42}, {698, 160}}}}, + {"arrival", {{{523, 65}, {0, 18}, {659, 70}, {0, 18}, {1046, 125}}}}, + {"soft", {{{523, 90}, {0, 26}, {659, 110}}}}, + {"ping", {{{1046, 95}}}}, + {"repair", {{{440, 70}, {0, 18}, {554, 70}, {0, 18}, {740, 140}}}}, +}; +static_assert(std::size(kSounds) == static_cast(NotificationSound::kCount)); + +constexpr std::pair kAliases[] = { + {"off", NotificationSound::kNone}, {"silent", NotificationSound::kNone}, + {"victory", NotificationSound::kSuccess}, {"warn", NotificationSound::kWarning}, + {"attack", NotificationSound::kAlarm}, {"arrive", NotificationSound::kArrival}, + {"quiet", NotificationSound::kSoft}, {"repaired", NotificationSound::kRepair}, +}; + +constexpr int kSampleRate = 44100; +constexpr double kTwoPi = 6.28318530717958647692; +constexpr double kAmplitude = 0.26; + +void append_u16(std::vector& buffer, uint16_t value) +{ + buffer.push_back(static_cast(value & 0xff)); + buffer.push_back(static_cast((value >> 8) & 0xff)); +} + +void append_u32(std::vector& buffer, uint32_t value) +{ + buffer.push_back(static_cast(value & 0xff)); + buffer.push_back(static_cast((value >> 8) & 0xff)); + buffer.push_back(static_cast((value >> 16) & 0xff)); + buffer.push_back(static_cast((value >> 24) & 0xff)); +} + +void append_ascii(std::vector& buffer, std::string_view value) +{ buffer.insert(buffer.end(), value.begin(), value.end()); } + +std::vector build_wav(std::span pattern) +{ + uint32_t sample_count = 0; + for (const auto& segment : pattern) { + sample_count += static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); + } + + constexpr uint16_t channels = 1; + constexpr uint16_t bits_per_sample = 16; + const uint32_t data_bytes = sample_count * channels * (bits_per_sample / 8); + + std::vector buffer; + buffer.reserve(44 + data_bytes); + append_ascii(buffer, "RIFF"); + append_u32(buffer, 36 + data_bytes); + append_ascii(buffer, "WAVE"); + append_ascii(buffer, "fmt "); + append_u32(buffer, 16); + append_u16(buffer, 1); + append_u16(buffer, channels); + append_u32(buffer, kSampleRate); + append_u32(buffer, kSampleRate * channels * (bits_per_sample / 8)); + append_u16(buffer, channels * (bits_per_sample / 8)); + append_u16(buffer, bits_per_sample); + append_ascii(buffer, "data"); + append_u32(buffer, data_bytes); + + double phase = 0.0; + for (const auto& segment : pattern) { + const auto segment_samples = static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); + const auto step = segment.frequency_hz > 0.0 ? kTwoPi * segment.frequency_hz / kSampleRate : 0.0; + for (int index = 0; index < segment_samples; ++index) { + const auto fade_in = std::min(1.0, static_cast(index) / 80.0); + const auto fade_out = std::min(1.0, static_cast(segment_samples - index) / 120.0); + const auto sample = segment.frequency_hz > 0.0 ? std::sin(phase) * kAmplitude * fade_in * fade_out : 0.0; + append_u16(buffer, static_cast(static_cast(sample * 32767.0))); + phase += step; + if (phase > kTwoPi) { + phase -= kTwoPi; + } + } + } + return buffer; +} + +} // namespace + +std::string_view NotificationSoundName(NotificationSound sound) +{ + const auto index = static_cast(sound); + return index < std::size(kSounds) ? kSounds[index].name : "none"; +} + +std::optional ParseNotificationSound(std::string_view name) +{ + const auto normalized = AsciiStrToLower(name); + for (size_t i = 0; i < std::size(kSounds); ++i) + if (kSounds[i].name == normalized) + return static_cast(i); + for (const auto& [alias, sound] : kAliases) + if (alias == normalized) + return sound; + return std::nullopt; +} + +std::span NotificationSoundBytes(NotificationSound sound) +{ + const auto index = static_cast(sound); + if (index == 0 || index >= std::size(kSounds)) + return {}; + static std::array ready; + static std::array, std::size(kSounds)> buffers; + // Generate only the requested cue, once. Buffers stay alive while asynchronous playback uses them. + std::call_once(ready[index], [index] { buffers[index] = build_wav(kSounds[index].pattern); }); + return buffers[index]; +} + +void PlayNotificationSound(NotificationSound sound) +{ + const auto buffer = NotificationSoundBytes(sound); + if (!buffer.empty() && !PlayNotificationBuffer(buffer.data(), buffer.size())) + spdlog::warn("[NotifyAudio] Failed to play '{}' cue", NotificationSoundName(sound)); +} + +#if _WIN32 +bool PlayNotificationBuffer(const uint8_t* data, size_t) +{ return PlaySoundW(reinterpret_cast(data), nullptr, SND_ASYNC | SND_MEMORY | SND_NODEFAULT) != FALSE; } +#elif !defined(__APPLE__) +bool PlayNotificationBuffer(const uint8_t*, size_t) +{ return false; } +#endif diff --git a/mods/src/patches/parts/notification_audio.h b/mods/src/patches/parts/notification_audio.h new file mode 100644 index 000000000..60718ddb0 --- /dev/null +++ b/mods/src/patches/parts/notification_audio.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include + +enum class NotificationSound : uint8_t { + kNone = 0, + kDefault, + kInfo, + kSuccess, + kWarning, + kAlarm, + kArrival, + kSoft, + kPing, + kRepair, + kCount, +}; + +[[nodiscard]] std::string_view NotificationSoundName(NotificationSound sound); +[[nodiscard]] std::optional ParseNotificationSound(std::string_view name); +// Generated mono PCM WAV bytes, cached for the process lifetime. None and invalid sounds have no data. +std::span NotificationSoundBytes(NotificationSound sound); +void PlayNotificationSound(NotificationSound sound); diff --git a/mods/src/notification_audio_mac.mm b/mods/src/patches/parts/notification_audio_mac.mm similarity index 76% rename from mods/src/notification_audio_mac.mm rename to mods/src/patches/parts/notification_audio_mac.mm index cae47a9eb..56acecc57 100644 --- a/mods/src/notification_audio_mac.mm +++ b/mods/src/patches/parts/notification_audio_mac.mm @@ -1,17 +1,19 @@ #ifdef __APPLE__ -#include "patches/notification_audio_platform.h" +#include +#include #import -bool notification_audio_platform_play(const uint8_t* data, size_t size) +bool PlayNotificationBuffer(const uint8_t* data, size_t size) { @autoreleasepool { static NSSound* current_sound = nil; NSData* sound_data = [NSData dataWithBytesNoCopy:const_cast(data) length:size freeWhenDone:NO]; NSSound* sound = [[NSSound alloc] initWithData:sound_data]; - if (!sound) return false; + if (!sound) + return false; [current_sound stop]; #if __has_feature(objc_arc) diff --git a/mods/src/patches/parts/toast_text.h b/mods/src/patches/parts/toast_text.h index 89cd88487..d01d9bac5 100644 --- a/mods/src/patches/parts/toast_text.h +++ b/mods/src/patches/parts/toast_text.h @@ -3,6 +3,7 @@ // Text handling for toast notifications that does not touch the game, kept apart so it can be tested. #include +#include #include #include #include @@ -11,40 +12,38 @@ namespace toast_text { // Replaces {N} with values[N]. The zero-padded form {N:000} pads the value to that width; other formats are // filled without padding. Placeholders whose value is empty are left as they are. -inline std::string FormatPlaceholders(std::string text, std::span values) +inline std::string FormatPlaceholders(std::string_view text, std::span values) { - for (size_t i = 0; i < values.size(); ++i) { - const auto& value = values[i]; - if (value.empty()) - continue; - const auto prefix = "{" + std::to_string(i); - for (auto pos = text.find(prefix); pos != std::string::npos; pos = text.find(prefix, pos)) { - const auto after = pos + prefix.size(); - const auto end = text.find('}', after); - if (end == std::string::npos) - break; - auto replacement = value; - if (after != end) { - // "{1" also starts "{10}"; only "{1}" and "{1:...}" are this placeholder. - if (text[after] != ':') { - pos = after; - continue; + std::string result; + result.reserve(text.size()); + for (size_t pos = 0; pos < text.size();) { + if (text[pos] == '{') { + const auto* first = text.data() + pos + 1; + size_t index = 0; + const auto [after, error] = std::from_chars(first, text.data() + text.size(), index); + if (error == std::errc{} && after != text.data() + text.size() && (*after == '}' || *after == ':') + && !(first[0] == '0' && after != first + 1) && index < values.size() && !values[index].empty()) { + const auto offset = static_cast(after - text.data()); + const auto end = text.find('}', offset); + if (end == std::string_view::npos) { + result += text.substr(pos); + break; } - const auto format = std::string_view(text).substr(after + 1, end - after - 1); - if (!format.empty() && format.find_first_not_of('0') == std::string_view::npos - && replacement.size() < format.size()) - replacement.insert(0, format.size() - replacement.size(), '0'); + const auto& value = values[index]; + const auto format = *after == ':' ? text.substr(offset + 1, end - offset - 1) : std::string_view{}; + if (format.find_first_not_of('0') == std::string_view::npos && value.size() < format.size()) + result.append(format.size() - value.size(), '0'); + // Only the template is scanned; a player name containing "{1}" stays literal. + result += value; + pos = end + 1; + continue; } - text.replace(pos, end - pos + 1, replacement); - pos += replacement.size(); } + result += text[pos++]; } - return text; + return result; } -inline size_t CountPlaceholders(std::string_view text) -{ return static_cast(std::ranges::count(text, '{')); } - // Removes Unity rich text tags such as , and . inline std::string StripRichText(std::string_view text) { diff --git a/mods/src/patches/parts/toasts.cc b/mods/src/patches/parts/toasts.cc index 2ee11bbb8..36e8265ee 100644 --- a/mods/src/patches/parts/toasts.cc +++ b/mods/src/patches/parts/toasts.cc @@ -246,40 +246,13 @@ std::vector ParameterValues(il2cpp::Array* parameter return values; } -// Fills placeholders from whichever array field of `object` resolves the most of them. The parameter array has -// moved and been renamed between game builds, including generated backing fields, so this scans the runtime -// fields rather than naming one. -std::string FormatFromArrayFields(const std::string& text, Il2CppObject* object, std::string& fields_seen) -{ - auto best = text; - il2cpp::ForEachInstanceField(object, [&](FieldInfo* field) { - if (field->type->type != IL2CPP_TYPE_SZARRAY && field->type->type != IL2CPP_TYPE_ARRAY) - return; - auto* array = il2cpp::ReadField*>(object, field); - fields_seen += fmt::format("{}{}[{}]", fields_seen.empty() ? "" : ", ", field->name, - array ? std::to_string(array->size()) : "null"); - if (!array || array->size() == 0) - return; - auto candidate = FormatPlaceholders(text, ParameterValues(array)); - if (toast_text::CountPlaceholders(candidate) < toast_text::CountPlaceholders(best)) - best = std::move(candidate); - }); - return best; -} - -// Fills placeholders from Toast.TextParameters, or failing that from any array on the toast or its text context. -std::string FormatToastText(const std::string& text, Toast* toast, LocaleTextContext* context) +// Use the named object[] binding. Other array fields may contain unboxed values, not object pointers. +std::string FormatToastText(const std::string& text, Toast* toast) { if (auto* parameters = toast->TextParameters(); parameters && parameters->size() > 0) return FormatPlaceholders(text, ParameterValues(parameters)); - - std::string fields_seen; - for (auto* source : {static_cast(toast), static_cast(context)}) - if (auto result = FormatFromArrayFields(text, source, fields_seen); result != text) - return result; if (text.find('{') != std::string::npos) - spdlog::warn("[Notify] Placeholders unresolved; array fields seen: {}. Template: \"{}\"", - fields_seen.empty() ? "none" : fields_seen, text); + spdlog::warn("[Notify] TextParameters unavailable; placeholders unresolved in template: \"{}\"", text); return text; } @@ -295,7 +268,7 @@ std::string ToastText(Toast* toast) // LocaleUtilities.Localize fills the context's own parameters; the toast may carry them instead. if (auto* text = LocaleUtilities::Localize(context, true, false)) { auto result = il2cpp::ToUtf8(text); - return result.find('{') == std::string::npos ? result : FormatToastText(result, toast, context); + return result.find('{') == std::string::npos ? result : FormatToastText(result, toast); } Il2CppString* text = nullptr; @@ -304,7 +277,7 @@ std::string ToastText(Toast* toast) spdlog::debug("[Notify] toast state {} could not be localized", static_cast(toast->State())); return {}; } - return FormatToastText(il2cpp::ToUtf8(text), toast, context); + return FormatToastText(il2cpp::ToUtf8(text), toast); } std::string EventCategory(Toast* toast) @@ -390,11 +363,30 @@ void Notify(Toast* toast) // Notifies, then reports whether the in-game banner should be shown. bool ShowBanner(Toast* toast) { - if (toast) - notification_audio_play(Config::Get().NotificationSoundForToast(static_cast(toast->State()))); - const auto& ui = Config::Get().ui; - if (!toast || (ui.disabled_banner_types.empty() && ui.notify_banner_types.empty())) + if (!toast) return true; + const auto& audio = Config::Get().audio; + NotificationSound sound = NotificationSound::kNone; + switch (toast->State()) { + case ToastState::Victory: + sound = audio.alert_victory; + break; + case ToastState::Defeat: + sound = audio.alert_defeat; + break; + case ToastState::ArmadaCreated: + sound = audio.alert_armada_created; + break; + case ToastState::ArmadaBattleWon: + sound = audio.alert_armada_battle_won; + break; + case ToastState::ArmadaBattleLost: + sound = audio.alert_armada_battle_lost; + break; + default: + break; + } + PlayNotificationSound(sound); Notify(toast); const auto& disabled = Config::Get().ui.disabled_banner_types; return std::ranges::find(disabled, static_cast(toast->State())) == disabled.end(); diff --git a/tests/mod_logic.cc b/tests/mod_logic.cc index d23dc00a8..06253c8d9 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -1,5 +1,6 @@ // Settings parsing and shortcut matching, run against production code with Unity input replaced by a fake. #include "patches/chat_startup.h" +#include "patches/parts/notification_audio.h" #include "patches/parts/toast_text.h" #include "patches/ship_shortcut_hint.h" #include "patches/shortcuts.h" @@ -110,6 +111,12 @@ void ToastText() Check(FormatPlaceholders("{10} and {1}", values) == "{10} and 7", "{1} does not match {10}"); Check(FormatPlaceholders("{2} stays", values) == "{2} stays", "empty values are left alone"); Check(FormatPlaceholders("{1:x2}", values) == "7", "other formats are filled unpadded"); + const std::vector literal_values{"{1}", "Enterprise"}; + Check(FormatPlaceholders("{0} vs {1}", literal_values) == "{1} vs Enterprise", + "replacement text stays literal instead of being interpreted as another placeholder"); + Check(FormatPlaceholders("{00} {+1} {99999999999999999999999999} {0", values) + == "{00} {+1} {99999999999999999999999999} {0", + "malformed, overflowing and unclosed placeholders remain unchanged"); Check(StripRichText("Red bold") == "Red bold", "rich text tags removed"); Check(StripRichText("a < b") == "a < b", "an unclosed '<' is text"); Check(TitleCase("USS ENTERPRISE-D") == "Uss Enterprise-D", "title case"); @@ -180,6 +187,56 @@ void Loading() Check(Value(parsed, "shortcuts", "show_bookmarks") == "", "skipped shortcuts are not recorded"); } +void NotificationAudio() +{ + struct Fixture { + NotificationSound sound; + std::string_view name; + size_t bytes; + }; + constexpr Fixture fixtures[] = { + {NotificationSound::kNone, "none", 0}, {NotificationSound::kDefault, "default", 17240}, + {NotificationSound::kInfo, "info", 18918}, {NotificationSound::kSuccess, "success", 26148}, + {NotificationSound::kWarning, "warning", 31264}, {NotificationSound::kAlarm, "alarm", 37440}, + {NotificationSound::kArrival, "arrival", 26146}, {NotificationSound::kSoft, "soft", 19976}, + {NotificationSound::kPing, "ping", 8422}, {NotificationSound::kRepair, "repair", 27912}, + }; + for (const auto& fixture : fixtures) { + Check(NotificationSoundName(fixture.sound) == fixture.name + && ParseNotificationSound(AsciiStrToUpper(fixture.name)) == fixture.sound, + "cue names round-trip"); + const auto data = NotificationSoundBytes(fixture.sound); + Check(data.size() == fixture.bytes, "cue length matches the independent pre-refactor fixture"); + Check(NotificationSoundBytes(fixture.sound).data() == data.data(), "cue data stays cached"); + if (data.empty()) + continue; + const auto word = [&](size_t at) { + return uint32_t(data[at]) | uint32_t(data[at + 1]) << 8 | uint32_t(data[at + 2]) << 16 + | uint32_t(data[at + 3]) << 24; + }; + Check(std::string_view(reinterpret_cast(data.data()), 4) == "RIFF" + && std::string_view(reinterpret_cast(data.data() + 8), 8) == "WAVEfmt ", + "WAV signature"); + Check(word(4) == data.size() - 8 && word(40) == data.size() - 44, "WAV chunk lengths"); + Check(word(24) == 44100 && word(28) == 88200 && data[20] == 1 && data[22] == 1 && data[34] == 16, + "cue data is 44.1kHz mono 16-bit PCM"); + } + for (const auto& [name, expected] : + std::initializer_list>{{"off", NotificationSound::kNone}, + {"silent", NotificationSound::kNone}, + {"victory", NotificationSound::kSuccess}, + {"warn", NotificationSound::kWarning}, + {"attack", NotificationSound::kAlarm}, + {"arrive", NotificationSound::kArrival}, + {"quiet", NotificationSound::kSoft}, + {"repaired", NotificationSound::kRepair}}) + Check(ParseNotificationSound(name) == expected, "legacy cue aliases remain valid"); + Check(!ParseNotificationSound("unknown") && !ParseNotificationSound(" none "), "unknown cue names are rejected"); + Check(NotificationSoundBytes(NotificationSound::kCount).empty() + && NotificationSoundBytes(static_cast(255)).empty(), + "invalid cues have no WAV data"); +} + void Matching() { Load(""); // defaults: move_down = "S|DOWN", toggle_shortcut_hints = "HOME" @@ -261,6 +318,7 @@ int main() ShipNames(); Chords(); Loading(); + NotificationAudio(); Matching(); ChatStartupGate(); if (failures) diff --git a/xmake.lua b/xmake.lua index 8ff4f35a4..a41ac49d8 100644 --- a/xmake.lua +++ b/xmake.lua @@ -57,6 +57,12 @@ target("mod-logic-tests") set_kind("binary") set_default(false) set_exceptions("cxx") - add_files("tests/mod_logic.cc", "mods/src/patches/shortcuts.cc") + add_files("tests/mod_logic.cc", "mods/src/patches/shortcuts.cc", "mods/src/patches/parts/notification_audio.cc") add_includedirs("mods/src", "libs/il2cpp/include", "third_party/libil2cpp", "libs/unity/include") add_packages("spdlog", "toml++") + if is_plat("macosx") then + add_frameworks("Cocoa") + add_files("mods/src/patches/parts/notification_audio_mac.mm") + elseif is_plat("windows") then + add_syslinks("shell32", "winmm") + end From 7aec35204a19030cb92426ea3a002bb79d07dd87 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 02/10] Port Haven hooks onto typed bindings Haven camera and history hooks move onto game bindings and feature descriptors, and the last callers of the legacy helper layer go away with them. The road depth code merges into the Haven feature and its separate cache header and test are gone. Invalid zoom values fall back to the section default, and Haven settings stay together in the reader. --- libs/unity/include/unity/core.h | 3 + mods/src/config.cc | 9 +- mods/src/config.h | 4 +- mods/src/eastl_new.cc | 13 - mods/src/il2cpp/il2cpp-functions.h | 31 -- mods/src/il2cpp/il2cpp_helper.cc | 6 - mods/src/il2cpp/il2cpp_helper.h | 384 --------------------- mods/src/il2cpp/method_contract.h | 46 --- mods/src/il2cpp/runtime.h | 40 --- mods/src/patches/haven_road_cache.h | 62 ---- mods/src/patches/parts/haven.cc | 257 ++++++++++++++ mods/src/patches/parts/haven_history.cc | 85 ----- mods/src/patches/parts/haven_road_depth.cc | 332 ------------------ mods/src/patches/parts/haven_zoom.cc | 295 ---------------- mods/src/patches/parts/zoom.cc | 46 ++- mods/src/patches/patches.cc | 8 +- mods/src/patches/patches.h | 6 +- mods/src/prime/haven.h | 123 +++++++ mods/src/prime/navigation.h | 9 + mods/xmake.lua | 2 +- tests/haven_road_cache_test.cc | 168 --------- xmake/dependencies/common.lua | 1 - 22 files changed, 450 insertions(+), 1480 deletions(-) delete mode 100644 mods/src/eastl_new.cc delete mode 100644 mods/src/il2cpp/il2cpp-functions.h delete mode 100644 mods/src/il2cpp/il2cpp_helper.cc delete mode 100644 mods/src/il2cpp/il2cpp_helper.h delete mode 100644 mods/src/il2cpp/method_contract.h delete mode 100644 mods/src/il2cpp/runtime.h delete mode 100644 mods/src/patches/haven_road_cache.h create mode 100644 mods/src/patches/parts/haven.cc delete mode 100644 mods/src/patches/parts/haven_history.cc delete mode 100644 mods/src/patches/parts/haven_road_depth.cc delete mode 100644 mods/src/patches/parts/haven_zoom.cc create mode 100644 mods/src/prime/haven.h delete mode 100644 tests/haven_road_cache_test.cc diff --git a/libs/unity/include/unity/core.h b/libs/unity/include/unity/core.h index a347b04cc..3a3992fc0 100644 --- a/libs/unity/include/unity/core.h +++ b/libs/unity/include/unity/core.h @@ -104,6 +104,9 @@ struct GameObject : il2cpp::Object { { return Get<"transform", Transform*>(); } void SetActive(bool active) { Call<"SetActive">(active); } + // Whether it and every parent are active. + bool activeInHierarchy() + { return Get<"activeInHierarchy", bool>(); } // Passing System.Type selects the (Type) overloads over the string and generic ones. A null Type would throw in // managed code, so a missing class yields null instead. diff --git a/mods/src/config.cc b/mods/src/config.cc index fea8cba54..e2030c382 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -546,11 +546,12 @@ void Config::Load(const SettingsFile& file, bool first) r.Read("graphics", "ui_scale_ship", g.ui_scale_ship); r.Read("graphics", "ui_scale_viewer", g.ui_scale_viewer); r.Read("graphics", "zoom", g.zoom); + const auto haven_default = g.haven_zoom; r.Read("graphics", "haven_zoom", g.haven_zoom); if (!std::isfinite(g.haven_zoom) || g.haven_zoom < 0.0f) { - spdlog::warn("invalid g.haven_zoom {}; using 4000", g.haven_zoom); - g.haven_zoom = 4000.0f; - r.Record("graphics", "haven_zoom", g.haven_zoom); + spdlog::warn("invalid graphics.haven_zoom {}; using {}", g.haven_zoom, haven_default); + g.haven_zoom = haven_default; + r.Record("graphics", "haven_zoom", haven_default); } r.Read("graphics", "fr_scale", g.fr_scale); for (auto [profile, prefix] : {std::pair{&g.zoom_label_player, std::string("zoom_label_player")}, @@ -634,6 +635,7 @@ void Config::Load(const SettingsFile& file, bool first) r.Read("ui", "disable_preview_recall", u.disable_preview_recall); r.Read("ui", "disable_first_popup", u.disable_first_popup); r.Read("ui", "only_show_first_popup", u.only_show_first_popup); + r.Read("ui", "reverse_haven_history", u.reverse_haven_history); r.Read("ui", "disable_move_keys", u.disable_move_keys); r.Read("ui", "disable_toast_banners", u.disable_toast_banners); r.Read("ui", "default_chat_width", u.default_chat_width); @@ -653,7 +655,6 @@ void Config::Load(const SettingsFile& file, bool first) r.Read("ui", "focus_search", u.focus_search); r.ReadRenamed("ui", "format_cargo_values", "cargo_format", u.format_cargo_values); r.Read("ui", "officer_sort", u.officer_sort); - r.Read("ui", "reverse_haven_history", u.reverse_haven_history); r.Read("ui", "arrow_keys_to_select_ship", u.arrow_keys_to_select_ship); r.Read("ui", "auto_confirm_ft_upgrade", u.auto_confirm_ft_upgrade); r.Read("ui", "forbidden_tech_ship_name", u.forbidden_tech_ship_name); diff --git a/mods/src/config.h b/mods/src/config.h index 72f8cb027..7a67d60de 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -276,8 +276,6 @@ class Config final bool dock_syncmanagement = true; bool auto_confirm_reveal_ability = false; bool show_player_title = true; - bool only_show_first_popup = false; - bool reverse_haven_history = true; bool disable_escape_exit = true; // Maximum gap in milliseconds between two Escape presses that opens the exit prompt; 0 never opens it. int disable_escape_exit_timer = 0; @@ -285,6 +283,8 @@ class Config final bool disable_preview_recall = false; // TODO: both are read but have no effect; disable_first_popup was switched off in misc.cc. bool disable_first_popup = false; + bool only_show_first_popup = false; + bool reverse_haven_history = true; bool disable_toast_banners = false; bool disable_move_keys = false; bool disable_galaxy_chat = false; diff --git a/mods/src/eastl_new.cc b/mods/src/eastl_new.cc deleted file mode 100644 index 96eac364f..000000000 --- a/mods/src/eastl_new.cc +++ /dev/null @@ -1,13 +0,0 @@ -// The allocation hooks EASTL requires every program to define. Its containers free with operator delete[], so -// these must stay plain malloc: honouring `alignment` with an aligned allocator would mismatch that free. - -#include -#include - -void* operator new[](size_t size, const char* /*name*/, int /*flags*/, unsigned /*debugFlags*/, const char* /*file*/, - int /*line*/) -{ return malloc(size); } - -void* operator new[](size_t size, size_t /*alignment*/, size_t /*alignmentOffset*/, const char* /*name*/, int /*flags*/, - unsigned /*debugFlags*/, const char* /*file*/, int /*line*/) -{ return malloc(size); } diff --git a/mods/src/il2cpp/il2cpp-functions.h b/mods/src/il2cpp/il2cpp-functions.h deleted file mode 100644 index 8fc3dd94b..000000000 --- a/mods/src/il2cpp/il2cpp-functions.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#ifdef _WIN32 -#pragma comment(lib, "GameAssembly.lib") -#endif - -#include -#include -#include -#include - -#if defined(__cplusplus) -extern "C" { -#endif // __cplusplus -#if _WIN32 -#define DO_API(r, n, p) IL2CPP_IMPORT r n p; -#define DO_API_NO_RETURN(r, n, p) IL2CPP_IMPORT NORETURN r n p; -#else -#define DO_API(r, n, p) \ - using n##_t = r(*) p; \ - extern n##_t n; -#define DO_API_NO_RETURN(r, n, p) DO_API(r, n, p) -#endif -#include "il2cpp-api-functions.h" -#undef DO_API -#undef DO_API_NORETURN -#if defined(__cplusplus) -} -#endif // __cplusplus - -void init_il2cpp_pointers(); diff --git a/mods/src/il2cpp/il2cpp_helper.cc b/mods/src/il2cpp/il2cpp_helper.cc deleted file mode 100644 index bb58ef05c..000000000 --- a/mods/src/il2cpp/il2cpp_helper.cc +++ /dev/null @@ -1,6 +0,0 @@ -#include "il2cpp_helper.h" - -Il2CppObject* get_target(Il2CppGCHandle handle) -{ - return il2cpp_gchandle_get_target(handle); -} diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h deleted file mode 100644 index 16f5e06d9..000000000 --- a/mods/src/il2cpp/il2cpp_helper.h +++ /dev/null @@ -1,384 +0,0 @@ -#pragma once - -#include "il2cpp-functions.h" - -#include -#include -#include -#include -#include - -#include -#include -#include - -#if !_WIN32 -#include -#include -#endif - -class IL2CppPropertyHelper -{ -public: - IL2CppPropertyHelper(Il2CppClass* cls, const PropertyInfo* propInfo) - { - this->cls = cls; - this->propInfo = propInfo; - } - - bool isValidHelper() - { -#if DEBUG - return true; -#else - return this->cls != nullptr && propInfo != nullptr; -#endif - } - - template void SetRaw(void* _this, T& v) - { - if (!this->propInfo) { - return; - } - - auto set_method = il2cpp_property_get_set_method((PropertyInfo*)this->propInfo); - auto set_method_virtual = il2cpp_object_get_virtual_method((Il2CppObject*)_this, set_method); - - Il2CppException* exception = nullptr; - void* params[1] = {&v}; - - il2cpp_runtime_invoke(set_method_virtual, _this, params, &exception); - } - - template T* GetRaw(void* _this) - { - if (!this->propInfo) { - return nullptr; - } - - auto get_method = il2cpp_property_get_get_method((PropertyInfo*)this->propInfo); - auto get_method_virtual = il2cpp_object_get_virtual_method((Il2CppObject*)_this, get_method); - - Il2CppException* exception = nullptr; - auto result = il2cpp_runtime_invoke(get_method_virtual, _this, nullptr, &exception); - - if (exception) { - return nullptr; - } - - return (T*)(result); - } - - template T* Get(void* _this) - { - auto r = GetRaw(_this); - return !r ? nullptr : (T*)(il2cpp_object_unbox(r)); - } - - template T* GetUnboxedSelf(void* _this) - { - auto r = GetRaw(il2cpp_object_unbox((Il2CppObject*)_this)); - return !r ? nullptr : (T*)(il2cpp_object_unbox(r)); - } - -private: - Il2CppClass* cls; - const PropertyInfo* propInfo; -}; - -class IL2CppFieldHelper -{ -public: - IL2CppFieldHelper(Il2CppClass* cls, FieldInfo* fieldInfo) - { - this->cls = cls; - this->fieldInfo = fieldInfo; - } - - bool isValidHelper() - { -#if DEBUG - return true; -#else - return this->cls != nullptr && fieldInfo != nullptr; -#endif - } - - FieldInfo* get_info() const - { - return this->fieldInfo; - } - - inline ptrdiff_t offset() const - { - return this->fieldInfo->offset; - } - -private: - Il2CppClass* cls; - FieldInfo* fieldInfo; -}; - -class IL2CppStaticFieldHelper -{ -public: - IL2CppStaticFieldHelper(Il2CppClass* cls, FieldInfo* fieldInfo) - { - this->cls = cls; - this->fieldInfo = fieldInfo; - } - - template inline T Get() const - { - T v; - il2cpp_field_static_get_value(this->fieldInfo, &v); - return v; - } - -private: - Il2CppClass* cls; - FieldInfo* fieldInfo; -}; - -class IL2CppClassHelper -{ -public: - IL2CppClassHelper(Il2CppClass* cls) - { - this->cls = cls; - } - - template T* New() - { - return (T*)il2cpp_object_new(this->cls); - } - - void* GetType() - { - auto obj = il2cpp_type_get_object(&this->cls->byval_arg); - return obj; - } - - bool isValidHelper() - { -#if DEBUG - return true; -#else - return this->cls != nullptr; -#endif - } - - template T* GetMethod(const char* name, int arg_count = -1) - { - if (!this->cls) { - return nullptr; - } - - auto fn = il2cpp_class_get_method_from_name(this->cls, name, arg_count); - if (fn != nullptr) { - return (T*)fn->methodPointer; - } - - return nullptr; - } - - template T* GetVirtualMethod(const char* name, int arg_count = -1) - { - if (!this->cls) { - return nullptr; - } - - auto fn = il2cpp_class_get_method_from_name(this->cls, name, arg_count); - - auto get_method_virtual = il2cpp_object_get_virtual_method((Il2CppObject*)this, fn); - - return (T*)get_method_virtual->methodPointer; - } - - template class InvokerMethod - { - - public: - InvokerMethod(const MethodInfo* fn) - : fn(fn) - { - } - - R Invoke(void* _this, Args... args) - { - Il2CppException* exception = nullptr; - void* params = {}; - auto result = il2cpp_runtime_invoke(this->fn, _this, ¶ms, &exception); - - return *(R*)il2cpp_object_unbox(result); - } - - const MethodInfo* fn; - }; - - template - const InvokerMethod GetInvokeMethod(const char* name, int arg_count = -1) - { - if (!this->cls) { - return nullptr; - } - - auto fn = il2cpp_class_get_method_from_name(this->cls, name, arg_count); - - return InvokerMethod(fn); - } - - const MethodInfo* - GetMethodInfoSpecial(const char* name, - std::function arg_filter = nullptr) - { - if (!this->cls) { - return nullptr; - } - - auto flags = 0; - void* iter = NULL; - while (const MethodInfo* method = il2cpp_class_get_methods(this->cls, &iter)) { - if (method->name[0] == name[0] && !strcmp(name, method->name) && ((method->flags & flags) == flags)) { - if (!arg_filter || arg_filter(method->parameters_count, method->parameters)) { - return method; - } - } - } - return nullptr; - } - - template - T* GetMethodSpecial(const char* name, - std::function arg_filter = nullptr) - { - if (!this->cls) { - return nullptr; - } - auto info = GetMethodInfoSpecial(name, arg_filter); - if (info) { - return (T*)info->methodPointer; - } - return nullptr; - } - - template T* GetMethodSpecial2(Il2CppObject* obj, const char* name) - { - if (!this->cls) { - return nullptr; - } - - for (auto i = 0; i < obj->klass->method_count; ++i) { - auto method = obj->klass->klass->methods[i]; - if (method->name[0] == name[0] && !strcmp(name, method->name)) { - return (T*)method->methodPointer; - } - } - return nullptr; - } - - const MethodInfo* GetMethodInfo(const char* name, int arg_count = -1) - { - if (!this->cls) { - return nullptr; - } - - return il2cpp_class_get_method_from_name(this->cls, name, arg_count); - } - - inline IL2CppPropertyHelper GetProperty(const char* name) - { - return IL2CppPropertyHelper{this->cls, il2cpp_class_get_property_from_name(this->cls, name)}; - } - - inline IL2CppFieldHelper GetField(const char* name) - { - return IL2CppFieldHelper{this->cls, il2cpp_class_get_field_from_name(this->cls, name)}; - } - - inline IL2CppStaticFieldHelper GetStaticField(const char* name) - { - return IL2CppStaticFieldHelper{this->cls, il2cpp_class_get_field_from_name(this->cls, name)}; - } - - inline IL2CppClassHelper GetParent(const char* name) - { - Il2CppClass* pcls = this->cls; - if (pcls) { - do { - if (pcls->name[0] == name[0] && !strcmp(name, pcls->name)) { - return IL2CppClassHelper{pcls}; - } - - pcls = il2cpp_class_get_parent(pcls); - } while (pcls); - } - return IL2CppClassHelper{nullptr}; - } - - inline IL2CppClassHelper GetNestedType(const char* name) - { - for (int i = 0; i < this->cls->nested_type_count; ++i) { - auto type = this->cls->nestedTypes[i]; - if (strcmp(type->name, name) == 0) { - return type; - } - } - - return nullptr; - } - - Il2CppClass* get_cls() - { - return this->cls; - } - -private: - Il2CppClass* cls; -}; - -#define il2cpp_get_class_helper(assembly, namespacez, name) il2cpp_get_class_helper_impl(assembly, namespacez, name) - -inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) -{ - auto domain = il2cpp_domain_get(); - auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; - auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; - - auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; - - return IL2CppClassHelper{cls}; -} - -template inline T* il2cpp_get_array_element(Il2CppArray* array, size_t index) -{ - Il2CppArraySize* n = (Il2CppArraySize*)(array); - return (T*)n->vector[index]; -} - -extern eastl::unordered_map> tracked_objects; - -template class ObjectFinder -{ -public: - static T* Get() - { - auto& objects = tracked_objects[T::get_class_helper().get_cls()]; - if (objects.empty()) { - // TODO: assert? - return nullptr; - } - return reinterpret_cast(objects.back()); - } - - static eastl::span GetAll() - { - auto& objects = tracked_objects[T::get_class_helper().get_cls()]; - return {reinterpret_cast(objects.data()), reinterpret_cast(objects.data()) + objects.size()}; - } -}; - -template T* il2cpp_resolve_icall_typed(const char* name) -{ - return (T*)il2cpp_resolve_icall(name); -} diff --git a/mods/src/il2cpp/method_contract.h b/mods/src/il2cpp/method_contract.h deleted file mode 100644 index b96778bba..000000000 --- a/mods/src/il2cpp/method_contract.h +++ /dev/null @@ -1,46 +0,0 @@ -#pragma once - -#include "il2cpp-functions.h" -#include -#include -#include - -namespace method_contract -{ -inline bool Type(const Il2CppType* type, const char* name) -{ - if (!type || type->byref) return false; - auto* actual = il2cpp_type_get_name(type); - const bool matches = actual && std::strcmp(actual, name) == 0; - il2cpp_free(actual); - return matches; -} - -// Resolve the entire managed signature, including static/instance dispatch. -// A renamed, ambiguous or generic method is not a compatible callback. -inline const MethodInfo* Resolve(Il2CppClass* cls, const char* name, bool is_static, - const char* result, std::initializer_list parameters) -{ - if (!cls) return nullptr; - const MethodInfo* found = nullptr; - void* iterator = nullptr; - while (auto* method = il2cpp_class_get_methods(cls, &iterator)) { - if (std::strcmp(method->name, name) != 0 || !method->methodPointer || method->is_generic || method->is_inflated - || bool(method->flags & METHOD_ATTRIBUTE_STATIC) != is_static - || method->parameters_count != parameters.size() || !Type(method->return_type, result)) continue; - bool matches = true; - unsigned i = 0; - for (auto* parameter : parameters) - matches = Type(method->parameters[i++], parameter) && matches; - if (!matches) continue; - if (found) return nullptr; - found = method; - } - return found; -} - -inline void* Pointer(const MethodInfo* method) -{ return method ? reinterpret_cast(method->methodPointer) : nullptr; } -} // namespace method_contract - -namespace il2cpp { namespace contract = ::method_contract; } diff --git a/mods/src/il2cpp/runtime.h b/mods/src/il2cpp/runtime.h deleted file mode 100644 index b3043673e..000000000 --- a/mods/src/il2cpp/runtime.h +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -#include "il2cpp_helper.h" -#include - -// Mechanics shared by optional runtime features. Signature/overload selection, -// argument storage and feature-specific failure policy remain with the caller. -namespace Il2CppRuntime -{ -// The method and argument ABI must already be established by the caller. -// IL2CPP takes references directly, but value/byref arguments as addresses. -// A null target is valid for static methods. Success is independent of whether -// the return value is null (including void methods). Outputs change on success only. -inline bool TryInvoke(const MethodInfo* method, void* target, void** args, Il2CppObject** result = nullptr) -{ - if (!method) - return false; - Il2CppException* exception = nullptr; - auto* value = il2cpp_runtime_invoke(method, target, args, &exception); - if (exception) - return false; - if (result) - *result = value; - return true; -} - -inline bool TryBoolean(Il2CppObject* boxed, bool& value) -{ - if (!boxed || !boxed->klass) - return false; - const auto* type = il2cpp_class_get_type(boxed->klass); - if (!type || type->byref || type->type != IL2CPP_TYPE_BOOLEAN) - return false; - auto* data = static_cast(il2cpp_object_unbox(boxed)); - if (!data) - return false; - value = *data; - return true; -} -} // namespace Il2CppRuntime diff --git a/mods/src/patches/haven_road_cache.h b/mods/src/patches/haven_road_cache.h deleted file mode 100644 index 5b5bae2b6..000000000 --- a/mods/src/patches/haven_road_cache.h +++ /dev/null @@ -1,62 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace haven_roads -{ -inline constexpr std::size_t kRendererLimit = 4096; -inline constexpr std::size_t kMaterialsPerRenderer = 16; -inline constexpr std::size_t kMaterialLimit = kRendererLimit * kMaterialsPerRenderer; - -// The backend owns native liveness/rooting. Retained owner and material entries -// are weak; a refresh may hold a temporary rooted owner without retaining it. -template class DiscoveryCache -{ -public: - using Clock = std::chrono::steady_clock; - using Weak = typename Backend::Weak; - - void Refresh(Clock::time_point now) - { - if (!Backend::GetOwner(owner_)) { - owner_ = {}; - materials_.clear(); - } - if (now < next_discovery_) - return; - next_discovery_ = now + std::chrono::seconds(5); - // A replacement can occupy the canonical path while the old wrapper stays - // alive and active. Recheck identity at discovery cadence, never per frame. - auto owner = Backend::FindOwner(); - if (!owner) { - owner_ = {}; - materials_.clear(); - return; - } - owner_ = Backend::Retain(owner); - if (!owner_) { - materials_.clear(); - return; - } - std::vector next; - if (!Backend::ScanOwner(owner, next)) { - materials_.clear(); - return; - } - if (next.size() > kMaterialLimit) - next.resize(kMaterialLimit); - materials_ = std::move(next); - } - - const std::vector& Materials() const - { return materials_; } - -private: - Weak owner_; - std::vector materials_; - Clock::time_point next_discovery_{}; -}; -} // namespace haven_roads diff --git a/mods/src/patches/parts/haven.cc b/mods/src/patches/parts/haven.cc new file mode 100644 index 000000000..0f7a7ba2c --- /dev/null +++ b/mods/src/patches/parts/haven.cc @@ -0,0 +1,257 @@ +// Haven's extended camera range, road surface depth and help-history ordering. +#include "prime/haven.h" +#include "patches/patches.h" + +#include +#include + +#include +#include +#include +#include +#include + +namespace +{ +// The road materials under Haven's visuals. Searching for them (GetComponentsInChildren and every material's name) is +// the costly part, so it runs every 5 s while the bias is checked every second. Weak handles, so a cached material +// never keeps an unloaded scene or asset alive. +class HavenRoads +{ + using Clock = std::chrono::steady_clock; + +public: + void Apply() + { + const auto now = Clock::now(); + if (now < next_apply_) + return; + next_apply_ = now + std::chrono::seconds(1); + if (auto* owner = owner_.Get(); !unity::Alive(owner) || !owner->activeInHierarchy()) { + owner_.Reset(); + materials_.clear(); + } + // A replacement can take the visuals' path while the old object is still alive and active, so the owner is + // looked up again at every search, not only once it is gone. + if (now >= next_search_) { + next_search_ = now + std::chrono::seconds(5); + Search(); + } + for (const auto& handle : materials_) + if (auto* material = handle.Get(); unity::Alive(material)) + Bias(material); + } + +private: + void Search() + { + materials_.clear(); + auto* root = unity::GameObject::Find("StarbaseRoot/StarbaseManager/StarbasePlanetaryVisualsHolder"); + owner_ = il2cpp::GcHandle::Weak(root); + auto* renderers = root ? root->GetComponentsInChildren(true) : nullptr; + if (!renderers || renderers->size() > 4096) + return; + std::unordered_set seen; + for (auto* renderer : *renderers) { + auto* materials = renderer ? renderer->SharedMaterials() : nullptr; + if (!materials || materials->size() > 16) + continue; + for (auto* material : *materials) + if (material && IsRoad(material) && seen.insert(material).second) + materials_.push_back(il2cpp::GcHandle::Weak(material)); + } + } + + static bool IsRoad(unity::Material* material) + { + const auto name = material->Name(); + bool road = false; + for (const auto* prefix : + {"mat_PB_3101101_asphalt_", "mat_PB_3101201_asphalt_", "mat_PB_3101301_asphalt_", "mat_PB_3101401_asphalt_", + "mat_PB_3101501_asphalt_", "mat_PB_3101601_asphalt_", "mat_PB_3101701_asphalt_"}) + road |= name.starts_with(prefix); + auto* shader = road ? material->shader() : nullptr; + return shader && shader->Name() == "Custom/LitMSAE"; + } + + static void Bias(unity::Material* material) + { + const std::array properties{il2cpp::NewString("_OffsetFactor"), il2cpp::NewString("_OffsetUnits")}; + if (!material->HasProperty(properties[0]) || !material->HasProperty(properties[1])) + return; + const std::array before{material->GetFloat(properties[0]), material->GetFloat(properties[1])}; + if (!std::isfinite(before[0]) || !std::isfinite(before[1])) + return; + // Keep an already stronger native bias. + for (size_t i = 0; i < properties.size(); ++i) + if (before[i] > -1.0f) + material->SetFloat(properties[i], -1.0f); + } + + il2cpp::GcHandle owner_; + std::vector materials_; + Clock::time_point next_apply_{}, next_search_{}; +}; + +void ApplyHavenRoadDepthBias() +{ + static const bool available = unity::Material::Class::Method("HasProperty") + && unity::Material::Class::Method("GetFloat") + && unity::Material::Class::Method("SetFloat"); + if (!available) + return; + // Leaked: its handles must not be freed by static destructors after the runtime is gone. + static auto* roads = new HavenRoads; + roads->Apply(); +} + +bool ReadHavenRadius(HavenFrameProvider* provider, float& radius) +{ + if (!provider || !provider->Is() || !provider->Is()) + return false; + auto* orbit = provider->As(); + auto* constraint = orbit->Constraint(); + auto* data = constraint ? constraint->Radius() : nullptr; + if (!data || !data->Enabled()) + return false; + const auto minimum = data->Minimum(), maximum = data->Maximum(); + radius = orbit->Radius(); + return std::isfinite(radius) && std::isfinite(maximum) && maximum > 0.0f && minimum == maximum + && std::abs(radius - maximum) < 0.01f; +} + +void HavenCamera_UpdateCameraFrame_Hook(auto original, HavenBlendProvider* provider, unity::Camera* camera) +{ + // Native input, constraints and endpoint updates run first. Only this blend's + // newly generated output is extended; endpoint assets and normalized LOD stay native. + original(provider, camera); + const auto max_zoom = Config::Get().graphics.haven_zoom; + if (provider == nullptr || !std::isfinite(max_zoom) || max_zoom <= 0.0f) + return; + + auto* source = provider->Source(); + auto* target = provider->Target(); + float source_radius = 0.0f, target_radius = 0.0f; + if (!ReadHavenRadius(source, source_radius) || !ReadHavenRadius(target, target_radius) + || source_radius == target_radius || max_zoom <= std::max(source_radius, target_radius)) + return; + + auto* source_orbit = source->As(); + auto* target_orbit = target->As(); + auto* pivot = source_orbit->Pivot(); + if (!pivot || target_orbit->Pivot() != pivot || source_orbit->LookTarget() != pivot + || target_orbit->LookTarget() != pivot) + return; + auto* source_frame = provider->SourceFrame(); + auto* target_frame = provider->TargetFrame(); + HavenCameraFrame* result = provider->ResultFrame(); + auto* curve = provider->Curve(); + const auto minimum = provider->Minimum(), maximum = provider->Maximum(), ratio = provider->Ratio(); + if (source_frame == nullptr || target_frame == nullptr || result == nullptr || curve == nullptr + || source_frame == target_frame || result == source_frame || result == target_frame || !std::isfinite(minimum) + || !std::isfinite(maximum) || !std::isfinite(ratio) || minimum < 0.0f || maximum > 1.0f || maximum <= minimum) + return; + + auto position = result->Position(); + const auto ar = source_frame->Rotation(), br = target_frame->Rotation(); + const auto af = source_frame->Fov(), bf = target_frame->Fov(); + const auto source_orthographic = source_frame->Orthographic(); + const auto target_orthographic = target_frame->Orthographic(); + const auto result_orthographic = result->Orthographic(); + const auto rotation_dot = ar.x * br.x + ar.y * br.y + ar.z * br.z + ar.w * br.w; + const auto gap = std::abs(source_radius - target_radius); + const auto rotation_length = ar.x * ar.x + ar.y * ar.y + ar.z * ar.z + ar.w * ar.w; + // Qualify the common-pivot, same-facing perspective orbit measured in Haven. + // Preserve native output when a client changes that geometry or camera mode. + if (!std::isfinite(rotation_length) || std::abs(rotation_length - 1.0f) > 0.0001f || !std::isfinite(rotation_dot) + || std::abs(std::abs(rotation_dot) - 1.0f) > 0.0001f || !std::isfinite(af) || !std::isfinite(bf) || af <= 0.0f + || std::abs(af - bf) > 0.001f || source_orthographic || target_orthographic || result_orthographic) + return; + + const auto at_ratio = curve->Evaluate(ratio); + const auto at_minimum = curve->Evaluate(minimum); + const auto at_maximum = curve->Evaluate(maximum); + if (!std::isfinite(at_ratio) || !std::isfinite(at_minimum) || !std::isfinite(at_maximum)) + return; + const bool source_is_far = source_radius > target_radius; + const auto outer_weight = + source_is_far ? 1.0f - std::clamp(at_minimum, 0.0f, 1.0f) : std::clamp(at_maximum, 0.0f, 1.0f); + const auto inner_weight = + source_is_far ? 1.0f - std::clamp(at_maximum, 0.0f, 1.0f) : std::clamp(at_minimum, 0.0f, 1.0f); + if (outer_weight <= inner_weight) + return; + const auto weight = source_is_far ? 1.0 - std::clamp(at_ratio, 0.0f, 1.0f) : std::clamp(at_ratio, 0.0f, 1.0f); + const auto inner_radius = std::min(source_radius, target_radius); + const auto native_outer = inner_radius + gap * outer_weight; + const auto native_distance = inner_radius + gap * weight; + const auto progress = std::clamp((weight - inner_weight) / (outer_weight - inner_weight), 0.0, 1.0); + const auto extra = std::min(max_zoom - native_distance, (max_zoom - native_outer) * progress); + if (!std::isfinite(extra)) + return; + ApplyHavenRoadDepthBias(); + if (extra <= 0.0) + return; + + // Sequential endpoint updates can cache different positions of their shared + // pivot during pan. Use the camera back axis so expansion remains steady. + position.x += static_cast(-2.0 * (ar.x * ar.z + ar.w * ar.y) * extra); + position.y += static_cast(-2.0 * (ar.y * ar.z - ar.w * ar.x) * extra); + position.z += static_cast((2.0 * (ar.x * ar.x + ar.y * ar.y) - 1.0) * extra); + auto clip = result->FarClip(); + if (!std::isfinite(clip) || clip <= 0.0f) + return; + clip += static_cast(extra); + if (!std::isfinite(position.x) || !std::isfinite(position.y) || !std::isfinite(position.z) || !std::isfinite(clip) + || clip <= 0.0f) + return; + result->SetPosition(position); + result->SetFarClip(clip); + static bool reported = false; + if (!reported) { + spdlog::info("[HavenZoom] expanded native outer distance {} to maximum {}", native_outer, max_zoom); + reported = true; + } +} + +void PlanetaryHelpHistoryContext_ApplyRecords_Hook(auto original, PlanetaryHelpHistoryContext* _this, + il2cpp::List* records) +{ + if (records && Config::Get().ui.reverse_haven_history) { + const auto count = records->Count(); + if (count >= 0 && count <= 2000) { + if (!records->Method("get_Item") + || !records->Method("set_Item")) { + original(_this, records); + return; + } + // Swap through the managed setter so reference writes keep their GC barriers. + for (int32_t i = 0; i < count / 2; ++i) { + auto* first = (*records)[i]; + auto* last = (*records)[count - i - 1]; + records->Call<"set_Item">(i, last); + records->Call<"set_Item">(count - i - 1, first); + } + } else { + spdlog::warn("Haven history count {} is implausible; keeping native order", count); + } + } + original(_this, records); +} + +void InstallHavenZoom() +{ + if (!HavenCameraBindingsReady()) + return; + IL2CPP_HOOK(HavenBlendProvider::Class::Method("UpdateCameraFrame"), + HavenCamera_UpdateCameraFrame_Hook); +} + +void InstallHavenHistory() +{ + IL2CPP_HOOK(PlanetaryHelpHistoryContext::Class::Method("ApplyRecords", 1), + PlanetaryHelpHistoryContext_ApplyRecords_Hook); +} +} // namespace + +const Feature kHavenZoom{"HavenZoom", &Config::Patches::havenzoomhooks, InstallHavenZoom}; +const Feature kHavenHistory{"HavenHistory", &Config::Patches::havenhistoryhooks, InstallHavenHistory}; diff --git a/mods/src/patches/parts/haven_history.cc b/mods/src/patches/parts/haven_history.cc deleted file mode 100644 index 8241c9dcd..000000000 --- a/mods/src/patches/parts/haven_history.cc +++ /dev/null @@ -1,85 +0,0 @@ -#include "config.h" - -#include -#include - -#include -#include - -#include -#include - -namespace -{ - -void ReverseRecords(void* records) -{ - if (!records) - return; - - auto* listClass = il2cpp_object_get_class(reinterpret_cast(records)); - if (!listClass) - return; - - auto* getCount = il2cpp_class_get_method_from_name(listClass, "get_Count", 0); - auto* getItem = il2cpp_class_get_method_from_name(listClass, "get_Item", 1); - auto* setItem = il2cpp_class_get_method_from_name(listClass, "set_Item", 2); - if (!getCount || !getItem || !setItem) { - spdlog::warn("[HavenHistory] Record list is missing expected IList methods"); - return; - } - - Il2CppException* exception = nullptr; - auto* countObj = il2cpp_runtime_invoke(getCount, records, nullptr, &exception); - if (exception || !countObj) - return; - - const auto count = *reinterpret_cast(il2cpp_object_unbox(countObj)); - if (count <= 1) - return; - if (count > 2000) { - spdlog::warn("[HavenHistory] Record list reported an implausible count ({}); skipping", count); - return; - } - - std::vector items; - items.reserve(count); - for (int32_t index = 0; index < count; ++index) { - void* getArgs[1] = {&index}; - exception = nullptr; - auto* item = il2cpp_runtime_invoke(getItem, records, getArgs, &exception); - if (exception) - return; - items.push_back(item); - } - - for (int32_t index = 0; index < count; ++index) { - void* setArgs[2] = {&index, items[count - index - 1]}; - exception = nullptr; - il2cpp_runtime_invoke(setItem, records, setArgs, &exception); - if (exception) - return; - } -} - -void PlanetaryHelpHistoryContext_ApplyRecords_Hook(auto original, void* _this, void* records) -{ - if (Config::Get().ui.reverse_haven_history) - ReverseRecords(records); - original(_this, records); -} - -} // namespace - -void InstallHavenHistoryHooks() -{ - auto context = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.PlanetaryBase", "PlanetaryHelpHistoryContext"); - auto applyRecords = context.GetMethod("ApplyRecords", 1); - if (!applyRecords) { - spdlog::error("Unable to find method PlanetaryHelpHistoryContext.ApplyRecords"); - return; - } - - SPUD_STATIC_DETOUR(applyRecords, PlanetaryHelpHistoryContext_ApplyRecords_Hook); - spdlog::info("Haven history: hooks installed"); -} diff --git a/mods/src/patches/parts/haven_road_depth.cc b/mods/src/patches/parts/haven_road_depth.cc deleted file mode 100644 index fad14ee29..000000000 --- a/mods/src/patches/parts/haven_road_depth.cc +++ /dev/null @@ -1,332 +0,0 @@ -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -namespace -{ -struct RoadDepthApi { - Il2CppClass* renderer = nullptr; - const MethodInfo *find = nullptr, *children = nullptr, *materials = nullptr; - const MethodInfo *name = nullptr, *shader = nullptr, *has = nullptr, *get = nullptr, *set = nullptr; - const MethodInfo *alive = nullptr, *active = nullptr, *instance_id = nullptr; - - RoadDepthApi() - { - auto cls = [](const char* n) { - return il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", n).get_cls(); - }; - auto *game_object = cls("GameObject"), *material = cls("Material"), *object = cls("Object"); - renderer = cls("Renderer"); - find = method_contract::Resolve(game_object, "Find", true, "UnityEngine.GameObject", {"System.String"}); - children = method_contract::Resolve(game_object, "GetComponentsInChildren", false, "UnityEngine.Component[]", - {"System.Type", "System.Boolean"}); - materials = method_contract::Resolve(renderer, "get_sharedMaterials", false, "UnityEngine.Material[]", {}); - name = method_contract::Resolve(object, "get_name", false, "System.String", {}); - shader = method_contract::Resolve(material, "get_shader", false, "UnityEngine.Shader", {}); - has = method_contract::Resolve(material, "HasProperty", false, "System.Boolean", {"System.String"}); - get = method_contract::Resolve(material, "GetFloat", false, "System.Single", {"System.String"}); - set = method_contract::Resolve(material, "SetFloat", false, "System.Void", {"System.String", "System.Single"}); - alive = method_contract::Resolve(object, "op_Implicit", true, "System.Boolean", {"UnityEngine.Object"}); - active = method_contract::Resolve(game_object, "get_activeInHierarchy", false, "System.Boolean", {}); - instance_id = method_contract::Resolve(object, "GetInstanceID", false, "System.Int32", {}); - } - - bool Valid() const - { - return renderer && find && children && materials && name && shader && has && get && set && alive && active - && instance_id; - } -}; - -RoadDepthApi& Api() -{ - static RoadDepthApi api; - return api; -} - -Il2CppObject* Invoke(const MethodInfo* method, Il2CppObject* object, void** args = nullptr) -{ - Il2CppObject* result = nullptr; - return Il2CppRuntime::TryInvoke(method, object, args, &result) ? result : nullptr; -} - -// All roots are temporary and released on the owner-thread stack. Persistent -// discovery keeps weak handles, which do not retain scene objects or assets. -class StrongObject -{ -public: - explicit StrongObject(Il2CppObject* object = nullptr) - : handle_(object ? il2cpp_gchandle_new(object, false) : nullptr) - { - } - StrongObject(const StrongObject&) = delete; - StrongObject& operator=(const StrongObject&) = delete; - StrongObject(StrongObject&& other) noexcept - : handle_(std::exchange(other.handle_, nullptr)) - { - } - StrongObject& operator=(StrongObject&& other) noexcept - { - if (this != &other) { - if (handle_) - il2cpp_gchandle_free(handle_); - handle_ = std::exchange(other.handle_, nullptr); - } - return *this; - } - ~StrongObject() - { - if (handle_) - il2cpp_gchandle_free(handle_); - } - explicit operator bool() const - { return handle_ != nullptr; } - Il2CppObject* Get() const - { return handle_ ? il2cpp_gchandle_get_target(handle_) : nullptr; } - -private: - Il2CppGCHandle handle_ = nullptr; -}; - -bool Alive(const StrongObject& root) -{ - auto* object = root.Get(); - bool alive = false; - void* args[]{object}; - return object && Il2CppRuntime::TryBoolean(Invoke(Api().alive, nullptr, args), alive) && alive; -} - -class WeakObject -{ -public: - WeakObject() = default; - explicit WeakObject(const StrongObject& root) - : handle_(root.Get() ? il2cpp_gchandle_new_weakref(root.Get(), false) : nullptr) - { - } - WeakObject(const WeakObject&) = delete; - WeakObject& operator=(const WeakObject&) = delete; - WeakObject(WeakObject&& other) noexcept - : handle_(std::exchange(other.handle_, nullptr)) - { - } - WeakObject& operator=(WeakObject&& other) noexcept - { - if (this != &other) { - if (handle_) - il2cpp_gchandle_free(handle_); - handle_ = std::exchange(other.handle_, nullptr); - } - return *this; - } - ~WeakObject() - { - if (handle_) - il2cpp_gchandle_free(handle_); - } - explicit operator bool() const - { return handle_ != nullptr; } - StrongObject Lock() const - { - StrongObject root(handle_ ? il2cpp_gchandle_get_target(handle_) : nullptr); - if (!Alive(root)) - return StrongObject{}; - return root; - } - -private: - Il2CppGCHandle handle_ = nullptr; -}; - -std::string Name(const StrongObject& object) -{ - StrongObject value(object ? Invoke(Api().name, object.Get()) : nullptr); - const auto* string = reinterpret_cast(value.Get()); - return string && string->length >= 0 && string->length <= 256 ? il2cpp::ToUtf8(string) : std::string{}; -} - -bool IsRoad(const std::string& name) -{ - for (const char* prefix : - {"mat_PB_3101101_asphalt_", "mat_PB_3101201_asphalt_", "mat_PB_3101301_asphalt_", "mat_PB_3101401_asphalt_", - "mat_PB_3101501_asphalt_", "mat_PB_3101601_asphalt_", "mat_PB_3101701_asphalt_"}) { - if (name.starts_with(prefix)) - return true; - } - return false; -} - -struct NativeBackend { - using Object = StrongObject; - using Weak = WeakObject; - - static Object GetOwner(const Weak& weak) - { - auto root = weak.Lock(); - bool active = false; - if (!root || !Il2CppRuntime::TryBoolean(Invoke(Api().active, root.Get()), active) || !active) - return Object{}; - return root; - } - static Weak Retain(const Object& object) - { return Weak(object); } - static Object FindOwner() - { - StrongObject name(reinterpret_cast( - il2cpp_string_new("StarbaseRoot/StarbaseManager/StarbasePlanetaryVisualsHolder"))); - if (!name) - return Object{}; - void* args[]{name.Get()}; - Object root(Invoke(Api().find, nullptr, args)); - if (!Alive(root)) - return Object{}; - return root; - } - static bool ScanOwner(const Object& owner, std::vector& roads) - { - auto& api = Api(); - StrongObject type(reinterpret_cast(il2cpp_type_get_object(il2cpp_class_get_type(api.renderer)))); - if (!type) - return false; - bool inactive = true; - void* args[]{type.Get(), &inactive}; - StrongObject renderer_root(Invoke(api.children, owner.Get(), args)); - auto* renderers = reinterpret_cast(renderer_root.Get()); - if (!renderers || renderers->max_length > haven_roads::kRendererLimit) - return false; - std::unordered_set road_ids; - for (uintptr_t i = 0; i < renderers->max_length; ++i) { - StrongObject renderer(reinterpret_cast(renderers->vector[i])); - if (!Alive(renderer)) - continue; - StrongObject material_root(Invoke(api.materials, renderer.Get())); - auto* materials = reinterpret_cast(material_root.Get()); - if (!materials || materials->max_length > haven_roads::kMaterialsPerRenderer) - continue; - for (uintptr_t j = 0; j < materials->max_length; ++j) { - StrongObject material(reinterpret_cast(materials->vector[j])); - if (!Alive(material) || !IsRoad(Name(material))) - continue; - StrongObject shader(Invoke(api.shader, material.Get())); - if (!Alive(shader) || Name(shader) != "Custom/LitMSAE") - continue; - auto* boxed_id = Invoke(api.instance_id, material.Get()); - if (!boxed_id || !method_contract::Type(il2cpp_class_get_type(boxed_id->klass), "System.Int32")) - continue; - const int id = *static_cast(il2cpp_object_unbox(boxed_id)); - if (road_ids.insert(id).second) { - if (auto weak = Retain(material); weak) - roads.push_back(std::move(weak)); - if (roads.size() == haven_roads::kMaterialLimit) - return true; - } - } - } - return true; - } -}; - -bool ReadDepth(Il2CppObject* material, Il2CppString* property, float& value) -{ - void* args[]{property}; - bool present = false; - if (!Il2CppRuntime::TryBoolean(Invoke(Api().has, material, args), present) || !present) - return false; - auto* boxed = Invoke(Api().get, material, args); - if (!boxed || !method_contract::Type(il2cpp_class_get_type(boxed->klass), "System.Single")) - return false; - value = *static_cast(il2cpp_object_unbox(boxed)); - return std::isfinite(value); -} -} // namespace - -// The existing qualified Haven camera path owns this call. Keep its no-argument, -// one-way material bias contract; discovery retains only weak owner/materials. -void ApplyHavenRoadDepthBias() -{ - using Clock = std::chrono::steady_clock; - static auto next = Clock::time_point{}; - const auto now = Clock::now(); - if (now < next) - return; - next = now + std::chrono::seconds(1); - if (!Api().Valid()) { - static bool warned = false; - if (!warned) { - const auto& api = Api(); - if (!api.renderer) - spdlog::warn("[HavenZoom] road depth unavailable: missing UnityEngine.Renderer class"); - for (const auto& [method, signature] : std::array, 11>{ - {{api.find, "GameObject.Find(String) -> GameObject [static]"}, - {api.children, "GameObject.GetComponentsInChildren(Type, Boolean) -> Component[]"}, - {api.materials, "Renderer.get_sharedMaterials() -> Material[]"}, - {api.name, "Object.get_name() -> String"}, - {api.shader, "Material.get_shader() -> Shader"}, - {api.has, "Material.HasProperty(String) -> Boolean"}, - {api.get, "Material.GetFloat(String) -> Single"}, - {api.set, "Material.SetFloat(String, Single) -> Void"}, - {api.alive, "Object.op_Implicit(Object) -> Boolean [static]"}, - {api.active, "GameObject.get_activeInHierarchy() -> Boolean"}, - {api.instance_id, "Object.GetInstanceID() -> Int32"}}}) { - if (!method) - spdlog::warn("[HavenZoom] keeping native road depth: missing or incompatible UnityEngine.{}", signature); - } - warned = true; - } - return; - } - // Never run IL2CPP handle destructors after runtime teardown. Replacement and - // invalidation release old weak handles during normal owner-thread calls. - static auto* cache = new haven_roads::DiscoveryCache; - cache->Refresh(now); - if (cache->Materials().empty()) - return; - const std::array properties{ - StrongObject(reinterpret_cast(il2cpp_string_new("_OffsetFactor"))), - StrongObject(reinterpret_cast(il2cpp_string_new("_OffsetUnits")))}; - if (!properties[0] || !properties[1]) - return; - for (const auto& weak : cache->Materials()) { - auto material = weak.Lock(); - if (!material) - continue; - std::array before{}; - if (!ReadDepth(material.Get(), reinterpret_cast(properties[0].Get()), before[0]) - || !ReadDepth(material.Get(), reinterpret_cast(properties[1].Get()), before[1])) - continue; - bool changed = false, verified = true; - for (unsigned k = 0; k < 2; ++k) { - float target = std::min(before[k], -1.0f); - if (target == before[k]) - continue; - void* args[]{properties[k].Get(), &target}; - if (!Il2CppRuntime::TryInvoke(Api().set, material.Get(), args)) { - verified = false; - continue; - } - changed = true; - float after = 0.0f; - verified = ReadDepth(material.Get(), reinterpret_cast(properties[k].Get()), after) - && after == target && verified; - } - if (changed) { - spdlog::debug("[HavenZoom] road depth bias: verified={}", verified); - static bool reported = false; - if (verified && !reported) { - spdlog::info("[HavenZoom] corrected road surface depth for expanded Haven zoom"); - reported = true; - } - } - } -} diff --git a/mods/src/patches/parts/haven_zoom.cc b/mods/src/patches/parts/haven_zoom.cc deleted file mode 100644 index 9af66a705..000000000 --- a/mods/src/patches/parts/haven_zoom.cc +++ /dev/null @@ -1,295 +0,0 @@ -#include "config.h" - -#include -#include -#include -#include -#include - -#include -#include -#include -#include - -void ApplyHavenRoadDepthBias(); - -namespace -{ -Il2CppClass *planetary_provider_class = nullptr; -FieldInfo *blend_source = nullptr, *blend_target = nullptr, *blend_curve = nullptr; -FieldInfo *blend_minimum = nullptr, *blend_maximum = nullptr, *blend_ratio = nullptr; -FieldInfo *blend_source_frame = nullptr, *blend_target_frame = nullptr, *blend_result_frame = nullptr; -FieldInfo *provider_constraint = nullptr, *provider_radius = nullptr, *constraint_radius = nullptr; -FieldInfo *provider_pivot = nullptr, *provider_look_target = nullptr; -FieldInfo *radius_enabled = nullptr, *radius_minimum = nullptr, *radius_maximum = nullptr; -FieldInfo *frame_position = nullptr, *frame_rotation = nullptr, *frame_fov = nullptr; -FieldInfo *frame_far_clip = nullptr, *frame_orthographic = nullptr; -const MethodInfo *curve_evaluate = nullptr; - -struct HavenRotation { - float x, y, z, w; -}; - -bool ReadHavenRadius(Il2CppObject *provider, float &radius) -{ - if (provider == nullptr || !il2cpp_class_is_assignable_from(planetary_provider_class, provider->klass)) - return false; - Il2CppObject *constraint = nullptr, *data = nullptr; - il2cpp_field_get_value(provider, provider_constraint, &constraint); - if (constraint == nullptr) - return false; - il2cpp_field_get_value(constraint, constraint_radius, &data); - if (data == nullptr) - return false; - bool enabled = false; - il2cpp_field_get_value(data, radius_enabled, &enabled); - if (!enabled) - return false; - float minimum = 0.0f, maximum = 0.0f; - il2cpp_field_get_value(data, radius_minimum, &minimum); - il2cpp_field_get_value(data, radius_maximum, &maximum); - il2cpp_field_get_value(provider, provider_radius, &radius); - return std::isfinite(radius) && std::isfinite(maximum) && maximum > 0.0f && minimum == maximum - && std::abs(radius - maximum) < 0.01f; -} - -void HavenCamera_UpdateCameraFrame_Hook(auto original, Il2CppObject *provider, unity::Camera *camera) -{ - // Native input, constraints and endpoint updates run first. Only this blend's - // newly generated output is extended; endpoint assets and normalized LOD stay native. - original(provider, camera); - const auto max_zoom = Config::Get().graphics.haven_zoom; - if (provider == nullptr || !std::isfinite(max_zoom) || max_zoom <= 0.0f) - return; - - Il2CppObject *source = nullptr, *target = nullptr; - il2cpp_field_get_value(provider, blend_source, &source); - il2cpp_field_get_value(provider, blend_target, &target); - float source_radius = 0.0f, target_radius = 0.0f; - if (!ReadHavenRadius(source, source_radius) || !ReadHavenRadius(target, target_radius) - || source_radius == target_radius || max_zoom <= std::max(source_radius, target_radius)) - return; - - Il2CppObject *pivot = nullptr, *target_pivot = nullptr, *source_look_target = nullptr, *target_look_target = nullptr; - il2cpp_field_get_value(source, provider_pivot, &pivot); - il2cpp_field_get_value(target, provider_pivot, &target_pivot); - il2cpp_field_get_value(source, provider_look_target, &source_look_target); - il2cpp_field_get_value(target, provider_look_target, &target_look_target); - if (pivot == nullptr || target_pivot != pivot || source_look_target != pivot || target_look_target != pivot) - return; - Il2CppObject *source_frame = nullptr, *target_frame = nullptr, *result = nullptr, *curve = nullptr; - float minimum = 0.0f, maximum = 0.0f, ratio = 0.0f; - il2cpp_field_get_value(provider, blend_source_frame, &source_frame); - il2cpp_field_get_value(provider, blend_target_frame, &target_frame); - il2cpp_field_get_value(provider, blend_result_frame, &result); - il2cpp_field_get_value(provider, blend_curve, &curve); - il2cpp_field_get_value(provider, blend_minimum, &minimum); - il2cpp_field_get_value(provider, blend_maximum, &maximum); - il2cpp_field_get_value(provider, blend_ratio, &ratio); - if (source_frame == nullptr || target_frame == nullptr || result == nullptr || curve == nullptr - || source_frame == target_frame || result == source_frame || result == target_frame || !std::isfinite(minimum) - || !std::isfinite(maximum) || !std::isfinite(ratio) || minimum < 0.0f || maximum > 1.0f || maximum <= minimum) - return; - - unity::Vector3 position{}; - HavenRotation ar{}, br{}; - float af = 0.0f, bf = 0.0f; - bool source_orthographic = false, target_orthographic = false, result_orthographic = false; - il2cpp_field_get_value(result, frame_position, &position); - il2cpp_field_get_value(source_frame, frame_rotation, &ar); - il2cpp_field_get_value(target_frame, frame_rotation, &br); - il2cpp_field_get_value(source_frame, frame_fov, &af); - il2cpp_field_get_value(target_frame, frame_fov, &bf); - il2cpp_field_get_value(source_frame, frame_orthographic, &source_orthographic); - il2cpp_field_get_value(target_frame, frame_orthographic, &target_orthographic); - il2cpp_field_get_value(result, frame_orthographic, &result_orthographic); - const auto rotation_dot = ar.x * br.x + ar.y * br.y + ar.z * br.z + ar.w * br.w; - const auto gap = std::abs(source_radius - target_radius); - const auto rotation_length = ar.x * ar.x + ar.y * ar.y + ar.z * ar.z + ar.w * ar.w; - // Qualify the common-pivot, same-facing perspective orbit measured in Haven. - // Preserve native output when a client changes that geometry or camera mode. - if (!std::isfinite(rotation_length) || std::abs(rotation_length - 1.0f) > 0.0001f || !std::isfinite(rotation_dot) - || std::abs(std::abs(rotation_dot) - 1.0f) > 0.0001f || !std::isfinite(af) || !std::isfinite(bf) || af <= 0.0f - || std::abs(af - bf) > 0.001f || source_orthographic || target_orthographic || result_orthographic) - return; - - const auto evaluate = - reinterpret_cast(curve_evaluate->methodPointer); - const auto at_ratio = evaluate(curve, ratio, curve_evaluate); - const auto at_minimum = evaluate(curve, minimum, curve_evaluate); - const auto at_maximum = evaluate(curve, maximum, curve_evaluate); - if (!std::isfinite(at_ratio) || !std::isfinite(at_minimum) || !std::isfinite(at_maximum)) - return; - const bool source_is_far = source_radius > target_radius; - const auto outer_weight = - source_is_far ? 1.0f - std::clamp(at_minimum, 0.0f, 1.0f) : std::clamp(at_maximum, 0.0f, 1.0f); - const auto inner_weight = - source_is_far ? 1.0f - std::clamp(at_maximum, 0.0f, 1.0f) : std::clamp(at_minimum, 0.0f, 1.0f); - if (outer_weight <= inner_weight) - return; - const auto weight = source_is_far ? 1.0 - std::clamp(at_ratio, 0.0f, 1.0f) : std::clamp(at_ratio, 0.0f, 1.0f); - const auto inner_radius = std::min(source_radius, target_radius); - const auto native_outer = inner_radius + gap * outer_weight; - const auto native_distance = inner_radius + gap * weight; - const auto progress = std::clamp((weight - inner_weight) / (outer_weight - inner_weight), 0.0, 1.0); - const auto extra = std::min(max_zoom - native_distance, (max_zoom - native_outer) * progress); - if (!std::isfinite(extra)) - return; - ApplyHavenRoadDepthBias(); - if (extra <= 0.0) - return; - - // Sequential endpoint updates can cache different positions of their shared - // pivot during pan. Use the camera back axis so expansion remains steady. - position.x += static_cast(-2.0 * (ar.x * ar.z + ar.w * ar.y) * extra); - position.y += static_cast(-2.0 * (ar.y * ar.z - ar.w * ar.x) * extra); - position.z += static_cast((2.0 * (ar.x * ar.x + ar.y * ar.y) - 1.0) * extra); - float clip = 0.0f; - il2cpp_field_get_value(result, frame_far_clip, &clip); - if (!std::isfinite(clip) || clip <= 0.0f) - return; - clip += static_cast(extra); - if (!std::isfinite(position.x) || !std::isfinite(position.y) || !std::isfinite(position.z) || !std::isfinite(clip) - || clip <= 0.0f) - return; - il2cpp_field_set_value(result, frame_position, &position); - il2cpp_field_set_value(result, frame_far_clip, &clip); - static bool reported = false; - if (!reported) { - spdlog::info("[HavenZoom] expanded native outer distance {} to maximum {}", native_outer, max_zoom); - reported = true; - } -} -} // namespace - -void InstallHavenZoomHooks() -{ - auto blend = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.CameraController", "BlendFrameProvider"); - auto frame_provider = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.CameraController", "FrameProvider"); - auto orbit = - il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.CameraController", "AbstractOrbitFrameProvider"); - auto target = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.CameraController", "AbstractTargetController"); - auto planetary = - il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.CameraController", "PlanetaryOrbitFrameProvider"); - auto orbit_constraint = - il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.CameraController", "OrbitConstraint"); - auto constraint = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.CameraController", "Constraint"); - auto frame = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.CameraController", "CameraFrame"); - auto editable = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.CameraController", "EditableCameraFrame"); - auto curve = il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", "AnimationCurve"); - for (const auto &[name, helper] : {std::pair{"BlendFrameProvider", &blend}, - {"FrameProvider", &frame_provider}, - {"AbstractOrbitFrameProvider", &orbit}, - {"AbstractTargetController", &target}, - {"PlanetaryOrbitFrameProvider", &planetary}, - {"OrbitConstraint", &orbit_constraint}, - {"Constraint", &constraint}, - {"CameraFrame", &frame}, - {"EditableCameraFrame", &editable}, - {"AnimationCurve", &curve}}) { - if (helper->get_cls() == nullptr) { - spdlog::warn("[HavenZoom] class {} not found; skipping hook installation and keeping native zoom range", name); - return; - } - } - Il2CppClass *radius_class = nullptr; - void *iterator = nullptr; - while (auto *nested = il2cpp_class_get_nested_types(constraint.get_cls(), &iterator)) { - if (std::strcmp(il2cpp_class_get_name(nested), "FloatData") == 0) { - radius_class = nested; - break; - } - } - if (radius_class == nullptr) { - spdlog::warn("[HavenZoom] nested class Constraint.FloatData not found; skipping hook installation and keeping " - "native zoom range"); - return; - } - IL2CppClassHelper radius(radius_class); - const auto *update = - method_contract::Resolve(blend.get_cls(), "UpdateCameraFrame", false, "System.Void", {"UnityEngine.Camera"}); - curve_evaluate = method_contract::Resolve(curve.get_cls(), "Evaluate", false, "System.Single", {"System.Single"}); - planetary_provider_class = planetary.get_cls(); - if (update == nullptr || update->has_full_generic_sharing_signature) { - spdlog::warn("[HavenZoom] BlendFrameProvider.UpdateCameraFrame(UnityEngine.Camera) instance void method " - "unavailable or incompatible; skipping hook installation and keeping native zoom range"); - return; - } - if (curve_evaluate == nullptr || curve_evaluate->has_full_generic_sharing_signature) { - spdlog::warn("[HavenZoom] AnimationCurve.Evaluate(System.Single) instance float method unavailable or " - "incompatible; skipping hook installation and keeping native zoom range"); - return; - } - if (!il2cpp_class_is_assignable_from(orbit.get_cls(), planetary_provider_class)) { - spdlog::warn("[HavenZoom] PlanetaryOrbitFrameProvider no longer derives from AbstractOrbitFrameProvider; skipping " - "hook installation and keeping native zoom range"); - return; - } - struct FieldBinding { - FieldInfo **destination; - IL2CppClassHelper *owner; - const char *name, *value_type; - Il2CppClass *reference_type; - }; - const FieldBinding fields[] = { - {&blend_source, &blend, "_sourceFrameProvider", nullptr, frame_provider.get_cls()}, - {&blend_target, &blend, "_targetFrameProvider", nullptr, frame_provider.get_cls()}, - {&blend_curve, &blend, "_blendCurve", nullptr, curve.get_cls()}, - {&blend_minimum, &blend, "_softLimitMin", "System.Single", nullptr}, - {&blend_maximum, &blend, "_softLimitMax", "System.Single", nullptr}, - {&blend_ratio, &blend, "_blendRatio", "System.Single", nullptr}, - {&blend_source_frame, &blend, "_sourceFrame", nullptr, frame.get_cls()}, - {&blend_target_frame, &blend, "_targetFrame", nullptr, frame.get_cls()}, - {&blend_result_frame, &blend, "_blendedCameraFrame", nullptr, editable.get_cls()}, - {&provider_constraint, &orbit, "_constraint", nullptr, orbit_constraint.get_cls()}, - {&provider_radius, &orbit, "_radius", "System.Single", nullptr}, - {&provider_pivot, &orbit, "_orbitPivot", nullptr, target.get_cls()}, - {&provider_look_target, &orbit, "_lookTarget", nullptr, target.get_cls()}, - {&constraint_radius, &orbit_constraint, "_radius", nullptr, radius_class}, - {&radius_enabled, &radius, "_enabled", "System.Boolean", nullptr}, - {&radius_minimum, &radius, "_min", "System.Single", nullptr}, - {&radius_maximum, &radius, "_max", "System.Single", nullptr}, - {&frame_position, &frame, "_position", "UnityEngine.Vector3", nullptr}, - {&frame_rotation, &frame, "_rotation", "UnityEngine.Quaternion", nullptr}, - {&frame_fov, &frame, "_FOV", "System.Single", nullptr}, - {&frame_far_clip, &frame, "_farPlane", "System.Single", nullptr}, - {&frame_orthographic, &frame, "_orthographic", "System.Boolean", nullptr}, - }; - for (const auto &binding : fields) { - auto *field = binding.owner->GetField(binding.name).get_info(); - const char *failure = nullptr; - if (field == nullptr) - failure = "field not found"; - else if (il2cpp_field_get_flags(field) & FIELD_ATTRIBUTE_STATIC) - failure = "field became static"; - else if (field->type == nullptr || field->type->byref) - failure = "missing or by-reference field type"; - else if (binding.reference_type != nullptr) { - if (il2cpp_class_is_valuetype(binding.reference_type) - || il2cpp_class_from_type(field->type) != binding.reference_type) - failure = "incompatible reference type"; - } else if (!method_contract::Type(field->type, binding.value_type)) { - failure = "incompatible value type"; - } - if (failure != nullptr) { - auto *actual = field && field->type ? il2cpp_type_get_name(field->type) : nullptr; - auto *expected = - binding.reference_type ? il2cpp_type_get_name(il2cpp_class_get_type(binding.reference_type)) : nullptr; - spdlog::warn( - "[HavenZoom] {}.{}: {}; expected instance {}, found {}. Skipping hook installation; native zoom retained", - il2cpp_class_get_name(binding.owner->get_cls()), binding.name, failure, - expected ? expected : (binding.value_type ? binding.value_type : ""), - actual ? actual : ""); - il2cpp_free(expected); - il2cpp_free(actual); - return; - } - *binding.destination = field; - } - // [patches].havenzoomhooks controls installation independently of haven_zoom. - if (SPUD_STATIC_DETOUR(update->methodPointer, HavenCamera_UpdateCameraFrame_Hook)) { - spdlog::info("[HavenZoom] installed planetary blend camera hook (maximum distance {})", Config::Get().graphics.haven_zoom); - } else { - spdlog::warn("[HavenZoom] camera hook was not installed; keeping native zoom range"); - } -} diff --git a/mods/src/patches/parts/zoom.cc b/mods/src/patches/parts/zoom.cc index a30ab5263..9dede3c57 100644 --- a/mods/src/patches/parts/zoom.cc +++ b/mods/src/patches/parts/zoom.cc @@ -193,7 +193,10 @@ void NavigationZoom_SetDepth_Hook(auto original, NavigationZoom* _this, NodeDept // Planet flat renderables (the 2D planet sprites) are scaled by fr_scale. The game reuses them and asks for them on // every zoom, so each transform remembers the size it was scaled to and is left alone while it still has it. // Planets differ in base size, so this is per transform: one shared value made two planets rescale each other. -void ScaleFlatRenderable(unity::Component* renderable) +bool authored_backdrop = false; +uint64_t backdrop_revision = 0; + +void ScaleFlatRenderable(unity::Component* renderable, bool authored) { // A system has a handful of planets; past this, entries from earlier systems are dropped. constexpr size_t kRemembered = 256; @@ -203,7 +206,7 @@ void ScaleFlatRenderable(unity::Component* renderable) if (!transform) return; const auto scale = transform->localScale(); - if (const auto it = scaled.find(transform); it != scaled.end() && std::fabs(scale.x - it->second) < 0.1f) + if (const auto it = scaled.find(transform); !authored && it != scaled.end() && std::fabs(scale.x - it->second) < 0.1f) return; if (scaled.size() >= kRemembered) scaled.clear(); @@ -214,7 +217,7 @@ void ScaleFlatRenderable(unity::Component* renderable) unity::Component* PlanetViewUtils_get_FlatRenderable_Hook(auto original, PlanetViewUtils* _this) { auto* renderable = original(_this); - ScaleFlatRenderable(renderable); + ScaleFlatRenderable(renderable, authored_backdrop); return renderable; } @@ -227,6 +230,36 @@ void PlanetViewUtils_CameraZoomedEventHandler_Hook(auto original, PlanetViewUtil _this->FlatRenderable(); } +void ScaleAuthoredBackdrop(PlanetViewUtils* view) +{ + const auto previous = std::exchange(authored_backdrop, true); + view->FlatRenderable(); + authored_backdrop = previous; +} + +void PlanetViewUtils_CreateBackdrop_Hook(auto original, PlanetViewUtils* _this, Il2CppObject* backdrop, + Il2CppObject* resource, bool using_default) +{ + original(_this, backdrop, resource, using_default); + if (_this && resource) { + ++backdrop_revision; + ScaleAuthoredBackdrop(_this); + } +} + +void PlanetViewUtils_Populate_Hook(auto original, PlanetViewUtils* _this, Il2CppObject* system, int64_t node_id, + NodeDepth depth) +{ + auto* previous = _this ? _this->Population() : nullptr; + const auto revision = backdrop_revision; + original(_this, system, node_id, depth); + auto* population = _this ? _this->Population() : nullptr; + // Cached variation backdrops reload authored scale without calling CreateBackdrop. + if (depth == NodeDepth::SolarSystem && population && population != previous && population->VariationCollection() + && revision == backdrop_revision) + ScaleAuthoredBackdrop(_this); +} + // --- Panning and rotation --- // Whether the mod, not the game, moves the camera with the Move* shortcuts. @@ -331,6 +364,13 @@ TKTouch* TKTouch_populateWithPosition_Hook(auto original, TKTouch* _this, uintpt void InstallZoom() { + IL2CPP_HOOK(PlanetViewUtils::Class::Method("Populate", 3), PlanetViewUtils_Populate_Hook); + IL2CPP_HOOK(PlanetViewUtils::Class::Method( + "CreateBackdrop", {"Digit.PrimeServer.Models.ITreeNode", "UnityEngine.Object", "System.Boolean"}), + PlanetViewUtils_CreateBackdrop_Hook); + IL2CPP_HOOK(PlanetViewUtils::Class::Method("CreateBackdrop", {"Digit.PrimeServer.Models.ITreeNode", + "SystemVariationConfigCollection", "System.Boolean"}), + PlanetViewUtils_CreateBackdrop_Hook); IL2CPP_HOOK(PlanetViewUtils::Class::Method("CameraZoomedEventHandler"), PlanetViewUtils_CameraZoomedEventHandler_Hook); IL2CPP_HOOK(PlanetViewUtils::Class::Method("get_FlatRenderable"), PlanetViewUtils_get_FlatRenderable_Hook); diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 7c9d12c8a..78b0f1942 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -33,8 +33,6 @@ namespace { // Installed in this order; the log numbers them. -const Feature kLegacyHavenZoom{"HavenZoom", &Config::Patches::havenzoomhooks, InstallHavenZoomHooks}; -const Feature kLegacyHavenHistory{"HavenHistory", &Config::Patches::havenhistoryhooks, InstallHavenHistoryHooks}; constexpr const Feature* kFeatures[] = { &kInteriorZoom, &kChatTitles, @@ -45,11 +43,11 @@ constexpr const Feature* kFeatures[] = { &kPinnedShipClick, &kDockSelection, &kAbilityButtons, - &kLegacyHavenZoom, - &kLegacyHavenHistory, - &kAllianceGamesStore, &kUiScale, &kZoom, + &kHavenZoom, + &kHavenHistory, + &kAllianceGamesStore, &kFleetLabels, &kPan, &kOutOfDockPower, diff --git a/mods/src/patches/patches.h b/mods/src/patches/patches.h index f4b23ddf1..8b370533f 100644 --- a/mods/src/patches/patches.h +++ b/mods/src/patches/patches.h @@ -81,6 +81,10 @@ extern const Feature kForbiddenTechShipName; // game_config.cc extern const Feature kGameConfigOverrides; +// haven.cc +extern const Feature kHavenZoom; +extern const Feature kHavenHistory; + // hotkeys.cc extern const Feature kHotkeys; // The shortcut dispatch for one frame, run by the frame tick. True when the game's own ScreenManager.Update must @@ -144,8 +148,6 @@ float WindowDpiScale(bool refresh = false); // sync/sync.cc extern const Feature kSync; -void InstallHavenZoomHooks(); -void InstallHavenHistoryHooks(); diff --git a/mods/src/prime/haven.h b/mods/src/prime/haven.h new file mode 100644 index 000000000..4513e750f --- /dev/null +++ b/mods/src/prime/haven.h @@ -0,0 +1,123 @@ +#pragma once + +// Haven's camera providers, blend frames and help history. +#include "prime/ui.h" + +#include +#include + +struct HavenRadiusConstraint : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "Constraint/FloatData">; + bool Enabled() + { return Field<"_enabled", bool>(); } + float Minimum() + { return Field<"_min", float>(); } + float Maximum() + { return Field<"_max", float>(); } +}; + +struct HavenOrbitConstraint : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "OrbitConstraint">; + HavenRadiusConstraint* Radius() + { return Field<"_radius", HavenRadiusConstraint*>(); } +}; + +struct HavenTargetController : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "AbstractTargetController">; +}; + +struct HavenOrbitProvider : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "AbstractOrbitFrameProvider">; + HavenOrbitConstraint* Constraint() + { return Field<"_constraint", HavenOrbitConstraint*>(); } + float Radius() + { return Field<"_radius", float>(); } + HavenTargetController* Pivot() + { return Field<"_orbitPivot", HavenTargetController*>(); } + HavenTargetController* LookTarget() + { return Field<"_lookTarget", HavenTargetController*>(); } +}; + +struct HavenPlanetaryProvider : HavenOrbitProvider { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "PlanetaryOrbitFrameProvider">; +}; + +struct HavenCameraFrame : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "CameraFrame">; + unity::Vector3 Position() + { return Field<"_position", unity::Vector3>(); } + unity::Quaternion Rotation() + { return Field<"_rotation", unity::Quaternion>(); } + float Fov() + { return Field<"_FOV", float>(); } + float FarClip() + { return Field<"_farPlane", float>(); } + bool Orthographic() + { return Field<"_orthographic", bool>(); } + void SetPosition(unity::Vector3 value) + { SetField<"_position">(value); } + void SetFarClip(float value) + { SetField<"_farPlane">(value); } +}; + +struct HavenEditableCameraFrame : HavenCameraFrame { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "EditableCameraFrame">; +}; + +struct HavenFrameProvider : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "FrameProvider">; +}; + +struct HavenBlendProvider : HavenFrameProvider { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "BlendFrameProvider">; + HavenFrameProvider* Source() + { return Field<"_sourceFrameProvider", HavenFrameProvider*>(); } + HavenFrameProvider* Target() + { return Field<"_targetFrameProvider", HavenFrameProvider*>(); } + unity::AnimationCurve* Curve() + { return Field<"_blendCurve", unity::AnimationCurve*>(); } + float Minimum() + { return Field<"_softLimitMin", float>(); } + float Maximum() + { return Field<"_softLimitMax", float>(); } + float Ratio() + { return Field<"_blendRatio", float>(); } + HavenCameraFrame* SourceFrame() + { return Field<"_sourceFrame", HavenCameraFrame*>(); } + HavenCameraFrame* TargetFrame() + { return Field<"_targetFrame", HavenCameraFrame*>(); } + HavenEditableCameraFrame* ResultFrame() + { return Field<"_blendedCameraFrame", HavenEditableCameraFrame*>(); } +}; + +struct PlanetaryHelpHistoryContext : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.PlanetaryBase", "PlanetaryHelpHistoryContext">; +}; + +// Validate the complete write path before installing the camera hook. A missing field must retain native output. +inline bool HavenCameraBindingsReady() +{ + return HavenBlendProvider::Class::HasField<"_sourceFrameProvider", HavenFrameProvider*>() + && HavenBlendProvider::Class::HasField<"_targetFrameProvider", HavenFrameProvider*>() + && HavenBlendProvider::Class::HasField<"_blendCurve", unity::AnimationCurve*>() + && HavenBlendProvider::Class::HasField<"_softLimitMin", float>() + && HavenBlendProvider::Class::HasField<"_softLimitMax", float>() + && HavenBlendProvider::Class::HasField<"_blendRatio", float>() + && HavenBlendProvider::Class::HasField<"_sourceFrame", HavenCameraFrame*>() + && HavenBlendProvider::Class::HasField<"_targetFrame", HavenCameraFrame*>() + && HavenBlendProvider::Class::HasField<"_blendedCameraFrame", HavenEditableCameraFrame*>() + && HavenOrbitProvider::Class::HasField<"_constraint", HavenOrbitConstraint*>() + && HavenOrbitProvider::Class::HasField<"_radius", float>() + && HavenOrbitProvider::Class::HasField<"_orbitPivot", HavenTargetController*>() + && HavenOrbitProvider::Class::HasField<"_lookTarget", HavenTargetController*>() + && HavenOrbitConstraint::Class::HasField<"_radius", HavenRadiusConstraint*>() + && HavenRadiusConstraint::Class::HasField<"_enabled", bool>() + && HavenRadiusConstraint::Class::HasField<"_min", float>() + && HavenRadiusConstraint::Class::HasField<"_max", float>() + && HavenCameraFrame::Class::HasField<"_position", unity::Vector3>() + && HavenCameraFrame::Class::HasField<"_rotation", unity::Quaternion>() + && HavenCameraFrame::Class::HasField<"_FOV", float>() + && HavenCameraFrame::Class::HasField<"_farPlane", float>() + && HavenCameraFrame::Class::HasField<"_orthographic", bool>() + && static_cast(unity::AnimationCurve::Class::Method("Evaluate")); +} diff --git a/mods/src/prime/navigation.h b/mods/src/prime/navigation.h index 40e208064..7a4efb1f1 100644 --- a/mods/src/prime/navigation.h +++ b/mods/src/prime/navigation.h @@ -208,9 +208,18 @@ struct OrbitFrameProvider : il2cpp::Object { { SetField<"_rotationAngleDelta">(value); } }; +struct PopulatedSystemData : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "PopulatedSystemData">; + Il2CppObject* VariationCollection() + { return Field<"k__BackingField", Il2CppObject*>(); } +}; + struct PlanetViewUtils : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "PlanetViewUtils">; + PopulatedSystemData* Population() + { return Field<"_popData", PopulatedSystemData*>(); } + // The planet's 2D sprite, a Component. Calling this also runs any detour on get_FlatRenderable. unity::Component* FlatRenderable() { return static_cast(Get<"FlatRenderable", Il2CppObject*>()); } diff --git a/mods/xmake.lua b/mods/xmake.lua index ccdba4da7..8d81cd1b8 100644 --- a/mods/xmake.lua +++ b/mods/xmake.lua @@ -95,7 +95,7 @@ do add_includedirs("src", { public = true }) -- Packages - add_packages("spud", "nlohmann_json", "protobuf", "libil2cpp", "eastl", "toml++", "spdlog", "libcurl", "capstone", "cpr") + add_packages("spud", "nlohmann_json", "protobuf", "libil2cpp", "toml++", "spdlog", "libcurl", "capstone", "cpr") if os.getenv("STFC_PROTOBUF_SCCACHE") == "1" then add_rules("stfc.protobuf.cpp.sccache") else diff --git a/tests/haven_road_cache_test.cc b/tests/haven_road_cache_test.cc deleted file mode 100644 index e0c29a6e6..000000000 --- a/tests/haven_road_cache_test.cc +++ /dev/null @@ -1,168 +0,0 @@ -#include "../mods/src/patches/haven_road_cache.h" - -#include -#include -#include - -namespace -{ -void Check(bool condition) -{ - if (!condition) - std::abort(); -} - -struct Backend { - using Object = int; - struct Weak { - static inline int handles = 0; - int object = 0; - Weak() = default; - explicit Weak(int value) - : object(value) - { - if (object) - ++handles; - } - Weak(const Weak&) = delete; - Weak& operator=(const Weak&) = delete; - Weak(Weak&& other) noexcept - : object(std::exchange(other.object, 0)) - { - } - Weak& operator=(Weak&& other) noexcept - { - if (this != &other) { - if (object) - --handles; - object = std::exchange(other.object, 0); - } - return *this; - } - ~Weak() - { - if (object) - --handles; - } - explicit operator bool() const - { return object != 0; } - }; - static inline int owner = 0, finds = 0, scans = 0, scanned_owner = 0, fail_retain = 0; - static inline bool scan_ok = true; - static inline std::unordered_set live, active; - static inline std::vector materials; - static int GetOwner(const Weak& weak) - { return live.contains(weak.object) && active.contains(weak.object) ? weak.object : 0; } - static int FindOwner() - { - ++finds; - return live.contains(owner) && active.contains(owner) ? owner : 0; - } - static Weak Retain(int object) - { return Weak(object == fail_retain ? 0 : object); } - static bool ScanOwner(int owner, std::vector& out) - { - ++scans; - scanned_owner = owner; - for (int object : materials) - if (auto weak = Retain(object); weak) - out.push_back(std::move(weak)); - return scan_ok; - } -}; -} // namespace - -int main() -{ - using Cache = haven_roads::DiscoveryCache; - using namespace std::chrono_literals; - const auto t = Cache::Clock::time_point{} + 100s; - { - Cache cache; - // Missing owner and repeated same-timestamp callers cannot multiply finds. - for (int i = 0; i < 10000; ++i) - cache.Refresh(t); - Check(Backend::finds == 1 && Backend::scans == 0); - Backend::owner = 1; - Backend::live = Backend::active = {1}; - Backend::materials = {10}; - cache.Refresh(t + 4s); - Check(Backend::finds == 1 && cache.Materials().empty()); - cache.Refresh(t + 5s); - Check(Backend::finds == 2 && Backend::scans == 1 && Backend::scanned_owner == 1); - Check(cache.Materials().size() == 1 && cache.Materials()[0].object == 10); - for (int i = 0; i < 10000; ++i) - cache.Refresh(t + 6s); - Check(Backend::finds == 2 && Backend::scans == 1 && Backend::Weak::handles == 2); - - // Reacquire the canonical path even while the former owner stays active. - Backend::owner = 2; - Backend::live.insert(2); - Backend::active.insert(2); - Backend::materials = {20}; - cache.Refresh(t + 10s); - Check(Backend::finds == 3 && Backend::scans == 2 && Backend::scanned_owner == 2); - Check(cache.Materials()[0].object == 20 && Backend::Weak::handles == 2); - - // Descendant/material replacement under the same owner is rediscovered. - Backend::materials = {21}; - cache.Refresh(t + 15s); - Check(Backend::finds == 4 && Backend::scans == 3 && cache.Materials()[0].object == 21); - - // Failed scans retire stale membership and retry at the same bounded rate. - Backend::scan_ok = false; - cache.Refresh(t + 20s); - cache.Refresh(t + 21s); - Check(Backend::finds == 5 && Backend::scans == 4 && cache.Materials().empty()); - Check(Backend::Weak::handles == 1); - Backend::scan_ok = true; - Backend::fail_retain = 2; - cache.Refresh(t + 25s); - Check(Backend::finds == 6 && Backend::scans == 4 && Backend::Weak::handles == 0); - cache.Refresh(t + 26s); - Check(Backend::finds == 6 && Backend::scans == 4); - Backend::fail_retain = 0; - - // Preserve coverage above256 materials: the ceiling follows the legacy - // renderer/material scan bounds, rather than arbitrarily truncating roads. - Backend::materials.resize(1000); - std::iota(Backend::materials.begin(), Backend::materials.end(), 10000); - cache.Refresh(t + 30s); - Check(cache.Materials().size() == 1000 && Backend::Weak::handles == 1001); - Check(cache.Materials().front().object == 10000 && cache.Materials().back().object == 10999); - Backend::materials.resize(haven_roads::kMaterialLimit + 10); - std::iota(Backend::materials.begin(), Backend::materials.end(), 20000); - cache.Refresh(t + 35s); - Check(cache.Materials().size() == haven_roads::kMaterialLimit); - Check(Backend::Weak::handles == static_cast(haven_roads::kMaterialLimit + 1)); - // A failed material weak retain publishes no stale/unretained entries, and - // does not turn the next owner callback into another full discovery scan. - Backend::materials.assign(1000, 31); - Backend::fail_retain = 31; - cache.Refresh(t + 40s); - const auto scans = Backend::scans; - cache.Refresh(t + 41s); - Check(cache.Materials().empty() && Backend::Weak::handles == 1 && Backend::scans == scans); - Backend::fail_retain = 0; - Backend::materials.resize(1000); - std::iota(Backend::materials.begin(), Backend::materials.end(), 30000); - cache.Refresh(t + 45s); - Check(cache.Materials().size() == 1000 && Backend::Weak::handles == 1001); - for (int i = 1; i < 96; ++i) { - cache.Refresh(t + 45s + i * 5s); - Check(Backend::Weak::handles == 1001); - } - // Native-inactive but managed-live owner invalidates immediately, with no - // scene-wide retry before the next scheduled discovery. - Backend::active.erase(2); - cache.Refresh(t + 521s); - Check(cache.Materials().empty() && Backend::Weak::handles == 0); - const auto finds = Backend::finds; - cache.Refresh(t + 522s); - Check(Backend::finds == finds); - Backend::owner = 0; - cache.Refresh(t + 525s); - Check(Backend::finds == finds + 1 && Backend::Weak::handles == 0); - } - Check(Backend::Weak::handles == 0); -} diff --git a/xmake/dependencies/common.lua b/xmake/dependencies/common.lua index e0fd89675..8978823eb 100644 --- a/xmake/dependencies/common.lua +++ b/xmake/dependencies/common.lua @@ -14,7 +14,6 @@ package("libil2cpp") end) package_end() -add_requires("eastl") add_requires("spdlog") add_requires("toml++") add_requires("nlohmann_json") From 4b4dcefb6d98ad01a62a5086e421af5ae92647ba Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 03/10] Track inventory widgets through their active lifecycle Pooled artifact widgets can leave and re-enter the live instance set. The inventory activation lifecycle is bound so the tracker follows the widget while it is active instead of keeping a stale entry. The shop keeps its first popup per session and suppresses later ones before they open, not after. --- example_community_patch_settings_en.toml | 2 -- mods/src/config.cc | 1 - mods/src/config.h | 14 +++++----- .../patches/parts/open_bulk_claim_gifts.cc | 27 +++++++++---------- mods/src/patches/parts/tracked_objects.cc | 2 +- mods/src/prime/inventory.h | 10 +++---- 6 files changed, 24 insertions(+), 32 deletions(-) diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 943c169b2..417336053 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -730,8 +730,6 @@ disable_escape_exit = true # Maximum gap in milliseconds between Escape presses that opens the exit prompt; 0 disables double-tap exit disable_escape_exit_timer = 0 -# ⚠️ EXPERIMENTAL: Prevent the first popup advert from appearing -disable_first_popup = false # ⚠️ EXPERIMENTAL: Prevent the standard move keys from working disable_move_keys = false diff --git a/mods/src/config.cc b/mods/src/config.cc index e2030c382..7ccfcbe55 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -633,7 +633,6 @@ void Config::Load(const SettingsFile& file, bool first) r.Read("ui", "disable_escape_exit_timer", u.disable_escape_exit_timer); r.Read("ui", "disable_preview_locate", u.disable_preview_locate); r.Read("ui", "disable_preview_recall", u.disable_preview_recall); - r.Read("ui", "disable_first_popup", u.disable_first_popup); r.Read("ui", "only_show_first_popup", u.only_show_first_popup); r.Read("ui", "reverse_haven_history", u.reverse_haven_history); r.Read("ui", "disable_move_keys", u.disable_move_keys); diff --git a/mods/src/config.h b/mods/src/config.h index 7a67d60de..993df94a4 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -281,14 +281,12 @@ class Config final int disable_escape_exit_timer = 0; bool disable_preview_locate = false; bool disable_preview_recall = false; - // TODO: both are read but have no effect; disable_first_popup was switched off in misc.cc. - bool disable_first_popup = false; - bool only_show_first_popup = false; - bool reverse_haven_history = true; - bool disable_toast_banners = false; - bool disable_move_keys = false; - bool disable_galaxy_chat = false; - bool disable_veil_chat = false; + bool only_show_first_popup = false; + bool reverse_haven_history = true; + bool disable_toast_banners = false; + bool disable_move_keys = false; + bool disable_galaxy_chat = false; + bool disable_veil_chat = false; // Side chat: its initial width in canvas units (0 keeps the game's), and opening it once per login. float default_chat_width = 0.0f; diff --git a/mods/src/patches/parts/open_bulk_claim_gifts.cc b/mods/src/patches/parts/open_bulk_claim_gifts.cc index f29683406..dd87acfe8 100644 --- a/mods/src/patches/parts/open_bulk_claim_gifts.cc +++ b/mods/src/patches/parts/open_bulk_claim_gifts.cc @@ -11,7 +11,7 @@ #include #include -#include +#include namespace { @@ -137,32 +137,31 @@ void InventoryUsePopupViewController_OnDidBindCanvasContext_Hook(auto original, object->SetActive(false); } +bool popup_shown = false; -std::atomic_bool popup_shown{false}; - -bool ShouldSuppressAdditionalPopup() +bool SuppressPopup() { - bool expected = false; - return !popup_shown.compare_exchange_strong(expected, true); + if (!Config::Get().ui.only_show_first_popup) + return false; + return std::exchange(popup_shown, true); } -void InterstitialViewController_AboutToShow_Hook(auto original, InterstitialViewController* _this) +void InterstitialViewController_AboutToShow_Hook(auto original, void* _this) { - original(_this); - if (Config::Get().ui.only_show_first_popup && _this && ShouldSuppressAdditionalPopup()) - _this->CloseWhenReady(); + if (!SuppressPopup()) + original(_this); } void ShopSceneManager_ShowPlcOfferPopup_Hook(auto original, void* _this) { - if (!Config::Get().ui.only_show_first_popup || !ShouldSuppressAdditionalPopup()) + if (!SuppressPopup()) original(_this); } void InstallSkipRevealSequence() -{ IL2CPP_HOOK(ShopSceneManager::Method("ShouldShowRevealSequence"), ShopSceneManager_ShouldShowRevealSequence_Hook); - using PopupShop = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Shop", "ShopSceneManager">; - IL2CPP_HOOK(PopupShop::Method("ShowPlcOfferPopup", 0), ShopSceneManager_ShowPlcOfferPopup_Hook); +{ + IL2CPP_HOOK(ShopSceneManager::Method("ShouldShowRevealSequence"), ShopSceneManager_ShouldShowRevealSequence_Hook); + IL2CPP_HOOK(ShopSceneManager::Method("ShowPlcOfferPopup", 0), ShopSceneManager_ShowPlcOfferPopup_Hook); IL2CPP_HOOK(InterstitialViewController::Class::Method("AboutToShow", 0), InterstitialViewController_AboutToShow_Hook); } diff --git a/mods/src/patches/parts/tracked_objects.cc b/mods/src/patches/parts/tracked_objects.cc index 32115f1b5..a5d7b6f44 100644 --- a/mods/src/patches/parts/tracked_objects.cc +++ b/mods/src/patches/parts/tracked_objects.cc @@ -23,7 +23,7 @@ void Install() unity::Track(); unity::Track(); unity::Track(); - unity::Track(); + unity::Track("OnDidBindCanvasContext", "OnAboutToReleaseCanvasContext"); unity::Track(); unity::Track(); unity::Track(); diff --git a/mods/src/prime/inventory.h b/mods/src/prime/inventory.h index bf345208a..215be81c3 100644 --- a/mods/src/prime/inventory.h +++ b/mods/src/prime/inventory.h @@ -10,6 +10,10 @@ #include #include +struct InterstitialViewController : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Interstitial", "InterstitialViewController">; +}; + // The context of a use, purchase or donation popup. struct InventoryForPopup : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Inventories", "InventoryForPopup">; @@ -318,12 +322,6 @@ inline bool AllianceGamesStoreFieldsPresent() using ShopSceneManager = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Shop", "ShopSceneManager">; -struct InterstitialViewController : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Interstitial", "InterstitialViewController">; - void CloseWhenReady() - { Call<"CloseWhenReady">(); } -}; - // The forbidden tech inventory tiles and the large selected-item panel. struct ForbiddenTechInventoryItemWidget : Widget { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ForbiddenTechs", "ForbiddenTechInventoryItemWidget">; From 60dbad18fa2865421f6aba2c7bb9425adfd501ab Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 04/10] Refresh mission HUD hooks for the current client The current client no longer exposes the daily-goals button, so its binding and reader key are removed. The HUD hooks follow the supported mission refresh path and read current mission state. Mission HUD preferences are typed fields named after their TOML keys, read directly instead of through a string map rebuilt per refresh. --- mods/src/config.cc | 12 +-- mods/src/config.h | 10 +-- .../patches/parts/daily_faction_bulk_claim.cc | 86 +++++++++++++------ mods/src/prime/hud.h | 2 +- 4 files changed, 71 insertions(+), 39 deletions(-) diff --git a/mods/src/config.cc b/mods/src/config.cc index 7ccfcbe55..ae1205aff 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -459,12 +459,6 @@ void Config::ReloadIfChanged() Reload(); } -MissionHudVisibility Config::Ui::MissionHudButtonVisibility(std::string_view button) const -{ - const auto it = mission_hud_buttons.find(button); - return it == mission_hud_buttons.end() ? MissionHudVisibility::kAuto : it->second; -} - void Config::Load(const SettingsFile& file, bool first) { const auto& config = file.table; @@ -670,8 +664,10 @@ void Config::Load(const SettingsFile& file, bool first) r.Read("ui", "cargo_significant_decimals", u.cargo_significant_decimals); r.Read("ui", "always_skip_reveal_sequence", u.always_skip_reveal_sequence); r.ReadRenamed("ui", "disable_exchange_all", "hide_artifact_exchange_all", u.disable_exchange_all); - for (const char* button : {"q_trials", "field_training", "outposts", "daily_goals", "missions"}) - r.Choose("ui", std::string("hud_") + button, u.mission_hud_buttons[button], kHudVisibility); + r.Choose("ui", "hud_q_trials", u.hud_q_trials, kHudVisibility); + r.Choose("ui", "hud_field_training", u.hud_field_training, kHudVisibility); + r.Choose("ui", "hud_outposts", u.hud_outposts, kHudVisibility); + r.Choose("ui", "hud_missions", u.hud_missions, kHudVisibility); u.disabled_banner_types = ReadBannerTypes(r, "disabled_banner_types"); u.notify_banner_types = ReadBannerTypes(r, "notify_banner_types"); diff --git a/mods/src/config.h b/mods/src/config.h index 993df94a4..cf6d495ca 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -329,15 +329,15 @@ class Config final bool always_skip_reveal_sequence = true; // Hides the artifact shards popup's Exchange All button; single exchanges stay. Takes effect on the next popup. - bool disable_exchange_all = false; - // Mission HUD buttons by name (q_trials, field_training, outposts, daily_goals, missions). - std::map> mission_hud_buttons; + bool disable_exchange_all = false; + MissionHudVisibility hud_q_trials = MissionHudVisibility::kAuto; + MissionHudVisibility hud_field_training = MissionHudVisibility::kAuto; + MissionHudVisibility hud_outposts = MissionHudVisibility::kAuto; + MissionHudVisibility hud_missions = MissionHudVisibility::kAuto; // ToastState values. std::vector disabled_banner_types; std::vector notify_banner_types; - - [[nodiscard]] MissionHudVisibility MissionHudButtonVisibility(std::string_view button) const; } ui; struct Sync { diff --git a/mods/src/patches/parts/daily_faction_bulk_claim.cc b/mods/src/patches/parts/daily_faction_bulk_claim.cc index f846553ba..30a0742f0 100644 --- a/mods/src/patches/parts/daily_faction_bulk_claim.cc +++ b/mods/src/patches/parts/daily_faction_bulk_claim.cc @@ -27,30 +27,59 @@ template unity::Component* HudButton(MissionsHudViewControl { return controller->ButtonField(); } struct ButtonSetting { - const char* name; // the hud_ setting - ButtonGetter button; + MissionHudVisibility Config::Ui::* visibility; + ButtonGetter button; }; constexpr ButtonSetting kButtons[] = { - {"q_trials", HudButton<"_challengesButton">}, {"field_training", HudButton<"_achievementsButton">}, - {"outposts", HudButton<"_outpostsButton">}, {"daily_goals", HudButton<"_dailyGoalsButton">}, - {"missions", HudButton<"_missionsButton">}, + {&Config::Ui::hud_q_trials, HudButton<"_challengesButton">}, + {&Config::Ui::hud_field_training, HudButton<"_achievementsButton">}, + {&Config::Ui::hud_outposts, HudButton<"_outpostsButton">}, + {&Config::Ui::hud_missions, HudButton<"_missionsButton">}, }; // The game decides visibility here; the hud_* settings are applied on top every time it does. -int32_t MissionsHudViewController_UpdateButtons_Hook(auto original, MissionsHudViewController* _this) +bool hud_ready = false; + +void ApplyHudVisibility(MissionsHudViewController* _this) { - const auto result = original(_this); - const auto& ui = Config::Get().ui; + if (!hud_ready) + return; + const auto& ui = Config::Get().ui; for (const auto& button : kButtons) { - const auto visibility = ui.MissionHudButtonVisibility(button.name); + const auto visibility = ui.*button.visibility; if (visibility == MissionHudVisibility::kAuto) continue; auto* component = button.button(_this); if (auto* object = component ? component->gameObject() : nullptr) object->SetActive(visibility == MissionHudVisibility::kAlways); } - return result; +} + +void MissionsHudViewController_OnEnable_Hook(auto original, MissionsHudViewController* _this) +{ + original(_this); + ApplyHudVisibility(_this); +} +void MissionsHudViewController_SetupAchievementsButton_Hook(auto original, MissionsHudViewController* _this) +{ + original(_this); + ApplyHudVisibility(_this); +} +void MissionsHudViewController_SetupChallengesButton_Hook(auto original, MissionsHudViewController* _this, bool value) +{ + original(_this, value); + ApplyHudVisibility(_this); +} +void MissionsHudViewController_SetupOutpostsButton_Hook(auto original, MissionsHudViewController* _this, bool value) +{ + original(_this, value); + ApplyHudVisibility(_this); +} +void MissionsHudViewController_HandleOutpostsAndChallengesHUD_Hook(auto original, MissionsHudViewController* _this) +{ + original(_this); + ApplyHudVisibility(_this); } // --- Daily faction claims --- @@ -74,18 +103,6 @@ bool Claimed(std::string_view faction) return factions.empty() || std::ranges::find(factions, faction) != factions.end(); } -unity::Transform* FindChild(unity::Transform* root, std::string_view name) -{ - if (!root) - return nullptr; - if (auto* object = root->gameObject(); object && object->Name() == name) - return root; - for (int32_t i = 0; i < root->childCount(); ++i) - if (auto* found = FindChild(root->GetChild(i), name)) - return found; - return nullptr; -} - void ShowClaimedFactionIcons(MissionViewController* controller) { auto* toggle = controller->claimAllToggle(); @@ -93,7 +110,7 @@ void ShowClaimedFactionIcons(MissionViewController* controller) if (!parent) return; for (const auto& faction : kFactions) { - auto* icon = FindChild(parent, faction.icon); + auto* icon = parent->FindNamed(faction.icon); auto* object = icon ? icon->gameObject() : nullptr; if (object) object->SetActive(Claimed(faction.name)); @@ -179,8 +196,27 @@ void MissionsUpdate() void InstallMissionHud() { - IL2CPP_HOOK(MissionsHudViewController::Class::Method("UpdateButtons", 0), - MissionsHudViewController_UpdateButtons_Hook); + using Controller = MissionsHudViewController::Class; + const std::array methods{ + Controller::Method("OnEnable"), Controller::Method("SetupAchievementsButton"), + Controller::Method("SetupChallengesButton"), Controller::Method("SetupOutpostsButton"), + Controller::Method("HandleOutpostsAndChallengesHUD")}; + // The lifecycle paths must all be distinct and available before any preference is applied. + for (size_t i = 0; i < methods.size(); ++i) { + if (!methods[i]) + return; + for (size_t j = 0; j < i; ++j) + if (methods[i].Pointer() == methods[j].Pointer()) { + spdlog::error("Mission HUD refresh paths share a native target; overrides disabled"); + return; + } + } + const bool enabled = IL2CPP_HOOK(methods[0], MissionsHudViewController_OnEnable_Hook); + const bool achievements = IL2CPP_HOOK(methods[1], MissionsHudViewController_SetupAchievementsButton_Hook); + const bool challenges = IL2CPP_HOOK(methods[2], MissionsHudViewController_SetupChallengesButton_Hook); + const bool outposts = IL2CPP_HOOK(methods[3], MissionsHudViewController_SetupOutpostsButton_Hook); + const bool combined = IL2CPP_HOOK(methods[4], MissionsHudViewController_HandleOutpostsAndChallengesHUD_Hook); + hud_ready = enabled && achievements && challenges && outposts && combined; } void InstallDailyClaimFilter() diff --git a/mods/src/prime/hud.h b/mods/src/prime/hud.h index 0f7f78cc8..4639af05a 100644 --- a/mods/src/prime/hud.h +++ b/mods/src/prime/hud.h @@ -9,7 +9,7 @@ #include -// The Q-Trials, field training, outposts, daily goals and missions buttons. +// The Q-Trials, field training, outposts and missions buttons. struct MissionsHudViewController : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.HUD", "MissionsHudViewController">; From d1ed3aed0b68a154b9b237da443df603d3c0c6e1 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 05/10] Track sync records per target with retryable cursors Global sent caches and dropping queues become canonical records with an independent cursor per target. Payloads are shared and immutable, and requests are built outside the store lock. Failed uploads retry with backoff, newer values coalesce, and stale session work is rejected. Journals keep 300 recent entries per session so they can replay after login. --- example_community_patch_settings_en.toml | 6 + mods/src/config.h | 56 +- mods/src/file.cc | 26 +- mods/src/file.h | 1 - mods/src/patches/parts/sync/battlelogs.cc | 237 ++-- mods/src/patches/parts/sync/fleets.cc | 28 +- mods/src/patches/parts/sync/records.cc | 1505 ++++++++------------- mods/src/patches/parts/sync/state.cc | 231 ++++ mods/src/patches/parts/sync/sync.cc | 107 +- mods/src/patches/parts/sync/sync.h | 204 ++- mods/src/patches/parts/sync/upload.cc | 277 ++-- mods/src/patches/patches.cc | 1 - mods/src/prime/UserProfileManager.h | 13 - mods/src/prime/server.h | 8 + tests/sync_logic.cc | 624 +++++++++ xmake.lua | 10 + 16 files changed, 1951 insertions(+), 1383 deletions(-) create mode 100644 mods/src/patches/parts/sync/state.cc delete mode 100644 mods/src/prime/UserProfileManager.h create mode 100644 tests/sync_logic.cc diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 417336053..199061219 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -923,6 +923,11 @@ show_station_cargo = true [sync] +# Target changes apply on settings reload and receive the latest observed state. +# Failed uploads retry; pending changes to an entity use its latest value. +# Up to 300 recent battle journals are retained per session. Journals may replay +# after restarting or logging in again; the old sent-ID file is no longer used. + # Subgroup: Data # -------------- @@ -942,6 +947,7 @@ buildings = true haven = true # Do you want to sync live fleet state (mining, docked, warping...)? Opt-in, off by default +# Every state change is sent; up to 300 recent changes are retained per session. fleets = false # Do you want to sync inventory data? diff --git a/mods/src/config.h b/mods/src/config.h index cf6d495ca..fefa447e7 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -39,7 +39,6 @@ struct SyncConfig { struct Option { Type type; - std::string_view type_str; // used in JSON body std::string_view option_str; // used in TOML file bool SyncConfig::* option; }; @@ -66,48 +65,35 @@ struct SyncConfig { bool traits = false; [[nodiscard]] bool enabled(Type type) const; + bool operator==(const SyncConfig&) const = default; }; constexpr std::array SyncOptions{ - SyncConfig::Option{SyncConfig::Type::kAwayAssignments, "away_assignment", "away_assignments", - &SyncConfig::away_assignments}, - SyncConfig::Option{SyncConfig::Type::kBattles, "battlelog", "battlelogs", &SyncConfig::battlelogs}, - SyncConfig::Option{SyncConfig::Type::kBuffs, "buff", "buffs", &SyncConfig::buffs}, - SyncConfig::Option{SyncConfig::Type::kBuildings, "module", "buildings", &SyncConfig::buildings}, - SyncConfig::Option{SyncConfig::Type::kEmeraldChain, "emerald_chain", "buffs", &SyncConfig::buffs}, - SyncConfig::Option{SyncConfig::Type::kFleets, "fleet", "fleets", &SyncConfig::fleets}, - SyncConfig::Option{SyncConfig::Type::kHaven, "haven", "haven", &SyncConfig::haven}, - SyncConfig::Option{SyncConfig::Type::kInventory, "inventory", "inventory", &SyncConfig::inventory}, - SyncConfig::Option{SyncConfig::Type::kJobs, "job", "jobs", &SyncConfig::jobs}, - SyncConfig::Option{SyncConfig::Type::kMissions, "mission", "missions", &SyncConfig::missions}, - SyncConfig::Option{SyncConfig::Type::kOfficer, "officer", "officers", &SyncConfig::officers}, - SyncConfig::Option{SyncConfig::Type::kProfile, "profile", "profile", &SyncConfig::profile}, - SyncConfig::Option{SyncConfig::Type::kResearch, "research", "research", &SyncConfig::research}, - SyncConfig::Option{SyncConfig::Type::kResources, "resource", "resources", &SyncConfig::resources}, - SyncConfig::Option{SyncConfig::Type::kShips, "ship", "ships", &SyncConfig::ships}, - SyncConfig::Option{SyncConfig::Type::kSlots, "slot", "slots", &SyncConfig::slots}, - SyncConfig::Option{SyncConfig::Type::kStoreOffers, "store_offer", "store_offers", &SyncConfig::store_offers}, - SyncConfig::Option{SyncConfig::Type::kTech, "ft", "tech", &SyncConfig::tech}, - SyncConfig::Option{SyncConfig::Type::kTraits, "trait", "traits", &SyncConfig::traits}, + SyncConfig::Option{SyncConfig::Type::kAwayAssignments, "away_assignments", &SyncConfig::away_assignments}, + SyncConfig::Option{SyncConfig::Type::kBattles, "battlelogs", &SyncConfig::battlelogs}, + SyncConfig::Option{SyncConfig::Type::kBuffs, "buffs", &SyncConfig::buffs}, + SyncConfig::Option{SyncConfig::Type::kBuildings, "buildings", &SyncConfig::buildings}, + SyncConfig::Option{SyncConfig::Type::kEmeraldChain, "buffs", &SyncConfig::buffs}, + SyncConfig::Option{SyncConfig::Type::kFleets, "fleets", &SyncConfig::fleets}, + SyncConfig::Option{SyncConfig::Type::kHaven, "haven", &SyncConfig::haven}, + SyncConfig::Option{SyncConfig::Type::kInventory, "inventory", &SyncConfig::inventory}, + SyncConfig::Option{SyncConfig::Type::kJobs, "jobs", &SyncConfig::jobs}, + SyncConfig::Option{SyncConfig::Type::kMissions, "missions", &SyncConfig::missions}, + SyncConfig::Option{SyncConfig::Type::kOfficer, "officers", &SyncConfig::officers}, + SyncConfig::Option{SyncConfig::Type::kProfile, "profile", &SyncConfig::profile}, + SyncConfig::Option{SyncConfig::Type::kResearch, "research", &SyncConfig::research}, + SyncConfig::Option{SyncConfig::Type::kResources, "resources", &SyncConfig::resources}, + SyncConfig::Option{SyncConfig::Type::kShips, "ships", &SyncConfig::ships}, + SyncConfig::Option{SyncConfig::Type::kSlots, "slots", &SyncConfig::slots}, + SyncConfig::Option{SyncConfig::Type::kStoreOffers, "store_offers", &SyncConfig::store_offers}, + SyncConfig::Option{SyncConfig::Type::kTech, "tech", &SyncConfig::tech}, + SyncConfig::Option{SyncConfig::Type::kTraits, "traits", &SyncConfig::traits}, }; -constexpr std::string to_string(const SyncConfig::Type type) -{ - for (const auto& opt : SyncOptions) - if (opt.type == type) - return std::string(opt.type_str); - return {}; -} - -constexpr std::string operator+(const std::string& prefix, const SyncConfig::Type type) -{ return prefix + to_string(type); } - -constexpr std::string operator+(const SyncConfig::Type type, const std::string& suffix) -{ return to_string(type) + suffix; } - struct SyncTargetConfig : SyncConfig { std::string url; std::string token; + bool operator==(const SyncTargetConfig&) const = default; }; enum class MissionHudVisibility { diff --git a/mods/src/file.cc b/mods/src/file.cc index 256b03aca..f24a200e4 100644 --- a/mods/src/file.cc +++ b/mods/src/file.cc @@ -18,16 +18,15 @@ std::filesystem::path MacLibraryDirectory(); // file_macos.mm namespace { -constexpr std::string_view kDefaultConfig = "community_patch_settings.toml"; -constexpr std::string_view kDefaultVars = "community_patch_runtime.vars"; -constexpr std::string_view kDefaultLog = "community_patch.log"; -constexpr std::string_view kDefaultBattles = "patch_battlelogs_sent.json"; +constexpr std::string_view kDefaultConfig = "community_patch_settings.toml"; +constexpr std::string_view kDefaultVars = "community_patch_runtime.vars"; +constexpr std::string_view kDefaultLog = "community_patch.log"; // Names used by older releases. constexpr std::string_view kLegacyVars = "community_path_runtime.vars"; constexpr std::string_view kLegacyParsed = "community_patch_settings_parsed.toml"; -std::string config_name, vars_name, log_name, battles_name, profile; +std::string config_name, vars_name, log_name, profile; const std::string default_config{kDefaultConfig}; std::filesystem::path profile_path; #ifdef _MODDBG @@ -131,10 +130,9 @@ void Init() } if (profile_path.empty()) { - config_name = kDefaultConfig; - vars_name = kDefaultVars; - log_name = kDefaultLog; - battles_name = kDefaultBattles; + config_name = kDefaultConfig; + vars_name = kDefaultVars; + log_name = kDefaultLog; return; } @@ -142,10 +140,9 @@ void Init() auto with_extension = [](const char* extension) { return ToUtf8(std::filesystem::path(profile_path).replace_extension(extension)); }; - config_name = with_extension(".toml"); - vars_name = with_extension(".vars"); - log_name = with_extension(".log"); - battles_name = with_extension(".json"); + config_name = with_extension(".toml"); + vars_name = with_extension(".vars"); + log_name = with_extension(".log"); } const std::string& Config() @@ -157,9 +154,6 @@ const std::string& Vars() const std::string& Log() { return log_name; } -const std::string& Battles() -{ return battles_name; } - const std::string& DefaultConfig() { return default_config; } diff --git a/mods/src/file.h b/mods/src/file.h index cdce7510a..a3b83082d 100644 --- a/mods/src/file.h +++ b/mods/src/file.h @@ -16,7 +16,6 @@ void Init(); const std::string& Config(); const std::string& Vars(); const std::string& Log(); -const std::string& Battles(); // The settings file name used without -ccm, for messages that point users at it. const std::string& DefaultConfig(); diff --git a/mods/src/patches/parts/sync/battlelogs.cc b/mods/src/patches/parts/sync/battlelogs.cc index aa8e740ae..72782655f 100644 --- a/mods/src/patches/parts/sync/battlelogs.cc +++ b/mods/src/patches/parts/sync/battlelogs.cc @@ -1,7 +1,6 @@ // Battle logs: the game only sends battle headers, so each new battle's journal is fetched from the game server // with the participants' player and alliance names resolved, then uploaded. -#include "file.h" #include "patches/parts/sync/sync.h" #include "version.h" @@ -11,11 +10,10 @@ #include #include -#include -#include #include #include #include +#include #include #include #include @@ -33,10 +31,6 @@ namespace using json = nlohmann::json; using Clock = std::chrono::steady_clock; - // Battles already sent, persisted so a restart does not resend the recent ones. The game lists far fewer - // headers than this at a time. - constexpr size_t kSentBattlesKept = 300; - // Names seen in battles and profile responses, kept for [sync] resolver_cache_ttl seconds. A busy server shows // a few thousand players a day; past this size, expired entries are dropped before adding more. constexpr size_t kNameCacheSize = 10'000; @@ -46,10 +40,16 @@ namespace template class NameCache { public: - std::optional Find(const Key& key) + std::optional Find(const Key& key, uint64_t generation) { std::scoped_lock lock(mutex_); - const auto it = entries_.find(key); + if (generation < generation_) + return std::nullopt; + if (generation != generation_) { + entries_.clear(); + generation_ = generation; + } + const auto it = entries_.find(key); if (it == entries_.end()) return std::nullopt; if (it->second.expires_at <= Clock::now()) { @@ -59,10 +59,16 @@ namespace return it->second.value; } - void Store(const Key& key, Value value) + void Store(const Key& key, Value value, uint64_t generation) { const auto expires_at = Clock::now() + std::chrono::seconds(Config::Get().sync.resolver_cache_ttl); std::scoped_lock lock(mutex_); + if (generation < generation_) + return; + if (generation != generation_) { + entries_.clear(); + generation_ = generation; + } if (entries_.size() >= kNameCacheSize) { std::erase_if(entries_, [now = Clock::now()](const auto& entry) { return entry.second.expires_at <= now; }); // Everything still valid (a long TTL and a lot of browsing): start over rather than grow past the bound. @@ -77,6 +83,7 @@ namespace Value value; Clock::time_point expires_at; }; + uint64_t generation_ = 0; std::mutex mutex_; std::unordered_map entries_; }; @@ -94,33 +101,17 @@ namespace // Shared with the processing thread and never destroyed, see sync.h. auto& players = *new NameCache; auto& alliances = *new NameCache; - auto& battles = *new BoundedQueue{"battle log", kSentBattlesKept}; - - // The newest kSentBattlesKept battles sent, oldest first. Loaded before the threads start, then only touched by - // the processing thread. - std::deque sent_battles; - - void RememberSent(uint64_t id) - { - sent_battles.push_back(id); - if (sent_battles.size() > kSentBattlesKept) - sent_battles.pop_front(); - } + struct Battle { + uint64_t id; + GameSession session; + }; + auto& battles = *new BoundedQueue{"battle log", kRecentBattles}; + auto& battle_mutex = *new std::mutex; + // Keep one fetch per journal in flight; target delivery is tracked by the canonical record owner. + auto& pending = *new std::set>; - // The game's Unity build, sent as the game client sends it. const std::string kUnityVersion = "6000.0.59f2-deadlock-fix-test"; - // Written by game hooks on the main thread and read by the battle-log thread. - struct GameSession { - std::mutex mutex; - std::string server_url; - std::string session_id; - // Until the game reports its own version. - std::string version = "1.000.51336"; - int32_t instance_id = 0; - }; - auto& session = *new GameSession; - std::string NewUuid() { #if _WIN32 @@ -175,6 +166,10 @@ namespace session.verify_ssl = options.verify_ssl; session.http = std::make_unique(); auto& http = *session.http; +#ifndef _MODDBG + http.SetConnectTimeout(cpr::ConnectTimeout{3'000}); + http.SetTimeout(cpr::Timeout{10'000}); +#endif http.SetAcceptEncoding(cpr::AcceptEncoding{}); http.SetHttpVersion(cpr::HttpVersion{cpr::HttpVersionCode::VERSION_1_1}); spdlog::debug("scopely session: proxy='{}', verify_ssl={}", options.proxy, options.verify_ssl); @@ -195,26 +190,30 @@ namespace // POSTs to the game server as the game client would, for data the client did not request itself. Empty on // error. Battle-log thread only. - std::string RequestGameData(const std::string& path, const std::string& body) + std::string RequestGameData(const GameSession& session, const std::string& path, const std::string& body) { + const auto current = Session(); + if (session.generation != current.generation) + return {}; auto& http = GameServer(); auto& headers = http.GetHeader(); { - std::scoped_lock lock(session.mutex); http.SetUrl(WithPath(session.server_url, path)); headers.insert_or_assign("X-AUTH-SESSION-ID", session.session_id); - headers.insert_or_assign("X-PRIME-VERSION", session.version); - headers.insert_or_assign("X-Instance-ID", fmt::format("{:03}", session.instance_id)); + headers.insert_or_assign("X-PRIME-VERSION", current.version); + headers.insert_or_assign("X-Instance-ID", fmt::format("{:03}", current.instance_id)); } headers.insert_or_assign("X-TRANSACTION-ID", NewUuid()); http.SetBody(body); const auto response = http.Post(); + if (session.generation != Session().generation) + return {}; if (response.status_code == 0) { logging::error("DOWNLOAD", path, "Failed to send request: {}", response.error.message); return {}; } - if (response.status_code >= 400) { + if (response.status_code < 200 || response.status_code >= 300) { logging::error("DOWNLOAD", path, "Failed to communicate with server: {}", response.status_line); return {}; } @@ -254,19 +253,19 @@ namespace // Fetches profiles the cache does not have. A malformed profile is skipped, not the whole reply. The results are // returned as well as cached, since a zero TTL expires them at once. - std::unordered_map FetchPlayers(const std::vector& ids) + std::unordered_map FetchPlayers(const GameSession& session, const std::vector& ids) { std::unordered_map fetched; if (ids.empty()) return fetched; logging::trace("PROCESS", "combat log", "Fetching {} player profiles", ids.size()); const auto reply = - json::parse(RequestGameData("/user_profile/profiles", json{{"user_ids", ids}}.dump()), nullptr, false); + json::parse(RequestGameData(session, "/user_profile/profiles", json{{"user_ids", ids}}.dump()), nullptr, false); const auto profiles = reply.is_object() ? reply.value("user_profiles", json::object()) : json::object(); for (const auto& [id, profile] : profiles.items()) { try { Player player{profile.at("name").get(), profile.at("alliance_id").get()}; - players.Store(id, player); + players.Store(id, player, session.generation); fetched.emplace(id, std::move(player)); } catch (const json::exception& e) { spdlog::error("Failed to parse user profile {}: {}", id, e.what()); @@ -275,7 +274,7 @@ namespace return fetched; } - std::unordered_map FetchAlliances(const std::vector& ids) + std::unordered_map FetchAlliances(const GameSession& session, const std::vector& ids) { std::unordered_map fetched; if (ids.empty()) @@ -283,13 +282,13 @@ namespace logging::trace("PROCESS", "combat log", "Fetching {} alliance profiles", ids.size()); const json request{{"user_current_rank", 0}, {"alliance_id", 0}, {"alliance_ids", ids}}; const auto reply = - json::parse(RequestGameData("/alliance/get_alliances_public_info", request.dump()), nullptr, false); + json::parse(RequestGameData(session, "/alliance/get_alliances_public_info", request.dump()), nullptr, false); const auto infos = reply.is_object() ? reply.value("alliances_info", json::object()) : json::object(); for (const auto& [key, info] : infos.items()) { try { const auto id = info.at("id").get(); Alliance alliance{info.at("name").get(), info.at("tag").get()}; - alliances.Store(id, alliance); + alliances.Store(id, alliance, session.generation); fetched.emplace(id, std::move(alliance)); } catch (const json::exception& e) { spdlog::error("Failed to parse alliance profile: {}", e.what()); @@ -301,29 +300,29 @@ namespace // {player id: {name, alliance_name, alliance_tag}} for everyone in the battle, fetching what the caches lack. // A player whose alliance could not be resolved keeps alliance_id instead of a name and tag. Players without an // alliance (id 0 or below) are not looked up. - json ResolveNames(const json& journal) + json ResolveNames(const GameSession& session, const json& journal) { std::unordered_map known; std::vector missing_players; for (const auto& id : Participants(journal)) { - if (auto player = players.Find(id)) + if (auto player = players.Find(id, session.generation)) known.emplace(id, std::move(*player)); else missing_players.push_back(id); } - known.merge(FetchPlayers(missing_players)); + known.merge(FetchPlayers(session, missing_players)); std::unordered_map known_alliances; std::unordered_set missing_alliances; for (const auto& player : known | std::views::values) { if (player.alliance <= 0 || known_alliances.contains(player.alliance)) continue; - if (auto alliance = alliances.Find(player.alliance)) + if (auto alliance = alliances.Find(player.alliance, session.generation)) known_alliances.emplace(player.alliance, std::move(*alliance)); else missing_alliances.insert(player.alliance); } - known_alliances.merge(FetchAlliances({missing_alliances.begin(), missing_alliances.end()})); + known_alliances.merge(FetchAlliances(session, {missing_alliances.begin(), missing_alliances.end()})); auto names = json::object(); for (const auto& [id, player] : known) { @@ -340,135 +339,97 @@ namespace return names; } - void UploadBattle(uint64_t journal_id) + bool UploadBattle(const Battle& work) { + const auto journal_id = work.id; + const auto& session = work.session; logging::trace("PROCESS", "combat log", "Fetching combat log for battle {}", journal_id); - const auto reply = RequestGameData("/journals/get", json{{"journal_id", journal_id}}.dump()); + const auto reply = RequestGameData(session, "/journals/get", json{{"journal_id", journal_id}}.dump()); if (reply.empty()) - return; + return false; auto battle = json::parse(reply, nullptr, false); if (!battle.is_object() || !battle.contains("journal")) { spdlog::error("Error parsing journal response from game server for battle {}", journal_id); - return; + return false; } auto& journal = battle["journal"]; - auto names = ResolveNames(journal); - Queue(SyncConfig::Type::kBattles, - json::array( - {{{"type", SyncConfig::Type::kBattles}, {"names", std::move(names)}, {"journal", std::move(journal)}}})); + auto names = ResolveNames(session, journal); + Publish(Stream::kBattles, {{journal_id, {{"names", std::move(names)}, {"journal", std::move(journal)}}}}, + session.generation); + std::scoped_lock lock(battle_mutex); + pending.erase({session.generation, journal_id}); + return session.generation == Session().generation; } void RunBattleLogs() { for (;;) { - const auto journal_id = battles.Pop(); - try { - UploadBattle(journal_id); - } catch (const std::exception& e) { - spdlog::error("Error processing combat log {}: {}", journal_id, e.what()); - } catch (...) { - spdlog::error("Unknown error processing combat log {}", journal_id); + const auto work = battles.Pop(); + bool fetched = false; + if (work.session.generation == Session().generation && Config::Get().sync.options.battlelogs) { + try { + fetched = UploadBattle(work); + } catch (const std::exception& e) { + spdlog::error("Error processing combat log {}: {}", work.id, e.what()); + } + if (!fetched && work.session.generation == Session().generation) { + // Retry at the back so one unavailable journal cannot starve all the others. + std::this_thread::sleep_for(std::chrono::seconds(1)); + std::scoped_lock lock(battle_mutex); + if (auto dropped = battles.Push(work)) + pending.erase({dropped->session.generation, dropped->id}); + continue; + } + } + if (!fetched) { + std::scoped_lock lock(battle_mutex); + pending.erase({work.session.generation, work.id}); } } } - void LoadSentBattles() - { - std::ifstream file(File::Path(File::Battles()), std::ios::binary); - if (!file) { - spdlog::warn("Failed to open battles file (not found or not readable); starting with empty cache"); - return; - } - const auto ids = json::parse(file, nullptr, false); - if (!ids.is_array()) { - spdlog::error("Failed to parse battles file"); - return; - } - for (const auto& id : ids) - if (id.is_number_unsigned()) - RememberSent(id.get()); - spdlog::debug("Loaded {} previously sent battle logs", sent_battles.size()); - } - - void SaveSentBattles() - { - auto ids = json::array(); - for (const auto id : sent_battles) - ids.push_back(id); - std::ofstream file(File::Path(File::Battles(), true), std::ios::binary | std::ios::trunc); - if (!file) { - spdlog::error("Failed to open battles file for writing"); - return; - } - file << ids.dump(); - } } // namespace -void QueueNewBattles(const std::vector& battle_ids) +void QueueNewBattles(const std::vector& ids, uint64_t generation) { - // Oldest first, so the oldest are forgotten first. - std::vector fresh; - for (const auto id : battle_ids | std::views::reverse) { - if (std::ranges::find(sent_battles, id) == sent_battles.end()) { - RememberSent(id); - fresh.push_back(id); - } - } - if (fresh.empty()) + const auto session = Session(); + if (session.generation != generation) return; - - logging::debug("QUEUE", "battle headers", "Queuing {} battles for background processing", fresh.size()); - for (const auto id : fresh) - battles.Push(id); - SaveSentBattles(); + std::scoped_lock lock(battle_mutex); + std::erase_if(pending, [&](const auto& entry) { return entry.first != generation; }); + for (auto id : ids | std::views::reverse) { + if (HasBattle(id, generation) || !pending.emplace(generation, id).second) + continue; + if (auto dropped = battles.Push({id, session})) + pending.erase({dropped->session.generation, dropped->id}); + } } -void CachePlayerNames(const std::string& bytes) +void CachePlayerNames(const Input& input) { Digit::PrimeServer::Models::UserProfilesResponse response; - if (!response.ParseFromString(bytes)) { + if (!response.ParseFromString(input.bytes)) { spdlog::error("Failed to parse user profile"); return; } for (const auto& profile : response.userprofiles()) - players.Store(profile.userid(), {profile.name(), profile.allianceid()}); + players.Store(profile.userid(), {profile.name(), profile.allianceid()}, input.generation); } -void CacheAllianceNames(const std::string& bytes) +void CacheAllianceNames(const Input& input) { Digit::PrimeServer::Models::GetAllianceProfilesResponse response; - if (!response.ParseFromString(bytes)) { + if (!response.ParseFromString(input.bytes)) { spdlog::error("Failed to parse alliance profile"); return; } for (const auto& alliance : response.allianceprofiles()) if (alliance.id() > 0) - alliances.Store(alliance.id(), {alliance.name(), alliance.tag()}); + alliances.Store(alliance.id(), {alliance.name(), alliance.tag()}, input.generation); } void StartBattleLogs() -{ - LoadSentBattles(); - std::thread(RunBattleLogs).detach(); -} +{ std::thread(RunBattleLogs).detach(); } -void SetGameServer(std::string url, std::string session_id) -{ - std::scoped_lock lock(session.mutex); - session.server_url = std::move(url); - session.session_id = std::move(session_id); -} - -void SetGameVersion(std::string version) -{ - std::scoped_lock lock(session.mutex); - session.version = std::move(version); -} - -void SetInstanceId(int32_t instance_id) -{ - std::scoped_lock lock(session.mutex); - session.instance_id = instance_id; -} } // namespace data_sync diff --git a/mods/src/patches/parts/sync/fleets.cc b/mods/src/patches/parts/sync/fleets.cc index 742d0b460..ed98a8cf9 100644 --- a/mods/src/patches/parts/sync/fleets.cc +++ b/mods/src/patches/parts/sync/fleets.cc @@ -1,6 +1,6 @@ // Live fleet state for the `fleets` category: one record per FleetState transition from fleet_watch, a full snapshot // as a first sync, and late re-reads after server updates. Everything that reads the game runs on the game thread -// and only queues; uploads stay on the upload thread. +// and only publishes; uploads stay on the target workers. #include "patches/parts/sync/sync.h" @@ -34,8 +34,9 @@ namespace std::mutex reread_mutex; std::unordered_map reread_at; - // Set once fleet_watch accepted the subscription. Sync's frame work then drives fleet_watch. - bool fleet_sync_installed = false; + // Set once fleet_watch accepted the subscription. Sync's frame work then drives fleet_watch, and the processing + // thread's re-read requests are kept. + std::atomic_bool fleet_sync_installed{false}; const char* StateName(FleetState state) { @@ -161,8 +162,7 @@ namespace // those never come out negative in the record. const int64_t hull_id = hull ? static_cast(static_cast(hull->Id())) : 0; - json out = {{"type", SyncConfig::Type::kFleets}, - {"fid", fleet->Id()}, + json out = {{"fid", fleet->Id()}, {"slot", slot}, {"hull_id", hull_id}, {"level", fleet->Level()}, @@ -177,6 +177,14 @@ namespace return out; } + // Every batch is its own journal entry, delivered in order and never merged with a later one: Mining -> Docked -> + // Repairing between two uploads still sends all three. Game thread only. + void Append(json records, bool snapshot) + { + static uint64_t sequence = 0; + Publish(Stream::kFleets, {{json::array({++sequence, snapshot}), std::move(records)}}, Session().generation); + } + // A snapshot is the complete list of occupied slots, so it is sent as a first sync. void QueueSnapshot(const char* reason) { @@ -191,18 +199,17 @@ namespace if (!records.empty()) { logging::debug("PROCESS", "fleets", "Fleet snapshot ({}): {} fleets", reason, records.size()); - Queue(SyncConfig::Type::kFleets, records, true); + Append(std::move(records), true); } } - // Each transition is its own upload, so Mining -> Docked -> Repairing between two uploads still sends all three. void OnTransition(const fleet_watch::Transition& transition) { if (!transition.fleet) return; auto records = json::array(); records.push_back(Record(transition.after.slot, transition.fleet, transition.after.state, transition.before.state)); - Queue(SyncConfig::Type::kFleets, records); + Append(std::move(records), false); } void OnBaseline() @@ -244,7 +251,7 @@ namespace records.push_back(std::move(record)); } if (!records.empty()) - Queue(SyncConfig::Type::kFleets, records); + Append(std::move(records), false); } } // namespace @@ -265,6 +272,9 @@ void RequestFleetSnapshot() void RereadFleet(uint64_t fleet_id) { + // Deployed fleets are processed whether or not a target wants them; nothing would drain these. + if (!fleet_sync_installed) + return; std::scoped_lock lock(reread_mutex); reread_at.try_emplace(fleet_id, std::chrono::steady_clock::now() + std::chrono::seconds(1)); } diff --git a/mods/src/patches/parts/sync/records.cc b/mods/src/patches/parts/sync/records.cc index 60931ed55..57409d8f4 100644 --- a/mods/src/patches/parts/sync/records.cc +++ b/mods/src/patches/parts/sync/records.cc @@ -1,35 +1,25 @@ -// What each data category sends: the processors that turn the game's server responses and realtime messages into -// the records sync targets receive. Each keeps the last state it sent per record, so only changes go out. They all -// run on the processing thread, in the order the game received the data. - +// Game responses become canonical wire records. Delivery owns comparison, removal and initial-sync state. #include "patches/parts/sync/sync.h" #include "str_utils.h" -#include "prime/UserProfileManager.h" - #include #include -#include -#include -#include - #include #include #include #include -#include +#include #include -#include +#include +#include #include -#include -#include NLOHMANN_JSON_NAMESPACE_BEGIN template struct adl_serializer> { - static void to_json(json& j, const google::protobuf::RepeatedField& proto) + static void to_json(json& j, const google::protobuf::RepeatedField& values) { j = json::array(); - for (const auto& v : proto) - j.push_back(v); + for (const auto& value : values) + j.push_back(value); } }; NLOHMANN_JSON_NAMESPACE_END @@ -42,605 +32,346 @@ namespace namespace pb = Digit::PrimeServer::Models; namespace content = Digit::PrimePlatform::Content; - struct PairHash { - template size_t operator()(const std::pair& pair) const - { return std::hash()(pair.first) ^ (std::hash()(pair.second) << 1); } - }; - - // The last value sent for each record of one category, so only changes are uploaded. Everything below runs on - // the processing thread, so none of it is locked. - template > class SentState - { - public: - // Records `value` and reports whether it differs from what was last sent. - bool Changed(const Key& key, const Value& value) - { - auto [it, inserted] = sent_.try_emplace(key, value); - if (inserted) - return true; - if (it->second == value) - return false; - it->second = value; - return true; - } - - // Forgets every record not in `present` and calls `gone` for each. - template void Prune(const Present& present, Gone&& gone) - { - std::erase_if(sent_, [&](const auto& entry) { - if (present.contains(entry.first)) - return false; - gone(entry.first); - return true; - }); - } - - private: - std::unordered_map sent_; - }; - - // Each category's first upload in a session is marked as a full sync for the targets. - struct FirstSync { - bool pending = true; - bool Take() - { return std::exchange(pending, false); } - }; - - // The changed records of one category, queued for upload in one request. - class Batch - { - public: - explicit Batch(SyncConfig::Type type) - : type_(type) - { - } - - void Add(json record) - { records_.push_back(std::move(record)); } - - void Send(FirstSync* first = nullptr) - { - if (!records_.empty()) - Queue(type_, records_, first && first->Take()); - } - - private: - SyncConfig::Type type_; - json records_ = json::array(); - }; - - template std::optional Parse(const std::string& bytes, std::string_view what) + // Build the complete response before publishing: a malformed payload never partially changes current state. + template + void Read(const Input& input, Stream stream, Build build, Update mode = Update::kMerge) { Message message; - if (message.ParseFromString(bytes)) - return message; - spdlog::error("Failed to parse {}", what); - return std::nullopt; - } - - void Trace(std::string_view what, size_t count) - { logging::trace("PROCESS", what, "Processing {}", count); } - - // Officers and forbidden tech: rank or tier, level, shards. - using RankLevelShards = std::tuple; - - struct ShipState { - int32_t tier; - int32_t level; - double level_percentage; - std::vector components; - - // Level progress jitters in the last digits; only a real change is worth an upload. - bool operator==(const ShipState& other) const - { - return tier == other.tier && level == other.level && std::fabs(level_percentage - other.level_percentage) < 0.01 - && components == other.components; + if (!message.ParseFromString(input.bytes)) { + spdlog::error("Failed to parse {}", message.GetTypeName()); + return; } - }; - - // Everything the processing thread remembers about this session. A new login starts it over. - struct SentData { - SentState modules, resources, alliance_resources, slots; - SentState haven_pieces; - SentState> haven_map_buildings; - SentState> buffs; - SentState, int64_t, PairHash> inventory; - SentState active_jobs; - SentState officers, techs; - SentState research; - SentState, int32_t, PairHash> traits; - SentState ships; - SentState away_assignments; - // A hash of each deployed fleet's server record. - SentState deployed_fleets; - std::unordered_set active_missions, completed_missions; - // The first active mission list of a session is always sent, even empty, so a target can drop stale ones. - bool active_missions_sent = false; - int32_t emerald_chain_level = -1; - // The last store offers snapshot sent. - json store_offers; - // The protobuf and JSON resource lists are flagged separately, as two different first uploads. - FirstSync first_away_assignments, first_buffs, first_inventory, first_jobs, first_resources, first_json_resources, - first_alliance_resources, first_ships, first_profile, first_haven_pieces, first_haven_map; - // The own profile's last level, Power and Haven unlock sent. XP rides along but is not part of the key: an - // XP-only change waits for the next keyed change. - std::optional> own_profile; - }; - // Processing thread only, so not locked. Never destroyed, see sync.h. - auto& sent = *new SentData; + RecordSet records; + build(message, records); + Publish(stream, std::move(records), input.generation, mode); + } + json Seconds(bool has, int64_t seconds) + { return has ? json(seconds) : json(nullptr); } - // An away assignment is compared by a hash of its whole record: officers, state, job, traits and chances. - void AddAwayAssignment(Batch& batch, const pb::AwayAssignmentInstance& instance) + json AwayAssignment(const pb::AwayAssignmentInstance& instance) { - // officerIds is a protobuf map, whose order is not slot order. Sorting keeps the array, and so the 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& [slot, officer] : officer_slots) - if (officer != 0) - officer_ids.push_back(officer); - - auto officer_traits = json::array(); + std::vector> slots(instance.officerids().begin(), instance.officerids().end()); + std::ranges::sort(slots, {}, &std::pair::first); + auto officers = json::array(); + for (const auto& [slot, id] : slots) + if (id) + officers.push_back(id); + auto traits = json::array(); for (const auto& trait : instance.officertraits()) - officer_traits.push_back({{"tid", trait.traitid()}, {"max_level", trait.maxlevel()}}); - - // Same ordering concern as officer_ids: sort by trait id so the array and the de-dup hash are stable. - std::vector> critical_traits(instance.parameters().criticaltraitscores().begin(), - instance.parameters().criticaltraitscores().end()); - std::ranges::sort(critical_traits, {}, &std::pair::first); - auto critical_trait_scores = json::array(); - for (const auto& [tid, score] : critical_traits) - critical_trait_scores.push_back({{"tid", tid}, {"score", score}}); - - // The chances are in the de-dup hash, so an assignment is sent again when they change. - json record{{"type", SyncConfig::Type::kAwayAssignments}, - {"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()}, - {"officer_traits", officer_traits}, - {"critical_trait_scores", critical_trait_scores}, - {"success_chance", instance.parameters().successchance()}, - {"critical_success_chance", instance.parameters().criticalsuccesschance()}, - {"max_critical_success_chance", instance.maxcriticalsuccesschance()}, - {"key_stat", instance.keystat()}, - {"max_assignable_officers", instance.maxassignableofficerscount()}, - {"attack_weight", instance.attackweight()}, - {"defense_weight", instance.defenseweight()}, - {"health_weight", instance.healthweight()}}; - if (sent.away_assignments.Changed(instance.id(), std::hash{}(record))) - batch.Add(std::move(record)); + traits.push_back({{"tid", trait.traitid()}, {"max_level", trait.maxlevel()}}); + std::vector> scores(instance.parameters().criticaltraitscores().begin(), + instance.parameters().criticaltraitscores().end()); + std::ranges::sort(scores, {}, &std::pair::first); + auto critical = json::array(); + for (const auto& [id, score] : scores) + critical.push_back({{"tid", id}, {"score", score}}); + return {{"aid", instance.id()}, + {"template_id", instance.awayassignmenttemplateid()}, + {"state", instance.state()}, + {"officer_ids", officers}, + {"job_uuid", instance.jobuuid()}, + {"duration", instance.parameters().duration()}, + {"rarity", instance.rarity()}, + {"officer_traits", traits}, + {"critical_trait_scores", critical}, + {"success_chance", instance.parameters().successchance()}, + {"critical_success_chance", instance.parameters().criticalsuccesschance()}, + {"max_critical_success_chance", instance.maxcriticalsuccesschance()}, + {"key_stat", instance.keystat()}, + {"max_assignable_officers", instance.maxassignableofficerscount()}, + {"attack_weight", instance.attackweight()}, + {"defense_weight", instance.defenseweight()}, + {"health_weight", instance.healthweight()}}; + } + void ProcessAwayAssignments(const Input& input) + { + Read( + input, Stream::kAway, + [](const auto& response, auto& records) { + for (const auto& instance : response.instances()) + records[instance.id()] = AwayAssignment(instance); + }, + Update::kReplace); + } + void ProcessAwayAssignment(const Input& input) + { + Read(input, Stream::kAway, [](const auto& instance, auto& records) { + records[instance.id()] = AwayAssignment(instance); + }); } - - // The full list. An assignment that left it was collected. - void ProcessAwayAssignments(const std::string& bytes) + void ProcessBattleHeaders(const Input& input) { - auto response = Parse(bytes, "away assignments"); - if (!response) + pb::BattleResultHeadersResponse response; + if (!response.ParseFromString(input.bytes)) { + spdlog::error("Failed to parse battle headers"); return; - Trace("away assignments", response->instances_size()); - - Batch batch(SyncConfig::Type::kAwayAssignments); - std::unordered_set present; - for (const auto& instance : response->instances()) { - present.insert(instance.id()); - AddAwayAssignment(batch, instance); } - sent.away_assignments.Prune(present, [&](int64_t id) { - batch.Add({{"type", "collected_" + SyncConfig::Type::kAwayAssignments}, {"aid", id}}); + std::vector ids; + for (const auto& header : response.headers()) + ids.push_back(static_cast(header.id())); + QueueNewBattles(ids, input.generation); + } + void ProcessBuffs(const Input& input) + { + Read( + input, Stream::kBuffs, + [](const auto& response, auto& records) { + for (const auto& buff : response.globalactivebuffs()) { + const bool expires = buff.has_activebuff() && buff.activebuff().has_expirytime(); + records[buff.buffid()] = { + {"bid", buff.buffid()}, + {"level", buff.level()}, + {"expiry_time", Seconds(expires, expires ? buff.activebuff().expirytime().seconds() : 0)}}; + } + }, + Update::kReplace); + } + void ProcessAllianceGameProperties(const Input& input) + { + Read(input, Stream::kEmerald, [](const auto& response, auto& records) { + for (const auto& property : response.properties()) + if (property.propertyname() == "claimed_loyalty_tiers") { + int32_t level = -1; + for (const auto& tier : property.valuelist()) { + int32_t value = -1; + std::from_chars(tier.data(), tier.data() + tier.size(), value); + level = std::max(level, value); + } + records[nullptr] = {{"level", level}}; + break; + } }); - batch.Send(&sent.first_away_assignments); } - - // One assignment as it starts: first ready to start with its officers set, then in progress with its job. - void ProcessAwayAssignment(const std::string& bytes) + void ProcessStarbaseModules(const Input& input) { - auto instance = Parse(bytes, "away assignment instance"); - if (!instance) - return; - logging::trace("PROCESS", "away assignment", "Processing away assignment {}", instance->id()); - - Batch batch(SyncConfig::Type::kAwayAssignments); - AddAwayAssignment(batch, *instance); - batch.Send(); - } - - void ProcessBattleHeaders(const std::string& bytes) - { - auto response = Parse(bytes, "battle result headers"); - if (!response) - return; - Trace("battle headers", response->headers_size()); - - std::vector battle_ids; - for (const auto& header : response->headers()) - battle_ids.push_back(static_cast(header.id())); - QueueNewBattles(battle_ids); + Read(input, Stream::kBuildings, [](const auto& response, auto& records) { + for (const auto& module : response.modulestates()) + records[module.id()] = {{"bid", module.id()}, {"level", module.level()}}; + }); } - - void ProcessBuffs(const std::string& bytes) + // An empty list is not a complete Haven; upstream never sends one, and the first-sync flag waits for a real list. + void PublishHaven(Stream stream, RecordSet records, uint64_t generation) { - auto response = Parse(bytes, "global active buffs"); - if (!response) - return; - Trace("global buffs", response->globalactivebuffs_size()); - - Batch batch(SyncConfig::Type::kBuffs); - std::unordered_set present; - for (const auto& buff : response->globalactivebuffs()) { - present.insert(buff.buffid()); - const bool expires = buff.has_activebuff() && buff.activebuff().has_expirytime(); - const int64_t expiry = expires ? buff.activebuff().expirytime().seconds() : -1; - if (sent.buffs.Changed(buff.buffid(), {buff.level(), expiry})) - batch.Add({{"type", SyncConfig::Type::kBuffs}, - {"bid", buff.buffid()}, - {"level", buff.level()}, - {"expiry_time", expires ? json(expiry) : json(nullptr)}}); - } - sent.buffs.Prune(present, - [&](int64_t id) { batch.Add({{"type", "expired_" + SyncConfig::Type::kBuffs}, {"bid", id}}); }); - batch.Send(&sent.first_buffs); + if (!records.empty()) + Publish(stream, std::move(records), generation, Update::kFull); } - - // The emerald chain level is the highest tier the alliance has claimed. - void ProcessAllianceGameProperties(const std::string& bytes) + void ProcessHavenBase(const Input& input) { - auto response = Parse(bytes, "alliance games properties"); - if (!response) - return; - - for (const auto& property : response->properties()) { - if (property.propertyname() != "claimed_loyalty_tiers") - continue; - int32_t level = -1; - for (const auto& tier : property.valuelist()) { - int32_t value = -1; - std::from_chars(tier.data(), tier.data() + tier.size(), value); - level = std::max(level, value); - } - if (level != std::exchange(sent.emerald_chain_level, level)) { - Batch batch(SyncConfig::Type::kEmeraldChain); - batch.Add({{"type", SyncConfig::Type::kEmeraldChain}, {"level", level}}); - batch.Send(); - } + pb::PlanetaryBase response; + if (!response.ParseFromString(input.bytes)) { + spdlog::error("Failed to parse {}", response.GetTypeName()); return; } + RecordSet records; + for (const auto& [piece, count] : response.pieces()) + records[piece] = {{"sid", piece}, {"count", count}}; + PublishHaven(Stream::kHavenPieces, std::move(records), input.generation); } - - void ProcessStarbaseModules(const std::string& bytes) - { - auto response = Parse(bytes, "starbase modules"); - if (!response) - return; - Trace("starbase modules", response->modulestates_size()); - - Batch batch(SyncConfig::Type::kBuildings); - for (const auto& module : response->modulestates()) - if (sent.modules.Changed(module.id(), module.level())) - batch.Add({{"type", SyncConfig::Type::kBuildings}, {"bid", module.id()}, {"level", module.level()}}); - batch.Send(); - } - - void ProcessHavenBase(const std::string& bytes) + void HavenBuildings(const pb::PlanetaryMapData& response, RecordSet& records) { - auto response = Parse(bytes, "planetary base"); - if (!response) - return; - Trace("planetary base data", response->pieces_size()); - - Batch batch(SyncConfig::Type::kHaven); - for (const auto& [piece, count] : response->pieces()) - if (sent.haven_pieces.Changed(piece, count)) - batch.Add({{"type", SyncConfig::Type::kHaven + "_pieces"}, {"sid", piece}, {"count", count}}); - batch.Send(&sent.first_haven_pieces); + for (const auto& [id, building] : response.buildings()) + records[id] = {{"sid", building.specid()}, + {"id", id}, + {"level", building.level()}, + {"status", building.status()}, + {"position", building.position()}}; } - - void ProcessHavenMap(const std::string& bytes) + void ProcessHavenMap(const Input& input) { - auto response = Parse(bytes, "planetary map"); - if (!response) + pb::PlanetaryMapData response; + if (!response.ParseFromString(input.bytes)) { + spdlog::error("Failed to parse {}", response.GetTypeName()); return; - Trace("planetary map data", response->buildings_size()); - - Batch batch(SyncConfig::Type::kHaven); - for (const auto& [id, building] : response->buildings()) - if (sent.haven_map_buildings.Changed(id, {building.level(), static_cast(building.status()), building.position()})) - batch.Add({{"type", SyncConfig::Type::kHaven}, {"sid", building.specid()}, {"id", id}, - {"level", building.level()}, {"status", building.status()}, {"position", building.position()}}); - batch.Send(&sent.first_haven_map); + } + RecordSet records; + HavenBuildings(response, records); + PublishHaven(Stream::kHaven, std::move(records), input.generation); } - - void ProcessHavenMapResponse(const std::string& bytes) + void ProcessHavenResponse(const Input& input) { - auto response = Parse(bytes, "planetary map response"); - if (!response) + pb::PlanetaryMapResponse response; + if (!response.ParseFromString(input.bytes)) { + spdlog::error("Failed to parse planetary map response"); return; - const auto& owner = response->owneruserid(); - if (!owner.empty()) { - auto* profile = LocalUserProfile(); - auto* user_id = profile ? profile->UserId() : nullptr; - if (!user_id || owner != il2cpp::ToUtf8(user_id)) - return; } - Batch batch(SyncConfig::Type::kHaven); - for (const auto& [id, building] : response->mapdata().buildings()) - if (sent.haven_map_buildings.Changed(id, {building.level(), static_cast(building.status()), building.position()})) - batch.Add({{"type", SyncConfig::Type::kHaven}, {"sid", building.specid()}, {"id", id}, - {"level", building.level()}, {"status", building.status()}, {"position", building.position()}}); - batch.Send(&sent.first_haven_map); - } - - void ProcessHavenProsperity(const std::string& bytes) - { - auto response = Parse(bytes, "planetary map prosperity"); - if (!response) + if (!response.owneruserid().empty() && response.owneruserid() != input.user_id) return; - - Batch batch(SyncConfig::Type::kHaven); - batch.Add({{"type", SyncConfig::Type::kHaven + "_prosperity"}, {"prosperity", response->prosperity()}}); - batch.Send(); + RecordSet records; + HavenBuildings(response.mapdata(), records); + PublishHaven(Stream::kHaven, std::move(records), input.generation); } - - void ProcessInventory(const std::string& bytes) + void ProcessHavenProsperity(const Input& input) { - auto response = Parse(bytes, "player inventories"); - if (!response) - return; - Trace("player inventories", response->inventories_size()); - - Batch batch(SyncConfig::Type::kInventory); - for (const auto& items : response->inventories() | std::views::values) { - for (const auto& item : items.items()) { - if (!item.has_commonparams()) - continue; - const auto id = item.commonparams().refid(); - if (sent.inventory.Changed({item.type(), id}, item.count())) - batch.Add({{"type", SyncConfig::Type::kInventory}, - {"item_type", item.type()}, - {"refid", id}, - {"count", item.count()}}); - } - } - batch.Send(&sent.first_inventory); + Read(input, Stream::kHavenProsperity, [](const auto& response, auto& records) { + records[nullptr] = {{"prosperity", response.prosperity()}}; + }); } - - // The fields that identify what a job is working on, or nothing for job types that are not synced. - std::optional JobDetails(const pb::Job& job) - { + void ProcessInventory(const Input& input) + { + Read( + input, Stream::kInventory, + [](const auto& response, auto& records) { + for (const auto& inventory : response.inventories() | std::views::values) + for (const auto& item : inventory.items()) + if (item.has_commonparams()) { + const auto id = item.commonparams().refid(); + records[json::array({item.type(), id})] = { + {"item_type", item.type()}, {"refid", id}, {"count", item.count()}}; + } + }, + Update::kFull); + } + std::optional Job(const pb::Job& job) + { + json record{{"job_type", job.type()}, + {"uuid", job.uuid()}, + {"start_time", job.starttime().seconds()}, + {"duration", job.duration()}, + {"reduction", job.reductioninseconds()}}; switch (job.type()) { case pb::JOBTYPE_RESEARCH: - return json{{"rid", job.researchparams().projectid()}, {"level", job.researchparams().level()}}; + record.update({{"rid", job.researchparams().projectid()}, {"level", job.researchparams().level()}}); + break; case pb::JOBTYPE_STARBASECONSTRUCTION: - return json{{"bid", job.starbaseconstructionparams().moduleid()}, - {"level", job.starbaseconstructionparams().level()}}; + record.update({{"bid", job.starbaseconstructionparams().moduleid()}, + {"level", job.starbaseconstructionparams().level()}}); + break; case pb::JOBTYPE_SHIPTIERUP: - return json{{"psid", job.tierupshipparams().shipid()}, {"tier", job.tierupshipparams().newtier()}}; + record.update({{"psid", job.tierupshipparams().shipid()}, {"tier", job.tierupshipparams().newtier()}}); + break; case pb::JOBTYPE_SHIPSCRAP: - return json{{"psid", job.scrapyardparams().shipid()}, - {"hull_id", job.scrapyardparams().hullid()}, - {"level", job.scrapyardparams().level()}}; + record.update({{"psid", job.scrapyardparams().shipid()}, + {"hull_id", job.scrapyardparams().hullid()}, + {"level", job.scrapyardparams().level()}}); + break; case pb::JOBTYPE_AWAYASSIGNMENT: - return json{{"aid", job.awayassignmentparams().awayassignmentinstanceid()}}; + record["aid"] = job.awayassignmentparams().awayassignmentinstanceid(); + break; // Part of fleet sync: jobs-only targets see nothing new. The repair changes the fleet's state later, so the // fleet is re-read then. case pb::JOBTYPE_REPAIRFLEET: if (!Config::Get().sync.options.fleets) return std::nullopt; RereadFleet(job.repairfleetparams().fleetid()); - return json{{"fid", job.repairfleetparams().fleetid()}}; + record["fid"] = job.repairfleetparams().fleetid(); + break; default: return std::nullopt; } - } - - // Jobs are sent when they start and reported completed when they leave the list. - void ProcessJobs(const std::string& bytes) - { - auto response = Parse(bytes, "jobs"); - if (!response) - return; - Trace("jobs", response->jobs_size()); - - Batch batch(SyncConfig::Type::kJobs); - std::unordered_set present; - for (const auto& job : response->jobs()) { - present.insert(job.uuid()); - if (!sent.active_jobs.Changed(job.uuid(), true)) - continue; - auto details = JobDetails(job); - if (!details) - continue; - json record{{"type", SyncConfig::Type::kJobs}, - {"job_type", job.type()}, - {"uuid", job.uuid()}, - {"start_time", job.starttime().seconds()}, - {"duration", job.duration()}, - {"reduction", job.reductioninseconds()}}; - record.update(*details); - batch.Add(std::move(record)); - } - sent.active_jobs.Prune(present, [&](const std::string& uuid) { - batch.Add({{"type", "completed_" + SyncConfig::Type::kJobs}, {"uuid", uuid}}); + return record; + } + void ProcessJobs(const Input& input) + { + Read( + input, Stream::kJobs, + [](const auto& response, auto& records) { + for (const auto& job : response.jobs()) + if (auto record = Job(job)) + records[job.uuid()] = std::move(*record); + }, + Update::kReplace); + } + void ProcessActiveMissions(const Input& input) + { + Read(input, Stream::kActiveMissions, [](const auto& response, auto& records) { + std::set ids; + for (const auto& mission : response.activemissions()) + ids.insert(mission.id()); + auto missions = json::array(); + for (const auto id : ids) + missions.push_back({{"mid", id}}); + if (missions.empty()) + missions.push_back({{"mid", nullptr}}); + records[nullptr] = std::move(missions); }); - batch.Send(&sent.first_jobs); } - - // The whole set of active missions is sent whenever it changes; an empty set as one record with a null "mid". - void ProcessActiveMissions(const std::string& bytes) + void ProcessCompletedMissions(const Input& input) { - auto response = Parse(bytes, "active missions"); - if (!response) - return; - Trace("active missions", response->activemissions_size()); - - std::unordered_set current; - for (const auto& mission : response->activemissions()) - current.insert(mission.id()); - if (sent.active_missions_sent && current == sent.active_missions) - return; - sent.active_missions_sent = true; - sent.active_missions = std::move(current); - - Batch batch(SyncConfig::Type::kMissions); - for (const auto mission : sent.active_missions) - batch.Add({{"type", "active_" + SyncConfig::Type::kMissions}, {"mid", mission}}); - // An empty set has no records to send, so targets would keep showing the last mission active. One record with - // a null mission id stands for "no active missions". - if (sent.active_missions.empty()) - batch.Add({{"type", "active_" + SyncConfig::Type::kMissions}, {"mid", nullptr}}); - batch.Send(); - } - - // Completed missions only grow, so only the new ones are sent. - void ProcessCompletedMissions(const std::string& bytes) - { - auto response = Parse(bytes, "completed missions"); - if (!response) - return; - Trace("completed missions", response->completedmissions_size()); - - Batch batch(SyncConfig::Type::kMissions); - for (const auto mission : response->completedmissions()) - if (sent.completed_missions.insert(mission).second) - batch.Add({{"type", SyncConfig::Type::kMissions}, {"mid", mission}}); - batch.Send(); - } - - void ProcessOfficers(const std::string& bytes) - { - auto response = Parse(bytes, "officers"); - if (!response) - return; - Trace("officers", response->officers_size()); - - Batch batch(SyncConfig::Type::kOfficer); - for (const auto& officer : response->officers()) - if (sent.officers.Changed(officer.id(), {officer.rankindex(), officer.level(), officer.shardcount()})) - batch.Add({{"type", SyncConfig::Type::kOfficer}, - {"oid", officer.id()}, - {"rank", officer.rankindex()}, - {"level", officer.level()}, - {"shard_count", officer.shardcount()}}); - batch.Send(); + Read(input, Stream::kCompletedMissions, [](const auto& response, auto& records) { + for (const auto id : response.completedmissions()) + records[id] = {{"mid", id}}; + }); } - - void ProcessResearch(const std::string& bytes) + void ProcessOfficers(const Input& input) { - auto response = Parse(bytes, "research trees state"); - if (!response) - return; - Trace("research projects", response->researchprojectlevels_size()); - - Batch batch(SyncConfig::Type::kResearch); - for (const auto& [id, level] : response->researchprojectlevels()) - if (sent.research.Changed(id, level)) - batch.Add({{"type", SyncConfig::Type::kResearch}, {"rid", id}, {"level", level}}); - batch.Send(); + Read(input, Stream::kOfficers, [](const auto& response, auto& records) { + for (const auto& officer : response.officers()) + records[officer.id()] = {{"oid", officer.id()}, + {"rank", officer.rankindex()}, + {"level", officer.level()}, + {"shard_count", officer.shardcount()}}; + }); } - - // Resource amounts arrive as full lists and as deltas, for the player and the alliance bank. All feed the same - // last-sent state per owner, which is why every response goes through one thread in order. - void AddResource(Batch& batch, SentState& state, std::string_view prefix, int64_t id, - int64_t amount) + void ProcessResearch(const Input& input) { - if (state.Changed(id, amount)) - batch.Add( - {{"type", std::string(prefix) + to_string(SyncConfig::Type::kResources)}, {"rid", id}, {"amount", amount}}); + Read(input, Stream::kResearch, [](const auto& response, auto& records) { + for (const auto& [id, level] : response.researchprojectlevels()) + records[id] = {{"rid", id}, {"level", level}}; + }); } - - void ProcessResources(const std::string& bytes) + void ProcessResources(const Input& input) { - auto response = Parse(bytes, "resources"); - if (!response) - return; - Trace("resources", response->resources_size()); - - Batch batch(SyncConfig::Type::kResources); - for (const auto& resource : response->resources()) - AddResource(batch, sent.resources, "", resource.id(), resource.currentamount()); - batch.Send(&sent.first_resources); + Read( + input, Stream::kResources, + [](const auto& response, auto& records) { + for (const auto& resource : response.resources()) + records[resource.id()] = {{"rid", resource.id()}, {"amount", resource.currentamount()}}; + }, + Update::kFull); } - - void ProcessResourcesDelta(const std::string& bytes) + void ProcessResourcesDelta(const Input& input) { - auto response = Parse(bytes, "resources delta"); - if (!response) - return; - Trace("resource changes", response->resources_size()); - - Batch batch(SyncConfig::Type::kResources); - for (const auto& resource : response->resources()) - AddResource(batch, sent.resources, "", resource.id(), resource.amount()); - batch.Send(); + Read(input, Stream::kResources, [](const auto& response, auto& records) { + for (const auto& resource : response.resources()) + records[resource.id()] = {{"rid", resource.id()}, {"amount", resource.amount()}}; + }); } - - void ProcessAllianceResources(const std::string& bytes) + void ProcessAllianceResources(const Input& input) { - auto response = Parse(bytes, "alliance resources"); - if (!response) - return; - Trace("alliance resources", response->items_size()); - - Batch batch(SyncConfig::Type::kResources); - for (const auto& item : response->items()) - if (item.has_commonparams()) - AddResource(batch, sent.alliance_resources, "alliance_", item.commonparams().refid(), item.count()); - batch.Send(&sent.first_alliance_resources); + Read( + input, Stream::kAllianceResources, + [](const auto& response, auto& records) { + for (const auto& item : response.items()) + if (item.has_commonparams()) { + const auto id = item.commonparams().refid(); + records[id] = {{"rid", id}, {"amount", item.count()}}; + } + }, + Update::kFull); } - - void ProcessAllianceResourcesDelta(const std::string& bytes) + void ProcessAllianceResourcesDelta(const Input& input) { - auto response = Parse(bytes, "alliance resources delta"); - if (!response) - return; - Trace("alliance resource changes", response->resources_size()); - - Batch batch(SyncConfig::Type::kResources); - for (const auto& resource : response->resources()) - AddResource(batch, sent.alliance_resources, "alliance_", resource.id(), resource.amount()); - batch.Send(); + Read( + input, Stream::kAllianceResources, [](const auto& response, auto& records) { + for (const auto& resource : response.resources()) + records[resource.id()] = {{"rid", resource.id()}, {"amount", resource.amount()}}; + }); } - // Slots change through server responses and through realtime messages, which describe the same slot in - // different shapes. Both reduce it to its params and one state value: the item id, or a hash of the params - // for slot types whose params are the content. - void AddSlot(Batch& batch, int64_t id, int32_t slot_type, const json& spec_id, const json& item_id, - const json& params, int64_t state) + // Both transports use these same parameter shapes. Realtime timestamps and fleet setup abbreviations are + // converted at the JSON boundary below; the wire format never depends on which transport supplied a slot. + json Slot(int64_t id, int32_t type, int64_t spec, json item, json params) { - if (sent.slots.Changed(id, state)) - batch.Add({{"type", SyncConfig::Type::kSlots}, - {"sid", id}, - {"slot_type", slot_type}, - {"spec_id", spec_id}, - {"item_id", item_id}, - {"params", params}}); + return {{"sid", id}, + {"slot_type", type}, + {"spec_id", spec}, + {"item_id", std::move(item)}, + {"params", std::move(params)}}; } - - int64_t Hash(const json& params) - { return static_cast(std::hash{}(params)); } - - json Seconds(bool has, int64_t seconds) - { return has ? json(seconds) : json(nullptr); } - - void AddSlot(Batch& batch, const pb::EntitySlot& slot) + std::optional Slot(const pb::EntitySlot& slot) { - json params; - int64_t state = slot.has_slotitemid() ? slot.slotitemid().value() : -1; + json params; switch (slot.slottype()) { case pb::SLOTTYPE_CONSUMABLE: if (slot.has_consumableslotparams()) { - const auto& consumable = slot.consumableslotparams(); - params["expiry_time"] = Seconds(consumable.has_expirytime(), consumable.expirytime().seconds()); + const auto& p = slot.consumableslotparams(); + params["expiry_time"] = Seconds(p.has_expirytime(), p.expirytime().seconds()); } break; case pb::SLOTTYPE_OFFICERPRESET: if (slot.has_officerpresetslotparams()) { - const auto& preset = slot.officerpresetslotparams(); - params = {{"name", preset.name()}, {"order", preset.order()}, {"officer_ids", preset.officerids()}}; - state = Hash(params); + const auto& p = slot.officerpresetslotparams(); + params = {{"name", p.name()}, {"order", p.order()}, {"officer_ids", p.officerids()}}; } break; case pb::SLOTTYPE_FLEETCOMMANDER: @@ -649,164 +380,139 @@ namespace break; case pb::SLOTTYPE_SELECTABLESKILL: if (slot.has_selectableskillslotparams()) { - const auto& skill = slot.selectableskillslotparams(); - params["cooldown_expiration"] = Seconds(skill.has_cooldownexpiration(), skill.cooldownexpiration().seconds()); + const auto& p = slot.selectableskillslotparams(); + params["cooldown_expiration"] = Seconds(p.has_cooldownexpiration(), p.cooldownexpiration().seconds()); } break; case pb::SLOTTYPE_FORBIDDENTECH: if (slot.has_forbiddentechslotparams()) { - const auto& tech = slot.forbiddentechslotparams(); - params = {{"owner_id", tech.ownerid()}, {"tier", tech.tier()}, {"level", tech.level()}}; - state = Hash(params); + const auto& p = slot.forbiddentechslotparams(); + params = {{"owner_id", p.ownerid()}, {"tier", p.tier()}, {"level", p.level()}}; } break; case pb::SLOTTYPE_FLEETPRESET: if (slot.has_fleetpresetslotparams()) { - const auto& preset = slot.fleetpresetslotparams(); + const auto& p = slot.fleetpresetslotparams(); auto setups = json::array(); - for (const auto& setup : preset.setups()) + for (const auto& setup : p.setups()) setups.push_back({{"drydock_id", setup.drydockid()}, - {"ship_id", setup.shipids_size() > 0 ? json(setup.shipids(0)) : json(nullptr)}, + {"ship_id", setup.shipids_size() ? json(setup.shipids(0)) : json(nullptr)}, {"officer_ids", setup.officerids()}}); - params = {{"name", preset.name()}, {"order", preset.order()}, {"setup", setups}}; - state = Hash(params); + params = {{"name", p.name()}, {"order", p.order()}, {"setup", setups}}; } break; default: - return; + return std::nullopt; } - AddSlot(batch, slot.id(), slot.slottype(), slot.slotspecid(), - slot.has_slotitemid() ? json(slot.slotitemid().value()) : json(nullptr), params, state); + return Slot(slot.id(), slot.slottype(), slot.slotspecid(), + slot.has_slotitemid() ? json(slot.slotitemid().value()) : json(nullptr), std::move(params)); } - - void ProcessEntitySlots(const std::string& bytes) + void ProcessEntitySlots(const Input& input) { - auto response = Parse(bytes, "entity slots"); - if (!response) - return; - Trace("entity slots", response->entityslots__size()); - - Batch batch(SyncConfig::Type::kSlots); - for (const auto& slot : response->entityslots_()) - AddSlot(batch, slot); - batch.Send(); + Read(input, Stream::kSlots, [](const auto& response, auto& records) { + for (const auto& slot : response.entityslots_()) + if (auto record = Slot(slot)) + records[slot.id()] = std::move(*record); + }); } - - void ProcessEntitySlotsData(const std::string& bytes) + void ProcessEntitySlotsData(const Input& input) { - auto response = Parse(bytes, "entity slots data"); - if (!response) - return; - Trace("entity slot groups", response->entityslots_size()); - - Batch batch(SyncConfig::Type::kSlots); - for (const auto& entity : response->entityslots()) - for (const auto& slot : entity.slots()) - AddSlot(batch, slot); - batch.Send(); + Read(input, Stream::kSlots, [](const auto& response, auto& records) { + for (const auto& entity : response.entityslots()) + for (const auto& slot : entity.slots()) + if (auto record = Slot(slot)) + records[slot.id()] = std::move(*record); + }); } - - void ProcessForbiddenTech(const std::string& bytes) + void ProcessForbiddenTech(const Input& input) { - auto response = Parse(bytes, "forbidden techs"); - if (!response) - return; - Trace("forbidden/chaos techs", response->forbiddentechs_size()); - - Batch batch(SyncConfig::Type::kTech); - for (const auto& tech : response->forbiddentechs()) - if (sent.techs.Changed(tech.id(), {tech.tier(), tech.level(), tech.shardcount()})) - batch.Add({{"type", SyncConfig::Type::kTech}, - {"fid", tech.id()}, - {"tier", tech.tier()}, - {"level", tech.level()}, - {"shard_count", tech.shardcount()}}); - batch.Send(); + Read(input, Stream::kTech, [](const auto& response, auto& records) { + for (const auto& tech : response.forbiddentechs()) + records[tech.id()] = { + {"fid", tech.id()}, {"tier", tech.tier()}, {"level", tech.level()}, {"shard_count", tech.shardcount()}}; + }); } - - void ProcessOfficerTraits(const std::string& bytes) + void ProcessOfficerTraits(const Input& input) { - auto response = Parse(bytes, "active officer traits"); - if (!response) - return; - Trace("officers with traits", response->activeofficertraits_size()); - - Batch batch(SyncConfig::Type::kTraits); - for (const auto& [officer, officer_traits] : response->activeofficertraits()) - for (const auto& trait : officer_traits.activetraits() | std::views::values) - if (sent.traits.Changed({officer, trait.traitid()}, trait.level())) - batch.Add({{"type", SyncConfig::Type::kTraits}, - {"oid", officer}, - {"tid", trait.traitid()}, - {"level", trait.level()}}); - batch.Send(); + Read(input, Stream::kTraits, [](const auto& response, auto& records) { + for (const auto& [officer, traits] : response.activeofficertraits()) + for (const auto& trait : traits.activetraits() | std::views::values) + records[json::array({officer, trait.traitid()})] = { + {"oid", officer}, {"tid", trait.traitid()}, {"level", trait.level()}}; + }); } - - // Store offer prices are in Resource.amount; amountUlong is the fallback. - int64_t OfferAmount(const content::Resource& resource) - { return static_cast(resource.amount() != 0 ? resource.amount() : resource.amountulong()); } - - // Platform ids are strings; numeric ones go out as numbers, like the rest of the sync data. - json OfferId(const std::string& id) + json Ship(int64_t id, int64_t hull, int32_t tier, int32_t level, double progress, json components) { - int64_t value = 0; - const auto* end = id.data() + id.size(); - const auto [ptr, ec] = std::from_chars(id.data(), end, value); - return !id.empty() && ec == std::errc{} && ptr == end ? json(value) : json(id); + return {{"psid", id}, + {"hull_id", hull}, + {"tier", tier}, + {"level", level}, + {"level_percentage", progress}, + {"components", std::move(components)}}; } - - // The consumable a bundle sells. Faction store offers are favor levels, sold as consumables. - const content::ContentItem* OfferConsumable(const content::BundleContent& bundle) + void ProcessShips(const Input& input) { - for (const auto* list : {&bundle.static_(), &bundle.showcase(), &bundle.gifts()}) - for (const auto& item : list->items()) - if (item.type() == "consumable") - return &item; - return nullptr; + Read( + input, Stream::kShips, + [](const auto& response, auto& records) { + for (const auto& ship : response.ships() | std::views::values) + records[ship.id()] = + Ship(ship.id(), ship.hullid(), ship.tier(), ship.level(), ship.levelpercentage(), ship.components()); + }, + Update::kFull); } - void AddShip(Batch& batch, int64_t id, int64_t hull_id, const ShipState& state) + void ProcessJson(const Input& input) { - if (sent.ships.Changed(id, state)) - batch.Add({{"type", SyncConfig::Type::kShips}, - {"psid", id}, - {"level", state.level}, - {"level_percentage", state.level_percentage}, - {"tier", state.tier}, - {"hull_id", hull_id}, - {"components", state.components}}); - } - - void ProcessShips(const std::string& bytes) - { - auto response = Parse(bytes, "player ships"); - if (!response) + const auto& options = Config::Get().sync.options; + const auto result = json::parse(input.bytes, nullptr, false); + if (!result.is_object()) { + spdlog::error("Failed to parse sync JSON"); return; - Trace("ships", response->ships_size()); - - Batch batch(SyncConfig::Type::kShips); - for (const auto& ship : response->ships() | std::views::values) - AddShip( - batch, ship.id(), ship.hullid(), - {ship.tier(), ship.level(), ship.levelpercentage(), {ship.components().begin(), ship.components().end()}}); - batch.Send(&sent.first_ships); - } - - // Early in a session resources and starbase modules come in the game's JSON responses rather than as protobuf - // entity groups; before client build 267, so did ships and battle headers. Server data can lack any field, so - // every read goes through at(), which throws, and a record is read in full before anything is recorded as sent. - // A malformed record is skipped, not its section. - template void ForEachRecord(const json& section, std::string_view what, Read&& read) - { - for (const auto& [key, record] : section.items()) { - try { - read(key, record); - } catch (const std::exception& e) { - spdlog::error("Skipping malformed {} record {}: {}", what, key, e.what()); + } + for (const auto& [name, section] : result.items()) { + if (name == "battle_result_headers" && options.battlelogs) { + std::vector ids; + for (const auto& header : section) + if (header.is_object()) { + const auto id = header.find("id"); + if (id != header.end() && id->is_number_unsigned()) + ids.push_back(id->get()); + } + QueueNewBattles(ids, input.generation); + continue; } + Stream stream; + if (name == "resources") + stream = Stream::kResources; + else if (name == "starbase_modules") + stream = Stream::kBuildings; + else if (name == "ships") + stream = Stream::kShips; + else + continue; + RecordSet records; + for (const auto& [key, record] : section.items()) { + try { + if (stream == Stream::kResources) { + const auto id = std::stoll(key); + records[id] = {{"rid", id}, {"amount", record.at("current_amount").get()}}; + } else if (stream == Stream::kBuildings) { + const auto id = record.at("id").get(); + records[id] = {{"bid", id}, {"level", record.at("level").get()}}; + } else { + const auto id = record.at("id").get(); + records[id] = Ship(id, record.at("hull_id").get(), record.at("tier").get(), + record.at("level").get(), record.at("level_percentage").get(), + record.at("components").get>()); + } + } catch (const std::exception& e) { + spdlog::error("Skipping malformed {} record {}: {}", name, key, e.what()); + } + } + Publish(stream, std::move(records), input.generation, Update::kFull); } } - const char* DeployedStateName(pb::DeployedFleetState state) { switch (state) { @@ -883,298 +589,209 @@ namespace } } - // The server's view of the player's deployed fleets. It resends every fleet on each update, so only fleets that - // changed are sent; a fleet that left (docked, destroyed) is forgotten so it is sent again when it is back. Each - // changed or departed fleet is re-read on the game thread, which fills in some values from this data. - void ProcessDeployedFleets(const std::string& bytes) + // The server's view of the player's deployed fleets. It resends every fleet on each update; delivery sends only + // fleets that changed, and a fleet that left (docked, destroyed) is dropped so it is sent again when it is back. + void ProcessDeployedFleets(const Input& input) { - auto response = Parse(bytes, "deployed fleets"); - if (!response) - return; - Trace("deployed fleets", response->fleets_size()); - + // Each changed or departed fleet is re-read on the game thread, which fills in some values from this data. The + // last record per fleet, for this session only, tells which ones those are. Never destroyed, see sync.h. + static uint64_t seen_generation = 0; + static auto& seen = *new std::unordered_map; + if (seen_generation != input.generation) { + seen_generation = input.generation; + seen.clear(); + } const int64_t now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); - Batch batch(SyncConfig::Type::kFleets); - for (const auto& [fleet_id, fleet] : response->fleets()) { - json record{{"type", SyncConfig::Type::kFleets}, - {"fid", fleet_id}, - {"src", "server"}, - {"server_state", static_cast(fleet.state())}, - {"server_state_name", DeployedStateName(fleet.state())}, - {"node_id", fleet.nodeid()}}; - // Widened through uint32 like the client records. - if (fleet.hullids_size() > 0) - record["hull_id"] = static_cast(static_cast(fleet.hullids(0))); - if (fleet.has_nodeaddress()) - record["system_id"] = fleet.nodeaddress().system(); - PutPoints(record, "hull_hp", "hull_max", fleet, pb::CLIENTMODIFIERTYPE_MODHULLHPMAX, - pb::CLIENTMODIFIERTYPE_SHIPHULLDAMAGE); - PutPoints(record, "shield_hp", "shield_max", fleet, pb::CLIENTMODIFIERTYPE_MODSHIELDHPMAX, - pb::CLIENTMODIFIERTYPE_SHIPSHIELDDAMAGE); - PutStat(record, "cargo_cap", fleet.stats(), pb::CLIENTMODIFIERTYPE_MODCARGOCAPACITY); - PutStat(record, "warp_range", fleet.stats(), pb::CLIENTMODIFIERTYPE_MODWARPDISTANCE); - PutStat(record, "strength", fleet.attributes(), pb::CLIENTMODIFIERTYPE_FLEETSTRENGTH); - // A count only, no other players' ids. - record["pursued_by"] = fleet.pursuedbynum(); - - if (!sent.deployed_fleets.Changed(fleet_id, std::hash{}(record))) - continue; - record["t"] = now; - batch.Add(std::move(record)); - RereadFleet(fleet_id); - } - sent.deployed_fleets.Prune(response->fleets(), [](int64_t fleet_id) { RereadFleet(fleet_id); }); - batch.Send(); - } - - void ProcessJsonResources(const json& section) - { - Batch batch(SyncConfig::Type::kResources); - ForEachRecord(section, "resource", [&](const std::string& id, const json& resource) { - const auto amount = resource.at("current_amount").get(); - AddResource(batch, sent.resources, "", std::stoll(id), amount); - }); - batch.Send(&sent.first_json_resources); - } - - void ProcessJsonModules(const json& section) - { - Batch batch(SyncConfig::Type::kBuildings); - ForEachRecord(section, "starbase module", [&](const std::string&, const json& module) { - const auto id = module.at("id").get(); - const auto level = module.at("level").get(); - if (sent.modules.Changed(id, level)) - batch.Add({{"type", SyncConfig::Type::kBuildings}, {"bid", id}, {"level", level}}); - }); - batch.Send(); - } - - void ProcessJsonShips(const json& section) - { - Batch batch(SyncConfig::Type::kShips); - ForEachRecord(section, "ship", [&](const std::string&, const json& ship) { - AddShip(batch, ship.at("id").get(), ship.at("hull_id").get(), - {ship.at("tier").get(), ship.at("level").get(), - ship.at("level_percentage").get(), ship.at("components").get>()}); - }); - batch.Send(&sent.first_ships); - } - - void ProcessJsonBattles(const json& section) - { - std::vector battle_ids; - for (const auto& header : section) { - const auto id = header.is_object() ? header.find("id") : header.end(); - if (id == header.end() || !id->is_number_unsigned()) { - spdlog::error("Skipping a battle header without a numeric id"); - continue; - } - battle_ids.push_back(id->get()); - } - QueueNewBattles(battle_ids); - } - - void ProcessJson(const std::string& bytes) - { - const auto& options = Config::Get().sync.options; - try { - const auto result = json::parse(bytes); - for (const auto& [key, section] : result.items()) { - logging::trace("PROCESS", key, "Processing {} entries (JSON)", section.size()); - if (key == "battle_result_headers" && options.battlelogs) - ProcessJsonBattles(section); - else if (key == "resources" && options.resources) - ProcessJsonResources(section); - else if (key == "starbase_modules" && options.buildings) - ProcessJsonModules(section); - else if (key == "ships" && options.ships) - ProcessJsonShips(section); - } - } catch (const json::exception& e) { - spdlog::error("Error parsing json: {}", e.what()); - } + Read( + input, Stream::kDeployedFleets, + [&](const auto& response, auto& records) { + for (const auto& [fleet_id, fleet] : response.fleets()) { + json record{{"fid", fleet_id}, + {"src", "server"}, + {"server_state", static_cast(fleet.state())}, + {"server_state_name", DeployedStateName(fleet.state())}, + {"node_id", fleet.nodeid()}}; + // Widened through uint32 like the client records. + if (fleet.hullids_size() > 0) + record["hull_id"] = static_cast(static_cast(fleet.hullids(0))); + if (fleet.has_nodeaddress()) + record["system_id"] = fleet.nodeaddress().system(); + PutPoints(record, "hull_hp", "hull_max", fleet, pb::CLIENTMODIFIERTYPE_MODHULLHPMAX, + pb::CLIENTMODIFIERTYPE_SHIPHULLDAMAGE); + PutPoints(record, "shield_hp", "shield_max", fleet, pb::CLIENTMODIFIERTYPE_MODSHIELDHPMAX, + pb::CLIENTMODIFIERTYPE_SHIPSHIELDDAMAGE); + PutStat(record, "cargo_cap", fleet.stats(), pb::CLIENTMODIFIERTYPE_MODCARGOCAPACITY); + PutStat(record, "warp_range", fleet.stats(), pb::CLIENTMODIFIERTYPE_MODWARPDISTANCE); + PutStat(record, "strength", fleet.attributes(), pb::CLIENTMODIFIERTYPE_FLEETSTRENGTH); + // A count only, no other players' ids. + record["pursued_by"] = fleet.pursuedbynum(); + + if (auto& last = seen[fleet_id]; last != std::hash{}(record)) { + last = std::hash{}(record); + RereadFleet(fleet_id); + } + record["t"] = now; + records[fleet_id] = std::move(record); + } + std::erase_if(seen, [&](const auto& entry) { + if (response.fleets().contains(entry.first)) + return false; + RereadFleet(entry.first); + return true; + }); + }, + Update::kReplace); } struct Route { EntityGroup::Kind type; Processor process; - // The category that must be enabled; null for the JSON responses, which check their own sections. - bool SyncConfig::* option; + bool battle = false; }; - const Route kRoutes[] = { - {EntityGroup::Kind::AwayAssignmentsList, ProcessAwayAssignments, &SyncConfig::away_assignments}, - {EntityGroup::Kind::AwayAssignmentsInstance, ProcessAwayAssignment, &SyncConfig::away_assignments}, - {EntityGroup::Kind::BattleResultHeaders, ProcessBattleHeaders, &SyncConfig::battlelogs}, - {EntityGroup::Kind::UserProfiles, CachePlayerNames, &SyncConfig::battlelogs}, - {EntityGroup::Kind::AllianceProfiles, CacheAllianceNames, &SyncConfig::battlelogs}, - {EntityGroup::Kind::GlobalActiveBuffs, ProcessBuffs, &SyncConfig::buffs}, - {EntityGroup::Kind::AllianceGetGameProperties, ProcessAllianceGameProperties, &SyncConfig::buffs}, - {EntityGroup::Kind::StarbaseModules, ProcessStarbaseModules, &SyncConfig::buildings}, - {EntityGroup::Kind::MyDeployedFleets, ProcessDeployedFleets, &SyncConfig::fleets}, - {EntityGroup::Kind::PlanetaryBaseData, ProcessHavenBase, &SyncConfig::haven}, - {EntityGroup::Kind::PlanetaryMapResponse, ProcessHavenMapResponse, &SyncConfig::haven}, - {EntityGroup::Kind::PlanetaryMapData, ProcessHavenMap, &SyncConfig::haven}, - {EntityGroup::Kind::PlanetaryMapProsperity, ProcessHavenProsperity, &SyncConfig::haven}, - {EntityGroup::Kind::PlayerInventories, ProcessInventory, &SyncConfig::inventory}, - {EntityGroup::Kind::Jobs, ProcessJobs, &SyncConfig::jobs}, - {EntityGroup::Kind::ActiveMissions, ProcessActiveMissions, &SyncConfig::missions}, - {EntityGroup::Kind::CompletedMissions, ProcessCompletedMissions, &SyncConfig::missions}, - {EntityGroup::Kind::Officers, ProcessOfficers, &SyncConfig::officers}, - {EntityGroup::Kind::ResearchTreesState, ProcessResearch, &SyncConfig::research}, - {EntityGroup::Kind::Resources, ProcessResources, &SyncConfig::resources}, - {EntityGroup::Kind::ResourcesDelta, ProcessResourcesDelta, &SyncConfig::resources}, - {EntityGroup::Kind::AllianceGetBankResources, ProcessAllianceResources, &SyncConfig::resources}, - {EntityGroup::Kind::ResourcesDeltaAlliance, ProcessAllianceResourcesDelta, &SyncConfig::resources}, - {EntityGroup::Kind::Ships, ProcessShips, &SyncConfig::ships}, - {EntityGroup::Kind::EntitySlots, ProcessEntitySlots, &SyncConfig::slots}, - {EntityGroup::Kind::EntitySlotsData, ProcessEntitySlotsData, &SyncConfig::slots}, - {EntityGroup::Kind::ForbiddenTechs, ProcessForbiddenTech, &SyncConfig::tech}, - {EntityGroup::Kind::ActiveOfficerTraits, ProcessOfficerTraits, &SyncConfig::traits}, - {EntityGroup::Kind::Json, ProcessJson, nullptr}, + {EntityGroup::Kind::AwayAssignmentsList, ProcessAwayAssignments}, + {EntityGroup::Kind::AwayAssignmentsInstance, ProcessAwayAssignment}, + {EntityGroup::Kind::BattleResultHeaders, ProcessBattleHeaders, true}, + {EntityGroup::Kind::UserProfiles, CachePlayerNames, true}, + {EntityGroup::Kind::AllianceProfiles, CacheAllianceNames, true}, + {EntityGroup::Kind::GlobalActiveBuffs, ProcessBuffs}, + {EntityGroup::Kind::AllianceGetGameProperties, ProcessAllianceGameProperties}, + {EntityGroup::Kind::StarbaseModules, ProcessStarbaseModules}, + {EntityGroup::Kind::MyDeployedFleets, ProcessDeployedFleets}, + {EntityGroup::Kind::PlanetaryBaseData, ProcessHavenBase}, + {EntityGroup::Kind::PlanetaryMapData, ProcessHavenMap}, + {EntityGroup::Kind::PlanetaryMapResponse, ProcessHavenResponse}, + {EntityGroup::Kind::PlanetaryMapProsperity, ProcessHavenProsperity}, + {EntityGroup::Kind::PlayerInventories, ProcessInventory}, + {EntityGroup::Kind::Jobs, ProcessJobs}, + {EntityGroup::Kind::ActiveMissions, ProcessActiveMissions}, + {EntityGroup::Kind::CompletedMissions, ProcessCompletedMissions}, + {EntityGroup::Kind::Officers, ProcessOfficers}, + {EntityGroup::Kind::ResearchTreesState, ProcessResearch}, + {EntityGroup::Kind::Resources, ProcessResources}, + {EntityGroup::Kind::ResourcesDelta, ProcessResourcesDelta}, + {EntityGroup::Kind::AllianceGetBankResources, ProcessAllianceResources}, + {EntityGroup::Kind::ResourcesDeltaAlliance, ProcessAllianceResourcesDelta}, + {EntityGroup::Kind::Ships, ProcessShips}, + {EntityGroup::Kind::EntitySlots, ProcessEntitySlots}, + {EntityGroup::Kind::EntitySlotsData, ProcessEntitySlotsData}, + {EntityGroup::Kind::ForbiddenTechs, ProcessForbiddenTech}, + {EntityGroup::Kind::ActiveOfficerTraits, ProcessOfficerTraits}, + {EntityGroup::Kind::Json, ProcessJson}, }; -} // namespace - -void StartNewSession(const std::string&) -{ sent = SentData{}; } -UserProfile* LocalUserProfile() -{ - auto* manager = UserProfileManager::Instance(); - return manager ? manager->GetLocalUserProfile() : nullptr; -} + // Store offer prices are in Resource.amount; amountUlong is the fallback. + int64_t OfferAmount(const content::Resource& resource) + { return static_cast(resource.amount() != 0 ? resource.amount() : resource.amountulong()); } -// Only the record whose user id is `self` is read. The server record carries level 0, so the in-memory Level is used: -// it lags one own-record response after a level-up. -void ProcessOwnProfile(const std::string& bytes, const std::string& self, int32_t level) -{ - auto response = Parse(bytes, "own user profile"); - if (!response) - return; - const pb::UserProfile* own = nullptr; - for (const auto& profile : response->userprofiles()) { - if (profile.userid() == self) { - own = &profile; - break; - } + // Platform ids are strings; numeric ones go out as numbers, like the rest of the sync data. + json OfferId(const std::string& id) + { + int64_t value = 0; + const auto* end = id.data() + id.size(); + const auto [ptr, ec] = std::from_chars(id.data(), end, value); + return !id.empty() && ec == std::errc{} && ptr == end ? json(value) : json(id); } - if (!own) - return; - const auto key = std::make_tuple(level, own->militarymight(), own->planetarybaseunlocked()); - if (sent.own_profile == key) - return; - sent.own_profile = key; - Batch batch(SyncConfig::Type::kProfile); - batch.Add({{"type", SyncConfig::Type::kProfile}, - {"uid", self}, - {"level", level}, - {"xp", own->xp()}, - {"military_might", own->militarymight()}, - {"planetary_base_unlocked", own->planetarybaseunlocked()}}); - batch.Send(&sent.first_profile); -} + // The consumable a bundle sells. Faction store offers are favor levels, sold as consumables. + const content::ContentItem* OfferConsumable(const content::BundleContent& bundle) + { + for (const auto* list : {&bundle.static_(), &bundle.showcase(), &bundle.gifts()}) + for (const auto& item : list->items()) + if (item.type() == "consumable") + return &item; + return nullptr; + } +} // namespace -// Realtime slot updates are JSON with ISO timestamps and abbreviated fleet preset keys. -void ProcessRealtimeSlot(const std::string& bytes) +void ProcessRealtimeSlot(const Input& input) { - auto data = json::parse(bytes, nullptr, false); - if (data.is_discarded()) { - spdlog::error("Failed to parse slots JSON"); - return; - } try { - const auto timestamp = [](const json& value) { - const auto seconds = value.is_string() ? ParseUtcTimestamp(value.get_ref()) : std::nullopt; - return seconds ? json(*seconds) : json(nullptr); - }; - - const auto& item = data["slot_item_id"]; - int64_t state = item.is_null() ? -1 : item.get(); - json params; - const auto type = data["slot_type"].get(); - switch (type) { - case pb::SLOTTYPE_CONSUMABLE: - params["expiry_time"] = timestamp(data["consumable_slot_params"]["expiry_time"]); - break; - case pb::SLOTTYPE_OFFICERPRESET: - if (const auto& preset = data["officer_preset_slot_params"]; !preset.is_null()) { - params = preset; - state = Hash(params); - } - break; - case pb::SLOTTYPE_FLEETCOMMANDER: - if (const auto& commander = data["fleet_commander_slot_params"]; !commander.is_null()) - params["order"] = commander.at("order"); - break; - case pb::SLOTTYPE_SELECTABLESKILL: - if (const auto& skill = data["selectable_skill_slot_params"]; !skill.is_null()) - params["cooldown_expiration"] = timestamp(skill.value("cooldown_expiration", json())); - break; - case pb::SLOTTYPE_FORBIDDENTECH: - if (const auto& tech = data["forbidden_tech_slot_params"]; !tech.is_null()) { - params = tech; - state = Hash(params); - } - break; - case pb::SLOTTYPE_FLEETPRESET: - if (const auto& preset = data["fleet_preset_slot_params"]; !preset.is_null()) { - auto setups = json::array(); - for (const auto& setup : preset.at("setups")) { - const auto& ship_ids = setup.at("s"); - setups.push_back({{"drydock_id", setup.at("d")}, - {"ship_id", ship_ids.empty() ? json(nullptr) : ship_ids.at(0)}, - {"officer_ids", setup.at("o")}}); - } - params = {{"name", preset.at("name")}, {"order", preset.at("order")}, {"setup", setups}}; - state = Hash(params); + auto data = json::parse(input.bytes); + const int32_t type = data.at("slot_type").get(); + constexpr std::pair fields[] = { + {pb::SLOTTYPE_CONSUMABLE, "consumable_slot_params"}, + {pb::SLOTTYPE_OFFICERPRESET, "officer_preset_slot_params"}, + {pb::SLOTTYPE_FLEETCOMMANDER, "fleet_commander_slot_params"}, + {pb::SLOTTYPE_SELECTABLESKILL, "selectable_skill_slot_params"}, + {pb::SLOTTYPE_FORBIDDENTECH, "forbidden_tech_slot_params"}, + {pb::SLOTTYPE_FLEETPRESET, "fleet_preset_slot_params"}}; + const auto field = std::ranges::find(fields, type, &std::pair::first); + if (field == std::end(fields)) + return; + json params = data.value(field->second, json()); + if (!params.is_null()) { + if (type == pb::SLOTTYPE_CONSUMABLE || type == pb::SLOTTYPE_SELECTABLESKILL) { + const char* key = type == pb::SLOTTYPE_CONSUMABLE ? "expiry_time" : "cooldown_expiration"; + const auto timestamp = params.value(key, json()); + const auto seconds = + timestamp.is_string() ? ParseUtcTimestamp(timestamp.get_ref()) : std::nullopt; + params = {{key, seconds ? json(*seconds) : json(nullptr)}}; + } else if (type == pb::SLOTTYPE_FLEETPRESET) { + auto setups = json::array(); + for (const auto& setup : params.at("setups")) { + const auto ships = setup.at("s").get>(); + setups.push_back({{"drydock_id", setup.at("d").get()}, + {"ship_id", ships.empty() ? json(nullptr) : json(ships.at(0))}, + {"officer_ids", setup.at("o").get>()}}); } - break; - default: - return; + params = {{"name", params.at("name").get()}, + {"order", params.at("order").get()}, + {"setup", setups}}; + } else if (type == pb::SLOTTYPE_FLEETCOMMANDER) + params = {{"order", params.at("order").get()}}; + else if (type == pb::SLOTTYPE_OFFICERPRESET) + params = {{"name", params.at("name").get()}, + {"order", params.at("order").get()}, + {"officer_ids", params.at("officer_ids").get>()}}; + else + params = {{"owner_id", params.at("owner_id").get()}, + {"tier", params.at("tier").get()}, + {"level", params.at("level").get()}}; } - - Batch batch(SyncConfig::Type::kSlots); - AddSlot(batch, data["slot_id"].get(), type, data["slot_spec_id"], item, params, state); - batch.Send(); + const auto id = data.at("slot_id").get(); + const auto item = data.value("slot_item_id", json()); + auto record = Slot(id, type, data.at("slot_spec_id").get(), + item.is_null() ? json(nullptr) : json(item.get()), std::move(params)); + Publish(Stream::kSlots, {{id, std::move(record)}}, input.generation); } catch (const json::exception& e) { spdlog::error("Failed to parse slots JSON: {}", e.what()); } } - -// A store offers group carries whole store categories. The faction store's consumable offers go out as one snapshot, -// again whenever it changes. -void ProcessStoreOffers(const std::string& bytes) +// A store offers group carries whole store categories. The faction store's consumable offers are one record, which +// targets receive again whenever it changes. +void ProcessStoreOffers(const Input& input) { - auto response = Parse(bytes, "store offers"); - if (!response) + content::Bundles response; + if (!response.ParseFromString(input.bytes)) { + spdlog::error("Failed to parse {}", response.GetTypeName()); return; - Trace("store offers", response->bundles__size()); - + } constexpr std::string_view kCategory = "factions"; struct Offer { const content::Bundle* bundle; const content::ContentItem* consumable; }; - std::vector offers; + std::vector found; bool has_category = false; - for (const auto& bundle : response->bundles_()) { + for (const auto& bundle : response.bundles_()) { if (bundle.category() != kCategory) continue; has_category = true; if (const auto* consumable = OfferConsumable(bundle.content())) - offers.push_back({&bundle, consumable}); + found.push_back({&bundle, consumable}); } + // A group without the faction store says nothing about it. if (!has_category) return; - // In a fixed order, so neither the snapshot nor its hash depends on the server's. - std::ranges::sort(offers, {}, [](const Offer& offer) { + // In a fixed order, so the record does not change with the server's. + std::ranges::sort(found, {}, [](const Offer& offer) { return std::pair{offer.bundle->bundleid(), std::string_view{offer.consumable->id()}}; }); - auto records = json::array(); - for (const auto& [bundle, consumable] : offers) { + auto offers = json::array(); + for (const auto& [bundle, consumable] : found) { auto cost = json::array(); for (const auto& entry : bundle->cost()) for (const auto& resource : entry.cost_()) @@ -1185,33 +802,47 @@ void ProcessStoreOffers(const std::string& bytes) requirements.push_back( {{"type", resource.type()}, {"id", OfferId(resource.id())}, {"amount", OfferAmount(resource)}}); const auto& details = bundle->offerdetails(); - records.push_back({{"bid", bundle->bundleid()}, - {"cid", OfferId(consumable->id())}, - {"cost", cost}, - {"req", requirements}, - {"valid_count", details.validcount()}, - {"same_remaining_count", details.sameremainingcount()}}); - } + offers.push_back({{"bid", bundle->bundleid()}, + {"cid", OfferId(consumable->id())}, + {"cost", std::move(cost)}, + {"req", std::move(requirements)}, + {"valid_count", details.validcount()}, + {"same_remaining_count", details.sameremainingcount()}}); + } + RecordSet records; + records[std::string(kCategory)] = {{"category", kCategory}, {"offers", std::move(offers)}}; + Publish(Stream::kStoreOffers, std::move(records), input.generation); +} - json snapshot = {{"type", SyncConfig::Type::kStoreOffers}, {"category", kCategory}, {"offers", records}}; - if (snapshot == sent.store_offers) +// Only the record whose user id is the local player's is read. The server record carries level 0, so the in-memory +// Level captured with the response is used: it lags one own-record response after a level-up. +void ProcessOwnProfile(const Input& input) +{ + pb::UserProfilesResponse response; + if (!response.ParseFromString(input.bytes)) { + spdlog::error("Failed to parse own user profile"); return; - sent.store_offers = snapshot; - Batch batch(SyncConfig::Type::kStoreOffers); - batch.Add(std::move(snapshot)); - batch.Send(); + } + for (const auto& profile : response.userprofiles()) { + if (profile.userid() != input.user_id) + continue; + RecordSet records; + records[input.user_id] = {{"uid", input.user_id}, + {"level", input.level}, + {"xp", profile.xp()}, + {"military_might", profile.militarymight()}, + {"planetary_base_unlocked", profile.planetarybaseunlocked()}}; + Publish(Stream::kProfile, std::move(records), input.generation, Update::kFull); + return; + } } Processor ProcessorFor(EntityGroup::Kind kind) { - // Checked per response rather than at install, so targets and categories can change at runtime. Without a wanted - // category nothing is copied or parsed. - const auto route = std::ranges::find(kRoutes, kind, &Route::type); const auto& options = Config::Get().sync.options; - if (route == std::end(kRoutes) - || !(route->option ? options.*route->option - : options.battlelogs || options.resources || options.buildings || options.ships)) - return nullptr; - return route->process; + for (const auto& route : kRoutes) + if (route.type == kind) + return !route.battle || options.battlelogs ? route.process : nullptr; + return nullptr; } } // namespace data_sync diff --git a/mods/src/patches/parts/sync/state.cc b/mods/src/patches/parts/sync/state.cc new file mode 100644 index 000000000..bb9b36d87 --- /dev/null +++ b/mods/src/patches/parts/sync/state.cc @@ -0,0 +1,231 @@ +// Canonical record state and per-target acknowledgement, without threads or HTTP. +#include "patches/parts/sync/sync.h" +#include + +namespace data_sync +{ +namespace +{ + using json = nlohmann::json; + struct Spec { + SyncConfig::Type category; + const char* type; + const char* removed = nullptr; + const char* id = nullptr; + bool initial_full = false, always_full = false; + // Recent entries kept for a journal: every entry is delivered on its own and never merged with a later one. + size_t journal = 0; + }; + constexpr std::array kStreams{ + Spec{SyncConfig::Type::kAwayAssignments, "away_assignment", "collected_away_assignment", "aid", true}, + Spec{SyncConfig::Type::kBattles, "battlelog", nullptr, nullptr, false, false, kRecentBattles}, + Spec{SyncConfig::Type::kBuffs, "buff", "expired_buff", "bid", true}, + Spec{SyncConfig::Type::kBuildings, "module"}, + Spec{SyncConfig::Type::kEmeraldChain, "emerald_chain"}, + Spec{SyncConfig::Type::kFleets, "fleet", nullptr, nullptr, false, false, kRecentFleetChanges}, + Spec{SyncConfig::Type::kFleets, "fleet"}, + Spec{SyncConfig::Type::kHaven, "haven", nullptr, nullptr, true}, + Spec{SyncConfig::Type::kHaven, "haven_pieces", nullptr, nullptr, true}, + Spec{SyncConfig::Type::kHaven, "haven_prosperity"}, + Spec{SyncConfig::Type::kInventory, "inventory", nullptr, nullptr, true}, + Spec{SyncConfig::Type::kJobs, "job", "completed_job", "uuid", true}, + Spec{SyncConfig::Type::kMissions, "active_mission"}, + Spec{SyncConfig::Type::kMissions, "mission"}, + Spec{SyncConfig::Type::kOfficer, "officer"}, + Spec{SyncConfig::Type::kProfile, "profile", nullptr, nullptr, true}, + Spec{SyncConfig::Type::kResearch, "research"}, + Spec{SyncConfig::Type::kResources, "resource", nullptr, nullptr, true}, + Spec{SyncConfig::Type::kResources, "alliance_resource", nullptr, nullptr, true}, + Spec{SyncConfig::Type::kShips, "ship", nullptr, nullptr, true}, + Spec{SyncConfig::Type::kSlots, "slot"}, + Spec{SyncConfig::Type::kStoreOffers, "store_offer"}, + Spec{SyncConfig::Type::kTech, "ft"}, + Spec{SyncConfig::Type::kTraits, "trait"}, + }; + static_assert(kStreams.size() == static_cast(Stream::kCount)); +} // namespace +nlohmann::json Upload::Records() const +{ + auto result = nlohmann::json::array(); + for (const auto& record : records) { + if (record->is_array()) + for (const auto& item : *record) + result.push_back(item); + else + result.push_back(*record); + } + return result; +} +const char* Upload::Type() const +{ return kStreams[static_cast(stream)].type; } +void RecordStore::Reset() +{ + ++generation; + streams_ = {}; +} +bool RecordStore::Contains(Stream stream, const json& key) const +{ + const auto& records = streams_[static_cast(stream)]; + return records && records->records.contains(key); +} +bool RecordStore::Apply(Stream stream, RecordSet records, Update mode) +{ + auto& slot = streams_[static_cast(stream)]; + bool changed = !slot; + if (!slot) + slot.emplace(); + if (mode != Update::kMerge && !slot->full) { + slot->full = true; + changed = true; + } + auto& current = slot->records; + const size_t journal = kStreams[static_cast(stream)].journal; + for (auto& [key, record] : records) { + const auto type = kStreams[static_cast(stream)].type; + if (record.is_array()) + for (auto& item : record) + item["type"] = type; + else + record["type"] = type; + const auto old = current.find(key); + if (old != current.end()) { + if (record == *old->second) + continue; + if (stream == Stream::kShips + && std::fabs(record.at("level_percentage").get() - old->second->at("level_percentage").get()) + < 0.01) { + auto comparable = record; + comparable["level_percentage"] = old->second->at("level_percentage"); + if (comparable == *old->second) + continue; + } + // XP rides along but is not part of the key: an XP-only change waits for the next level, Power or Haven change. + if (stream == Stream::kProfile) { + auto comparable = record; + comparable["xp"] = old->second->at("xp"); + if (comparable == *old->second) + continue; + } + // The read time of a server fleet record rides along too: an unchanged fleet is not sent again. + if (stream == Stream::kDeployedFleets) { + auto comparable = record; + comparable["t"] = old->second->at("t"); + if (comparable == *old->second) + continue; + } + } + changed = true; + if (journal && old == current.end()) + slot->order.push_back(key); + current.insert_or_assign(key, std::make_shared(std::move(record))); + } + if (mode == Update::kReplace) + changed |= std::erase_if(current, [&](const auto& entry) { return !records.contains(entry.first); }) != 0; + // Journals are bounded; other streams contain the current account's entities. + while (journal && slot->order.size() > journal) { + changed |= current.erase(slot->order.front()) != 0; + slot->order.pop_front(); + } + if (changed) + ++slot->version; + return changed; +} +void Delivery::Configure(const SyncTargetConfig& config) +{ + if (config == config_) + return; + config_ = config; + ++revision_; + cursors_ = {}; +} +std::optional Delivery::Next(const RecordStore& store, Clock::time_point now) +{ + if (generation_ != store.generation) { + generation_ = store.generation; + cursors_ = {}; + } + for (size_t count = 0; count < kStreams.size(); ++count) { + const size_t i = (next_ + count) % kStreams.size(); + const auto& collection = store.streams_[i]; + auto& cursor = cursors_[i]; + const auto& spec = kStreams[i]; + if (!collection || !config_.enabled(spec.category) || now < cursor.retry_at) + continue; + const auto& current = collection->records; + const bool journal = spec.journal != 0; + const bool uncertain = cursor.retry_at != Clock::time_point{}; + if (journal) + std::erase_if(cursor.accepted, [&](const auto& entry) { return !current.contains(entry.first); }); + if (!uncertain && (journal ? current == cursor.accepted : collection->version == cursor.version)) + continue; + Upload upload{static_cast(i), store.generation, revision_, collection->version, {}}; + upload.first_sync = spec.always_full || (spec.initial_full && collection->full && !cursor.full); + bool blocked = false; + for (const auto& [key, record] : current) { + const bool accepted = cursor.accepted.contains(key); + if (spec.removed && !accepted && !cursor.attempted.contains(key)) { + // Bound uncertain writes by waiting for acknowledgement before introducing more entities. + if (cursor.attempted.size() >= 1024) { + blocked = true; + continue; + } + cursor.attempted.emplace(key, record); + } + if (!journal) + upload.snapshot.emplace(key, record); + if (upload.first_sync || (uncertain && !journal) || !accepted || cursor.accepted.at(key) != record) { + upload.records.push_back(record); + if (journal) { + upload.snapshot.emplace(key, record); + // Fleet journal keys are [sequence, snapshot]. A snapshot lists every fleet, so it is a first sync. + upload.first_sync = key.is_array() && key.back() == true; + break; + } + } + } + // A timeout may hide a successful server write. Remove records from attempted uploads too. + if (spec.removed) { + // Attempted contains only new, unacknowledged keys, so these maps are disjoint. + for (const auto* previous : {&cursor.accepted, &cursor.attempted}) + for (const auto& [key, record] : *previous) + if (!current.contains(key)) + upload.records.push_back( + std::make_shared(json{{"type", spec.removed}, {spec.id, record->at(spec.id)}})); + } + if (blocked) { + // A partial list cannot claim to be a full baseline or acknowledge the current stream version. + upload.first_sync = false; + upload.version = 0; + } + if (upload.records.empty() && !upload.first_sync) { + cursor.accepted = current; + cursor.version = collection->version; + continue; + } + next_ = (i + 1) % kStreams.size(); + return upload; + } + return std::nullopt; +} +void Delivery::Complete(Upload upload, bool success, uint64_t generation, Clock::time_point now) +{ + if (upload.generation != generation || upload.revision != revision_) + return; + auto& cursor = cursors_[static_cast(upload.stream)]; + if (!success) { + cursor.retry_at = now + cursor.backoff; + cursor.backoff = std::min(cursor.backoff * 2, std::chrono::seconds(60)); + return; + } + if (kStreams[static_cast(upload.stream)].journal) + for (auto& [key, record] : upload.snapshot) + cursor.accepted.insert_or_assign(key, std::move(record)); + else + cursor.accepted = std::move(upload.snapshot); + cursor.full |= upload.first_sync; + cursor.version = upload.version; + cursor.attempted.clear(); + cursor.retry_at = {}; + cursor.backoff = std::chrono::seconds(1); +} +} // namespace data_sync diff --git a/mods/src/patches/parts/sync/sync.cc b/mods/src/patches/parts/sync/sync.cc index 05c6b76f8..262336790 100644 --- a/mods/src/patches/parts/sync/sync.cc +++ b/mods/src/patches/parts/sync/sync.cc @@ -13,7 +13,6 @@ #include #include -#include #include namespace data_sync @@ -25,9 +24,8 @@ namespace constexpr size_t kJobQueueCapacity = 256; struct Job { - // A plain processor, or one bound to what the game thread knew when the response arrived. - std::function process; - std::string bytes; + Processor process; + Input input; }; auto& pending = *new BoundedQueue{"processing", kJobQueueCapacity}; @@ -39,7 +37,8 @@ namespace for (;;) { auto job = pending.Pop(); try { - job.process(job.bytes); + if (job.input.generation == Session().generation) + job.process(job.input); } catch (const std::exception& e) { spdlog::error("Exception while processing sync data: {}", e.what()); } catch (...) { @@ -48,57 +47,76 @@ namespace } } + // The game's record of the local player, or nullptr. + UserProfile* LocalUserProfile() + { + auto* manager = UserProfileManager::Instance(); + return manager ? manager->GetLocalUserProfile() : nullptr; + } + // Runs on the game thread: copies the bytes out and leaves the work to the processing thread. void HandleEntityGroup(EntityGroup* entity_group) { auto* group = entity_group ? entity_group->Group() : nullptr; - if (!group || !group->bytes() || group->Length() <= 0) + if (!group) + return; + const auto length = group->Length(); + auto* bytes = group->bytes(); + if (length < 0 || (length > 0 && (!bytes || static_cast(length) > bytes->size()))) return; - const auto type = entity_group->Type(); - const auto bytes = [group] { - return std::string(reinterpret_cast(group->bytes()->data()), static_cast(group->Length())); - }; - if (auto process = ProcessorFor(type)) - pending.Push({process, bytes()}); - - // The own profile needs the local player's id and level, which only the game thread can read. Skipped if the - // local profile is not ready; a later response retries. - if (type == EntityGroup::Kind::UserProfiles && Config::Get().sync.options.profile) { - auto* local = LocalUserProfile(); - auto* user_id = local ? local->UserId() : nullptr; - if (auto self = user_id ? il2cpp::ToUtf8(user_id) : std::string{}; !self.empty()) - pending.Push({[self = std::move(self), level = local->Level()](const std::string& bytes) { - ProcessOwnProfile(bytes, self, level); - }, - bytes()}); + const auto type = entity_group->Type(); + auto process = ProcessorFor(type); + // UserProfiles also carries the local player's own record, read only while a target syncs the profile. + const bool own_profile = type == EntityGroup::Kind::UserProfiles && Config::Get().sync.options.profile; + if (!process && !own_profile) + return; + Input input{Session().generation, + length ? std::string(reinterpret_cast(bytes->data()), static_cast(length)) + : std::string{}}; + if (type == EntityGroup::Kind::PlanetaryMapResponse || own_profile) { + if (auto* profile = LocalUserProfile()) { + input.user_id = il2cpp::ToUtf8(profile->UserId()); + input.level = profile->Level(); + } } + if (process) + pending.Push({process, input}); + // Skipped if the local profile is not ready; a later response retries. + if (own_profile && !input.user_id.empty()) + pending.Push({ProcessOwnProfile, std::move(input)}); } // Platform responses (store offers among them) pass through the same registry code as game server responses. Only - // store offers are read from them, and only while a target wants them. + // store offers are read from them, and only while a target wants them, so a target that turns them on later + // waits for the next store refresh. void HandlePlatformResponse(PlatformServiceResponse* response) { - if (auto* groups = response->EntityGroups()) - for (auto* entity_group : *groups) { - auto* group = entity_group && entity_group->Type() == PlatformEntityGroup::Kind::StoreOffers - ? entity_group->Group() - : nullptr; - if (group && group->bytes() && group->Length() > 0) - pending.Push({ProcessStoreOffers, std::string(reinterpret_cast(group->bytes()->data()), - static_cast(group->Length()))}); - } + auto* groups = response->EntityGroups(); + if (!groups) + return; + for (auto* entity_group : *groups) { + auto* group = entity_group && entity_group->Type() == PlatformEntityGroup::Kind::StoreOffers + ? entity_group->Group() + : nullptr; + const auto length = group ? group->Length() : 0; + auto* bytes = group ? group->bytes() : nullptr; + if (length > 0 && bytes && static_cast(length) <= bytes->size()) + pending.Push({ProcessStoreOffers, + {Session().generation, + std::string(reinterpret_cast(bytes->data()), static_cast(length))}}); + } } void* RtcParser_ParseFinalPayload_Hook(auto original, void* _this, CentrifugoInfo centrifugoInfo, RealtimeDataPayload* realtimeDataPayload, void* finalPayload) { - if (Config::Get().sync.options.slots && realtimeDataPayload && realtimeDataPayload->Target() - && realtimeDataPayload->DataType() && realtimeDataPayload->Data()) { + if (realtimeDataPayload && realtimeDataPayload->Target() && realtimeDataPayload->DataType() + && realtimeDataPayload->Data()) { // DataType arrives as a number in a string; JSON is "0". This runs inside the game's parser, so nothing here // may throw. const auto* type = realtimeDataPayload->DataType(); if (type->length == 1 && type->chars[0] == u'0') - pending.Push({ProcessRealtimeSlot, il2cpp::ToUtf8(realtimeDataPayload->Data())}); + pending.Push({ProcessRealtimeSlot, {Session().generation, il2cpp::ToUtf8(realtimeDataPayload->Data())}}); } return original(_this, centrifugoInfo, realtimeDataPayload, finalPayload); } @@ -122,11 +140,9 @@ namespace Il2CppString* gatewayServerUrl, Il2CppString* sessionId, Il2CppString* serverRegion) { - original(_this, gameServerUrl, gatewayServerUrl, sessionId, serverRegion); + // Establish the generation before original can deliver responses from the new session. SetGameServer(il2cpp::ToUtf8(gameServerUrl), il2cpp::ToUtf8(sessionId)); - // A new login (or a switch of account or server) must not be diffed against the last one's data: the new - // account gets its first full upload and haven data again, and nothing of the old one is reported gone. - pending.Push({StartNewSession, {}}); + original(_this, gameServerUrl, gatewayServerUrl, sessionId, serverRegion); } void GameServer_Initialise_Hook(auto original, void* _this, Il2CppString* sessionId, Il2CppString* gameVersion, @@ -162,12 +178,19 @@ namespace IL2CPP_HOOK(SlotAssignRtcParser::Method("ParseFinalPayload"), RtcParser_ParseFinalPayload_Hook); IL2CPP_HOOK(SlotClearRtcParser::Method("ParseFinalPayload"), RtcParser_ParseFinalPayload_Hook); + FollowSettings(); InstallFleetSync(); - StartBattleLogs(); std::thread(RunJobs).detach(); } + + // Frame work: settings changes reach the targets, then fleet state is polled. + void Frame() + { + FollowSettings(); + UpdateFleetSync(); + } } // namespace } // namespace data_sync -const Feature kSync{"Sync", &Config::Patches::syncpatches, data_sync::Install, data_sync::UpdateFleetSync}; +const Feature kSync{"Sync", &Config::Patches::syncpatches, data_sync::Install, data_sync::Frame}; diff --git a/mods/src/patches/parts/sync/sync.h b/mods/src/patches/parts/sync/sync.h index aa53feb7f..90637a3b0 100644 --- a/mods/src/patches/parts/sync/sync.h +++ b/mods/src/patches/parts/sync/sync.h @@ -1,11 +1,7 @@ #pragma once -// Internal to data sync. Game data is diffed against what was last sent on one processing thread, queued, and -// uploaded per target on worker threads. Battle logs are fetched from the game server on their own thread, -// because each needs extra requests to resolve player and alliance names. -// -// The threads are detached and run until the process ends, so everything they share is allocated once and never -// destroyed: static destructors at exit must not free state another thread is still using. +// Internal sync records, delivery cursors and game-session jobs. Network workers share one process-lifetime +// owner; each upload captures its session and target configuration before leaving the owner's lock. #include "config.h" @@ -14,26 +10,19 @@ #include #include +#include +#include #include #include #include +#include +#include #include +#include #include #include #include -NLOHMANN_JSON_NAMESPACE_BEGIN -template <> struct adl_serializer { - static void to_json(json& j, const SyncConfig::Type t) - { - j = nullptr; - for (const auto& opt : SyncOptions) - if (opt.type == t) - j = opt.type_str; - } -}; -NLOHMANN_JSON_NAMESPACE_END - namespace data_sync { // Sync messages only show up with [sync] logging enabled; debug and trace also need [sync] debug. The message is @@ -73,11 +62,13 @@ template class BoundedQueue { } - void Push(T item) + std::optional Push(T item) { + std::optional dropped; { std::scoped_lock lock(mutex_); if (items_.size() >= capacity_) { + dropped = std::move(items_.front()); items_.pop_front(); // Every 100th drop, so a dead target does not flood the log. if (dropped_++ % 100 == 0) @@ -86,6 +77,7 @@ template class BoundedQueue items_.push_back(std::move(item)); } ready_.notify_one(); + return dropped; } // Blocks until an item is available. @@ -107,52 +99,150 @@ template class BoundedQueue std::deque items_; }; -// upload.cc - -// Queues changed records of one category for every target that wants it. `first_sync` marks the first full -// upload of a category in this session (sent as X-PRIME-SYNC: 2). -void Queue(SyncConfig::Type type, const nlohmann::json& records, bool first_sync = false); - -// records.cc. Processing thread only. - -// Turns one server response into records. Its bytes are an entity group's payload. -using Processor = void (*)(const std::string& bytes); -// The processor for an entity group kind, or null when the mod does not sync it or no target wants it. -Processor ProcessorFor(EntityGroup::Kind kind); -void ProcessRealtimeSlot(const std::string& bytes); -// Platform responses only, so not routed by EntityGroup::Kind. -void ProcessStoreOffers(const std::string& bytes); -// The local player's own record from a UserProfiles response. `self` and `level` are read on the game thread when -// the response arrives, since only the game thread may call into the game. -void ProcessOwnProfile(const std::string& bytes, const std::string& self, int32_t level); -// A new login (or a switch of account or server): forgets what was sent, so the new account gets its first full -// upload and nothing of the old one is reported gone. -void StartNewSession(const std::string& bytes); -// The game's record of the local player, or nullptr. Calls into the game. -UserProfile* LocalUserProfile(); +// Separate streams preserve the wire protocol's player/alliance resources and active/completed missions. +enum class Stream { + kAway, + kBattles, + kBuffs, + kBuildings, + kEmerald, + // Client fleet changes, a journal like battles: one entry per transition, re-read or snapshot. + kFleets, + // The server's view of deployed fleets, one record per fleet. + kDeployedFleets, + kHaven, + kHavenPieces, + kHavenProsperity, + kInventory, + kJobs, + kActiveMissions, + kCompletedMissions, + kOfficers, + kProfile, + kResearch, + kResources, + kAllianceResources, + kShips, + kSlots, + kStoreOffers, + kTech, + kTraits, + kCount +}; +using RecordSet = std::map; +// Records are immutable and shared by current state and target cursors, so targets do not copy whole JSON trees. +using Snapshot = std::map>; +using Clock = std::chrono::steady_clock; +inline constexpr size_t kRecentBattles = 300; +// Fleet changes are a journal too, bounded like battles. +inline constexpr size_t kRecentFleetChanges = 300; + +// Full lists establish the initial-sync marker; Replace also prunes entities absent from the list. +enum class Update { kMerge, kFull, kReplace }; + +class RecordStore +{ +public: + void Reset(); + bool Apply(Stream stream, RecordSet records, Update mode = Update::kMerge); + bool Contains(Stream stream, const nlohmann::json& key) const; + uint64_t generation = 0; + +private: + friend class Delivery; + struct Collection { + Snapshot records; + uint64_t version = 0; + bool full = false; + // Journal keys in arrival order, oldest first. + std::deque order; + }; + std::array, static_cast(Stream::kCount)> streams_; +}; + +struct Upload { + Stream stream; + uint64_t generation, revision, version; + Snapshot snapshot; + std::vector> records; + bool first_sync = false; + nlohmann::json Records() const; + const char* Type() const; +}; + +class Delivery +{ +public: + void Reset(uint64_t generation) + { + generation_ = generation; + cursors_ = {}; + } + void Configure(const SyncTargetConfig& config); + std::optional Next(const RecordStore& store, Clock::time_point now); + // Only a successful response for the captured session and configuration advances the cursor. + void Complete(Upload upload, bool success, uint64_t generation, Clock::time_point now); + const SyncTargetConfig& Config() const + { return config_; } + uint64_t Revision() const + { return revision_; } + +private: + struct Cursor { + Snapshot accepted, attempted; + uint64_t version = 0; + bool full = false; + Clock::time_point retry_at{}; + std::chrono::seconds backoff{1}; + }; + SyncTargetConfig config_; + uint64_t revision_ = 0, generation_ = 0; + size_t next_ = 0; + std::array(Stream::kCount)> cursors_; +}; + +struct GameSession { + uint64_t generation = 0; + std::string server_url, session_id; + std::string version = "1.000.51336"; + int32_t instance_id = 0; +}; +struct Input { + uint64_t generation; + std::string bytes; + // Captured on the game thread for owner-qualified Haven map responses and the own profile. + std::string user_id; + // The local player's level, captured with user_id for the own profile: its server record carries level 0. + int32_t level = 0; +}; +using Processor = void (*)(const Input&); +Processor ProcessorFor(EntityGroup::Kind kind); +void ProcessRealtimeSlot(const Input& input); +void ProcessStoreOffers(const Input& input); +// The local player's own record from a UserProfiles response; needs user_id and level. +void ProcessOwnProfile(const Input& input); +void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode = Update::kMerge); +void FollowSettings(); +GameSession Session(); +void SetGameServer(std::string url, std::string session_id); +void SetGameVersion(std::string version); +void SetInstanceId(int32_t instance_id); +// Journals are cached per session, so every target can receive them without a global sent-ID filter. +void StartBattleLogs(); +void QueueNewBattles(const std::vector& ids, uint64_t generation); +bool HasBattle(uint64_t id, uint64_t generation); +void CachePlayerNames(const Input& input); +void CacheAllianceNames(const Input& input); // fleets.cc. Client fleet records are read on the game thread, from fleet_watch and the frame tick. // Subscribes to fleet_watch when a target wants fleets. void InstallFleetSync(); -// Sync's frame work: polls fleet_watch, then re-reads and snapshots what was asked for. Returns at once when fleet -// sync is off. +// Sync's frame work for fleets: polls fleet_watch, then re-reads and snapshots what was asked for. Returns at once +// when fleet sync is off. void UpdateFleetSync(); // Asks the game thread for a full fleet snapshot, sent as a first sync. Any thread. void RequestFleetSnapshot(); // Asks the game thread to re-read a fleet a second from now and resend it if its late values changed. Any thread. void RereadFleet(uint64_t fleet_id); - -// battlelogs.cc - -void StartBattleLogs(); -// Queues the battles that were not sent before. Processing thread only. -void QueueNewBattles(const std::vector& battle_ids); -// Fills the name caches from profile responses the game requested itself. -void CachePlayerNames(const std::string& bytes); -void CacheAllianceNames(const std::string& bytes); -// The game session, captured from the game's own server setup, for the battle-log requests. -void SetGameServer(std::string url, std::string session_id); -void SetGameVersion(std::string version); -void SetInstanceId(int32_t instance_id); } // namespace data_sync diff --git a/mods/src/patches/parts/sync/upload.cc b/mods/src/patches/parts/sync/upload.cc index 36e852436..748367cce 100644 --- a/mods/src/patches/parts/sync/upload.cc +++ b/mods/src/patches/parts/sync/upload.cc @@ -1,166 +1,175 @@ -// Uploads to sync targets: one worker thread, HTTP session and bounded queue per target. - +// One owner for canonical records, target cursors and session identity. HTTP runs outside its lock. #include "patches/parts/sync/sync.h" #include "version.h" - #include #include -#include - -#include -#include #include namespace data_sync { namespace { - // Each entry is one JSON batch. At the 10 s request timeout a dead target falls behind by a few batches a minute, - // so 256 covers a long outage before the oldest go. - constexpr size_t kTargetQueueCapacity = 256; - - struct Request { - SyncConfig::Type type; - std::string body; - bool first_sync; + class Target; + struct Owner { + std::mutex mutex; + std::condition_variable ready; + RecordStore records; + GameSession session; + std::map> targets; }; class Target { public: - Target(std::string name, const SyncTargetConfig& config) - : name_(std::move(name)) - , config_(config) + Target(Owner& owner, std::string name) + : worker_([this, &owner, name = std::move(name)] { Run(owner, name); }) { - Configure(); - std::thread(&Target::Run, this).detach(); } - - void Push(Request request) - { requests_.Push(std::move(request)); } - + Delivery delivery; // Accessed only under Owner::mutex. private: - // A fresh session for config_: one that had a proxy or relaxed TLS keeps them otherwise. - void Configure() - { - const auto& config = config_; - http_ = std::make_unique(); - auto& http = *http_; - http.SetUrl(config.url); - http.SetUserAgent("stfc community mod " VER_FILE_VERSION_STR " (libcurl/" LIBCURL_VERSION ")"); - http.SetAcceptEncoding(cpr::AcceptEncoding{}); - http.SetHttpVersion(cpr::HttpVersion{cpr::HttpVersionCode::VERSION_1_1}); - http.SetRedirect(cpr::Redirect{3, true, false, cpr::PostRedirectFlags::POST_ALL}); -#ifndef _MODDBG - // Debug builds wait indefinitely so a target can be stepped through in a debugger. - http.SetConnectTimeout(cpr::ConnectTimeout{3'000}); - http.SetTimeout(cpr::Timeout{10'000}); -#endif - spdlog::debug("sync target '{}': proxy='{}', verify_ssl={}", name_, config.proxy, config.verify_ssl); - if (!config.proxy.empty()) - http.SetProxies({{"http", config.proxy}, {"https", config.proxy}}); - if (!config.verify_ssl) - http.SetSslOptions( - cpr::Ssl(cpr::ssl::VerifyHost{false}, cpr::ssl::VerifyPeer{false}, cpr::ssl::NoRevoke{true})); - http.SetHeader({ - {"Content-Type", "application/json"}, - {"X-Powered-By", "stfc community mod/" VER_FILE_VERSION_STR}, - {"stfc-sync-token", config.token}, - }); - } - - // A settings reload can change the target's address, token or proxy; the session follows before the next - // upload. A target removed from the settings is no longer given uploads. - void FollowSettings() - { - const auto& targets = Config::Get().sync.targets; - const auto it = targets.find(name_); - if (it == targets.end()) - return; - const auto& now = it->second; - if (now.url == config_.url && now.token == config_.token && now.proxy == config_.proxy - && now.verify_ssl == config_.verify_ssl) - return; - config_ = now; - Configure(); - } - - void Run() + void Run(Owner& owner, const std::string& name) { + std::unique_ptr http; + uint64_t configured = 0; for (;;) { - auto request = requests_.Pop(); - FollowSettings(); - const auto label = fmt::format("{} ({})", name_, to_string(request.type)); + std::unique_lock lock(owner.mutex); + auto upload = delivery.Next(owner.records, Clock::now()); + if (!upload) { + owner.ready.wait_for(lock, std::chrono::seconds(1)); + continue; + } + const auto config = delivery.Config(); + const auto revision = delivery.Revision(); + lock.unlock(); + const auto label = fmt::format("{} ({})", name, upload->Type()); + bool success = false; try { - Send(label, std::move(request)); - } catch (const std::exception& error) { - spdlog::error("Failed to send {} sync data: {}", label, error.what()); - } catch (...) { - spdlog::error("Failed to send {} sync data: unknown error", label); + if (!http || configured != revision) { + http = std::make_unique(); + configured = revision; + http->SetUrl(config.url); + http->SetUserAgent("stfc community mod " VER_FILE_VERSION_STR " (libcurl/" LIBCURL_VERSION ")"); + http->SetAcceptEncoding(cpr::AcceptEncoding{}); + http->SetHttpVersion(cpr::HttpVersion{cpr::HttpVersionCode::VERSION_1_1}); + http->SetRedirect(cpr::Redirect{3, true, false, cpr::PostRedirectFlags::POST_ALL}); +#ifndef _MODDBG + http->SetConnectTimeout(cpr::ConnectTimeout{3'000}); + http->SetTimeout(cpr::Timeout{10'000}); +#endif + if (!config.proxy.empty()) + http->SetProxies({{"http", config.proxy}, {"https", config.proxy}}); + if (!config.verify_ssl) + http->SetSslOptions( + cpr::Ssl(cpr::ssl::VerifyHost{false}, cpr::ssl::VerifyPeer{false}, cpr::ssl::NoRevoke{true})); + http->SetHeader({{"Content-Type", "application/json"}, + {"X-Powered-By", "stfc community mod/" VER_FILE_VERSION_STR}, + {"stfc-sync-token", config.token}}); + } + auto& headers = http->GetHeader(); + if (upload->first_sync) + headers.insert_or_assign("X-PRIME-SYNC", "2"); + else + headers.erase("X-PRIME-SYNC"); + const auto records = upload->Records(); + http->SetBody(cpr::Body{records.dump()}); + const auto response = http->Post(); + success = response.status_code >= 200 && response.status_code < 300; + if (!success) + logging::error("UPLOAD", label, "Upload failed ({}): {}; retained for retry", response.status_code, + response.error.message); + else + logging::debug("UPLOAD", label, "Uploaded {} records in {:.0f} ms", records.size(), + response.elapsed * 1'000); + } catch (const std::exception& e) { + spdlog::error("Sync target {}: {}", label, e.what()); } + // A target took another category's first sync: fleet targets get a fresh fleet snapshot with it. + if (success && upload->first_sync && upload->stream != Stream::kFleets) + RequestFleetSnapshot(); + lock.lock(); + delivery.Complete(std::move(*upload), success, owner.records.generation, Clock::now()); } } - - void Send(const std::string& label, Request request) - { - auto& headers = http_->GetHeader(); - if (request.first_sync) { - headers.insert_or_assign("X-PRIME-SYNC", "2"); - logging::trace("UPLOAD", label, "Adding X-Prime-Sync header for initial sync"); - } else { - headers.erase("X-PRIME-SYNC"); - } - http_->SetBody(cpr::Body{std::move(request.body)}); - logging::debug("UPLOAD", label, "Sending data to {}", http_->GetFullRequestUrl()); - - const auto response = http_->Post(); - const auto elapsed = response.elapsed * 1'000; - if (response.status_code == 0) - logging::error("UPLOAD", label, "Failed to send request: {}", response.error.message); - else if (response.status_code == 501) - logging::error("UPLOAD", label, - "Sync target does not support this operation ({}, after {:.0f} ms). Consider turning off this " - "upload category for the target.", - response.status_line, elapsed); - else if (response.status_code >= 400) - logging::error("UPLOAD", label, "Failed to communicate with server: {} (after {:.0f} ms)", response.status_line, - elapsed); - else - logging::debug("UPLOAD", label, "Response: {} ({:.0f} ms elapsed)", response.status_line, elapsed); - } - - std::string name_; - SyncTargetConfig config_; - std::unique_ptr http_; - BoundedQueue requests_{"target", kTargetQueueCapacity}; + // The owner lives until process exit. Joinable workers belong to it and cannot outlive their storage. + std::thread worker_; }; - - // Started on first use, so a target added to the settings later still gets a worker. Never destroyed, see sync.h. - Target& TargetFor(const std::string& name, const SyncTargetConfig& config) + Owner& Sync() { - static auto& targets = *new std::map>; - static auto& mutex = *new std::mutex; - std::scoped_lock lock(mutex); - auto& target = targets[name]; - if (!target) - target = new Target(name, config); - return *target; + static auto& owner = *new Owner; + return owner; } } // namespace - -void Queue(SyncConfig::Type type, const nlohmann::json& records, bool first_sync) +void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode) +{ + auto& owner = Sync(); + { + std::scoped_lock lock(owner.mutex); + if (generation != owner.records.generation) + return; + if (!owner.records.Apply(stream, std::move(records), mode)) + return; + } + owner.ready.notify_all(); +} +bool HasBattle(uint64_t id, uint64_t generation) +{ + auto& owner = Sync(); + std::scoped_lock lock(owner.mutex); + return generation == owner.records.generation && owner.records.Contains(Stream::kBattles, id); +} +void FollowSettings() +{ + static const Config* applied = nullptr; + const auto& config = Config::Get(); + if (applied == &config) + return; + applied = &config; + auto& owner = Sync(); + { + std::scoped_lock lock(owner.mutex); + for (auto& [name, target] : owner.targets) { + const auto now = config.sync.targets.find(name); + target->delivery.Configure(now == config.sync.targets.end() ? SyncTargetConfig{} : now->second); + } + for (const auto& [name, options] : config.sync.targets) { + auto& target = owner.targets[name]; + if (!target) + target = std::make_unique(owner, name); + target->delivery.Configure(options); + } + } + owner.ready.notify_all(); +} +GameSession Session() +{ + auto& owner = Sync(); + std::scoped_lock lock(owner.mutex); + return owner.session; +} +void SetGameServer(std::string url, std::string session_id) { - // Another category's first sync: the targets get a full fleet snapshot with it. - if (first_sync && type != SyncConfig::Type::kFleets) - RequestFleetSnapshot(); - std::string body; - for (const auto& [name, config] : Config::Get().sync.targets) { - if (!config.enabled(type)) - continue; - if (body.empty()) - body = records.dump(); - TargetFor(name, config).Push({type, body, first_sync}); + auto& owner = Sync(); + { + std::scoped_lock lock(owner.mutex); + owner.records.Reset(); + owner.session.generation = owner.records.generation; + owner.session.server_url = std::move(url); + owner.session.session_id = std::move(session_id); + for (const auto& [name, target] : owner.targets) + target->delivery.Reset(owner.records.generation); } - logging::debug("QUEUE", to_string(type), "Added {} entries to sync queue", records.size()); + owner.ready.notify_all(); +} +void SetGameVersion(std::string version) +{ + auto& owner = Sync(); + std::scoped_lock lock(owner.mutex); + owner.session.version = std::move(version); +} +void SetInstanceId(int32_t id) +{ + auto& owner = Sync(); + std::scoped_lock lock(owner.mutex); + owner.session.instance_id = id; } } // namespace data_sync diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 78b0f1942..2a7ba3bf3 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -177,7 +177,6 @@ void LogBanner() spdlog::info(" Log: {}", File::Log()); spdlog::info(" Cfg: {}", File::Config()); spdlog::info(" Var: {}", File::Vars()); - spdlog::info(" BL: {}", File::Battles()); spdlog::info(""); if constexpr (VERSION_PATCH) { spdlog::warn("*** NOTE: Beta versions may have unexpected bugs and issues"); diff --git a/mods/src/prime/UserProfileManager.h b/mods/src/prime/UserProfileManager.h deleted file mode 100644 index a27b45e14..000000000 --- a/mods/src/prime/UserProfileManager.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -#include "prime/server.h" - -struct UserProfileManager : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.PlayerProfile", "UserProfileManager">; - - static UserProfileManager* Instance() - { return Class::Get<"Instance", UserProfileManager*>(); } - - [[nodiscard]] UserProfile* GetLocalUserProfile() - { return Call<"GetLocalUserProfile", UserProfile*>(); } -}; diff --git a/mods/src/prime/server.h b/mods/src/prime/server.h index a2b7bd64d..d4d049ce7 100644 --- a/mods/src/prime/server.h +++ b/mods/src/prime/server.h @@ -436,6 +436,14 @@ struct UserProfile : il2cpp::Object { { return Get<"Level", int32_t>(); } }; +struct UserProfileManager : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.PlayerProfile", "UserProfileManager">; + static UserProfileManager* Instance() + { return Class::Get<"Instance", UserProfileManager*>(); } + UserProfile* GetLocalUserProfile() + { return Call<"GetLocalUserProfile", UserProfile*>(); } +}; + enum class BattleType : int32_t { Fleet = 0, Base = 1, diff --git a/tests/sync_logic.cc b/tests/sync_logic.cc new file mode 100644 index 000000000..ad790b1b2 --- /dev/null +++ b/tests/sync_logic.cc @@ -0,0 +1,624 @@ +// Production encoders against independent wire fixtures, and delivery against controlled HTTP outcomes/time. +#include "patches/parts/sync/sync.h" +#include +#include +#include +#include + +using nlohmann::json; +namespace game = Digit::PrimeServer::Models; +namespace +{ +using namespace data_sync; +RecordStore store; +Config& settings = Config::Get(); +std::vector rereads; +Clock::time_point now{}; +SyncTargetConfig Options() +{ + SyncTargetConfig config; + config.url = "http://example.invalid"; + for (const auto& option : SyncOptions) + config.*option.option = true; + return config; +} +void Check(bool ok, const char* message) +{ + if (!ok) + throw std::runtime_error(message); +} +Delivery Target() +{ + Delivery target; + target.Configure(Options()); + return target; +} +Upload Take(Delivery& target) +{ + auto upload = target.Next(store, now); + Check(upload.has_value(), "expected a pending upload"); + return std::move(*upload); +} +json Send(Delivery& target) +{ + auto upload = Take(target); + auto records = upload.Records(); + target.Complete(std::move(upload), true, store.generation, now); + return records; +} +template void Process(EntityGroup::Kind kind, const Message& message) +{ + const auto process = ProcessorFor(kind); + Check(process != nullptr, "processor available"); + process({store.generation, message.SerializeAsString()}); +} +void Slots() +{ + auto target = Target(); + game::EntitySlots slots; + auto* slot = slots.add_entityslots_(); + slot->set_id(17); + slot->set_slotspecid(9); + slot->mutable_slotitemid()->set_value(42); + slot->set_slottype(game::SLOTTYPE_CONSUMABLE); + slot->mutable_consumableslotparams()->mutable_expirytime()->set_seconds(100); + Process(EntityGroup::Kind::EntitySlots, slots); + Check(Send(target) + == json::parse( + R"([{"type":"slot","sid":17,"slot_type":0,"spec_id":9,"item_id":42,"params":{"expiry_time":100}}])"), + "consumable wire fixture"); + Process(EntityGroup::Kind::EntitySlots, slots); + Check(!target.Next(store, now), "unchanged slot is not uploaded again"); + slot->mutable_consumableslotparams()->mutable_expirytime()->set_seconds(200); + Process(EntityGroup::Kind::EntitySlots, slots); + Check(Send(target)[0]["params"]["expiry_time"] == 200, "expiry changes with the same item id are uploaded"); + ProcessRealtimeSlot( + {store.generation, + R"({"slot_id":17,"slot_type":0,"slot_spec_id":9,"slot_item_id":42,"consumable_slot_params":{"expiry_time":"1970-01-01T00:03:20Z"}})"}); + Check(!target.Next(store, now), "protobuf and realtime expiry encode identical records"); + ProcessRealtimeSlot( + {store.generation, + R"({"slot_id":17,"slot_type":0,"slot_spec_id":9,"slot_item_id":42,"consumable_slot_params":{"expiry_time":"1970-01-01T00:05:00Z"}})"}); + Check(Send(target)[0]["params"]["expiry_time"] == 300, "realtime expiry change is uploaded"); + ProcessRealtimeSlot({store.generation, "not JSON"}); + Check(!target.Next(store, now), "malformed JSON cannot change state"); +} +void Presets() +{ + auto target = Target(); + game::EntitySlots slots; + auto* slot = slots.add_entityslots_(); + slot->set_id(7); + slot->set_slotspecid(3); + slot->set_slottype(game::SLOTTYPE_FLEETPRESET); + auto* preset = slot->mutable_fleetpresetslotparams(); + preset->set_name("Raid"); + preset->set_order(2); + auto* setup = preset->add_setups(); + setup->set_drydockid(1); + setup->add_shipids(99); + setup->add_officerids(4); + setup->add_officerids(5); + Process(EntityGroup::Kind::EntitySlots, slots); + Check( + Send(target) + == json::parse( + R"([{"type":"slot","sid":7,"slot_type":7,"spec_id":3,"item_id":null,"params":{"name":"Raid","order":2,"setup":[{"drydock_id":1,"ship_id":99,"officer_ids":[4,5]}]}}])"), + "fleet preset wire fixture"); + ProcessRealtimeSlot( + {store.generation, + R"({"slot_id":7,"slot_type":7,"slot_spec_id":3,"slot_item_id":null,"fleet_preset_slot_params":{"name":"Raid","order":2,"setups":[{"d":1,"s":[99],"o":[4,5]}]}})"}); + Check(!target.Next(store, now), "fleet preset transports agree"); +} +void Snapshots() +{ + auto target = Target(); + game::OfficersResponse officers; + auto* officer = officers.add_officers(); + officer->set_id(8); + officer->set_rankindex(2); + officer->set_level(10); + officer->set_shardcount(15); + Process(EntityGroup::Kind::Officers, officers); + Check(Send(target) == json::parse(R"([{"type":"officer","oid":8,"rank":2,"level":10,"shard_count":15}])"), + "officer wire fixture"); + game::ResourcesResponse resources; + auto* resource = resources.add_resources(); + resource->set_id(3); + resource->set_currentamount(100); + Process(EntityGroup::Kind::Resources, resources); + auto upload = Take(target); + Check(upload.first_sync, "initial resources use the full-sync header"); + Check(upload.Records() == json::parse(R"([{"type":"resource","rid":3,"amount":100}])"), "resource wire fixture"); + target.Complete(std::move(upload), true, store.generation, now); + ProcessorFor(EntityGroup::Kind::Json)( + {store.generation, R"({"resources":{"3":{"current_amount":100}},"starbase_modules":{"x":{"id":4,"level":9}}})"}); + Check(Send(target) == json::parse(R"([{"type":"module","bid":4,"level":9}])"), + "JSON modules share the canonical encoder"); + Check(!target.Next(store, now), "JSON and protobuf resources share one cursor"); + auto second = Target(); + Check(Take(second).Records().size() > 0, "new target receives unchanged cached state"); +} +void EmptyAndRemoved() +{ + auto target = Target(); + game::GlobalActiveBuffsResponse buffs; + auto* buff = buffs.add_globalactivebuffs(); + buff->set_buffid(5); + buff->set_level(2); + Process(EntityGroup::Kind::GlobalActiveBuffs, buffs); + Check(Send(target) == json::parse(R"([{"type":"buff","bid":5,"level":2,"expiry_time":null}])"), "buff wire fixture"); + buffs.clear_globalactivebuffs(); + Process(EntityGroup::Kind::GlobalActiveBuffs, buffs); + Check(Send(target) == json::parse(R"([{"type":"expired_buff","bid":5}])"), + "empty full response expires missing buffs"); + game::ActiveMissionsResponse active; + Process(EntityGroup::Kind::ActiveMissions, active); + Check(Send(target) == json::parse(R"([{"type":"active_mission","mid":null}])"), "empty active mission sentinel"); + game::AwayAssignmentUserListResponse away; + Process(EntityGroup::Kind::AwayAssignmentsList, away); + auto upload = Take(target); + Check(upload.first_sync && upload.Records().empty(), "empty initial snapshot is delivered"); +} +void Jobs() +{ + auto target = Target(); + game::JobResponse jobs; + auto* job = jobs.add_jobs(); + job->set_uuid("job-1"); + job->set_type(game::JOBTYPE_RESEARCH); + job->mutable_researchparams()->set_projectid(7); + job->mutable_researchparams()->set_level(2); + job->mutable_starttime()->set_seconds(100); + job->set_duration(50); + job->set_reductioninseconds(5); + Process(EntityGroup::Kind::Jobs, jobs); + auto records = Send(target); + Check( + records + == json::parse( + R"([{"type":"job","job_type":3,"uuid":"job-1","start_time":100,"duration":50,"reduction":5,"rid":7,"level":2}])"), + "research job wire fixture"); + job->set_reductioninseconds(20); + Process(EntityGroup::Kind::Jobs, jobs); + Check(Send(target)[0]["reduction"] == 20, "job speedups update the existing record"); + job->mutable_starttime()->set_seconds(120); + job->set_duration(80); + Process(EntityGroup::Kind::Jobs, jobs); + const auto timing = Send(target); + Check(timing[0]["start_time"] == 120 && timing[0]["duration"] == 80, + "existing jobs re-emit changed start time and duration"); + jobs.clear_jobs(); + Process(EntityGroup::Kind::Jobs, jobs); + Check(Send(target) == json::parse(R"([{"type":"completed_job","uuid":"job-1"}])"), "job completion wire fixture"); +} +void HavenRecovery() +{ + auto target = Target(); + const auto process = ProcessorFor(EntityGroup::Kind::PlanetaryBaseData); + process({store.generation, "\xff"}); + Check(!target.Next(store, now), "invalid protobuf publishes nothing"); + game::PlanetaryBase haven; + Process(EntityGroup::Kind::PlanetaryBaseData, haven); + Check(!target.Next(store, now), "an empty Haven list publishes nothing"); + (*haven.mutable_pieces())[8] = 2; + Process(EntityGroup::Kind::PlanetaryBaseData, haven); + Check(Send(target) == json::parse(R"([{"type":"haven_pieces","sid":8,"count":2}])"), + "valid Haven pieces after parse failure are delivered"); + (*haven.mutable_pieces())[8] = 3; + Process(EntityGroup::Kind::PlanetaryBaseData, haven); + Check(Send(target)[0]["count"] == 3, "Haven piece updates are delivered"); + + game::PlanetaryMapData map; + auto& building = (*map.mutable_buildings())[5]; + building.set_specid(8); + building.set_level(2); + building.set_status(1); + building.set_position(42); + Process(EntityGroup::Kind::PlanetaryMapData, map); + Check(Send(target) == json::parse(R"([{"type":"haven","id":5,"sid":8,"level":2,"status":1,"position":42}])"), + "Haven buildings include their map identity, status and position"); + building.set_position(99); + Process(EntityGroup::Kind::PlanetaryMapData, map); + Check(Send(target)[0]["position"] == 99, "moving a Haven building updates its record"); + Process(EntityGroup::Kind::PlanetaryMapData, map); + Check(!target.Next(store, now), "unchanged Haven buildings are suppressed"); + + game::PlanetaryMapResponse response; + *response.mutable_mapdata() = map; + (*response.mutable_mapdata()->mutable_buildings())[5].set_level(3); + response.set_owneruserid("other"); + const auto process_map = ProcessorFor(EntityGroup::Kind::PlanetaryMapResponse); + Check(process_map != nullptr, "Haven map response processor exists"); + process_map({store.generation, response.SerializeAsString(), "local"}); + Check(!target.Next(store, now), "another player's Haven map is ignored"); + response.set_owneruserid("local"); + process_map({store.generation, response.SerializeAsString()}); + Check(!target.Next(store, now), "owned maps require a captured local identity"); + process_map({store.generation, response.SerializeAsString(), "local"}); + Check(Send(target)[0]["level"] == 3, "local player's Haven response is delivered"); +} +void ShipTolerance() +{ + auto target = Target(); + const auto apply = [](int hull, double progress) { + store.Apply(Stream::kShips, {{1, + {{"psid", 1}, + {"hull_id", hull}, + {"tier", 2}, + {"level", 5}, + {"level_percentage", progress}, + {"components", json::array({3})}}}}); + }; + apply(9, 10.0); + Send(target); + apply(9, 10.005); + Check(!target.Next(store, now), "ship progress tolerance is retained"); + apply(10, 10.005); + Check(Send(target)[0]["hull_id"] == 10, "hull change is not hidden by progress tolerance"); + apply(10, 10.03); + Check(Send(target)[0]["level_percentage"] == 10.03, "meaningful progress change is uploaded"); +} +void RetryAndCoalescing() +{ + auto target = Target(); + store.Apply(Stream::kResources, {{3, {{"rid", 3}, {"amount", 100}}}}, Update::kFull); + auto upload = Take(target); + const auto old = upload.Records(); + target.Complete(std::move(upload), false, store.generation, now); + Check(!target.Next(store, now), "failed upload backs off"); + now += std::chrono::seconds(1); + upload = Take(target); + Check(upload.first_sync && upload.Records() == old, "retry retains the initial-sync marker and record"); + target.Complete(std::move(upload), false, store.generation, now); + for (int i = 0; i < 1000; ++i) + store.Apply(Stream::kResources, {{3, {{"rid", 3}, {"amount", i}}}}, Update::kFull); + now += std::chrono::seconds(2); + upload = Take(target); + Check(upload.Records().size() == 1 && upload.Records()[0]["amount"] == 999, + "updates coalesce without a dropping upload queue"); + target.Complete(std::move(upload), true, store.generation, now); + Check(!target.Next(store, now), "successful upload advances only that target"); + auto second = Target(); + Check(Take(second).Records()[0]["amount"] == 999, "targets acknowledge independently"); +} +void AmbiguousRemoval() +{ + auto target = Target(); + store.Apply(Stream::kJobs, {}, Update::kReplace); + Send(target); + store.Apply(Stream::kJobs, {{"j", {{"uuid", "j"}}}}, Update::kReplace); + auto upload = Take(target); + target.Complete(std::move(upload), false, store.generation, now); + store.Apply(Stream::kJobs, {}, Update::kReplace); + now += std::chrono::seconds(1); + Check(Send(target) == json::parse(R"([{"type":"completed_job","uuid":"j"}])"), + "timeout after a possible server write still emits removal"); + Check(!target.Next(store, now), "removal is acknowledged once"); +} +void EmptyBaselines() +{ + for (const auto stream : {Stream::kJobs, Stream::kBuffs, Stream::kInventory}) { + store.Reset(); + auto target = Target(); + store.Apply(stream, {}, Update::kReplace); + auto upload = Take(target); + Check(upload.first_sync && upload.Records().empty(), "empty initial lists still establish a baseline"); + target.Complete(std::move(upload), true, store.generation, now); + Check(!target.Next(store, now), "acknowledged empty baselines stop uploading"); + target.Reset(store.generation); + Check(Take(target).first_sync, "reset cursors resend an empty baseline"); + } +} +void AcknowledgedAndUncertainRemovals() +{ + auto target = Target(); + store.Apply(Stream::kJobs, {{20, {{"uuid", 20}}}}, Update::kReplace); + Send(target); + store.Apply(Stream::kJobs, {{10, {{"uuid", 10}}}, {20, {{"uuid", 20}}}}, Update::kReplace); + auto upload = Take(target); + target.Complete(std::move(upload), false, store.generation, now); + store.Apply(Stream::kJobs, {}, Update::kReplace); + now += std::chrono::seconds(1); + const auto removals = Send(target); + Check(removals.size() == 2, "acknowledged and uncertain records are each removed once"); + for (const auto& record : removals) + Check(record["type"] == "completed_job" && (record["uuid"] == 10 || record["uuid"] == 20), + "both removal sources preserve their wire identity"); + Check(removals[0]["uuid"] != removals[1]["uuid"], "removal sources are disjoint"); + Check(!target.Next(store, now), "successful removal clears both cursor maps"); +} +void InFlightUpdates() +{ + auto target = Target(); + store.Apply(Stream::kResources, {{3, {{"rid", 3}, {"amount", 100}}}}, Update::kFull); + auto upload = Take(target); + store.Apply(Stream::kResources, {{3, {{"rid", 3}, {"amount", 200}}}}, Update::kFull); + target.Complete(std::move(upload), true, store.generation, now); + Check(Send(target)[0]["amount"] == 200, "an in-flight acknowledgement cannot swallow a newer update"); + store.Apply(Stream::kResources, {{3, {{"rid", 3}, {"amount", 300}}}}, Update::kFull); + upload = Take(target); + target.Complete(std::move(upload), false, store.generation, now); + store.Apply(Stream::kResources, {{3, {{"rid", 3}, {"amount", 200}}}}, Update::kFull); + now += std::chrono::seconds(1); + Check(Send(target)[0]["amount"] == 200, "after a timeout a reverted value must repair the possibly committed write"); + Check(!target.Next(store, now), "successful repair stops retries"); +} +void RemovalBacklog() +{ + auto target = Target(); + store.Apply(Stream::kJobs, {}, Update::kReplace); + Send(target); + for (int id = 0; id <= 1024; ++id) { + store.Apply(Stream::kJobs, {{id, {{"uuid", id}}}}, Update::kReplace); + auto upload = Take(target); + target.Complete(std::move(upload), false, store.generation, now); + now += std::chrono::seconds(60); + } + store.Apply(Stream::kJobs, {{1025, {{"uuid", 1025}}}}, Update::kReplace); + auto upload = Take(target); + Check(!upload.first_sync && upload.Records().size() == 1024, + "removal backlog remains bounded without discarding removals"); + for (const auto& record : upload.Records()) + Check(record["type"] == "completed_job" && record["uuid"] != 1024, + "entities never introduced to the target need no removal"); + target.Complete(std::move(upload), true, store.generation, now); + Check(Send(target)[0]["uuid"] == 1025, "delivery resumes the latest state once removals are acknowledged"); + Check(!target.Next(store, now), "no blocked update was falsely acknowledged"); +} +void LargeBaseline() +{ + auto target = Target(); + RecordSet jobs; + for (int id = 0; id < 2500; ++id) + jobs[id] = {{"uuid", id}}; + store.Apply(Stream::kJobs, std::move(jobs), Update::kReplace); + auto upload = Take(target); + Check(!upload.first_sync && upload.Records().size() == 1024, "a bounded partial upload cannot claim a full baseline"); + target.Complete(std::move(upload), true, store.generation, now); + upload = Take(target); + Check(!upload.first_sync && upload.snapshot.size() == 2048, + "acknowledged entities do not consume the uncertain-write budget"); + target.Complete(std::move(upload), true, store.generation, now); + upload = Take(target); + Check(upload.first_sync && upload.Records().size() == 2500, "the complete baseline eventually includes every entity"); + target.Complete(std::move(upload), true, store.generation, now); + Check(!target.Next(store, now), "the complete baseline is acknowledged only after delivery"); +} +void DeltaBeforeBaseline() +{ + auto target = Target(); + game::ResourcesDeltaResponse delta; + auto* resource = delta.add_resources(); + resource->set_id(7); + resource->set_amount(50); + Process(EntityGroup::Kind::ResourcesDelta, delta); + auto upload = Take(target); + Check(!upload.first_sync, "a resource delta cannot claim to be a complete baseline"); + target.Complete(std::move(upload), true, store.generation, now); + game::ResourcesResponse baseline; + auto* full = baseline.add_resources(); + full->set_id(7); + full->set_currentamount(50); + full = baseline.add_resources(); + full->set_id(8); + full->set_currentamount(60); + Process(EntityGroup::Kind::Resources, baseline); + upload = Take(target); + Check(upload.first_sync && upload.Records().size() == 2, + "the first full list includes previously acknowledged deltas"); +} +void ConfigurationAndSession() +{ + auto target = Target(); + store.Apply(Stream::kResources, {{3, {{"rid", 3}, {"amount", 100}}}}, Update::kFull); + auto upload = Take(target); + auto options = Options(); + options.token = "new-account"; + target.Configure(options); + target.Complete(std::move(upload), true, store.generation, now); + upload = Take(target); + Check(upload.first_sync, "old configuration acknowledgement is rejected"); + target.Complete(std::move(upload), true, store.generation, now); + options.resources = false; + target.Configure(options); + Check(!target.Next(store, now), "disabled category produces no upload"); + options.resources = true; + target.Configure(options); + upload = Take(target); + Check(upload.first_sync, "re-enabled category receives a baseline"); + const auto old_generation = store.generation; + store.Reset(); + target.Complete(std::move(upload), true, store.generation, now); + Publish(Stream::kResources, {{3, {{"rid", 3}, {"amount", 999}}}}, old_generation, Update::kFull); + Check(!target.Next(store, now), "old queued work cannot populate a new session"); + Publish(Stream::kResources, {{3, {{"rid", 3}, {"amount", 200}}}}, store.generation, Update::kFull); + upload = Take(target); + Check(upload.first_sync && upload.Records()[0]["amount"] == 200, "new session starts with its own baseline"); +} +void BattlesAndFairness() +{ + auto target = Target(); + for (int i = 1; i <= 305; ++i) + store.Apply(Stream::kBattles, {{i, {{"journal", {{"id", i}}}, {"names", json::object()}}}}); + Check(!store.Contains(Stream::kBattles, 1) && store.Contains(Stream::kBattles, 305), + "recent journals have a 300-record bound"); + store.Apply(Stream::kResources, {{1, {{"rid", 1}, {"amount", 7}}}}, Update::kFull); + auto upload = Take(target); + Check(upload.Records().size() == 1, "journals retain one-record request batches"); + target.Complete(std::move(upload), false, store.generation, now); + upload = Take(target); + Check(upload.stream == Stream::kResources, "unavailable journal category cannot starve resource uploads"); + target.Complete(std::move(upload), true, store.generation, now); + now += std::chrono::seconds(1); + for (int id = 6; id <= 305; ++id) + Check(Send(target)[0]["journal"]["id"] == id, "each cached journal is acknowledged independently"); + Check(!target.Next(store, now), "journal delivery stops after acknowledgement"); + store.Apply(Stream::kBattles, {{306, {{"journal", {{"id", 306}}}, {"names", json::object()}}}}); + Check(Send(target)[0]["journal"]["id"] == 306, "pruning old journal cursors cannot suppress new journals"); + auto second = Target(); + Check(Send(second)[0]["journal"]["id"] == 7, "a new target receives the retained journals"); +} +void FleetTransitions() +{ + auto target = Target(); + const auto change = [](int sequence, const char* state, bool snapshot = false) { + store.Apply(Stream::kFleets, + {{json::array({sequence, snapshot}), + json::array({{{"fid", 5}, {"slot", 0}, {"state_name", state}, {"src", "client"}}})}}); + }; + change(1, "Mining"); + change(2, "Docked"); + change(3, "Repairing"); + auto upload = Take(target); + Check(!upload.first_sync + && upload.Records() + == json::parse(R"([{"type":"fleet","fid":5,"slot":0,"state_name":"Mining","src":"client"}])"), + "client fleet wire fixture"); + target.Complete(std::move(upload), false, store.generation, now); + Check(!target.Next(store, now), "a failed fleet change backs off"); + now += std::chrono::seconds(1); + Check(Send(target)[0]["state_name"] == "Mining", "a failed fleet change is retried before later ones"); + Check(Send(target)[0]["state_name"] == "Docked", "changes to one fleet are not merged"); + Check(Send(target)[0]["state_name"] == "Repairing", "every fleet change is delivered in order"); + Check(!target.Next(store, now), "fleet delivery stops after acknowledgement"); + change(4, "Repairing", true); + upload = Take(target); + Check(upload.first_sync && upload.Records().size() == 1, "a fleet snapshot uses the full-sync header"); + target.Complete(std::move(upload), true, store.generation, now); + auto second = Target(); + Check(Send(second)[0]["state_name"] == "Mining", "a new target receives the retained fleet changes"); + for (int sequence = 5; sequence <= 305; ++sequence) + change(sequence, "Docked"); + Check(!store.Contains(Stream::kFleets, json::array({5, false})) + && store.Contains(Stream::kFleets, json::array({6, false})), + "fleet changes have a 300-entry bound"); +} +void DeployedFleets() +{ + auto target = Target(); + rereads.clear(); + game::MyDeployedFleetsResponse response; + auto& fleet = (*response.mutable_fleets())[5]; + fleet.set_state(game::DEPLOYEDFLEETSTATE_DOCKED); + fleet.set_nodeid(77); + fleet.add_hullids(3005922948); + fleet.mutable_nodeaddress()->set_system(1234); + (*fleet.mutable_stats())[game::CLIENTMODIFIERTYPE_MODCARGOCAPACITY] = 1500; + (*fleet.mutable_attributes())[game::CLIENTMODIFIERTYPE_FLEETSTRENGTH] = 14.364f; + (*(*fleet.mutable_shipstats())[9].mutable_shipattributes_())[game::CLIENTMODIFIERTYPE_MODHULLHPMAX] = 1000; + auto& damage = + (*(*fleet.mutable_shipattributes())[9].mutable_shipattributes_())[game::CLIENTMODIFIERTYPE_SHIPHULLDAMAGE]; + damage = 250; + fleet.set_pursuedbynum(2); + Process(EntityGroup::Kind::MyDeployedFleets, response); + auto records = Send(target); + Check(records.size() == 1 && records[0]["t"].is_number_integer(), "server fleet records carry their read time"); + records[0].erase("t"); + Check( + records + == json::parse( + R"([{"type":"fleet","fid":5,"src":"server","server_state":5,"server_state_name":"OnNode","node_id":77,"hull_id":3005922948,"system_id":1234,"hull_hp":750,"hull_max":1000,"cargo_cap":1500,"strength":14.364,"pursued_by":2}])"), + "server fleet wire fixture"); + Check(rereads == std::vector{5}, "a changed deployed fleet is re-read"); + Process(EntityGroup::Kind::MyDeployedFleets, response); + Check(!target.Next(store, now) && rereads.size() == 1, "an unchanged deployed fleet is neither sent nor re-read"); + damage = 400; + Process(EntityGroup::Kind::MyDeployedFleets, response); + Check(Send(target)[0]["hull_hp"] == 600 && rereads.size() == 2, "a changed deployed fleet is sent and re-read"); + const auto departed = fleet; + response.clear_fleets(); + Process(EntityGroup::Kind::MyDeployedFleets, response); + Check(!target.Next(store, now) && rereads.size() == 3, "a departed fleet sends nothing and is re-read"); + (*response.mutable_fleets())[5] = departed; + Process(EntityGroup::Kind::MyDeployedFleets, response); + Check(Send(target)[0]["fid"] == 5, "a returning fleet is sent again"); +} +void RepairJobs() +{ + auto target = Target(); + rereads.clear(); + game::JobResponse jobs; + auto* job = jobs.add_jobs(); + job->set_uuid("repair-1"); + job->set_type(game::JOBTYPE_REPAIRFLEET); + job->mutable_repairfleetparams()->set_fleetid(5); + job->mutable_starttime()->set_seconds(100); + job->set_duration(30); + settings.sync.options.fleets = false; + Process(EntityGroup::Kind::Jobs, jobs); + settings.sync.options.fleets = true; + Check(Send(target).empty() && rereads.empty(), "repair jobs are left out without fleet sync"); + Process(EntityGroup::Kind::Jobs, jobs); + Check( + Send(target) + == json::parse( + R"([{"type":"job","job_type":5,"uuid":"repair-1","start_time":100,"duration":30,"reduction":0,"fid":5}])"), + "repair job wire fixture"); + Check(rereads == std::vector{5}, "a repair job re-reads its fleet"); +} +} // namespace +Config& Config::Get() +{ + static Config config; + return config; +} +bool SyncConfig::enabled(Type type) const +{ + for (const auto& option : SyncOptions) + if (option.type == type) + return this->*option.option; + return false; +} +namespace data_sync +{ +void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode) +{ + if (generation == store.generation) + store.Apply(stream, std::move(records), mode); +} +void QueueNewBattles(const std::vector&, uint64_t) {} +void CachePlayerNames(const Input&) {} +void CacheAllianceNames(const Input&) {} +void RereadFleet(uint64_t fleet_id) +{ rereads.push_back(fleet_id); } +} // namespace data_sync +int main() +{ + for (const auto& option : SyncOptions) + settings.sync.options.*option.option = true; + int failures = 0; + const auto run = [&](const char* name, void (*test)()) { + store.Reset(); + now = {}; + try { + test(); + } catch (const std::exception& e) { + std::cerr << name << ": " << e.what() << '\n'; + ++failures; + } + }; + run("slots", Slots); + run("presets", Presets); + run("snapshots", Snapshots); + run("removals", EmptyAndRemoved); + run("jobs", Jobs); + run("haven", HavenRecovery); + run("ships", ShipTolerance); + run("retry", RetryAndCoalescing); + run("ambiguous removal", AmbiguousRemoval); + run("empty baselines", EmptyBaselines); + run("removal sources", AcknowledgedAndUncertainRemovals); + run("in-flight updates", InFlightUpdates); + run("removal backlog", RemovalBacklog); + run("large baseline", LargeBaseline); + run("delta before baseline", DeltaBeforeBaseline); + run("configuration/session", ConfigurationAndSession); + run("battles", BattlesAndFairness); + run("fleet transitions", FleetTransitions); + run("deployed fleets", DeployedFleets); + run("repair jobs", RepairJobs); + if (!failures) + std::cout << "Sync wire and delivery tests passed\n"; + return failures ? 1 : 0; +} diff --git a/xmake.lua b/xmake.lua index a41ac49d8..795a8320b 100644 --- a/xmake.lua +++ b/xmake.lua @@ -66,3 +66,13 @@ target("mod-logic-tests") elseif is_plat("windows") then add_syslinks("shell32", "winmm") end + +target("sync-logic-tests") + set_kind("binary") + set_default(false) + set_exceptions("cxx") + add_files("tests/sync_logic.cc", "mods/src/patches/parts/sync/records.cc", "mods/src/patches/parts/sync/state.cc") + add_includedirs("mods/src", "libs/il2cpp/include", "third_party/libil2cpp", "libs/unity/include") + add_packages("protobuf", "spdlog", "toml++", "nlohmann_json") + add_rules("protobuf.cpp") + add_files("mods/src/prime/proto/*.proto") From b2353f1bd3c3f6a42b02492ba4719d1e5c2fd354 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 06/10] Parse settings without side effects Parsing only turns a TOML table into settings now. Writing the vars file, creating a missing file and printing the first load move to the callers that own them. The reader becomes testable: the logic tests parse the English example with the real reader and check that every documented key is read and no documented value is rejected. --- mods/src/config.cc | 84 +++++++++++----------- mods/src/config.h | 13 ++-- tests/mod_logic.cc | 175 ++++++++++++++++++++++++++++++++++++++++++++- xmake.lua | 6 +- 4 files changed, 232 insertions(+), 46 deletions(-) diff --git a/mods/src/config.cc b/mods/src/config.cc index ae1205aff..8729fb14a 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -353,12 +353,6 @@ bool SyncConfig::enabled(SyncConfig::Type type) const return false; } -// The settings file as parsed. `loaded` is false when it was missing or did not parse and defaults are used. -struct SettingsFile { - toml::table table; - bool loaded = false; -}; - namespace { // The current settings. Replaced settings are never freed: a sync thread may still be reading them, and they only @@ -370,8 +364,26 @@ toml::table ParseSettingsFile() const auto path = File::Path(File::Config()).u8string(); return toml::parse_file(std::string_view(reinterpret_cast(path.data()), path.size())); } + +// Writes `effective` as the runtime vars file, and on first use creates the settings file from it when missing. +void SaveEffective(const toml::table& effective, bool first) +{ + if (first && !std::filesystem::exists(File::Path(File::Config()))) { + spdlog::warn("Creating {} (default config file)", File::Config()); + Save(effective, File::Config(), false); + } + spdlog::info("Creating {} (final config file)", File::Vars()); + Save(effective, File::Vars(), true); +} } // namespace +std::unique_ptr Config::Parse(const toml::table& file, toml::table& effective, bool log_values) +{ + std::unique_ptr config(new Config); + config->Read(file, effective, log_values); + return config; +} + Config& Config::Get() { if (auto* config = current_config.load(std::memory_order_acquire)) @@ -379,17 +391,28 @@ Config& Config::Get() // First use, inside il2cpp_init on the game thread. A broken file falls back to the defaults here. static Config* const first = [] { File::MigrateLegacyFiles(); - SettingsFile file; + toml::table file; + bool loaded = false; try { - file.table = ParseSettingsFile(); - file.loaded = true; + file = ParseSettingsFile(); + loaded = true; } catch (const toml::parse_error& e) { spdlog::warn("Failed to load config file, falling back to default settings: {}", e.description()); } catch (...) { spdlog::warn("Failed to load config file, falling back to default settings"); } - auto* config = new Config; - config->Load(file, true); + toml::table effective; + auto* config = Parse(file, effective, loaded).release(); + SaveEffective(effective, true); + std::cout << "\n\n-----------------------------\n\n" + << effective << "\n\n-----------------------------\n" +#if VERSION_PATCH + << "Loaded beta version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION + << " (Patch " << VERSION_PATCH << ")\n\nNOTE: Beta versions may have unexpected bugs and issues.\n\n"; +#else + << "Loaded release version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION + << "\n\n"; +#endif current_config.store(config, std::memory_order_release); return config; }(); @@ -398,10 +421,9 @@ Config& Config::Get() bool Config::Reload() { - SettingsFile file; + toml::table file; try { - file.table = ParseSettingsFile(); - file.loaded = true; + file = ParseSettingsFile(); } catch (const toml::parse_error& e) { spdlog::warn("Settings not reloaded, {} does not parse: {}", File::Config(), e.description()); return false; @@ -411,20 +433,22 @@ bool Config::Reload() } spdlog::info("Reloading settings from {}", File::Config()); - auto* next = new Config; + std::unique_ptr next; + toml::table effective; try { - next->Load(file, false); + next = Parse(file, effective, true); + SaveEffective(effective, false); } catch (const std::exception& e) { // Shortcuts may already be rebound from the new file; everything else keeps the current settings. spdlog::error("Settings not reloaded: {}", e.what()); - delete next; return false; } // Hooks are installed once, so the startup hook groups stay in force. if (!(next->patches == Get().patches)) spdlog::warn("Changes to [patches] take effect after a restart"); next->patches = Get().patches; - current_config.store(next, std::memory_order_release); + // Never freed, see current_config. + current_config.store(next.release(), std::memory_order_release); return true; } @@ -459,11 +483,9 @@ void Config::ReloadIfChanged() Reload(); } -void Config::Load(const SettingsFile& file, bool first) +void Config::Read(const toml::table& config, toml::table& parsed, bool log_values) { - const auto& config = file.table; - toml::table parsed; - Reader r(config, parsed, file.loaded); + Reader r(config, parsed, log_values); r.Read("graphics", "interior_zoom_scale", graphics.interior_zoom_scale); if (!std::isfinite(graphics.interior_zoom_scale) || graphics.interior_zoom_scale < 1.0f) { spdlog::warn("invalid graphics.interior_zoom_scale {}; using 2", graphics.interior_zoom_scale); @@ -701,22 +723,4 @@ void Config::Load(const SettingsFile& file, bool first) r.Read("config", "assets_url_override", game.assets_url_override); Shortcut::Load(config, parsed, control.hotkeys_extended, control.enable_experimental); - - if (first && !std::filesystem::exists(File::Path(File::Config()))) { - spdlog::warn("Creating {} (default config file)", File::Config()); - Save(parsed, File::Config(), false); - } - spdlog::info("Creating {} (final config file)", File::Vars()); - Save(parsed, File::Vars(), true); - - if (!first) - return; - std::cout << "\n\n-----------------------------\n\n" - << parsed << "\n\n-----------------------------\n" -#if VERSION_PATCH - << "Loaded beta version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION << " (Patch " - << VERSION_PATCH << ")\n\nNOTE: Beta versions may have unexpected bugs and issues.\n\n"; -#else - << "Loaded release version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION << "\n\n"; -#endif } diff --git a/mods/src/config.h b/mods/src/config.h index fefa447e7..2d0057c99 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -7,8 +7,11 @@ #include "patches/parts/notification_audio.h" +#include + #include #include +#include #include #include #include @@ -126,8 +129,6 @@ struct ShipFilter { bool all = false; }; -struct SettingsFile; - class Config final { public: @@ -342,8 +343,12 @@ class Config final std::string assets_url_override; } game; + // Settings from a parsed settings file; an empty table gives the defaults. `effective` receives every value + // used, which becomes the runtime vars file. Logs what it rejects, and each value at debug level when + // `log_values`. Also rebinds the shortcuts. + static std::unique_ptr Parse(const toml::table& file, toml::table& effective, bool log_values); + private: Config() = default; - // Applies `file`. The first load also creates the settings file if it is missing. - void Load(const SettingsFile& file, bool first); + void Read(const toml::table& file, toml::table& effective, bool log_values); }; diff --git a/tests/mod_logic.cc b/tests/mod_logic.cc index 06253c8d9..3f91b71ef 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -1,17 +1,21 @@ // Settings parsing and shortcut matching, run against production code with Unity input replaced by a fake. +#include "config.h" #include "patches/chat_startup.h" -#include "patches/parts/notification_audio.h" #include "patches/parts/toast_text.h" #include "patches/ship_shortcut_hint.h" #include "patches/shortcuts.h" #include "str_utils.h" +#include +#include #include #include #include #include +#include #include +#include #include namespace @@ -187,6 +191,60 @@ void Loading() Check(Value(parsed, "shortcuts", "show_bookmarks") == "", "skipped shortcuts are not recorded"); } +// The English example documents every setting. Each key it sets must be one the mod reads, and none may be +// rejected: otherwise the example and the reader have drifted apart. +void ExampleSettings() +{ + // TODO(alex): auto_confirm_discovery is documented but has never been read by any release; drop it from the + // examples, or implement it. + static const std::set kDocumentedButUnread{"ui.auto_confirm_discovery"}; + + std::vector rejected; + auto logger = std::make_shared( + "settings", std::make_shared([&](const spdlog::details::log_msg& message) { + if (message.level >= spdlog::level::warn) + rejected.emplace_back(message.payload.data(), message.payload.size()); + })); + const auto previous = spdlog::default_logger(); + spdlog::set_default_logger(logger); + + auto example = toml::parse_file(STFC_EXAMPLE_SETTINGS); + // Experimental settings and shortcuts are only read with this on. + example["control"].as_table()->insert_or_assign("enable_experimental", true); + toml::table effective; + const auto config = Config::Parse(example, effective, false); + spdlog::set_default_logger(previous); + + for (const auto& message : rejected) + std::cerr << "rejected: " << message << '\n'; + Check(rejected.empty(), "the example parses without warnings"); + + // Every value the example sets appears among the values the mod used. + const std::function compare = + [&](const toml::table& documented, const toml::table* used, const std::string& path) { + for (const auto& [key, node] : documented) { + const auto name = path.empty() ? std::string(key.str()) : path + "." + std::string(key.str()); + const auto* next = used ? used->get(key) : nullptr; + if (const auto* table = node.as_table()) { + compare(*table, next ? next->as_table() : nullptr, name); + } else if (!next && !kDocumentedButUnread.contains(name)) { + std::cerr << "unread setting: " << name << '\n'; + Check(false, "every example setting is read"); + } + } + }; + compare(example, &effective, ""); + + Check(config->graphics.ui_scale > 0.0f, "example values are applied"); + Check(config->ui.hud_q_trials == MissionHudVisibility::kAuto + && config->ui.hud_field_training == MissionHudVisibility::kAuto + && config->ui.hud_outposts == MissionHudVisibility::kAuto + && config->ui.hud_missions == MissionHudVisibility::kAuto, + "example HUD settings use native visibility"); + Check(effective["sync"]["fleets"].value() == false && !config->sync.options.fleets, + "the example documents fleet sync, off by default"); +} + void NotificationAudio() { struct Fixture { @@ -237,6 +295,117 @@ void NotificationAudio() "invalid cues have no WAV data"); } +void DailyFactionSettings() +{ + const std::vector all{"federation", "klingon", "romulan"}; + toml::table effective; + for (const auto text : {"", "[ui]\ndaily_bulk_claim_factions = \"\"", "[ui]\ndaily_bulk_claim_factions = \" \""}) { + const auto config = Config::Parse(toml::parse(text), effective, false); + Check(config->ui.daily_bulk_claim_factions == all, "missing and blank faction filters select all main factions"); + Check(effective["ui"]["daily_bulk_claim_factions"].value() == "federation,klingon,romulan", + "effective settings materialize the explicit daily faction default"); + Check(!config->ui.auto_confirm_alliance_contribution, "alliance contribution confirmation is opt-in"); + } + for (const auto filter : {"none", "NONE,federation"}) { + const auto config = Config::Parse(toml::parse(std::string("[ui]\ndaily_bulk_claim_factions = \"") + filter + "\""), + effective, false); + Check(config->ui.daily_bulk_claim_factions == std::vector{"none"}, "none overrides named factions"); + } + const auto config = Config::Parse( + toml::parse( + "[ui]\ndaily_bulk_claim_factions = \"Federation,romulan\"\nauto_confirm_alliance_contribution = true"), + effective, false); + Check(config->ui.daily_bulk_claim_factions == std::vector{"federation", "romulan"}, + "named faction filters remain case-insensitive"); + Check(config->ui.auto_confirm_alliance_contribution, "alliance contribution setting survives parsing"); +} + +void UpstreamSettings() +{ + toml::table effective; + const auto defaults = Config::Parse(toml::table{}, effective, false); + Check(defaults->graphics.haven_zoom == 4000.0f && defaults->ui.reverse_haven_history, + "Haven defaults match upstream"); + Check(!defaults->ui.only_show_first_popup && defaults->audio.alert_victory == NotificationSound::kNone, + "popup suppression and audio cues stay opt-in"); + for (const auto value : {"0.5", "inf", "nan"}) { + const auto invalid_zoom = + Config::Parse(toml::parse(std::string("[graphics]\ninterior_zoom_scale = ") + value), effective, false); + Check(invalid_zoom->graphics.interior_zoom_scale == 2.0f, + "invalid interior zoom scales fall back to the upstream default"); + Check(effective["graphics"]["interior_zoom_scale"].value() == 2.0f, + "effective settings report the interior zoom fallback"); + } + const auto configured = Config::Parse(toml::parse(R"( + [audio] + disabled_events = " engine, aLl, music " + alert_victory = "Victory" + alert_defeat = "alarm" + alert_armada_created = "ping" + alert_armada_battle_won = "success" + alert_armada_battle_lost = "not-a-sound" + [graphics] + haven_zoom = -1.0 + [ui] + reverse_haven_history = false + only_show_first_popup = true + hud_q_trials = "always" + hud_field_training = "never" + hud_outposts = "ALWAYS" + hud_missions = " auto " + )"), + effective, false); + Check(configured->audio.disable_all_events + && configured->audio.disabled_events == std::vector{"engine", "music"}, + "All is a case-insensitive wildcard, other event names retain their spelling"); + Check(configured->audio.alert_victory == NotificationSound::kSuccess + && configured->audio.alert_defeat == NotificationSound::kAlarm + && configured->audio.alert_armada_created == NotificationSound::kPing + && configured->audio.alert_armada_battle_won == NotificationSound::kSuccess + && configured->audio.alert_armada_battle_lost == NotificationSound::kNone, + "notification sound names, aliases and invalid fallback"); + Check(configured->ui.hud_q_trials == MissionHudVisibility::kAlways + && configured->ui.hud_field_training == MissionHudVisibility::kNever + && configured->ui.hud_outposts == MissionHudVisibility::kAlways + && configured->ui.hud_missions == MissionHudVisibility::kAuto, + "each HUD setting binds its own visibility"); + Check(Value(effective, "audio", "alert_victory") == "success" + && Value(effective, "audio", "alert_armada_battle_lost") == "none", + "effective sound settings use canonical names"); + Check(configured->graphics.haven_zoom == 4000.0f && !configured->ui.reverse_haven_history + && configured->ui.only_show_first_popup, + "Haven validation and new UI settings"); +} + +// Settings that were renamed still read their old names, and the new name wins when both are set. +void RenamedSettings() +{ + toml::table effective; + const auto old_names = Config::Parse(toml::parse(R"( + [graphics] + ui_scale_adjust = 0.1 + [ui] + cargo_format = false + hide_artifact_exchange_all = true + [sync] + officer = true + )"), + effective, false); + Check(old_names->graphics.ui_scale_step == 0.1f, "ui_scale_adjust still sets the UI scale step"); + Check(!old_names->ui.format_cargo_values, "cargo_format still sets format_cargo_values"); + Check(old_names->ui.disable_exchange_all, "hide_artifact_exchange_all still sets disable_exchange_all"); + Check(effective["ui"]["disable_exchange_all"].value_or(false), "the vars file records the new name"); + Check(!effective["ui"]["hide_artifact_exchange_all"], "the vars file drops the old name"); + + const auto both = Config::Parse(toml::parse(R"( + [graphics] + ui_scale_step = 0.2 + ui_scale_adjust = 0.1 + )"), + effective, false); + Check(both->graphics.ui_scale_step == 0.2f, "the new name wins over the old one"); +} + void Matching() { Load(""); // defaults: move_down = "S|DOWN", toggle_shortcut_hints = "HOME" @@ -318,7 +487,11 @@ int main() ShipNames(); Chords(); Loading(); + ExampleSettings(); NotificationAudio(); + DailyFactionSettings(); + UpstreamSettings(); + RenamedSettings(); Matching(); ChatStartupGate(); if (failures) diff --git a/xmake.lua b/xmake.lua index 795a8320b..027aa5615 100644 --- a/xmake.lua +++ b/xmake.lua @@ -57,15 +57,19 @@ target("mod-logic-tests") set_kind("binary") set_default(false) set_exceptions("cxx") - add_files("tests/mod_logic.cc", "mods/src/patches/shortcuts.cc", "mods/src/patches/parts/notification_audio.cc") + add_files("tests/mod_logic.cc", "mods/src/patches/shortcuts.cc", "mods/src/config.cc", "mods/src/file.cc", "mods/src/patches/parts/notification_audio.cc") add_includedirs("mods/src", "libs/il2cpp/include", "third_party/libil2cpp", "libs/unity/include") add_packages("spdlog", "toml++") + if is_plat("macosx") then + add_files("mods/src/file_macos.mm") + end if is_plat("macosx") then add_frameworks("Cocoa") add_files("mods/src/patches/parts/notification_audio_mac.mm") elseif is_plat("windows") then add_syslinks("shell32", "winmm") end + add_defines('STFC_EXAMPLE_SETTINGS="' .. path.translate(path.absolute("example_community_patch_settings_en.toml"), "/") .. '"') target("sync-logic-tests") set_kind("binary") From ecf036508b1367ce6195e3d48c93cd00ac813283 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 07/10] Define shortcuts once as a parsed value Actions, defaults, tiers and aliases come from one definition file instead of repeated tables. Bindings are built as a parsed value before publishing, so a bad chord cannot mutate live state. Each modifier is described once and used for parsing, display names and matching, keeping left and right aliases consistent across all three. --- mods/src/config.cc | 5 +- mods/src/config.h | 4 +- mods/src/patches/shortcuts.cc | 316 +++++++++------------------------- mods/src/patches/shortcuts.h | 137 ++------------- mods/src/shortcuts.def | 109 ++++++++++++ tests/mod_logic.cc | 62 ++++++- tests/shortcut_defaults.toml | 113 ++++++++++++ xmake.lua | 1 + 8 files changed, 383 insertions(+), 364 deletions(-) create mode 100644 mods/src/shortcuts.def create mode 100644 tests/shortcut_defaults.toml diff --git a/mods/src/config.cc b/mods/src/config.cc index 8729fb14a..ac5481dad 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -413,6 +413,7 @@ Config& Config::Get() << "Loaded release version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION << "\n\n"; #endif + Shortcut::Set(std::move(config->shortcuts_)); current_config.store(config, std::memory_order_release); return config; }(); @@ -439,7 +440,6 @@ bool Config::Reload() next = Parse(file, effective, true); SaveEffective(effective, false); } catch (const std::exception& e) { - // Shortcuts may already be rebound from the new file; everything else keeps the current settings. spdlog::error("Settings not reloaded: {}", e.what()); return false; } @@ -447,6 +447,7 @@ bool Config::Reload() if (!(next->patches == Get().patches)) spdlog::warn("Changes to [patches] take effect after a restart"); next->patches = Get().patches; + Shortcut::Set(std::move(next->shortcuts_)); // Never freed, see current_config. current_config.store(next.release(), std::memory_order_release); return true; @@ -722,5 +723,5 @@ void Config::Read(const toml::table& config, toml::table& parsed, bool log_value r.Read("config", "settings_url", game.settings_url); r.Read("config", "assets_url_override", game.assets_url_override); - Shortcut::Load(config, parsed, control.hotkeys_extended, control.enable_experimental); + shortcuts_ = Shortcut::Parse(config, parsed, control.hotkeys_extended, control.enable_experimental); } diff --git a/mods/src/config.h b/mods/src/config.h index 2d0057c99..b4d6b1da7 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -6,6 +6,7 @@ // the game thread except sync, which only reads. #include "patches/parts/notification_audio.h" +#include "patches/shortcuts.h" #include @@ -345,10 +346,11 @@ class Config final // Settings from a parsed settings file; an empty table gives the defaults. `effective` receives every value // used, which becomes the runtime vars file. Logs what it rejects, and each value at debug level when - // `log_values`. Also rebinds the shortcuts. + // `log_values`. Does not change the live settings or shortcuts. static std::unique_ptr Parse(const toml::table& file, toml::table& effective, bool log_values); private: + Shortcut::Bindings shortcuts_; Config() = default; void Read(const toml::table& file, toml::table& effective, bool log_values); }; diff --git a/mods/src/patches/shortcuts.cc b/mods/src/patches/shortcuts.cc index 8256d3022..cc80010d0 100644 --- a/mods/src/patches/shortcuts.cc +++ b/mods/src/patches/shortcuts.cc @@ -7,6 +7,7 @@ #include #include +#include #include namespace @@ -83,68 +84,7 @@ constexpr KeyName kKeys[] = { {">", unity::KeyCode::Greater}, {"~", unity::KeyCode::Tilde}, {"$", unity::KeyCode::Dollar}, - {"0", unity::KeyCode::Alpha0}, - {"1", unity::KeyCode::Alpha1}, - {"2", unity::KeyCode::Alpha2}, - {"3", unity::KeyCode::Alpha3}, - {"4", unity::KeyCode::Alpha4}, - {"5", unity::KeyCode::Alpha5}, - {"6", unity::KeyCode::Alpha6}, - {"7", unity::KeyCode::Alpha7}, - {"8", unity::KeyCode::Alpha8}, - {"9", unity::KeyCode::Alpha9}, - {"A", unity::KeyCode::A}, - {"B", unity::KeyCode::B}, - {"C", unity::KeyCode::C}, - {"D", unity::KeyCode::D}, - {"E", unity::KeyCode::E}, - {"F", unity::KeyCode::F}, - {"G", unity::KeyCode::G}, - {"H", unity::KeyCode::H}, - {"I", unity::KeyCode::I}, - {"J", unity::KeyCode::J}, - {"K", unity::KeyCode::K}, - {"L", unity::KeyCode::L}, - {"M", unity::KeyCode::M}, - {"N", unity::KeyCode::N}, - {"O", unity::KeyCode::O}, - {"P", unity::KeyCode::P}, - {"Q", unity::KeyCode::Q}, - {"R", unity::KeyCode::R}, - {"S", unity::KeyCode::S}, - {"T", unity::KeyCode::T}, - {"U", unity::KeyCode::U}, - {"V", unity::KeyCode::V}, - {"W", unity::KeyCode::W}, - {"X", unity::KeyCode::X}, - {"Y", unity::KeyCode::Y}, - {"Z", unity::KeyCode::Z}, - {"F1", unity::KeyCode::F1}, - {"F2", unity::KeyCode::F2}, - {"F3", unity::KeyCode::F3}, - {"F4", unity::KeyCode::F4}, - {"F5", unity::KeyCode::F5}, - {"F6", unity::KeyCode::F6}, - {"F7", unity::KeyCode::F7}, - {"F8", unity::KeyCode::F8}, - {"F9", unity::KeyCode::F9}, - {"F10", unity::KeyCode::F10}, - {"F11", unity::KeyCode::F11}, - {"F12", unity::KeyCode::F12}, - {"F13", unity::KeyCode::F13}, - {"F14", unity::KeyCode::F14}, - {"F15", unity::KeyCode::F15}, {"NUMLOCK", unity::KeyCode::Numlock}, - {"KEY0", unity::KeyCode::Keypad0}, - {"KEY1", unity::KeyCode::Keypad1}, - {"KEY2", unity::KeyCode::Keypad2}, - {"KEY3", unity::KeyCode::Keypad3}, - {"KEY4", unity::KeyCode::Keypad4}, - {"KEY5", unity::KeyCode::Keypad5}, - {"KEY6", unity::KeyCode::Keypad6}, - {"KEY7", unity::KeyCode::Keypad7}, - {"KEY8", unity::KeyCode::Keypad8}, - {"KEY9", unity::KeyCode::Keypad9}, {"KEYDIVIDE", unity::KeyCode::KeypadDivide}, {"KEYENTER", unity::KeyCode::KeypadEnter}, {"KEYEQUAL", unity::KeyCode::KeypadEquals}, @@ -155,13 +95,7 @@ constexpr KeyName kKeys[] = { }; // Modifier names. The unsided ones match either side of the keyboard. Badges use AutoHotkey's ASCII notation. -struct ModifierName { - std::string_view name; - std::array keys; - std::string_view hint; -}; - -constexpr ModifierName kModifiers[] = { +constexpr KeyChord::Modifier kModifiers[] = { {"SHIFT", {unity::KeyCode::LeftShift, unity::KeyCode::RightShift}, "+"}, {"CTRL", {unity::KeyCode::LeftControl, unity::KeyCode::RightControl}, "^"}, {"ALT", {unity::KeyCode::LeftAlt, unity::KeyCode::RightAlt}, "!"}, @@ -183,23 +117,52 @@ constexpr ModifierName kModifiers[] = { {"RWIN", {unity::KeyCode::RightWindows, unity::KeyCode::RightWindows}, ">#"}, }; -// Settings are only parsed at load, so the tables are searched in order. -const KeyName* FindKey(std::string_view name) +// Unity assigns contiguous codes to letters, digits, function keys and keypad digits. +std::optional FindKey(std::string_view name) { + if (name.size() == 1) { + const char c = name[0]; + if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) + return KeyName{name, static_cast(c >= 'A' ? c + ('a' - 'A') : c)}; + } + struct Range { + std::string_view prefix; + int32_t first, last; + unity::KeyCode key; + }; + constexpr Range ranges[] = {{"F", 1, 15, unity::KeyCode::F1}, {"KEY", 0, 9, unity::KeyCode::Keypad0}}; + for (const auto& range : ranges) { + if (!name.starts_with(range.prefix)) + continue; + const auto digits = name.substr(range.prefix.size()); + int32_t number = 0; + const auto [end, error] = std::from_chars(digits.data(), digits.data() + digits.size(), number); + if (error == std::errc{} && end == digits.data() + digits.size() && number >= range.first && number <= range.last + && digits == std::to_string(number)) + return KeyName{name, static_cast(static_cast(range.key) + number - range.first)}; + } const auto it = std::ranges::find(kKeys, name, &KeyName::name); - return it == std::end(kKeys) ? nullptr : it; + return it == std::end(kKeys) ? std::nullopt : std::optional(*it); } -const ModifierName* FindModifier(std::string_view name) +const KeyChord::Modifier* FindModifier(std::string_view name) { - const auto it = std::ranges::find(kModifiers, name, &ModifierName::name); + const auto it = std::ranges::find(kModifiers, name, &KeyChord::Modifier::name); return it == std::end(kModifiers) ? nullptr : it; } -std::array, static_cast(Action::kCount)> bindings; +Shortcut::Bindings bindings; -std::vector& BindingsOf(Action action) -{ return bindings[static_cast(action)]; } +std::string DescribeChords(const std::vector& chords) +{ + std::string text; + for (const auto& chord : chords) { + if (!text.empty()) + text += " | "; + text += chord.Text(); + } + return text; +} // Keys that completed a binding with modifiers, until released. A plain binding on the same key waits for the // release, so letting go of CTRL before PGUP does not turn a held CTRL-PGUP into PGUP. @@ -213,13 +176,13 @@ bool IsUsed(unity::KeyCode key) bool ModifiersHeld(const KeyChord& chord) { - return std::ranges::all_of(chord.modifiers, - [](const auto& group) { return std::ranges::any_of(group, unity::Key::Pressed); }); + return std::ranges::all_of( + chord.modifiers, [](const auto* modifier) { return std::ranges::any_of(modifier->keys, unity::Key::Pressed); }); } template bool Matches(Action action) { - for (const auto& chord : BindingsOf(action)) { + for (const auto& chord : bindings[static_cast(action)]) { if (chord.key == unity::KeyCode::None || !KeyState(chord.key)) continue; if (chord.modifiers.empty() ? unity::Key::IsModified() || IsUsed(chord.key) : !ModifiersHeld(chord)) @@ -234,8 +197,8 @@ template bool Matches(Action action) enum class Tier { kAlways, kExtended, kExperimental }; struct Setting { - std::string_view key; Action action; + std::string_view key; std::string_view fallback; Tier tier = Tier::kAlways; std::array aliases = {}; @@ -243,125 +206,19 @@ struct Setting { // Order is the order of the debug log; the TOML output is sorted by key anyway. constexpr Setting kSettings[] = { - {"move_up", Action::kMoveUp, "W|UP"}, - {"move_down", Action::kMoveDown, "S|DOWN"}, - {"move_left", Action::kMoveLeft, "A|LEFT"}, - {"move_right", Action::kMoveRight, "D|RIGHT"}, - {"set_hotkeys_disabled", - Action::kDisableHotKeys, - "CTRL-ALT-MINUS", - Tier::kAlways, - {"set_hotkeys_disble", "set_hotkeys_disable"}}, - {"set_hotkeys_enabled", Action::kEnableHotKeys, "CTRL-ALT-=", Tier::kAlways, {"set_hotkeys_enable"}}, - {"select_chatalliance", Action::kSelectChatAlliance, "CTRL-2"}, - {"select_chatglobal", Action::kSelectChatGlobal, "CTRL-1"}, - {"select_chatprivate", Action::kSelectChatPrivate, "CTRL-3"}, - {"quit", Action::kQuit, "F10"}, - {"select_ship1", Action::kSelectShip1, "1"}, - {"select_ship2", Action::kSelectShip2, "2"}, - {"select_ship3", Action::kSelectShip3, "3"}, - {"select_ship4", Action::kSelectShip4, "4"}, - {"select_ship5", Action::kSelectShip5, "5"}, - {"select_ship6", Action::kSelectShip6, "6"}, - {"select_ship7", Action::kSelectShip7, "7"}, - {"select_ship8", Action::kSelectShip8, "8"}, - {"select_current", Action::kSelectCurrent, "CTRL-SPACE"}, - {"focus_search", Action::kFocusSearch, "CTRL-F"}, - {"action_primary", Action::kActionPrimary, "SPACE|MOUSE1"}, - {"action_secondary", Action::kActionSecondary, "TAB|MOUSE4"}, - {"action_queue", Action::kActionQueue, "SPACE|MOUSE1"}, - {"action_queue_clear", Action::kActionQueueClear, "CTRL-C"}, - {"action_view", Action::kActionView, "V|MOUSE2"}, - {"action_recall", Action::kActionRecall, "R|MOUSE3"}, - {"action_recall_cancel", Action::kActionRecallCancel, "SPACE|MOUSE1"}, - {"action_repair", Action::kActionRepair, "R|MOUSE3"}, - {"show_chat", Action::kShowChat, "C"}, - {"show_chatside1", Action::kShowChatSide1, "ALT-C"}, - {"show_chatside2", Action::kShowChatSide2, "`"}, - {"show_galaxy", Action::kShowGalaxy, "G"}, - {"show_galaxy_native", Action::kNativeShortcutGalaxy, "CTRL-G", Tier::kAlways, {"native_shortcut_galaxy"}}, - {"show_system", Action::kShowSystem, "H"}, - {"zoom_preset1", Action::kZoomPreset1, "F1"}, - {"zoom_preset2", Action::kZoomPreset2, "F2"}, - {"zoom_preset3", Action::kZoomPreset3, "F3"}, - {"zoom_preset4", Action::kZoomPreset4, "F4"}, - {"zoom_preset5", Action::kZoomPreset5, "F5"}, - {"zoom_in", Action::kZoomIn, "Q"}, - {"zoom_out", Action::kZoomOut, "E"}, - {"zoom_max", Action::kZoomMax, "MINUS"}, - {"zoom_min", Action::kZoomMin, "BACKSPACE"}, - {"zoom_reset", Action::kZoomReset, "="}, - {"ui_scaleup", Action::kUiScaleUp, "PGUP"}, - {"ui_scaledown", Action::kUiScaleDown, "PGDOWN"}, - {"ui_scaleshipup", Action::kUiShipScaleUp, "CTRL-PGUP"}, - {"ui_scaleshipdown", Action::kUiShipScaleDown, "CTRL-PGDOWN"}, - {"ui_scaleviewerup", Action::kUiViewerScaleUp, "SHIFT-PGUP"}, - {"ui_scaleviewerdown", Action::kUiViewerScaleDown, "SHIFT-PGDOWN"}, - {"log_debug", Action::kLogLevelDebug, "CTRL-SHIFT-F9"}, - {"log_trace", Action::kLogLevelTrace, "CTRL-SHIFT-F7"}, - {"log_info", Action::kLogLevelInfo, "CTRL-SHIFT-F8"}, - {"log_warn", Action::kLogLevelWarn, "CTRL-SHIFT-F10"}, - {"log_error", Action::kLogLevelError, "CTRL-SHIFT-F11"}, - {"log_off", Action::kLogLevelOff, "CTRL-SHIFT-F12"}, - {"restart", Action::kRestart, "F9"}, - {"reload_settings", Action::kReloadSettings, "CTRL-SHIFT-F6"}, - {"show_awayteam", Action::kShowAwayTeam, "T"}, - {"show_gifts", Action::kShowGifts, "/"}, - {"show_artifacts", Action::kShowArtifacts, "SHIFT-I"}, - {"show_commander", Action::kShowCommander, "O"}, - {"show_daily", Action::kShowDaily, "Z"}, - {"show_events", Action::kShowEvents, "SHIFT-E"}, - {"show_events_native", Action::kNativeShortcutEvents, "CTRL-E", Tier::kAlways, {"native_shortcut_events"}}, - {"show_exocomp", Action::kShowExoComp, "X"}, - {"show_factions", Action::kShowFactions, "F"}, - {"show_inventory", Action::kShowInventory, "I"}, - {"show_missions", Action::kShowMissions, "M"}, - {"show_research", Action::kShowResearch, "U"}, - {"show_scrapyard", Action::kShowScrapYard, "Y"}, - {"show_settings", Action::kShowSettings, "SHIFT-S"}, - {"toggle_shortcut_hints", Action::kToggleShortcutHints, "HOME"}, - {"show_officers", Action::kShowOfficers, "SHIFT-O"}, - {"show_officerpresets", Action::kShowOfficerPresets, "CTRL-O"}, - {"show_officerswap", Action::kShowOfficerSwap, "ALT-O"}, - {"show_qtrials", Action::kShowQTrials, "SHIFT-Q"}, - {"show_refinery", Action::kShowRefinery, "SHIFT-F"}, - {"show_ships", Action::kShowShips, "N"}, - {"show_shipconstruction", Action::kShowShipConstruction, "SHIFT-N"}, - {"show_shipswap", Action::kShowShipSwap, "ALT-N"}, - {"show_shields", Action::kShowShields, "CTRL-S"}, - {"show_battlelogs", Action::kShowBattlelogs, "SHIFT-B"}, - {"show_stationexterior", Action::kShowStationExterior, "SHIFT-G"}, - {"show_stationinterior", Action::kShowStationInterior, "SHIFT-H"}, - {"show_haven", Action::kShowHaven, "ALT-H"}, - {"toggle_queue", Action::kToggleQueue, "CTRL-Q"}, - {"toggle_instant_warp", Action::kToggleAutoConfirmInstantWarp, "ALT-I"}, - {"show_alliance", Action::kShowAlliance, "ALT-'", Tier::kExtended}, - {"show_alliance_help", Action::kShowAllianceHelp, "SHIFT-'", Tier::kExperimental}, - {"show_alliance_armada", Action::kShowAllianceArmada, "CTRL-'", Tier::kExperimental}, - {"show_bookmarks", Action::kShowBookmarks, "B", Tier::kExtended}, - {"show_lookup", Action::kShowLookup, "L", Tier::kExperimental}, - {"set_zoom_preset1", Action::kSetZoomPreset1, "SHIFT-F1", Tier::kExtended}, - {"set_zoom_preset2", Action::kSetZoomPreset2, "SHIFT-F2", Tier::kExtended}, - {"set_zoom_preset3", Action::kSetZoomPreset3, "SHIFT-F3", Tier::kExtended}, - {"set_zoom_preset4", Action::kSetZoomPreset4, "SHIFT-F4", Tier::kExtended}, - {"set_zoom_preset5", Action::kSetZoomPreset5, "SHIFT-F5", Tier::kExtended}, - {"set_zoom_default", Action::kSetZoomDefault, "CTRL-=", Tier::kExtended}, - {"toggle_preview_locate", Action::kTogglePreviewLocate, "CTRL-R", Tier::kExtended}, - {"toggle_preview_recall", Action::kTogglePreviewRecall, "CTRL-T", Tier::kExtended}, - {"toggle_cargo_default", Action::kToggleCargoDefault, "ALT-1", Tier::kExtended}, - {"toggle_cargo_player", Action::kToggleCargoPlayer, "ALT-2", Tier::kExtended}, - {"toggle_cargo_station", Action::kToggleCargoStation, "ALT-3", Tier::kExtended}, - {"toggle_cargo_hostile", Action::kToggleCargoHostile, "ALT-4", Tier::kExtended}, - {"toggle_cargo_armada", Action::kToggleCargoArmada, "ALT-5", Tier::kExtended}, +#define SHORTCUT(action, ...) {Action::action, __VA_ARGS__}, +#include "shortcuts.def" +#undef SHORTCUT }; +static_assert(std::size(kSettings) == static_cast(Action::kCount)); // Binds every chord in `value` ("CTRL-G|F8"). Reports whether any was valid. -bool BindChords(const Setting& setting, std::string_view key, std::string_view value) +bool BindChords(std::vector& chords, std::string_view key, std::string_view value) { bool bound = false; for (const auto& text : StrSplit(value, '|')) { if (auto chord = KeyChord::Parse(text); chord.key != unity::KeyCode::None) { - Shortcut::Bind(setting.action, std::move(chord)); + chords.push_back(std::move(chord)); bound = true; } else { spdlog::warn("Invalid shortcut token [shortcuts].{} token='{}' value='{}'; ignoring token.", key, text, value); @@ -371,7 +228,8 @@ bool BindChords(const Setting& setting, std::string_view key, std::string_view v } // Binds one [shortcuts] setting and records the effective binding, and where it came from, in `out`. -void LoadSetting(const toml::table* shortcuts, toml::table& out, toml::table& out_source, const Setting& setting) +void LoadSetting(const toml::table* shortcuts, toml::table& out, toml::table& out_source, const Setting& setting, + std::vector& chords) { // The setting itself, else the first deprecated alias present, else the default. std::string_view key = setting.key; @@ -407,69 +265,64 @@ void LoadSetting(const toml::table* shortcuts, toml::table& out, toml::table& ou if (value.empty() || value == "NONE") return record("NONE", label); - if (!BindChords(setting, key, value)) { + if (!BindChords(chords, key, value)) { if (configured) spdlog::error("No valid shortcut tokens for [shortcuts].{} value='{}'; using default for [shortcuts].{}.", key, *configured, setting.key); - if (!configured || !BindChords(setting, setting.key, setting.fallback)) { + if (!configured || !BindChords(chords, setting.key, setting.fallback)) { spdlog::error("Default shortcut [shortcuts].{} value='{}' has no valid tokens; action disabled.", setting.key, setting.fallback); return record("NONE", "invalid"); } label = "default"; } - record(Shortcut::Describe(setting.action), label); + record(DescribeChords(chords), label); } } // namespace KeyChord KeyChord::Parse(std::string_view text) { - KeyChord chord; - std::string key_token; + KeyChord chord; for (auto& token : StrSplit(AsciiStrToUpper(text), '-')) { if (const auto* modifier = FindModifier(token)) { - chord.modifiers.push_back(modifier->keys); - chord.tokens.push_back(std::move(token)); - } else if (const auto* key = FindKey(token)) { + chord.modifiers.push_back(modifier); + } else if (const auto key = FindKey(token)) { // A chord has one key; a later one replaces an earlier one, so the text shows what is bound. - chord.key = key->key; - key_token = std::move(token); + chord.key = key->key; + chord.key_name_ = std::move(token); } } - if (chord.key != unity::KeyCode::None) - chord.tokens.push_back(std::move(key_token)); return chord; } std::string KeyChord::Text() const { std::string text; - for (const auto& token : tokens) { - if (!text.empty()) - text += '-'; - text += token; + for (const auto* modifier : modifiers) { + text += modifier->name; + text += '-'; } + text += key_name_; + if (key_name_.empty() && !text.empty()) + text.pop_back(); return text; } std::string KeyChord::Hint() const { std::string hint; - for (size_t i = 0; i < tokens.size(); ++i) { - // The key is always last; everything before it is a modifier. - const auto* key = i + 1 == tokens.size() && this->key != unity::KeyCode::None ? FindKey(tokens[i]) : nullptr; - const auto* modifier = key ? nullptr : FindModifier(tokens[i]); - hint += key && !key->hint.empty() ? key->hint : modifier ? modifier->hint : std::string_view(tokens[i]); - } + for (const auto* modifier : modifiers) + hint += modifier->hint; + const auto key = FindKey(key_name_); + hint += key && !key->hint.empty() ? key->hint : std::string_view(key_name_); return hint; } namespace Shortcut { -void Load(const toml::table& config, toml::table& parsed, bool extended, bool experimental) +Bindings Parse(const toml::table& config, toml::table& parsed, bool extended, bool experimental) { - // A reload replaces every binding. - Clear(); + Bindings result; const auto* shortcuts = config["shortcuts"].as_table(); auto& out = *parsed.emplace("shortcuts").first->second.as_table(); auto& source = *parsed.emplace("shortcuts_source").first->second.as_table(); @@ -478,17 +331,14 @@ void Load(const toml::table& config, toml::table& parsed, bool extended, bool ex if ((setting.tier == Tier::kExtended && !extended) || (setting.tier == Tier::kExperimental && !(extended && experimental))) continue; - LoadSetting(shortcuts, out, source, setting); + LoadSetting(shortcuts, out, source, setting, result[static_cast(setting.action)]); } + return result; } -void Bind(Action action, KeyChord chord) -{ BindingsOf(action).push_back(std::move(chord)); } - -void Clear() +void Set(Bindings next) noexcept { - for (auto& chords : bindings) - chords.clear(); + bindings = std::move(next); used_keys.clear(); } @@ -497,7 +347,8 @@ bool Owns(unity::KeyCode key) bool HasBinding(Action action) { - return std::ranges::any_of(BindingsOf(action), [](const auto& chord) { return chord.key != unity::KeyCode::None; }); + return std::ranges::any_of(bindings[static_cast(action)], + [](const auto& chord) { return chord.key != unity::KeyCode::None; }); } bool IsPressed(Action action) @@ -507,19 +358,12 @@ bool IsDown(Action action) { return Matches(action); } std::string Describe(Action action) -{ - std::string text; - for (const auto& chord : BindingsOf(action)) { - if (!text.empty()) - text += " | "; - text += chord.Text(); - } - return text; -} +{ return DescribeChords(bindings[static_cast(action)]); } std::string Hint(Action action) { // The game's badges have room for one binding. - return BindingsOf(action).empty() ? std::string{} : BindingsOf(action).front().Hint(); + const auto& chords = bindings[static_cast(action)]; + return chords.empty() ? std::string{} : chords.front().Hint(); } } // namespace Shortcut diff --git a/mods/src/patches/shortcuts.h b/mods/src/patches/shortcuts.h index caff7c81b..993711db4 100644 --- a/mods/src/patches/shortcuts.h +++ b/mods/src/patches/shortcuts.h @@ -14,123 +14,23 @@ // What a shortcut does. Each [shortcuts] setting binds key chords to one action. enum class Action : uint8_t { - kMoveLeft, - kMoveRight, - kMoveUp, - kMoveDown, - kSelectChatAlliance, - kSelectChatGlobal, - kSelectChatPrivate, - kSelectShip1, - kSelectShip2, - kSelectShip3, - kSelectShip4, - kSelectShip5, - kSelectShip6, - kSelectShip7, - kSelectShip8, - kSelectCurrent, - kShowAlliance, - kShowAllianceArmada, - kShowAllianceHelp, - kShowArtifacts, - kShowOfficers, - kShowOfficerPresets, - kShowCommander, - kShowRefinery, - kShowQTrials, - kShowBookmarks, - kShowLookup, - kShowExoComp, - kShowFactions, - kShowGifts, - kShowDaily, - kShowAwayTeam, - kShowMissions, - kShowResearch, - kShowScrapYard, - kShowShips, - kShowInventory, - kShowStationInterior, - kShowHaven, - kShowStationExterior, - kShowGalaxy, - kNativeShortcutGalaxy, - kShowSystem, - kShowChat, - kShowChatSide1, - kShowChatSide2, - kShowEvents, - kNativeShortcutEvents, - kShowSettings, - kToggleShortcutHints, - kZoomPreset1, - kZoomPreset2, - kZoomPreset3, - kZoomPreset4, - kZoomPreset5, - kZoomIn, - kZoomOut, - kZoomMin, - kZoomMax, - kZoomReset, - kUiScaleUp, - kUiScaleDown, - kUiShipScaleUp, - kUiShipScaleDown, - kUiViewerScaleUp, - kUiViewerScaleDown, - kActionPrimary, - kActionSecondary, - kActionQueue, - kActionQueueClear, - kActionView, - kActionRecall, - kActionRecallCancel, - kActionRepair, - kSetZoomPreset1, - kSetZoomPreset2, - kSetZoomPreset3, - kSetZoomPreset4, - kSetZoomPreset5, - kSetZoomDefault, - kDisableHotKeys, - kEnableHotKeys, - kToggleQueue, - kToggleAutoConfirmInstantWarp, - kTogglePreviewLocate, - kTogglePreviewRecall, - kToggleCargoDefault, - kToggleCargoPlayer, - kToggleCargoStation, - kToggleCargoHostile, - kToggleCargoArmada, - kLogLevelDebug, - kLogLevelInfo, - kLogLevelTrace, - kLogLevelError, - kLogLevelWarn, - kLogLevelOff, - kRestart, - kReloadSettings, - kQuit, - kFocusSearch, - kShowShipConstruction, - kShowOfficerSwap, - kShowShipSwap, - kShowShields, - kShowBattlelogs, - +#define SHORTCUT(action, ...) action, +#include "shortcuts.def" +#undef SHORTCUT kCount }; // One key with its required modifiers, such as CTRL-SHIFT-F1. struct KeyChord { unity::KeyCode key = unity::KeyCode::None; - // Each group is satisfied by either of its keys: SHIFT is {LeftShift, RightShift}, LSHIFT is LeftShift twice. - std::vector> modifiers; - // The upper-cased tokens for display, modifiers first and the key last: {"CTRL", "SHIFT", "F1"}. - std::vector tokens; + struct Modifier { + std::string_view name; + // SHIFT accepts either side; LSHIFT stores LeftShift twice. + std::array keys; + std::string_view hint; + }; + // Points into the immutable modifier catalog; spelling, matching and badge text share one definition. + std::vector modifiers; // Parses one chord. Unknown tokens are ignored; a chord without a non-modifier key has key == None. With two // keys, the last one counts. @@ -140,18 +40,19 @@ struct KeyChord { std::string Text() const; // Compact form for the game's keybind badges: "^+F1". std::string Hint() const; + +private: + std::string key_name_; }; namespace Shortcut { -// Replaces every binding with the [shortcuts] entries from `config`, and records the effective bindings and where -// each came from in `parsed` ([shortcuts] and [shortcuts_source]). `extended` and `experimental` gate the optional -// actions. -void Load(const toml::table& config, toml::table& parsed, bool extended, bool experimental); +using Bindings = std::array, static_cast(Action::kCount)>; -// Adds a binding directly. Load uses this; tests use it to set up bindings. -void Bind(Action action, KeyChord chord); -void Clear(); +// Parses [shortcuts] without changing live input. Records effective bindings and their sources in `parsed`. +Bindings Parse(const toml::table& config, toml::table& parsed, bool extended, bool experimental); +// Activates a complete set of bindings and releases keys owned by the previous set. Game thread only. +void Set(Bindings bindings) noexcept; bool HasBinding(Action action); // Any binding of `action` is held with its modifiers. A binding without modifiers needs none held; one with diff --git a/mods/src/shortcuts.def b/mods/src/shortcuts.def new file mode 100644 index 000000000..ccc97a199 --- /dev/null +++ b/mods/src/shortcuts.def @@ -0,0 +1,109 @@ +// Action, TOML key, default chords, optional tier and deprecated aliases. +// Included by shortcuts.h for Action and shortcuts.cc for the settings table. +SHORTCUT(kMoveUp, "move_up", "W|UP") +SHORTCUT(kMoveDown, "move_down", "S|DOWN") +SHORTCUT(kMoveLeft, "move_left", "A|LEFT") +SHORTCUT(kMoveRight, "move_right", "D|RIGHT") +SHORTCUT(kDisableHotKeys, "set_hotkeys_disabled", "CTRL-ALT-MINUS", Tier::kAlways, + {"set_hotkeys_disble", "set_hotkeys_disable"}) +SHORTCUT(kEnableHotKeys, "set_hotkeys_enabled", "CTRL-ALT-=", Tier::kAlways, {"set_hotkeys_enable"}) +SHORTCUT(kSelectChatAlliance, "select_chatalliance", "CTRL-2") +SHORTCUT(kSelectChatGlobal, "select_chatglobal", "CTRL-1") +SHORTCUT(kSelectChatPrivate, "select_chatprivate", "CTRL-3") +SHORTCUT(kQuit, "quit", "F10") +SHORTCUT(kSelectShip1, "select_ship1", "1") +SHORTCUT(kSelectShip2, "select_ship2", "2") +SHORTCUT(kSelectShip3, "select_ship3", "3") +SHORTCUT(kSelectShip4, "select_ship4", "4") +SHORTCUT(kSelectShip5, "select_ship5", "5") +SHORTCUT(kSelectShip6, "select_ship6", "6") +SHORTCUT(kSelectShip7, "select_ship7", "7") +SHORTCUT(kSelectShip8, "select_ship8", "8") +SHORTCUT(kSelectCurrent, "select_current", "CTRL-SPACE") +SHORTCUT(kFocusSearch, "focus_search", "CTRL-F") +SHORTCUT(kActionPrimary, "action_primary", "SPACE|MOUSE1") +SHORTCUT(kActionSecondary, "action_secondary", "TAB|MOUSE4") +SHORTCUT(kActionQueue, "action_queue", "SPACE|MOUSE1") +SHORTCUT(kActionQueueClear, "action_queue_clear", "CTRL-C") +SHORTCUT(kActionView, "action_view", "V|MOUSE2") +SHORTCUT(kActionRecall, "action_recall", "R|MOUSE3") +SHORTCUT(kActionRecallCancel, "action_recall_cancel", "SPACE|MOUSE1") +SHORTCUT(kActionRepair, "action_repair", "R|MOUSE3") +SHORTCUT(kShowChat, "show_chat", "C") +SHORTCUT(kShowChatSide1, "show_chatside1", "ALT-C") +SHORTCUT(kShowChatSide2, "show_chatside2", "`") +SHORTCUT(kShowGalaxy, "show_galaxy", "G") +SHORTCUT(kNativeShortcutGalaxy, "show_galaxy_native", "CTRL-G", Tier::kAlways, {"native_shortcut_galaxy"}) +SHORTCUT(kShowSystem, "show_system", "H") +SHORTCUT(kZoomPreset1, "zoom_preset1", "F1") +SHORTCUT(kZoomPreset2, "zoom_preset2", "F2") +SHORTCUT(kZoomPreset3, "zoom_preset3", "F3") +SHORTCUT(kZoomPreset4, "zoom_preset4", "F4") +SHORTCUT(kZoomPreset5, "zoom_preset5", "F5") +SHORTCUT(kZoomIn, "zoom_in", "Q") +SHORTCUT(kZoomOut, "zoom_out", "E") +SHORTCUT(kZoomMax, "zoom_max", "MINUS") +SHORTCUT(kZoomMin, "zoom_min", "BACKSPACE") +SHORTCUT(kZoomReset, "zoom_reset", "=") +SHORTCUT(kUiScaleUp, "ui_scaleup", "PGUP") +SHORTCUT(kUiScaleDown, "ui_scaledown", "PGDOWN") +SHORTCUT(kUiShipScaleUp, "ui_scaleshipup", "CTRL-PGUP") +SHORTCUT(kUiShipScaleDown, "ui_scaleshipdown", "CTRL-PGDOWN") +SHORTCUT(kUiViewerScaleUp, "ui_scaleviewerup", "SHIFT-PGUP") +SHORTCUT(kUiViewerScaleDown, "ui_scaleviewerdown", "SHIFT-PGDOWN") +SHORTCUT(kLogLevelDebug, "log_debug", "CTRL-SHIFT-F9") +SHORTCUT(kLogLevelTrace, "log_trace", "CTRL-SHIFT-F7") +SHORTCUT(kLogLevelInfo, "log_info", "CTRL-SHIFT-F8") +SHORTCUT(kLogLevelWarn, "log_warn", "CTRL-SHIFT-F10") +SHORTCUT(kLogLevelError, "log_error", "CTRL-SHIFT-F11") +SHORTCUT(kLogLevelOff, "log_off", "CTRL-SHIFT-F12") +SHORTCUT(kRestart, "restart", "F9") +SHORTCUT(kReloadSettings, "reload_settings", "CTRL-SHIFT-F6") +SHORTCUT(kShowAwayTeam, "show_awayteam", "T") +SHORTCUT(kShowGifts, "show_gifts", "/") +SHORTCUT(kShowArtifacts, "show_artifacts", "SHIFT-I") +SHORTCUT(kShowCommander, "show_commander", "O") +SHORTCUT(kShowDaily, "show_daily", "Z") +SHORTCUT(kShowEvents, "show_events", "SHIFT-E") +SHORTCUT(kNativeShortcutEvents, "show_events_native", "CTRL-E", Tier::kAlways, {"native_shortcut_events"}) +SHORTCUT(kShowExoComp, "show_exocomp", "X") +SHORTCUT(kShowFactions, "show_factions", "F") +SHORTCUT(kShowInventory, "show_inventory", "I") +SHORTCUT(kShowMissions, "show_missions", "M") +SHORTCUT(kShowResearch, "show_research", "U") +SHORTCUT(kShowScrapYard, "show_scrapyard", "Y") +SHORTCUT(kShowSettings, "show_settings", "SHIFT-S") +SHORTCUT(kToggleShortcutHints, "toggle_shortcut_hints", "HOME") +SHORTCUT(kShowOfficers, "show_officers", "SHIFT-O") +SHORTCUT(kShowOfficerPresets, "show_officerpresets", "CTRL-O") +SHORTCUT(kShowOfficerSwap, "show_officerswap", "ALT-O") +SHORTCUT(kShowQTrials, "show_qtrials", "SHIFT-Q") +SHORTCUT(kShowRefinery, "show_refinery", "SHIFT-F") +SHORTCUT(kShowShips, "show_ships", "N") +SHORTCUT(kShowShipConstruction, "show_shipconstruction", "SHIFT-N") +SHORTCUT(kShowShipSwap, "show_shipswap", "ALT-N") +SHORTCUT(kShowShields, "show_shields", "CTRL-S") +SHORTCUT(kShowBattlelogs, "show_battlelogs", "SHIFT-B") +SHORTCUT(kShowStationExterior, "show_stationexterior", "SHIFT-G") +SHORTCUT(kShowStationInterior, "show_stationinterior", "SHIFT-H") +SHORTCUT(kShowHaven, "show_haven", "ALT-H") +SHORTCUT(kToggleQueue, "toggle_queue", "CTRL-Q") +SHORTCUT(kToggleAutoConfirmInstantWarp, "toggle_instant_warp", "ALT-I") +SHORTCUT(kShowAlliance, "show_alliance", "ALT-'", Tier::kExtended) +SHORTCUT(kShowAllianceHelp, "show_alliance_help", "SHIFT-'", Tier::kExperimental) +SHORTCUT(kShowAllianceArmada, "show_alliance_armada", "CTRL-'", Tier::kExperimental) +SHORTCUT(kShowBookmarks, "show_bookmarks", "B", Tier::kExtended) +SHORTCUT(kShowLookup, "show_lookup", "L", Tier::kExperimental) +SHORTCUT(kSetZoomPreset1, "set_zoom_preset1", "SHIFT-F1", Tier::kExtended) +SHORTCUT(kSetZoomPreset2, "set_zoom_preset2", "SHIFT-F2", Tier::kExtended) +SHORTCUT(kSetZoomPreset3, "set_zoom_preset3", "SHIFT-F3", Tier::kExtended) +SHORTCUT(kSetZoomPreset4, "set_zoom_preset4", "SHIFT-F4", Tier::kExtended) +SHORTCUT(kSetZoomPreset5, "set_zoom_preset5", "SHIFT-F5", Tier::kExtended) +SHORTCUT(kSetZoomDefault, "set_zoom_default", "CTRL-=", Tier::kExtended) +SHORTCUT(kTogglePreviewLocate, "toggle_preview_locate", "CTRL-R", Tier::kExtended) +SHORTCUT(kTogglePreviewRecall, "toggle_preview_recall", "CTRL-T", Tier::kExtended) +SHORTCUT(kToggleCargoDefault, "toggle_cargo_default", "ALT-1", Tier::kExtended) +SHORTCUT(kToggleCargoPlayer, "toggle_cargo_player", "ALT-2", Tier::kExtended) +SHORTCUT(kToggleCargoStation, "toggle_cargo_station", "ALT-3", Tier::kExtended) +SHORTCUT(kToggleCargoHostile, "toggle_cargo_hostile", "ALT-4", Tier::kExtended) +SHORTCUT(kToggleCargoArmada, "toggle_cargo_armada", "ALT-5", Tier::kExtended) diff --git a/tests/mod_logic.cc b/tests/mod_logic.cc index 3f91b71ef..b5381ad35 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -40,9 +40,8 @@ void Hold(std::initializer_list keys) toml::table Load(std::string_view settings, bool extended = true, bool experimental = false) { - Shortcut::Clear(); toml::table parsed; - Shortcut::Load(toml::parse(settings), parsed, extended, experimental); + Shortcut::Set(Shortcut::Parse(toml::parse(settings), parsed, extended, experimental)); return parsed; } @@ -51,7 +50,7 @@ std::string Value(const toml::table& parsed, std::string_view section, std::stri void ShipHints() { - Shortcut::Clear(); + Shortcut::Set({}); // A locked dock F leaves native G at the sixth selectable position (the reported 1,2,3,4,5,7 layout). const std::array gapped_fleets{0, 1, 2, 3, 4, 6}; auto ship_hint = [&](Action action) { @@ -80,7 +79,9 @@ void ShipHints() "Fleet beyond the eight supported mod shortcuts must not advertise a shortcut"); Check(Shortcut::Hint(ship_hint(Action::kSelectShip7)).empty(), "Unbound ship selection must not advertise a working shortcut"); - Shortcut::Bind(Action::kSelectShip6, KeyChord::Parse("CTRL-F8")); + Shortcut::Bindings custom{}; + custom[static_cast(Action::kSelectShip6)].push_back(KeyChord::Parse("CTRL-F8")); + Shortcut::Set(std::move(custom)); Check(Shortcut::Hint(ship_hint(Action::kSelectShip7)) == "^F8", "Gapped ship hint must use the actual custom binding, not a hardcoded number"); } @@ -155,6 +156,42 @@ void Chords() Check(KeyChord::Parse("pgup-lshift").Hint() == "<+PU", "modifiers come first in the badge"); } +void KeyRanges() +{ + for (const auto [name, key] : {std::pair{"A", unity::KeyCode::A}, + {"Z", unity::KeyCode::Z}, + {"0", unity::KeyCode::Alpha0}, + {"9", unity::KeyCode::Alpha9}, + {"F1", unity::KeyCode::F1}, + {"F9", unity::KeyCode::F9}, + {"F10", unity::KeyCode::F10}, + {"F15", unity::KeyCode::F15}, + {"KEY0", unity::KeyCode::Keypad0}, + {"KEY9", unity::KeyCode::Keypad9}}) { + const auto chord = KeyChord::Parse(name); + Check(chord.key == key && chord.Text() == name && chord.Hint() == name, "key range and display compatibility"); + } + for (const auto name : {"F0", "F16", "F01", "F1X", "KEY00", "KEY10", "KEY", "KEY1X", "F999999999999"}) + Check(KeyChord::Parse(name).key == unity::KeyCode::None, "out-of-range and noncanonical names stay invalid"); +} +void DefaultBindings() +{ + const auto fixture = toml::parse_file(STFC_SHORTCUT_DEFAULTS); + for (const auto [extended, experimental] : {std::pair{false, false}, {false, true}, {true, false}, {true, true}}) { + const auto parsed = Load("", extended, experimental); + for (const auto& [tier, entries] : fixture) { + const bool enabled = tier == "always" || (extended && (tier == "extended" || experimental)); + for (const auto& [key, expected] : *entries.as_table()) { + if (enabled) { + Check(Value(parsed, "shortcuts", key.str()) == expected.value_or(std::string{}), + "default chord compatibility"); + Check(Value(parsed, "shortcuts_source", key.str()) == "default", "default binding source compatibility"); + } else + Check(!parsed["shortcuts"][key.str()], "optional shortcut gates stay unchanged"); + } + } + } +} void Loading() { auto parsed = Load(R"( @@ -182,8 +219,7 @@ void Loading() Check(Value(parsed, "shortcuts_source", "set_hotkeys_disabled") == "alias:set_hotkeys_disble", "alias source"); Check(Shortcut::HasBinding(Action::kShowBookmarks), "extended shortcuts load when extended"); - Shortcut::Bind(Action::kQuit, KeyChord::Parse("F1")); - Shortcut::Load(toml::table{}, parsed, true, false); + parsed = Load(""); Check(Shortcut::Describe(Action::kQuit) == "F10", "loading again replaces earlier bindings"); Check(!Shortcut::HasBinding(Action::kShowLookup), "experimental shortcuts need experimental"); parsed = Load("", false); @@ -406,6 +442,15 @@ void RenamedSettings() Check(both->graphics.ui_scale_step == 0.2f, "the new name wins over the old one"); } +void ParseDoesNotActivateSettings() +{ + Load("[shortcuts]\nquit = \"F1\""); + toml::table effective; + const auto candidate = Config::Parse(toml::parse("[shortcuts]\nquit = \"F2\""), effective, false); + Check(Shortcut::Describe(Action::kQuit) == "F1", "parsing settings leaves the live bindings alone"); + Check(Value(effective, "shortcuts", "quit") == "F2", "the candidate records its own bindings"); +} + void Matching() { Load(""); // defaults: move_down = "S|DOWN", toggle_shortcut_hints = "HOME" @@ -434,7 +479,7 @@ void Matching() Check(Shortcut::IsPressed(Action::kUiScaleUp), "plain chord after a release"); // A modified movement binding keeps moving while its modifier is held. - Shortcut::Bind(Action::kMoveDown, KeyChord::Parse("CTRL-DOWN")); + Load("[shortcuts]\nmove_down = \"CTRL-DOWN\""); Hold({unity::KeyCode::LeftControl, unity::KeyCode::DownArrow}); Check(Shortcut::IsDown(Action::kMoveDown) && Shortcut::IsDown(Action::kMoveDown), "explicit movement chord keeps matching"); @@ -486,12 +531,15 @@ int main() ToastText(); ShipNames(); Chords(); + KeyRanges(); + DefaultBindings(); Loading(); ExampleSettings(); NotificationAudio(); DailyFactionSettings(); UpstreamSettings(); RenamedSettings(); + ParseDoesNotActivateSettings(); Matching(); ChatStartupGate(); if (failures) diff --git a/tests/shortcut_defaults.toml b/tests/shortcut_defaults.toml new file mode 100644 index 000000000..702ea1e06 --- /dev/null +++ b/tests/shortcut_defaults.toml @@ -0,0 +1,113 @@ +# Default bindings before the action registry rewrite. + +[always] +move_up = "W | UP" +move_down = "S | DOWN" +move_left = "A | LEFT" +move_right = "D | RIGHT" +set_hotkeys_disabled = "CTRL-ALT-MINUS" +set_hotkeys_enabled = "CTRL-ALT-=" +select_chatalliance = "CTRL-2" +select_chatglobal = "CTRL-1" +select_chatprivate = "CTRL-3" +quit = "F10" +select_ship1 = "1" +select_ship2 = "2" +select_ship3 = "3" +select_ship4 = "4" +select_ship5 = "5" +select_ship6 = "6" +select_ship7 = "7" +select_ship8 = "8" +select_current = "CTRL-SPACE" +focus_search = "CTRL-F" +action_primary = "SPACE | MOUSE1" +action_secondary = "TAB | MOUSE4" +action_queue = "SPACE | MOUSE1" +action_queue_clear = "CTRL-C" +action_view = "V | MOUSE2" +action_recall = "R | MOUSE3" +action_recall_cancel = "SPACE | MOUSE1" +action_repair = "R | MOUSE3" +show_chat = "C" +show_chatside1 = "ALT-C" +show_chatside2 = "`" +show_galaxy = "G" +show_galaxy_native = "CTRL-G" +show_system = "H" +zoom_preset1 = "F1" +zoom_preset2 = "F2" +zoom_preset3 = "F3" +zoom_preset4 = "F4" +zoom_preset5 = "F5" +zoom_in = "Q" +zoom_out = "E" +zoom_max = "MINUS" +zoom_min = "BACKSPACE" +zoom_reset = "=" +ui_scaleup = "PGUP" +ui_scaledown = "PGDOWN" +ui_scaleshipup = "CTRL-PGUP" +ui_scaleshipdown = "CTRL-PGDOWN" +ui_scaleviewerup = "SHIFT-PGUP" +ui_scaleviewerdown = "SHIFT-PGDOWN" +log_debug = "CTRL-SHIFT-F9" +log_trace = "CTRL-SHIFT-F7" +log_info = "CTRL-SHIFT-F8" +log_warn = "CTRL-SHIFT-F10" +log_error = "CTRL-SHIFT-F11" +log_off = "CTRL-SHIFT-F12" +restart = "F9" +reload_settings = "CTRL-SHIFT-F6" +show_awayteam = "T" +show_gifts = "/" +show_artifacts = "SHIFT-I" +show_commander = "O" +show_daily = "Z" +show_events = "SHIFT-E" +show_events_native = "CTRL-E" +show_exocomp = "X" +show_factions = "F" +show_inventory = "I" +show_missions = "M" +show_research = "U" +show_scrapyard = "Y" +show_settings = "SHIFT-S" +toggle_shortcut_hints = "HOME" +show_officers = "SHIFT-O" +show_officerpresets = "CTRL-O" +show_officerswap = "ALT-O" +show_qtrials = "SHIFT-Q" +show_refinery = "SHIFT-F" +show_ships = "N" +show_shipconstruction = "SHIFT-N" +show_shipswap = "ALT-N" +show_shields = "CTRL-S" +show_battlelogs = "SHIFT-B" +show_stationexterior = "SHIFT-G" +show_stationinterior = "SHIFT-H" +show_haven = "ALT-H" +toggle_queue = "CTRL-Q" +toggle_instant_warp = "ALT-I" + +[extended] +show_alliance = "ALT-'" +show_bookmarks = "B" +set_zoom_preset1 = "SHIFT-F1" +set_zoom_preset2 = "SHIFT-F2" +set_zoom_preset3 = "SHIFT-F3" +set_zoom_preset4 = "SHIFT-F4" +set_zoom_preset5 = "SHIFT-F5" +set_zoom_default = "CTRL-=" +toggle_preview_locate = "CTRL-R" +toggle_preview_recall = "CTRL-T" +toggle_cargo_default = "ALT-1" +toggle_cargo_player = "ALT-2" +toggle_cargo_station = "ALT-3" +toggle_cargo_hostile = "ALT-4" +toggle_cargo_armada = "ALT-5" + +[experimental] +show_alliance_help = "SHIFT-'" +show_alliance_armada = "CTRL-'" +show_lookup = "L" diff --git a/xmake.lua b/xmake.lua index 027aa5615..546940ff3 100644 --- a/xmake.lua +++ b/xmake.lua @@ -70,6 +70,7 @@ target("mod-logic-tests") add_syslinks("shell32", "winmm") end add_defines('STFC_EXAMPLE_SETTINGS="' .. path.translate(path.absolute("example_community_patch_settings_en.toml"), "/") .. '"') + add_defines('STFC_SHORTCUT_DEFAULTS="' .. path.translate(path.absolute("tests/shortcut_defaults.toml"), "/") .. '"') target("sync-logic-tests") set_kind("binary") From b6cd137cab6d6f6e15b0a0d0f224e4629b3af8df Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 08/10] Own sync settings across reloads Sync threads held references into settings that a reload replaces. They now own an immutable snapshot, and workers for removed targets are released with their queues. Suppressed log levels no longer copy settings or build request descriptions, and session checks compare a generation number instead of copying credentials on every poll. --- mods/src/config.cc | 111 ++++++------- mods/src/config.h | 76 ++++----- mods/src/patches/parts/sync/battlelogs.cc | 13 +- mods/src/patches/parts/sync/fleets.cc | 4 +- mods/src/patches/parts/sync/records.cc | 8 +- mods/src/patches/parts/sync/state.cc | 60 +++---- mods/src/patches/parts/sync/sync.cc | 12 +- mods/src/patches/parts/sync/sync.h | 28 ++-- mods/src/patches/parts/sync/upload.cc | 182 +++++++++++----------- tests/config_reload.cc | 135 ++++++++++++++++ tests/mod_logic.cc | 3 +- tests/sync_logic.cc | 67 ++++++-- xmake.lua | 14 ++ 13 files changed, 439 insertions(+), 274 deletions(-) create mode 100644 tests/config_reload.cc diff --git a/mods/src/config.cc b/mods/src/config.cc index ac5481dad..93367cc18 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -186,17 +186,6 @@ class Reader bool log_; }; -// Sync options renamed since; their old names are still read, per target as well. -constexpr std::pair kRenamedSyncOptions[] = { - {SyncConfig::Type::kOfficer, "officer"}}; - -std::string_view OldName(const SyncConfig::Option& option) -{ - const auto it = - std::ranges::find(kRenamedSyncOptions, option.type, &std::pair::first); - return it == std::end(kRenamedSyncOptions) ? std::string_view{} : it->second; -} - // "*" matches every ship; otherwise a comma list of ship names, each split into words by ShipNameWords. ShipFilter ReadShipFilter(Reader& reader, std::string_view key) { @@ -310,7 +299,7 @@ void ReadSyncTargets(Reader& reader, std::map& sy {"url", target.url}, {"token", target.token}, {"proxy", target.proxy}, {"verify_ssl", target.verify_ssl}}; for (const auto& option : SyncOptions) { const auto key = - !values->contains(option.option_str) && !OldName(option).empty() ? OldName(option) : option.option_str; + !values->contains(option.option_str) && !option.old_name.empty() ? option.old_name : option.option_str; target.*option.option = (*values)[key].value().value_or(defaults.*option.option); parsed.insert(option.option_str, target.*option.option); } @@ -345,19 +334,11 @@ void Save(const toml::table& table, const std::string& name, bool warning_header } } // namespace -bool SyncConfig::enabled(SyncConfig::Type type) const -{ - for (const auto& opt : SyncOptions) - if (opt.type == type) - return this->*opt.option; - return false; -} - namespace { -// The current settings. Replaced settings are never freed: a sync thread may still be reading them, and they only -// pile up as fast as someone edits the file. -std::atomic current_config{nullptr}; +std::unique_ptr current_config; +// Detached sync workers can still read during static teardown, so the publication slot lives until process exit. +auto& sync_config = *new std::shared_ptr{std::make_shared()}; toml::table ParseSettingsFile() { @@ -384,40 +365,42 @@ std::unique_ptr Config::Parse(const toml::table& file, toml::table& effe return config; } +std::shared_ptr Config::Sync::Get() +{ return std::atomic_load_explicit(&sync_config, std::memory_order_acquire); } + Config& Config::Get() { - if (auto* config = current_config.load(std::memory_order_acquire)) - return *config; // First use, inside il2cpp_init on the game thread. A broken file falls back to the defaults here. - static Config* const first = [] { - File::MigrateLegacyFiles(); - toml::table file; - bool loaded = false; - try { - file = ParseSettingsFile(); - loaded = true; - } catch (const toml::parse_error& e) { - spdlog::warn("Failed to load config file, falling back to default settings: {}", e.description()); - } catch (...) { - spdlog::warn("Failed to load config file, falling back to default settings"); - } - toml::table effective; - auto* config = Parse(file, effective, loaded).release(); - SaveEffective(effective, true); - std::cout << "\n\n-----------------------------\n\n" - << effective << "\n\n-----------------------------\n" + if (!current_config) + current_config = [] { + File::MigrateLegacyFiles(); + toml::table file; + bool loaded = false; + try { + file = ParseSettingsFile(); + loaded = true; + } catch (const toml::parse_error& e) { + spdlog::warn("Failed to load config file, falling back to default settings: {}", e.description()); + } catch (...) { + spdlog::warn("Failed to load config file, falling back to default settings"); + } + toml::table effective; + auto config = Parse(file, effective, loaded); + SaveEffective(effective, true); + std::cout << "\n\n-----------------------------\n\n" + << effective << "\n\n-----------------------------\n" #if VERSION_PATCH - << "Loaded beta version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION - << " (Patch " << VERSION_PATCH << ")\n\nNOTE: Beta versions may have unexpected bugs and issues.\n\n"; + << "Loaded beta version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION + << " (Patch " << VERSION_PATCH << ")\n\nNOTE: Beta versions may have unexpected bugs and issues.\n\n"; #else - << "Loaded release version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION - << "\n\n"; + << "Loaded release version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION + << "\n\n"; #endif - Shortcut::Set(std::move(config->shortcuts_)); - current_config.store(config, std::memory_order_release); - return config; - }(); - return *first; + Shortcut::Set(std::move(config->shortcuts_)); + std::atomic_store_explicit(&sync_config, config->sync_, std::memory_order_release); + return config; + }(); + return *current_config; } bool Config::Reload() @@ -448,8 +431,8 @@ bool Config::Reload() spdlog::warn("Changes to [patches] take effect after a restart"); next->patches = Get().patches; Shortcut::Set(std::move(next->shortcuts_)); - // Never freed, see current_config. - current_config.store(next.release(), std::memory_order_release); + std::atomic_store_explicit(&sync_config, next->sync_, std::memory_order_release); + current_config = std::move(next); return true; } @@ -694,18 +677,19 @@ void Config::Read(const toml::table& config, toml::table& parsed, bool log_value u.disabled_banner_types = ReadBannerTypes(r, "disabled_banner_types"); u.notify_banner_types = ReadBannerTypes(r, "notify_banner_types"); - r.Read("sync", "debug", sync.debug); - r.Read("sync", "logging", sync.logging); - r.Read("sync", "resolver_cache_ttl", sync.resolver_cache_ttl); + auto sync = std::make_shared(); + r.Read("sync", "debug", sync->debug); + r.Read("sync", "logging", sync->logging); + r.Read("sync", "resolver_cache_ttl", sync->resolver_cache_ttl); SyncConfig sync_defaults; r.Read("sync", "proxy", sync_defaults.proxy); r.Read("sync", "verify_ssl", sync_defaults.verify_ssl); for (const auto& option : SyncOptions) - if (OldName(option).empty()) + if (option.old_name.empty()) r.Read("sync", option.option_str, sync_defaults.*option.option); else - r.ReadRenamed("sync", option.option_str, OldName(option), sync_defaults.*option.option); - ReadSyncTargets(r, sync.targets, sync_defaults); + r.ReadRenamed("sync", option.option_str, option.old_name, sync_defaults.*option.option); + ReadSyncTargets(r, sync->targets, sync_defaults); if (auto sync_file = config["sync"]["file"].value(); sync_file && !sync_file->empty()) { spdlog::error("Deprecation Notice: The 'sync_file' config option has been deprecated and removed. " @@ -713,15 +697,16 @@ void Config::Read(const toml::table& config, toml::table& parsed, bool log_value } // The global options say whether any target wants a category, so the game hooks can skip the rest. - sync.options.proxy = sync_defaults.proxy; - sync.options.verify_ssl = sync_defaults.verify_ssl; + sync->options.proxy = sync_defaults.proxy; + sync->options.verify_ssl = sync_defaults.verify_ssl; for (const auto& option : SyncOptions) { - sync.options.*option.option = std::ranges::any_of(sync.targets | std::views::values, - [&](const auto& target) { return target.*option.option; }); + sync->options.*option.option = std::ranges::any_of(sync->targets | std::views::values, + [&](const auto& target) { return target.*option.option; }); } r.Read("config", "settings_url", game.settings_url); r.Read("config", "assets_url_override", game.assets_url_override); shortcuts_ = Shortcut::Parse(config, parsed, control.hotkeys_extended, control.enable_experimental); + sync_ = std::move(sync); } diff --git a/mods/src/config.h b/mods/src/config.h index b4d6b1da7..8a3c4e21e 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -3,7 +3,7 @@ // User settings from the settings TOML. Each struct is a TOML section and each member is named after its key, // with its default as the initializer: Config::Get().graphics.ui_scale is [graphics] ui_scale. They are read at // startup and again whenever the file changes; a few are also toggled at runtime by hotkeys. Everything runs on -// the game thread except sync, which only reads. +// the game thread except sync, whose workers retain immutable snapshots. #include "patches/parts/notification_audio.h" #include "patches/shortcuts.h" @@ -19,32 +19,10 @@ // Which categories of game data are uploaded, globally ([sync]) or for one target ([sync.targets.]). struct SyncConfig { - enum class Type { - kAwayAssignments, - kBattles, - kBuffs, - kBuildings, - kEmeraldChain, - kFleets, - kHaven, - kInventory, - kJobs, - kMissions, - kOfficer, - kProfile, - kResearch, - kResources, - kShips, - kSlots, - kStoreOffers, - kTech, - kTraits - }; - struct Option { - Type type; std::string_view option_str; // used in TOML file bool SyncConfig::* option; + std::string_view old_name = {}; }; std::string proxy; @@ -68,30 +46,28 @@ struct SyncConfig { bool tech = false; bool traits = false; - [[nodiscard]] bool enabled(Type type) const; - bool operator==(const SyncConfig&) const = default; + bool operator==(const SyncConfig&) const = default; }; constexpr std::array SyncOptions{ - SyncConfig::Option{SyncConfig::Type::kAwayAssignments, "away_assignments", &SyncConfig::away_assignments}, - SyncConfig::Option{SyncConfig::Type::kBattles, "battlelogs", &SyncConfig::battlelogs}, - SyncConfig::Option{SyncConfig::Type::kBuffs, "buffs", &SyncConfig::buffs}, - SyncConfig::Option{SyncConfig::Type::kBuildings, "buildings", &SyncConfig::buildings}, - SyncConfig::Option{SyncConfig::Type::kEmeraldChain, "buffs", &SyncConfig::buffs}, - SyncConfig::Option{SyncConfig::Type::kFleets, "fleets", &SyncConfig::fleets}, - SyncConfig::Option{SyncConfig::Type::kHaven, "haven", &SyncConfig::haven}, - SyncConfig::Option{SyncConfig::Type::kInventory, "inventory", &SyncConfig::inventory}, - SyncConfig::Option{SyncConfig::Type::kJobs, "jobs", &SyncConfig::jobs}, - SyncConfig::Option{SyncConfig::Type::kMissions, "missions", &SyncConfig::missions}, - SyncConfig::Option{SyncConfig::Type::kOfficer, "officers", &SyncConfig::officers}, - SyncConfig::Option{SyncConfig::Type::kProfile, "profile", &SyncConfig::profile}, - SyncConfig::Option{SyncConfig::Type::kResearch, "research", &SyncConfig::research}, - SyncConfig::Option{SyncConfig::Type::kResources, "resources", &SyncConfig::resources}, - SyncConfig::Option{SyncConfig::Type::kShips, "ships", &SyncConfig::ships}, - SyncConfig::Option{SyncConfig::Type::kSlots, "slots", &SyncConfig::slots}, - SyncConfig::Option{SyncConfig::Type::kStoreOffers, "store_offers", &SyncConfig::store_offers}, - SyncConfig::Option{SyncConfig::Type::kTech, "tech", &SyncConfig::tech}, - SyncConfig::Option{SyncConfig::Type::kTraits, "traits", &SyncConfig::traits}, + SyncConfig::Option{"away_assignments", &SyncConfig::away_assignments}, + SyncConfig::Option{"battlelogs", &SyncConfig::battlelogs}, + SyncConfig::Option{"buffs", &SyncConfig::buffs}, + SyncConfig::Option{"buildings", &SyncConfig::buildings}, + SyncConfig::Option{"fleets", &SyncConfig::fleets}, + SyncConfig::Option{"haven", &SyncConfig::haven}, + SyncConfig::Option{"inventory", &SyncConfig::inventory}, + SyncConfig::Option{"jobs", &SyncConfig::jobs}, + SyncConfig::Option{"missions", &SyncConfig::missions}, + SyncConfig::Option{"officers", &SyncConfig::officers, "officer"}, + SyncConfig::Option{"profile", &SyncConfig::profile}, + SyncConfig::Option{"research", &SyncConfig::research}, + SyncConfig::Option{"resources", &SyncConfig::resources}, + SyncConfig::Option{"ships", &SyncConfig::ships}, + SyncConfig::Option{"slots", &SyncConfig::slots}, + SyncConfig::Option{"store_offers", &SyncConfig::store_offers}, + SyncConfig::Option{"tech", &SyncConfig::tech}, + SyncConfig::Option{"traits", &SyncConfig::traits}, }; struct SyncTargetConfig : SyncConfig { @@ -133,7 +109,7 @@ struct ShipFilter { class Config final { public: - // The current settings. Call it again rather than keeping the reference: a reload replaces them. + // Game thread only. Call it again rather than keeping the reference: a reload replaces the settings. [[nodiscard]] static Config& Get(); // Re-reads the settings file and makes it current, rebinding shortcuts. A file that does not parse keeps the // current settings. [patches] decides which hooks are installed, so its changes wait for a restart. Game thread. @@ -329,6 +305,9 @@ class Config final } ui; struct Sync { + // Workers keep this snapshot alive while reading; reloads publish a separate immutable copy. + [[nodiscard]] static std::shared_ptr Get(); + bool debug = false; bool logging = false; // Seconds that resolved player and alliance names stay cached. @@ -336,7 +315,7 @@ class Config final // The categories any target wants, so the game hooks can skip the rest. SyncConfig options; std::map targets; - } sync; + }; // [config]: overrides for the game's own server settings. struct Game { @@ -350,7 +329,8 @@ class Config final static std::unique_ptr Parse(const toml::table& file, toml::table& effective, bool log_values); private: - Shortcut::Bindings shortcuts_; + Shortcut::Bindings shortcuts_; + std::shared_ptr sync_; Config() = default; void Read(const toml::table& file, toml::table& effective, bool log_values); }; diff --git a/mods/src/patches/parts/sync/battlelogs.cc b/mods/src/patches/parts/sync/battlelogs.cc index 72782655f..d6e3a271f 100644 --- a/mods/src/patches/parts/sync/battlelogs.cc +++ b/mods/src/patches/parts/sync/battlelogs.cc @@ -61,7 +61,7 @@ namespace void Store(const Key& key, Value value, uint64_t generation) { - const auto expires_at = Clock::now() + std::chrono::seconds(Config::Get().sync.resolver_cache_ttl); + const auto expires_at = Clock::now() + std::chrono::seconds(Config::Sync::Get()->resolver_cache_ttl); std::scoped_lock lock(mutex_); if (generation < generation_) return; @@ -158,7 +158,8 @@ namespace bool verify_ssl = true; }; static auto& session = *new Session; // never destroyed, see sync.h - const auto& options = Config::Get().sync.options; + const auto config = Config::Sync::Get(); + const auto& options = config->options; if (session.http && options.proxy == session.proxy && options.verify_ssl == session.verify_ssl) return *session.http; @@ -207,7 +208,7 @@ namespace http.SetBody(body); const auto response = http.Post(); - if (session.generation != Session().generation) + if (session.generation != SessionGeneration()) return {}; if (response.status_code == 0) { logging::error("DOWNLOAD", path, "Failed to send request: {}", response.error.message); @@ -359,7 +360,7 @@ namespace session.generation); std::scoped_lock lock(battle_mutex); pending.erase({session.generation, journal_id}); - return session.generation == Session().generation; + return session.generation == SessionGeneration(); } void RunBattleLogs() @@ -367,13 +368,13 @@ namespace for (;;) { const auto work = battles.Pop(); bool fetched = false; - if (work.session.generation == Session().generation && Config::Get().sync.options.battlelogs) { + if (work.session.generation == SessionGeneration() && Config::Sync::Get()->options.battlelogs) { try { fetched = UploadBattle(work); } catch (const std::exception& e) { spdlog::error("Error processing combat log {}: {}", work.id, e.what()); } - if (!fetched && work.session.generation == Session().generation) { + if (!fetched && work.session.generation == SessionGeneration()) { // Retry at the back so one unavailable journal cannot starve all the others. std::this_thread::sleep_for(std::chrono::seconds(1)); std::scoped_lock lock(battle_mutex); diff --git a/mods/src/patches/parts/sync/fleets.cc b/mods/src/patches/parts/sync/fleets.cc index ed98a8cf9..046c4a32e 100644 --- a/mods/src/patches/parts/sync/fleets.cc +++ b/mods/src/patches/parts/sync/fleets.cc @@ -182,7 +182,7 @@ namespace void Append(json records, bool snapshot) { static uint64_t sequence = 0; - Publish(Stream::kFleets, {{json::array({++sequence, snapshot}), std::move(records)}}, Session().generation); + Publish(Stream::kFleets, {{json::array({++sequence, snapshot}), std::move(records)}}, SessionGeneration()); } // A snapshot is the complete list of occupied slots, so it is sent as a first sync. @@ -257,7 +257,7 @@ namespace void InstallFleetSync() { - if (!Config::Get().sync.options.fleets) + if (!Config::Sync::Get()->options.fleets) return; if (!fleet_watch::Subscribe({.on_transition = OnTransition, .on_baseline = OnBaseline})) { spdlog::warn("Sync: fleet state subscriber could not be installed; fleet sync disabled"); diff --git a/mods/src/patches/parts/sync/records.cc b/mods/src/patches/parts/sync/records.cc index 57409d8f4..90f785296 100644 --- a/mods/src/patches/parts/sync/records.cc +++ b/mods/src/patches/parts/sync/records.cc @@ -250,7 +250,7 @@ namespace // Part of fleet sync: jobs-only targets see nothing new. The repair changes the fleet's state later, so the // fleet is re-read then. case pb::JOBTYPE_REPAIRFLEET: - if (!Config::Get().sync.options.fleets) + if (!Config::Sync::Get()->options.fleets) return std::nullopt; RereadFleet(job.repairfleetparams().fleetid()); record["fid"] = job.repairfleetparams().fleetid(); @@ -464,7 +464,8 @@ namespace void ProcessJson(const Input& input) { - const auto& options = Config::Get().sync.options; + const auto config = Config::Sync::Get(); + const auto& options = config->options; const auto result = json::parse(input.bytes, nullptr, false); if (!result.is_object()) { spdlog::error("Failed to parse sync JSON"); @@ -839,7 +840,8 @@ void ProcessOwnProfile(const Input& input) Processor ProcessorFor(EntityGroup::Kind kind) { - const auto& options = Config::Get().sync.options; + const auto config = Config::Sync::Get(); + const auto& options = config->options; for (const auto& route : kRoutes) if (route.type == kind) return !route.battle || options.battlelogs ? route.process : nullptr; diff --git a/mods/src/patches/parts/sync/state.cc b/mods/src/patches/parts/sync/state.cc index bb9b36d87..b93db361c 100644 --- a/mods/src/patches/parts/sync/state.cc +++ b/mods/src/patches/parts/sync/state.cc @@ -8,39 +8,39 @@ namespace { using json = nlohmann::json; struct Spec { - SyncConfig::Type category; - const char* type; - const char* removed = nullptr; - const char* id = nullptr; - bool initial_full = false, always_full = false; + bool SyncConfig::* enabled; + const char* type; + const char* removed = nullptr; + const char* id = nullptr; + bool initial_full = false, always_full = false; // Recent entries kept for a journal: every entry is delivered on its own and never merged with a later one. size_t journal = 0; }; constexpr std::array kStreams{ - Spec{SyncConfig::Type::kAwayAssignments, "away_assignment", "collected_away_assignment", "aid", true}, - Spec{SyncConfig::Type::kBattles, "battlelog", nullptr, nullptr, false, false, kRecentBattles}, - Spec{SyncConfig::Type::kBuffs, "buff", "expired_buff", "bid", true}, - Spec{SyncConfig::Type::kBuildings, "module"}, - Spec{SyncConfig::Type::kEmeraldChain, "emerald_chain"}, - Spec{SyncConfig::Type::kFleets, "fleet", nullptr, nullptr, false, false, kRecentFleetChanges}, - Spec{SyncConfig::Type::kFleets, "fleet"}, - Spec{SyncConfig::Type::kHaven, "haven", nullptr, nullptr, true}, - Spec{SyncConfig::Type::kHaven, "haven_pieces", nullptr, nullptr, true}, - Spec{SyncConfig::Type::kHaven, "haven_prosperity"}, - Spec{SyncConfig::Type::kInventory, "inventory", nullptr, nullptr, true}, - Spec{SyncConfig::Type::kJobs, "job", "completed_job", "uuid", true}, - Spec{SyncConfig::Type::kMissions, "active_mission"}, - Spec{SyncConfig::Type::kMissions, "mission"}, - Spec{SyncConfig::Type::kOfficer, "officer"}, - Spec{SyncConfig::Type::kProfile, "profile", nullptr, nullptr, true}, - Spec{SyncConfig::Type::kResearch, "research"}, - Spec{SyncConfig::Type::kResources, "resource", nullptr, nullptr, true}, - Spec{SyncConfig::Type::kResources, "alliance_resource", nullptr, nullptr, true}, - Spec{SyncConfig::Type::kShips, "ship", nullptr, nullptr, true}, - Spec{SyncConfig::Type::kSlots, "slot"}, - Spec{SyncConfig::Type::kStoreOffers, "store_offer"}, - Spec{SyncConfig::Type::kTech, "ft"}, - Spec{SyncConfig::Type::kTraits, "trait"}, + Spec{&SyncConfig::away_assignments, "away_assignment", "collected_away_assignment", "aid", true}, + Spec{&SyncConfig::battlelogs, "battlelog", nullptr, nullptr, false, false, kRecentBattles}, + Spec{&SyncConfig::buffs, "buff", "expired_buff", "bid", true}, + Spec{&SyncConfig::buildings, "module"}, + Spec{&SyncConfig::buffs, "emerald_chain"}, + Spec{&SyncConfig::fleets, "fleet", nullptr, nullptr, false, false, kRecentFleetChanges}, + Spec{&SyncConfig::fleets, "fleet"}, + Spec{&SyncConfig::haven, "haven", nullptr, nullptr, true}, + Spec{&SyncConfig::haven, "haven_pieces", nullptr, nullptr, true}, + Spec{&SyncConfig::haven, "haven_prosperity"}, + Spec{&SyncConfig::inventory, "inventory", nullptr, nullptr, true}, + Spec{&SyncConfig::jobs, "job", "completed_job", "uuid", true}, + Spec{&SyncConfig::missions, "active_mission"}, + Spec{&SyncConfig::missions, "mission"}, + Spec{&SyncConfig::officers, "officer"}, + Spec{&SyncConfig::profile, "profile", nullptr, nullptr, true}, + Spec{&SyncConfig::research, "research"}, + Spec{&SyncConfig::resources, "resource", nullptr, nullptr, true}, + Spec{&SyncConfig::resources, "alliance_resource", nullptr, nullptr, true}, + Spec{&SyncConfig::ships, "ship", nullptr, nullptr, true}, + Spec{&SyncConfig::slots, "slot"}, + Spec{&SyncConfig::store_offers, "store_offer"}, + Spec{&SyncConfig::tech, "ft"}, + Spec{&SyncConfig::traits, "trait"}, }; static_assert(kStreams.size() == static_cast(Stream::kCount)); } // namespace @@ -149,7 +149,7 @@ std::optional Delivery::Next(const RecordStore& store, Clock::time_point const auto& collection = store.streams_[i]; auto& cursor = cursors_[i]; const auto& spec = kStreams[i]; - if (!collection || !config_.enabled(spec.category) || now < cursor.retry_at) + if (!collection || !(config_.*spec.enabled) || now < cursor.retry_at) continue; const auto& current = collection->records; const bool journal = spec.journal != 0; diff --git a/mods/src/patches/parts/sync/sync.cc b/mods/src/patches/parts/sync/sync.cc index 262336790..79fe63ab5 100644 --- a/mods/src/patches/parts/sync/sync.cc +++ b/mods/src/patches/parts/sync/sync.cc @@ -37,7 +37,7 @@ namespace for (;;) { auto job = pending.Pop(); try { - if (job.input.generation == Session().generation) + if (job.input.generation == SessionGeneration()) job.process(job.input); } catch (const std::exception& e) { spdlog::error("Exception while processing sync data: {}", e.what()); @@ -67,10 +67,10 @@ namespace const auto type = entity_group->Type(); auto process = ProcessorFor(type); // UserProfiles also carries the local player's own record, read only while a target syncs the profile. - const bool own_profile = type == EntityGroup::Kind::UserProfiles && Config::Get().sync.options.profile; + const bool own_profile = type == EntityGroup::Kind::UserProfiles && Config::Sync::Get()->options.profile; if (!process && !own_profile) return; - Input input{Session().generation, + Input input{SessionGeneration(), length ? std::string(reinterpret_cast(bytes->data()), static_cast(length)) : std::string{}}; if (type == EntityGroup::Kind::PlanetaryMapResponse || own_profile) { @@ -102,7 +102,7 @@ namespace auto* bytes = group ? group->bytes() : nullptr; if (length > 0 && bytes && static_cast(length) <= bytes->size()) pending.Push({ProcessStoreOffers, - {Session().generation, + {SessionGeneration(), std::string(reinterpret_cast(bytes->data()), static_cast(length))}}); } } @@ -116,7 +116,7 @@ namespace // may throw. const auto* type = realtimeDataPayload->DataType(); if (type->length == 1 && type->chars[0] == u'0') - pending.Push({ProcessRealtimeSlot, {Session().generation, il2cpp::ToUtf8(realtimeDataPayload->Data())}}); + pending.Push({ProcessRealtimeSlot, {SessionGeneration(), il2cpp::ToUtf8(realtimeDataPayload->Data())}}); } return original(_this, centrifugoInfo, realtimeDataPayload, finalPayload); } @@ -129,7 +129,7 @@ namespace service_response && service_response->Is() ? service_response->EntityGroups() : nullptr) for (auto* group : *groups) HandleEntityGroup(group); - else if (auto* platform = service_response && Config::Get().sync.options.store_offers + else if (auto* platform = service_response && Config::Sync::Get()->options.store_offers ? service_response->As() : nullptr) HandlePlatformResponse(platform); diff --git a/mods/src/patches/parts/sync/sync.h b/mods/src/patches/parts/sync/sync.h index 90637a3b0..faaf0ef1f 100644 --- a/mods/src/patches/parts/sync/sync.h +++ b/mods/src/patches/parts/sync/sync.h @@ -29,24 +29,32 @@ namespace data_sync // only formatted when it will be logged. namespace logging { + inline bool Enabled(spdlog::level::level_enum level) + { + if (!spdlog::should_log(level)) + return false; + const auto config = Config::Sync::Get(); + return config->logging && (level == spdlog::level::err || config->debug); + } + template void error(std::string_view type, std::string_view target, fmt::format_string format, Args&&... args) { - if (Config::Get().sync.logging) + if (Enabled(spdlog::level::err)) spdlog::error("SYNC-{} - {}: {}", type, target, fmt::format(format, std::forward(args)...)); } template void debug(std::string_view type, std::string_view target, fmt::format_string format, Args&&... args) { - if (Config::Get().sync.logging && Config::Get().sync.debug) + if (Enabled(spdlog::level::debug)) spdlog::debug("SYNC-{} - {}: {}", type, target, fmt::format(format, std::forward(args)...)); } template void trace(std::string_view type, std::string_view target, fmt::format_string format, Args&&... args) { - if (Config::Get().sync.logging && Config::Get().sync.debug) + if (Enabled(spdlog::level::trace)) spdlog::trace("SYNC-{} - {}: {}", type, target, fmt::format(format, std::forward(args)...)); } } // namespace logging @@ -216,13 +224,15 @@ struct Input { int32_t level = 0; }; using Processor = void (*)(const Input&); -Processor ProcessorFor(EntityGroup::Kind kind); -void ProcessRealtimeSlot(const Input& input); -void ProcessStoreOffers(const Input& input); +Processor ProcessorFor(EntityGroup::Kind kind); +void ProcessRealtimeSlot(const Input& input); +void ProcessStoreOffers(const Input& input); +void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode = Update::kMerge); +void FollowSettings(); // The local player's own record from a UserProfiles response; needs user_id and level. -void ProcessOwnProfile(const Input& input); -void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode = Update::kMerge); -void FollowSettings(); +void ProcessOwnProfile(const Input& input); +// Jobs only need the generation; Session() also copies the request headers. +uint64_t SessionGeneration(); GameSession Session(); void SetGameServer(std::string url, std::string session_id); void SetGameVersion(std::string version); diff --git a/mods/src/patches/parts/sync/upload.cc b/mods/src/patches/parts/sync/upload.cc index 748367cce..e5b8dcac3 100644 --- a/mods/src/patches/parts/sync/upload.cc +++ b/mods/src/patches/parts/sync/upload.cc @@ -3,96 +3,88 @@ #include "version.h" #include #include +#include #include namespace data_sync { namespace { - class Target; struct Owner { - std::mutex mutex; - std::condition_variable ready; - RecordStore records; - GameSession session; - std::map> targets; + std::mutex mutex; + std::condition_variable ready; + RecordStore records; + GameSession session; + std::map> targets; }; - class Target + // The registry owns active targets. Workers borrow one per iteration, so removed targets finish any HTTP + // request and then exit without joining on the game thread. + void UploadTarget(Owner& owner, std::weak_ptr target, std::string name) { - public: - Target(Owner& owner, std::string name) - : worker_([this, &owner, name = std::move(name)] { Run(owner, name); }) - { - } - Delivery delivery; // Accessed only under Owner::mutex. - private: - void Run(Owner& owner, const std::string& name) - { - std::unique_ptr http; - uint64_t configured = 0; - for (;;) { - std::unique_lock lock(owner.mutex); - auto upload = delivery.Next(owner.records, Clock::now()); - if (!upload) { - owner.ready.wait_for(lock, std::chrono::seconds(1)); - continue; - } - const auto config = delivery.Config(); - const auto revision = delivery.Revision(); - lock.unlock(); - const auto label = fmt::format("{} ({})", name, upload->Type()); - bool success = false; - try { - if (!http || configured != revision) { - http = std::make_unique(); - configured = revision; - http->SetUrl(config.url); - http->SetUserAgent("stfc community mod " VER_FILE_VERSION_STR " (libcurl/" LIBCURL_VERSION ")"); - http->SetAcceptEncoding(cpr::AcceptEncoding{}); - http->SetHttpVersion(cpr::HttpVersion{cpr::HttpVersionCode::VERSION_1_1}); - http->SetRedirect(cpr::Redirect{3, true, false, cpr::PostRedirectFlags::POST_ALL}); + std::unique_ptr http; + uint64_t configured = 0; + for (;;) { + std::unique_lock lock(owner.mutex); + const auto delivery = target.lock(); + if (!delivery) + return; + auto upload = delivery->Next(owner.records, Clock::now()); + if (!upload) { + owner.ready.wait_for(lock, std::chrono::seconds(1)); + continue; + } + const auto config = delivery->Config(); + const auto revision = delivery->Revision(); + lock.unlock(); + const auto label = fmt::format("{} ({})", name, upload->Type()); + bool success = false; + try { + if (!http || configured != revision) { + http = std::make_unique(); + http->SetUrl(config.url); + http->SetUserAgent("stfc community mod " VER_FILE_VERSION_STR " (libcurl/" LIBCURL_VERSION ")"); + http->SetAcceptEncoding(cpr::AcceptEncoding{}); + http->SetHttpVersion(cpr::HttpVersion{cpr::HttpVersionCode::VERSION_1_1}); + http->SetRedirect(cpr::Redirect{3, true, false, cpr::PostRedirectFlags::POST_ALL}); #ifndef _MODDBG - http->SetConnectTimeout(cpr::ConnectTimeout{3'000}); - http->SetTimeout(cpr::Timeout{10'000}); + http->SetConnectTimeout(cpr::ConnectTimeout{3'000}); + http->SetTimeout(cpr::Timeout{10'000}); #endif - if (!config.proxy.empty()) - http->SetProxies({{"http", config.proxy}, {"https", config.proxy}}); - if (!config.verify_ssl) - http->SetSslOptions( - cpr::Ssl(cpr::ssl::VerifyHost{false}, cpr::ssl::VerifyPeer{false}, cpr::ssl::NoRevoke{true})); - http->SetHeader({{"Content-Type", "application/json"}, - {"X-Powered-By", "stfc community mod/" VER_FILE_VERSION_STR}, - {"stfc-sync-token", config.token}}); - } - auto& headers = http->GetHeader(); - if (upload->first_sync) - headers.insert_or_assign("X-PRIME-SYNC", "2"); - else - headers.erase("X-PRIME-SYNC"); - const auto records = upload->Records(); - http->SetBody(cpr::Body{records.dump()}); - const auto response = http->Post(); - success = response.status_code >= 200 && response.status_code < 300; - if (!success) - logging::error("UPLOAD", label, "Upload failed ({}): {}; retained for retry", response.status_code, - response.error.message); - else - logging::debug("UPLOAD", label, "Uploaded {} records in {:.0f} ms", records.size(), - response.elapsed * 1'000); - } catch (const std::exception& e) { - spdlog::error("Sync target {}: {}", label, e.what()); + if (!config.proxy.empty()) + http->SetProxies({{"http", config.proxy}, {"https", config.proxy}}); + if (!config.verify_ssl) + http->SetSslOptions( + cpr::Ssl(cpr::ssl::VerifyHost{false}, cpr::ssl::VerifyPeer{false}, cpr::ssl::NoRevoke{true})); + http->SetHeader({{"Content-Type", "application/json"}, + {"X-Powered-By", "stfc community mod/" VER_FILE_VERSION_STR}, + {"stfc-sync-token", config.token}}); + configured = revision; } - // A target took another category's first sync: fleet targets get a fresh fleet snapshot with it. - if (success && upload->first_sync && upload->stream != Stream::kFleets) - RequestFleetSnapshot(); - lock.lock(); - delivery.Complete(std::move(*upload), success, owner.records.generation, Clock::now()); + auto& headers = http->GetHeader(); + if (upload->first_sync) + headers.insert_or_assign("X-PRIME-SYNC", "2"); + else + headers.erase("X-PRIME-SYNC"); + const auto records = upload->Records(); + http->SetBody(cpr::Body{records.dump()}); + const auto response = http->Post(); + success = response.status_code >= 200 && response.status_code < 300; + if (!success) + logging::error("UPLOAD", label, "Upload failed ({}): {}; retained for retry", response.status_code, + response.error.message); + else + logging::debug("UPLOAD", label, "Uploaded {} records in {:.0f} ms", records.size(), response.elapsed * 1'000); + } catch (const std::exception& e) { + spdlog::error("Sync target {}: {}", label, e.what()); } + // A target took another category's first sync: fleet targets get a fresh fleet snapshot with it. + if (success && upload->first_sync && upload->stream != Stream::kFleets) + RequestFleetSnapshot(); + lock.lock(); + delivery->Complete(std::move(*upload), success, owner.records.generation, Clock::now()); } - // The owner lives until process exit. Joinable workers belong to it and cannot outlive their storage. - std::thread worker_; - }; + } Owner& Sync() { static auto& owner = *new Owner; @@ -119,27 +111,39 @@ bool HasBattle(uint64_t id, uint64_t generation) } void FollowSettings() { - static const Config* applied = nullptr; - const auto& config = Config::Get(); - if (applied == &config) + static std::shared_ptr applied; + const auto config = Config::Sync::Get(); + if (applied == config) return; - applied = &config; auto& owner = Sync(); { std::scoped_lock lock(owner.mutex); - for (auto& [name, target] : owner.targets) { - const auto now = config.sync.targets.find(name); - target->delivery.Configure(now == config.sync.targets.end() ? SyncTargetConfig{} : now->second); - } - for (const auto& [name, options] : config.sync.targets) { - auto& target = owner.targets[name]; - if (!target) - target = std::make_unique(owner, name); - target->delivery.Configure(options); + std::erase_if(owner.targets, [&](const auto& entry) { + if (config->targets.contains(entry.first)) + return false; + entry.second->Configure({}); // Reject acknowledgements from a removed target's in-flight request. + return true; + }); + for (const auto& [name, options] : config->targets) { + if (const auto found = owner.targets.find(name); found != owner.targets.end()) { + found->second->Configure(options); + } else { + auto target = std::make_shared(); + target->Configure(options); + std::thread(UploadTarget, std::ref(owner), std::weak_ptr(target), name).detach(); + owner.targets.emplace(name, std::move(target)); + } } } + applied = config; owner.ready.notify_all(); } +uint64_t SessionGeneration() +{ + auto& owner = Sync(); + std::scoped_lock lock(owner.mutex); + return owner.session.generation; +} GameSession Session() { auto& owner = Sync(); @@ -156,7 +160,7 @@ void SetGameServer(std::string url, std::string session_id) owner.session.server_url = std::move(url); owner.session.session_id = std::move(session_id); for (const auto& [name, target] : owner.targets) - target->delivery.Reset(owner.records.generation); + target->Reset(owner.records.generation); } owner.ready.notify_all(); } diff --git a/tests/config_reload.cc b/tests/config_reload.cc new file mode 100644 index 000000000..d382dcf90 --- /dev/null +++ b/tests/config_reload.cc @@ -0,0 +1,135 @@ +// Real reloads against an isolated filesystem boundary, including readers that outlive a settings replacement. +#include "config.h" +#include "file.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const auto directory = std::filesystem::temp_directory_path() + / ("stfc-config-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); +const std::string config_name = "settings.toml", vars_name = "runtime.vars"; +bool fail_vars = false; + +void Check(bool ok, const char* message) +{ + if (!ok) + throw std::runtime_error(message); +} +void Write(std::string_view settings) +{ + std::ofstream file(directory / config_name); + file.exceptions(std::ios::failbit | std::ios::badbit); + file << settings; +} +} // namespace + +namespace File +{ +const std::string& Config() +{ return config_name; } +const std::string& DefaultConfig() +{ return config_name; } +const std::string& Vars() +{ return vars_name; } +std::filesystem::path Path(std::string_view name, bool) +{ + if (fail_vars && name == vars_name) + throw std::runtime_error("runtime vars directory is unavailable"); + return directory / name; +} +void MigrateLegacyFiles() {} +} // namespace File + +namespace unity::Key +{ +bool Pressed(KeyCode) +{ return false; } +bool Down(KeyCode) +{ return false; } +bool IsModified() +{ return false; } +} // namespace unity::Key + +int main() +{ + spdlog::set_level(spdlog::level::off); + std::filesystem::create_directories(directory); + int result = 0; + try { + Write(R"( + [patches] + uiscalehooks = false + [shortcuts] + quit = "F1" + [sync] + resolver_cache_ttl = 123 + [sync.targets.test] + url = "https://example.invalid" + token = "original" + resources = true + )"); + Check(!Config::Get().patches.uiscalehooks && Shortcut::Describe(Action::kQuit) == "F1", "startup activation"); + auto first = Config::Sync::Get(); + std::weak_ptr retired = first; + std::latch captured(1); + bool reader_ok = false; + // This reader spans every reload below and must keep its original settings throughout. + std::jthread reader([snapshot = std::move(first), &captured, &reader_ok](std::stop_token stop) { + std::mutex mutex; + std::condition_variable_any ready; + std::unique_lock lock(mutex); + captured.count_down(); + ready.wait(lock, stop, [] { return false; }); + reader_ok = snapshot->resolver_cache_ttl == 123 && snapshot->options.resources + && snapshot->targets.at("test").token == "original"; + }); + captured.wait(); + bool reloads_ok = true; + for (int i = 0; i < 20; ++i) { + std::weak_ptr previous = Config::Sync::Get(); + Write("[patches]\nuiscalehooks = true\n[shortcuts]\nquit = \"F2\"\n[sync]\nresolver_cache_ttl = " + + std::to_string(200 + i)); + reloads_ok &= Config::Reload(); + reloads_ok &= Config::Sync::Get()->resolver_cache_ttl == 200 + i; + if (i != 0) + reloads_ok &= previous.expired(); + } + const bool reader_retained = !retired.expired(); + reader.request_stop(); + reader.join(); + Check(reloads_ok, "reloads replace snapshots and reclaim unreferenced settings"); + Check(reader_retained && reader_ok && retired.expired(), "an old snapshot lives exactly as long as its reader"); + Check(!Config::Get().patches.uiscalehooks && Shortcut::Describe(Action::kQuit) == "F2", + "reload activates bindings but preserves startup hook groups"); + const auto current = Config::Sync::Get(); + const auto ui_scale = Config::Get().graphics.ui_scale; + Write("[shortcuts]\nquit = \"F3\"\n[sync]\nresolver_cache_ttl = 999\n[graphics]\nui_scale = 2.0"); + fail_vars = true; + Check(!Config::Reload(), "a filesystem preparation failure rejects the reload"); + fail_vars = false; + Check(Config::Sync::Get() == current && Shortcut::Describe(Action::kQuit) == "F2" + && Config::Get().graphics.ui_scale == ui_scale, + "a rejected reload changes neither settings, snapshots nor bindings"); + Write("[broken"); + Check(!Config::Reload() && Config::Sync::Get() == current && Shortcut::Describe(Action::kQuit) == "F2", + "invalid TOML preserves the current settings"); + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + result = 1; + } + std::filesystem::remove_all(directory); + if (!result) + std::cout << "config reload tests passed\n"; + return result; +} diff --git a/tests/mod_logic.cc b/tests/mod_logic.cc index b5381ad35..b3623fa83 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -277,8 +277,7 @@ void ExampleSettings() && config->ui.hud_outposts == MissionHudVisibility::kAuto && config->ui.hud_missions == MissionHudVisibility::kAuto, "example HUD settings use native visibility"); - Check(effective["sync"]["fleets"].value() == false && !config->sync.options.fleets, - "the example documents fleet sync, off by default"); + Check(effective["sync"]["fleets"].value() == false, "the example documents fleet sync, off by default"); } void NotificationAudio() diff --git a/tests/sync_logic.cc b/tests/sync_logic.cc index ad790b1b2..b3ea2aa30 100644 --- a/tests/sync_logic.cc +++ b/tests/sync_logic.cc @@ -5,13 +5,24 @@ #include #include +struct CountedLogValue { + int* formats; +}; +template <> struct fmt::formatter : fmt::formatter { + auto format(const CountedLogValue& value, fmt::format_context& context) const + { + ++*value.formats; + return fmt::formatter::format("record", context); + } +}; + using nlohmann::json; namespace game = Digit::PrimeServer::Models; namespace { using namespace data_sync; RecordStore store; -Config& settings = Config::Get(); +auto settings = std::make_shared(); std::vector rereads; Clock::time_point now{}; SyncTargetConfig Options() @@ -436,6 +447,38 @@ void ConfigurationAndSession() upload = Take(target); Check(upload.first_sync && upload.Records()[0]["amount"] == 200, "new session starts with its own baseline"); } +void SyncLogging() +{ + const auto previous_level = spdlog::get_level(); + const auto previous_logging = settings->logging, previous_debug = settings->debug; + int formats = 0; + settings->logging = settings->debug = true; + spdlog::set_level(spdlog::level::off); + logging::error("TEST", "target", "{}", CountedLogValue{&formats}); + logging::debug("TEST", "target", "{}", CountedLogValue{&formats}); + logging::trace("TEST", "target", "{}", CountedLogValue{&formats}); + const bool skipped = formats == 0; + formats = 0; + spdlog::set_level(spdlog::level::trace); + settings->logging = false; + logging::error("TEST", "target", "{}", CountedLogValue{&formats}); + settings->logging = true; + settings->debug = false; + logging::debug("TEST", "target", "{}", CountedLogValue{&formats}); + logging::trace("TEST", "target", "{}", CountedLogValue{&formats}); + const bool gated = formats == 0; + formats = 0; + logging::error("TEST", "target", "{}", CountedLogValue{&formats}); + settings->debug = true; + logging::debug("TEST", "target", "{}", CountedLogValue{&formats}); + logging::trace("TEST", "target", "{}", CountedLogValue{&formats}); + spdlog::set_level(previous_level); + settings->logging = previous_logging; + settings->debug = previous_debug; + Check(skipped, "globally disabled sync logs do not format their arguments"); + Check(gated, "sync logging and debug flags still suppress formatting"); + Check(formats == 3, "enabled sync messages format once"); +} void BattlesAndFairness() { auto target = Target(); @@ -545,9 +588,9 @@ void RepairJobs() job->mutable_repairfleetparams()->set_fleetid(5); job->mutable_starttime()->set_seconds(100); job->set_duration(30); - settings.sync.options.fleets = false; + settings->options.fleets = false; Process(EntityGroup::Kind::Jobs, jobs); - settings.sync.options.fleets = true; + settings->options.fleets = true; Check(Send(target).empty() && rereads.empty(), "repair jobs are left out without fleet sync"); Process(EntityGroup::Kind::Jobs, jobs); Check( @@ -558,18 +601,9 @@ void RepairJobs() Check(rereads == std::vector{5}, "a repair job re-reads its fleet"); } } // namespace -Config& Config::Get() -{ - static Config config; - return config; -} -bool SyncConfig::enabled(Type type) const -{ - for (const auto& option : SyncOptions) - if (option.type == type) - return this->*option.option; - return false; -} +std::shared_ptr Config::Sync::Get() +{ return settings; } + namespace data_sync { void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode) @@ -586,7 +620,7 @@ void RereadFleet(uint64_t fleet_id) int main() { for (const auto& option : SyncOptions) - settings.sync.options.*option.option = true; + settings->options.*option.option = true; int failures = 0; const auto run = [&](const char* name, void (*test)()) { store.Reset(); @@ -618,6 +652,7 @@ int main() run("fleet transitions", FleetTransitions); run("deployed fleets", DeployedFleets); run("repair jobs", RepairJobs); + run("logging", SyncLogging); if (!failures) std::cout << "Sync wire and delivery tests passed\n"; return failures ? 1 : 0; diff --git a/xmake.lua b/xmake.lua index 546940ff3..0cb8a2858 100644 --- a/xmake.lua +++ b/xmake.lua @@ -81,3 +81,17 @@ target("sync-logic-tests") add_packages("protobuf", "spdlog", "toml++", "nlohmann_json") add_rules("protobuf.cpp") add_files("mods/src/prime/proto/*.proto") + +target("config-reload-tests") + set_kind("binary") + set_default(false) + set_exceptions("cxx") + add_files("tests/config_reload.cc", "mods/src/patches/shortcuts.cc", "mods/src/config.cc", "mods/src/patches/parts/notification_audio.cc") + add_includedirs("mods/src", "libs/il2cpp/include", "third_party/libil2cpp", "libs/unity/include") + add_packages("spdlog", "toml++") + if is_plat("macosx") then + add_frameworks("Cocoa") + add_files("mods/src/patches/parts/notification_audio_mac.mm") + elseif is_plat("windows") then + add_syslinks("shell32", "winmm") + end From 54ee0fac7d5782adddaac7a33f49a6ca449e59a9 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 09/10] Keep frame input and shortcuts consistent Overlapping timers become explicit ship swap, officer swap and officer preset stages. A new screen request replaces the old one, and disabling hotkeys cancels pending work. Held and down results are cached in the frame snapshot, including negative results, so repeated checks in one frame agree. Asking for alliance help runs before its result is combined, where short-circuiting previously skipped it. --- libs/unity/include/unity/input.h | 3 +- libs/unity/src/input.cc | 33 ++++-- mods/src/patches/parts/screens.cc | 87 ++++++++------- mods/src/patches/parts/space_actions.cc | 2 +- tests/il2cpp_bindings.cc | 141 +++++++++++++++++++++++- 5 files changed, 212 insertions(+), 54 deletions(-) diff --git a/libs/unity/include/unity/input.h b/libs/unity/include/unity/input.h index 15891a816..1d734bbb3 100644 --- a/libs/unity/include/unity/input.h +++ b/libs/unity/include/unity/input.h @@ -418,8 +418,7 @@ struct InputActionCallbackContext { }; static_assert(sizeof(InputActionCallbackContext) == 16); -// This frame's keyboard. Unity answers each key from its own per-frame state; the snapshot only skips asking on -// idle frames and caches the derived checks. +// This frame's keyboard. Idle frames skip key queries; otherwise each key/state is read from Unity at most once. namespace Key { // Starts a frame's reads. Every hook that reads keys calls it first, since hooks run in no fixed order within a diff --git a/libs/unity/src/input.cc b/libs/unity/src/input.cc index 6d0d8fab7..c23edde51 100644 --- a/libs/unity/src/input.cc +++ b/libs/unity/src/input.cc @@ -17,14 +17,24 @@ namespace bool any_down = false; int8_t focused = 0; // 0 = not read yet, 1 = true, -1 = false int8_t modified = 0; + std::array(KeyCode::Max)>, 2> keys{}; + + bool Read(KeyCode key, bool down) + { + if (!(down ? any_down : any_held)) + return false; + auto& value = keys[down][static_cast(key)]; + if (value == 0) + value = (down ? Input::GetKeyDown(key) : Input::GetKey(key)) ? 1 : -1; + return value == 1; + } } frame; - TMP_InputField* FocusedInputField() + bool InputFocused(EventSystem* event_system) { - auto* event_system = EventSystem::current(); - auto* selected = event_system ? event_system->currentSelectedGameObject() : nullptr; - auto* field = selected ? selected->GetComponent() : nullptr; - return field && field->isFocused() ? field : nullptr; + auto* selected = event_system ? event_system->currentSelectedGameObject() : nullptr; + auto* field = selected ? selected->GetComponent() : nullptr; + return field && field->isFocused(); } } // namespace @@ -40,10 +50,10 @@ namespace Key } bool Pressed(KeyCode key) - { return frame.any_held && Input::GetKey(key); } + { return frame.Read(key, false); } bool Down(KeyCode key) - { return frame.any_down && Input::GetKeyDown(key); } + { return frame.Read(key, true); } bool IsModified() { @@ -60,14 +70,17 @@ namespace Key bool IsInputFocused() { if (frame.focused == 0) - frame.focused = FocusedInputField() ? 1 : -1; + frame.focused = InputFocused(EventSystem::current()) ? 1 : -1; return frame.focused == 1; } void ClearInputFocus() { - if (FocusedInputField()) - EventSystem::current()->SetSelectedGameObject(nullptr); + auto* event_system = EventSystem::current(); + if (InputFocused(event_system)) + event_system->SetSelectedGameObject(nullptr); + // Selection callbacks can focus another field. Re-read the actual focus on the next query this frame. + frame.focused = 0; } } // namespace Key } // namespace unity diff --git a/mods/src/patches/parts/screens.cc b/mods/src/patches/parts/screens.cc index 2e00fe3ea..9221ff1ff 100644 --- a/mods/src/patches/parts/screens.cc +++ b/mods/src/patches/parts/screens.cc @@ -14,6 +14,7 @@ #include "prime/ui.h" #include +#include #include #include @@ -24,12 +25,13 @@ namespace // frames is a few seconds at the game's frame rate. constexpr int kButtonWaitFrames = 300; -struct Waits { - int ship_swap = 0; - int officer_swap = 0; - int officer_presets = 0; - bool assignment_opened = false; -} waits; +enum class ButtonStage { kShipSwap, kOfficerSwap, kOfficerAssignment, kOfficerPresets }; + +struct ButtonWait { + ButtonStage stage; + int frames = kButtonWaitFrames; +}; +std::optional pending_button; // Runs one of the game's own shortcut handlers. A handler missing from this client is reported once by the binding // layer and does nothing. @@ -132,22 +134,23 @@ bool OpenManageShipsFor(const char* what) void ShowShipSwap() { if (!PressShipSwapButton() && OpenManageShipsFor("Ship Swap")) - waits.ship_swap = kButtonWaitFrames; + pending_button = ButtonWait{ButtonStage::kShipSwap}; } void ShowOfficerSwap() { if (!PressOfficerSwapButton() && OpenManageShipsFor("Officer Swap")) - waits.officer_swap = kButtonWaitFrames; + pending_button = ButtonWait{ButtonStage::kOfficerSwap}; } void ShowOfficerPresets() { if (PressOfficerPresetsButton()) return; - waits.assignment_opened = PressOfficerSwapButton(); - if (waits.assignment_opened || OpenManageShipsFor("Officer Presets")) - waits.officer_presets = kButtonWaitFrames; + if (PressOfficerSwapButton()) + pending_button = ButtonWait{ButtonStage::kOfficerPresets}; + else if (OpenManageShipsFor("Officer Presets")) + pending_button = ButtonWait{ButtonStage::kOfficerAssignment}; } struct ScreenShortcut { @@ -291,37 +294,41 @@ bool FocusSearchBox() bool PressAwaitedButton() { - if (waits.officer_presets > 0) { - if (PressOfficerPresetsButton()) { - waits.officer_presets = 0; - waits.assignment_opened = false; - return true; - } - // Manage Ships is up but officer assignment not yet: open it on the way. - if (!waits.assignment_opened && PressOfficerSwapButton()) - waits.assignment_opened = true; - if (--waits.officer_presets == 0) { - waits.assignment_opened = false; - spdlog::warn("[Hotkeys] timed out waiting for the Officer Presets button"); - } + const auto& control = Config::Get().control; + if (!control.hotkeys_enabled || control.use_scopely_hotkeys) { + pending_button.reset(); + return false; } + if (!pending_button) + return false; - if (waits.officer_swap > 0) { - if (PressOfficerSwapButton()) { - waits.officer_swap = 0; - return true; - } - if (--waits.officer_swap == 0) - spdlog::warn("[Hotkeys] timed out waiting for the Officer Swap button"); + bool pressed = false; + const char* name = "Officer Presets"; + switch (pending_button->stage) { + case ButtonStage::kShipSwap: + name = "Ship Swap"; + pressed = PressShipSwapButton(); + break; + case ButtonStage::kOfficerSwap: + name = "Officer Swap"; + pressed = PressOfficerSwapButton(); + break; + case ButtonStage::kOfficerAssignment: + pressed = PressOfficerPresetsButton(); + if (!pressed && PressOfficerSwapButton()) + pending_button->stage = ButtonStage::kOfficerPresets; + break; + case ButtonStage::kOfficerPresets: + pressed = PressOfficerPresetsButton(); + break; } - - if (waits.ship_swap > 0) { - if (PressShipSwapButton()) { - waits.ship_swap = 0; - return true; - } - if (--waits.ship_swap == 0) - spdlog::warn("[Hotkeys] timed out waiting for the Ship Swap button"); + if (pressed) { + pending_button.reset(); + return true; + } + if (--pending_button->frames == 0) { + pending_button.reset(); + spdlog::warn("[Hotkeys] timed out waiting for the {} button", name); } return false; } @@ -336,6 +343,8 @@ bool ScreenShortcuts() return true; for (const auto& screen : kScreens) { if (Shortcut::IsDown(screen.shortcut)) { + // A new screen request replaces any button still waiting on the previous screen. + pending_button.reset(); screen.run(); return screen.consumes; } diff --git a/mods/src/patches/parts/space_actions.cc b/mods/src/patches/parts/space_actions.cc index 9659cb2b6..9bf2d885d 100644 --- a/mods/src/patches/parts/space_actions.cc +++ b/mods/src/patches/parts/space_actions.cc @@ -152,7 +152,7 @@ bool RequestFleetAction(FleetBarViewController* fleet_bar, ActionType type, std: requested = panel->RequestAction(fleet, type, 0, ActionBehaviour::Default); } if (help_state != FleetState::Unknown && (requested || fleet->CurrentState() == help_state)) - requested = requested || panel->RequestAction(fleet, type, 0, ActionBehaviour::AskHelp); + requested = panel->RequestAction(fleet, type, 0, ActionBehaviour::AskHelp) || requested; spdlog::trace("Fleet action {} on fleet {} in state {}: {}", type.flagValue, fleet->Id(), static_cast(fleet->CurrentState()), requested); return requested; diff --git a/tests/il2cpp_bindings.cc b/tests/il2cpp_bindings.cc index c1da93b59..8e7d57480 100644 --- a/tests/il2cpp_bindings.cc +++ b/tests/il2cpp_bindings.cc @@ -1,7 +1,10 @@ // Typed bindings against a controlled managed runtime: lookup, signature checks, native calls, // fields, dispatch, and failure reporting. #include "il2cpp_test_api.h" +#include +#include +#include #include #include #include @@ -15,6 +18,43 @@ namespace { +void Require(bool condition, const char* message); + +int input_frame = 0, held_reads = 0, down_reads = 0; +bool any_held = false, any_down = false; +std::array(unity::KeyCode::Max)> held_keys{}, down_keys{}; + +void KeyboardSnapshot() +{ + using namespace unity; + Key::Poll(); + Require(!Key::Pressed(KeyCode::A) && !Key::Down(KeyCode::A), "idle keyboard reads false"); + Require(held_reads == 0 && down_reads == 0, "idle keyboard avoids key queries"); + + ++input_frame; + any_held = any_down = true; + held_keys[static_cast(KeyCode::A)] = true; + down_keys[static_cast(KeyCode::A)] = true; + Key::Poll(); + for (int i = 0; i < 100; ++i) { + Key::Poll(); + Require(Key::Pressed(KeyCode::A) && Key::Down(KeyCode::A), "held and down snapshot"); + Require(!Key::Pressed(KeyCode::B) && !Key::Down(KeyCode::B), "negative key queries are cached too"); + } + Require(held_reads == 2 && down_reads == 2, "each key is queried once per state per frame"); + ++input_frame; + held_keys[static_cast(KeyCode::A)] = false; + down_keys[static_cast(KeyCode::A)] = false; + Key::Poll(); + Require(!Key::Pressed(KeyCode::A) && !Key::Down(KeyCode::A), "new frames refresh the snapshot"); + Require(held_reads == 3 && down_reads == 3, "new frame queries keys again"); + ++input_frame; + any_held = any_down = false; + Key::Poll(); + Require(!Key::Pressed(KeyCode::A) && !Key::Down(KeyCode::A), "return to idle clears held keys"); + Require(held_reads == 3 && down_reads == 3, "idle frame stays query-free"); +} + struct FakeClass { // MSVC cannot embed Il2CppClass because its vtable is a zero-length trailing array. std::unique_ptr metadata = std::make_unique(); @@ -124,6 +164,63 @@ FakeClass& delegate_base = Define("System", "Delegate", IL2CPP_TYPE_CLASS, &obje FakeClass& action_class = Define("System", "Action", IL2CPP_TYPE_CLASS, &delegate_base); FakeClass& intptr_class = Define("System", "IntPtr", IL2CPP_TYPE_I, nullptr, sizeof(void*)); +FakeClass& event_system_class = Define("UnityEngine.EventSystems", "EventSystem", IL2CPP_TYPE_CLASS, &object_class); +FakeClass& game_object_class = Define("UnityEngine", "GameObject", IL2CPP_TYPE_CLASS, &object_class); +FakeClass& input_field_class = Define("TMPro", "TMP_InputField", IL2CPP_TYPE_CLASS, &object_class); + +unity::EventSystem event_system{}; +unity::GameObject selected_object{}; +unity::TMP_InputField input_field{}; +unity::GameObject* selected = &selected_object; +bool has_event_system = true, has_input_field = true, redirect_selection = false; +int event_system_reads = 0; + +unity::EventSystem* EventSystem_get_current(const MethodInfo*) +{ + ++event_system_reads; + return has_event_system ? &event_system : nullptr; +} +unity::GameObject* EventSystem_get_selected(unity::EventSystem*, const MethodInfo*) +{ return selected; } +void EventSystem_set_selected(unity::EventSystem*, unity::GameObject* value, const MethodInfo*) +{ selected = redirect_selection ? &selected_object : value; } +unity::TMP_InputField* GameObject_get_component(unity::GameObject*, Il2CppObject*, const MethodInfo*) +{ return has_input_field ? &input_field : nullptr; } +bool InputField_get_focused(unity::TMP_InputField*, const MethodInfo*) +{ return true; } + +void InputFocus() +{ + using namespace unity; + ++input_frame; + Key::Poll(); + Require(Key::IsInputFocused(), "selected text field has focus"); + const int reads = event_system_reads; + Require(Key::IsInputFocused() && event_system_reads == reads, "focus is cached within a frame"); + Key::ClearInputFocus(); + Require(event_system_reads == reads + 1, "clearing focus reads the event system once"); + Require(selected == nullptr && !Key::IsInputFocused(), "clearing selection invalidates focus within the frame"); + + selected = &selected_object; + redirect_selection = true; + Key::ClearInputFocus(); + Require(Key::IsInputFocused(), "selection callbacks can focus another field when focus is cleared"); + redirect_selection = false; + + has_input_field = false; + ++input_frame; + Key::Poll(); + Require(!Key::IsInputFocused(), "a selected object without a text field is not text focus"); + Key::ClearInputFocus(); + Require(selected == &selected_object, "clearing text focus preserves a selected non-text object"); + + has_event_system = false; + ++input_frame; + Key::Poll(); + Require(!Key::IsInputFocused(), "no event system has no text focus"); + Key::ClearInputFocus(); +} + TestObject base_object{}, derived_object{}, other_object{}, list_a{}, list_b{}, allocated{}; Il2CppObject string_object{}, type_object{}; Il2CppDelegate delegate_object{}; @@ -256,6 +353,23 @@ void BuildRuntime() AddMethod(base_class, "OnEvent", reinterpret_cast(&NativeTarget), T(void_class), {}, METHOD_ATTRIBUTE_PUBLIC | METHOD_ATTRIBUTE_STATIC); + auto& current = AddMethod(event_system_class, "get_current", reinterpret_cast(&EventSystem_get_current), + T(event_system_class), {}, METHOD_ATTRIBUTE_PUBLIC | METHOD_ATTRIBUTE_STATIC); + AddProperty(event_system_class, "current", ¤t); + auto& selected_getter = AddMethod(event_system_class, "get_currentSelectedGameObject", + reinterpret_cast(&EventSystem_get_selected), T(game_object_class), {}); + AddProperty(event_system_class, "currentSelectedGameObject", &selected_getter); + AddMethod(event_system_class, "SetSelectedGameObject", reinterpret_cast(&EventSystem_set_selected), + T(void_class), {T(game_object_class)}); + AddMethod(game_object_class, "GetComponent", reinterpret_cast(&GameObject_get_component), T(input_field_class), + {T(type_class)}); + auto& focused = AddMethod(input_field_class, "get_isFocused", reinterpret_cast(&InputField_get_focused), + T(bool_class), {}); + AddProperty(input_field_class, "isFocused", &focused); + event_system.klass = &event_system_class.klass; + selected_object.klass = &game_object_class.klass; + input_field.klass = &input_field_class.klass; + inner_class.klass.declaringType = &outer_class.klass; outer_class.nested.push_back(&inner_class.klass); @@ -371,9 +485,30 @@ API(const Il2CppType*, il2cpp_class_get_type, (Il2CppClass * klass)) END_API API(Il2CppMethodPointer, il2cpp_resolve_icall, (const char* name)) { - // The only internal call the fake runtime has. static constexpr auto answer = +[]() -> int32_t { return 42; }; - return std::string_view(name) == "Test::Answer()" ? reinterpret_cast(answer) : nullptr; + static constexpr auto frame = +[]() -> int32_t { return input_frame; }; + static constexpr auto held = +[]() { return any_held; }; + static constexpr auto down = +[]() { return any_down; }; + static constexpr auto key = +[](unity::KeyCode key) { + ++held_reads; + return held_keys[static_cast(key)]; + }; + static constexpr auto key_down = +[](unity::KeyCode key) { + ++down_reads; + return down_keys[static_cast(key)]; + }; + const std::pair calls[] = { + {"Test::Answer()", reinterpret_cast(answer)}, + {"UnityEngine.Time::get_frameCount()", reinterpret_cast(frame)}, + {"UnityEngine.Input::get_anyKey()", reinterpret_cast(held)}, + {"UnityEngine.Input::get_anyKeyDown()", reinterpret_cast(down)}, + {"UnityEngine.Input::GetKeyInt(UnityEngine.KeyCode)", reinterpret_cast(key)}, + {"UnityEngine.Input::GetKeyDownInt(UnityEngine.KeyCode)", reinterpret_cast(key_down)}, + }; + for (const auto& [key, function] : calls) + if (key == name) + return function; + return nullptr; } END_API API(Il2CppObject*, il2cpp_type_get_object, (const Il2CppType*)) @@ -685,5 +820,7 @@ int main() Require(base->TryCall<"Select", void(int32_t)>(5) && seen_int == 5, "tried void call"); Require(!base->TryCall<"Vanished", int32_t()>(), "tried missing method is empty"); + KeyboardSnapshot(); + InputFocus(); std::cout << "IL2CPP binding tests passed\n"; } From d09d88471fc3336ac27e0924b8d3adb5de5d5741 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 15 Oct 2026 16:40:42 -0700 Subject: [PATCH 10/10] Rewrite only changed slots when pinning ships Pinning cleared and re-added every ship in the dock list. It now visits a snapshot in pinned and unpinned passes and sets only changed slots, so the list keeps its count. Instance snapshots are taken once per operation instead of rescanning GC handle lists for each tracked object. --- libs/il2cpp/include/il2cpp/il2cpp.h | 2 + libs/unity/include/unity/tracker.h | 13 ++- libs/unity/src/tracker.cc | 11 +- .../patches/parts/double_click_assign_ship.cc | 22 ++-- tests/il2cpp_bindings.cc | 20 ++++ tests/unity_tracker.cc | 100 ++++++++++++++++++ xmake.lua | 9 ++ 7 files changed, 150 insertions(+), 27 deletions(-) create mode 100644 tests/unity_tracker.cc diff --git a/libs/il2cpp/include/il2cpp/il2cpp.h b/libs/il2cpp/include/il2cpp/il2cpp.h index fe89cbfaa..033b65455 100644 --- a/libs/il2cpp/include/il2cpp/il2cpp.h +++ b/libs/il2cpp/include/il2cpp/il2cpp.h @@ -611,6 +611,8 @@ template struct List : Object { { return Call<"get_Count", int32_t>(); } T operator[](int32_t index) { return Call<"get_Item", T>(index); } + void Set(int32_t index, T value) + { Call<"set_Item">(index, value); } void Add(T value) { Call<"Add">(value); } void RemoveAt(int32_t index) diff --git a/libs/unity/include/unity/tracker.h b/libs/unity/include/unity/tracker.h index 5305d429a..1b582fcc3 100644 --- a/libs/unity/include/unity/tracker.h +++ b/libs/unity/include/unity/tracker.h @@ -7,6 +7,7 @@ #include #include +#include #include namespace unity @@ -51,13 +52,11 @@ template void Track(std::string_view created = ".ctor", std::string template T* Newest() { return static_cast(detail::Newest(T::Class::Raw())); } -// Every live instance of T, oldest first. A copy, so the caller may call into the game while the GC's finalizer -// thread keeps updating the tracker. -template std::vector Instances() +// Every live instance of T, oldest first. The range owns a snapshot of borrowed pointers, so the caller may call +// into the game while the GC's finalizer thread keeps updating the tracker. +template auto Instances() { - std::vector result; - for (auto* object : detail::Instances(T::Class::Raw())) - result.push_back(static_cast(object)); - return result; + return detail::Instances(T::Class::Raw()) + | std::views::transform([](Il2CppObject* object) { return static_cast(object); }); } } // namespace unity diff --git a/libs/unity/src/tracker.cc b/libs/unity/src/tracker.cc index 65e341bc0..0bd1a6258 100644 --- a/libs/unity/src/tracker.cc +++ b/libs/unity/src/tracker.cc @@ -113,15 +113,10 @@ namespace { original(state); - std::scoped_lock lock(registry.mutex); - std::vector released; - // Every object sits in its own class's list, so checking those lists alone visits each object once. + std::scoped_lock lock(registry.mutex); + // Filter each class list once, including inherited entries; untracking one object at a time rescans them. for (auto& [klass, instances] : registry.objects) - for (auto* object : instances) - if (ClassOf(object) == klass && IsMarked(object)) - released.push_back(object); - for (auto* object : released) - Untrack(object); + std::erase_if(instances, IsMarked); } RegisterFinalizer FindRegisterFinalizer() diff --git a/mods/src/patches/parts/double_click_assign_ship.cc b/mods/src/patches/parts/double_click_assign_ship.cc index c9a1507e5..c9f38db01 100644 --- a/mods/src/patches/parts/double_click_assign_ship.cc +++ b/mods/src/patches/parts/double_click_assign_ship.cc @@ -19,7 +19,6 @@ #include #include -#include #include #include #include @@ -85,17 +84,16 @@ void PinShips(il2cpp::List* list) if (!any) return; - std::ranges::stable_partition(ships, &Ship::pinned); - bool unchanged = true; - for (int32_t i = 0; i < count && unchanged; ++i) - unchanged = ships[i].data == (*list)[i]; - if (unchanged) - return; - - // SortedIdleShips is a List, which always has Clear and Add; the rewrite cannot stop halfway. - list->Clear(); - for (const auto& ship : ships) - list->Add(ship.data); + // Read from the original snapshot in two passes to preserve both groups' order. Only changed slots cross the + // managed boundary; the list keeps its count and needs no partition buffer or second read of its items. + int32_t index = 0; + for (const bool pinned_group : {true, false}) + for (const auto& ship : ships) + if (ship.pinned == pinned_group) { + if (ship.data != ships[index].data) + list->Set(index, ship.data); + ++index; + } } void ShipManagementViewContext_Sort_Hook(auto original, ShipManagementViewContext* _this) diff --git a/tests/il2cpp_bindings.cc b/tests/il2cpp_bindings.cc index 8e7d57480..49e4dad12 100644 --- a/tests/il2cpp_bindings.cc +++ b/tests/il2cpp_bindings.cc @@ -288,6 +288,13 @@ int32_t ListB_get_Count(Il2CppObject*, const MethodInfo*) { return 3; } Il2CppObject* List_get_Item(Il2CppObject* self, int32_t index, const MethodInfo*) { return index == 0 ? self : &other_object.header; } +void List_set_Item(Il2CppObject* self, int32_t index, Il2CppObject* value, const MethodInfo* method) +{ + seen_self = self; + seen_int = index; + seen_method = method; + reinterpret_cast(self)->target = value; +} Il2CppException thrown{}; int32_t Base_Explode(Il2CppObject*, const MethodInfo*) { throw Il2CppExceptionWrapper(&thrown); } @@ -345,8 +352,12 @@ void BuildRuntime() AddMethod(list_a_class, "get_Count", reinterpret_cast(&ListA_get_Count), T(int_class), {}); AddMethod(list_a_class, "get_Item", reinterpret_cast(&List_get_Item), T(object_class), {T(int_class)}); + AddMethod(list_a_class, "set_Item", reinterpret_cast(&List_set_Item), T(void_class), + {T(int_class), T(object_class)}); AddMethod(list_b_class, "get_Count", reinterpret_cast(&ListB_get_Count), T(int_class), {}); AddMethod(list_b_class, "get_Item", reinterpret_cast(&List_get_Item), T(object_class), {T(int_class)}); + AddMethod(list_b_class, "set_Item", reinterpret_cast(&List_set_Item), T(void_class), + {T(int_class), T(object_class)}); auto& ctor = AddMethod(action_class, ".ctor", nullptr, T(void_class), {T(object_class), T(intptr_class)}); ctor.invoker_method = &DelegateInvoker; @@ -761,6 +772,15 @@ int main() "runtime collection method schema"); Require(!first->Method("get_Item"), "runtime collection schema rejects a value result"); + first->Set(1, &other_object.header); + Require(seen_self == &list_a.header && seen_int == 1 && list_a.target == &other_object.header, + "list setter passes the receiver, index and value"); + Require(seen_method && seen_method->klass == &list_a_class.klass, "list setter receives its runtime MethodInfo"); + second->Set(2, nullptr); + Require(seen_self == &list_b.header && seen_int == 2 && list_b.target == nullptr + && seen_method->klass == &list_b_class.klass, + "list setters resolve per runtime class and accept null"); + // Fully shared generic code is marshalled by the runtime. Require(base->Call<"Shared", int32_t>() == 41 && boxed_invocations == 1, "shared generic fallback"); diff --git a/tests/unity_tracker.cc b/tests/unity_tracker.cc new file mode 100644 index 000000000..cc813bfef --- /dev/null +++ b/tests/unity_tracker.cc @@ -0,0 +1,100 @@ +// Exercise the private GC callbacks without installing detours in the test process. +#include "il2cpp_test_api.h" + +#include +#include + +// No fixture path may enter the live runtime. These traps also replace Windows' GameAssembly imports. +#define DO_API(ret, name, params) \ + API(ret, name, params) \ + { \ + std::fputs("Unexpected runtime call: " #name "\n", stderr); \ + std::abort(); \ + } \ + END_API +#define DO_API_NO_RETURN(ret, name, params) DO_API(ret, name, params) +#include +#undef DO_API +#undef DO_API_NO_RETURN + +#include "../libs/unity/src/tracker.cc" + +#include +#include +#include + +namespace +{ +// Namespace storage is zero-initialized; MSVC rejects {} for this trailing-array SDK type. +Il2CppClass base_class, derived_class, leaf_class; + +struct Base : il2cpp::Object { + struct Class { + static Il2CppClass* Raw() + { return &base_class; } + }; +}; + +void Require(bool condition, const char* message) +{ + if (!condition) + throw std::runtime_error(message); +} + +void Mark(Il2CppObject* object) +{ object->klass = reinterpret_cast(reinterpret_cast(object->klass) | 1); } +} // namespace + +int main() +{ + using namespace unity::detail; + derived_class.parent = &base_class; + leaf_class.parent = &derived_class; + Base first{}, middle{}, last{}; + first.klass = last.klass = &base_class; + middle.klass = &leaf_class; + + Require(unity::Instances().empty() && !unity::Newest(), "empty registry"); + Constructed(nullptr); + Constructed(&first); + Constructed(&middle); + Constructed(&middle); // A hooked base constructor can see the same instance again. + Constructed(&last); + const std::array expected{&first, &middle, &last}; + auto snapshot = unity::Instances(); + Require(std::ranges::equal(snapshot, expected), "inherited instances keep construction order without duplicates"); + Require(Instances(&derived_class) == std::vector{&middle}, "all ancestor classes are indexed"); + Require(unity::Newest() == &last, "newest instance"); + + Destroyed(&middle); + Destroyed(&middle); + Require(std::ranges::equal(snapshot, expected), "a snapshot owns its list after registry changes"); + Require(Instances(&leaf_class).empty() && Instances(&derived_class).empty(), "destroy removes all ancestors"); + Require(Instances(&base_class) == std::vector{&first, &last}, "destroy preserves survivor order"); + Constructed(&middle); + + int state = 0; + LivenessFinalize_Hook( + [&](void* received) { + Require(received == &state, "liveness state is forwarded"); + Mark(&first); + Mark(&middle); + }, + &state); + Require(Instances(&base_class) == std::vector{&last}, "liveness removes marked objects"); + Require(Instances(&leaf_class).empty() && Instances(&derived_class).empty(), "liveness removes ancestor entries"); + Require(unity::Newest() == &last, "newest survivor"); + LivenessFinalize_Hook([](void*) {}, nullptr); + Destroyed(&middle); // OnDestroy/finalization may follow the liveness sweep, with the mark bit still set. + + bool finalized = false; + OnFinalize(&last, new ChainedFinalizer{[](void* object, void* data) { + Require(object != nullptr && Instances(&base_class).empty(), + "untrack before calling the prior finalizer"); + *static_cast(data) = true; + }, + &finalized}); + Require(finalized && !unity::Newest(), "prior finalizer runs and the last instance is removed"); + Destroyed(nullptr); + std::cout << "Unity tracker tests passed\n"; +} diff --git a/xmake.lua b/xmake.lua index 0cb8a2858..a181d344d 100644 --- a/xmake.lua +++ b/xmake.lua @@ -82,6 +82,15 @@ target("sync-logic-tests") add_rules("protobuf.cpp") add_files("mods/src/prime/proto/*.proto") +target("unity-tracker-tests") + set_kind("binary") + set_default(false) + set_exceptions("cxx") + add_files("tests/unity_tracker.cc") + add_includedirs("libs/unity/include") + add_packages("spud") + add_deps("il2cpp") + target("config-reload-tests") set_kind("binary") set_default(false)