diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 943c169b2..199061219 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 @@ -925,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 # -------------- @@ -944,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/libs/il2cpp/include/il2cpp/il2cpp.h b/libs/il2cpp/include/il2cpp/il2cpp.h index a2fc9517c..033b65455 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) @@ -627,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/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/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/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/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/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/config.cc b/mods/src/config.cc index 154869b6c..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,63 +334,80 @@ 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; -} - -// 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 -// 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() { 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; +} + +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(); - SettingsFile file; - try { - file.table = ParseSettingsFile(); - file.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); - current_config.store(config, std::memory_order_release); - return config; - }(); - return *first; + 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"; +#else + << "Loaded release version " << VERSION_MAJOR << "." << VERSION_MINOR << "." << VERSION_REVISION + << "\n\n"; +#endif + 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() { - 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 +417,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); + Shortcut::Set(std::move(next->shortcuts_)); + std::atomic_store_explicit(&sync_config, next->sync_, std::memory_order_release); + current_config = std::move(next); return true; } @@ -459,17 +467,9 @@ 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) +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); @@ -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")}, @@ -603,8 +604,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 +618,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"); @@ -634,8 +633,8 @@ 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); r.Read("ui", "disable_toast_banners", u.disable_toast_banners); r.Read("ui", "default_chat_width", u.default_chat_width); @@ -655,7 +654,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); @@ -672,23 +670,26 @@ 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"); - 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. " @@ -696,51 +697,16 @@ void Config::Load(const SettingsFile& file, bool first) } // 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); - 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 -} - -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; - } + 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 80944075a..8a3c4e21e 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -1,47 +1,28 @@ #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. +// the game thread except sync, whose workers retain immutable snapshots. + +#include "patches/parts/notification_audio.h" +#include "patches/shortcuts.h" + +#include #include #include +#include #include #include #include // 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 type_str; // used in JSON body std::string_view option_str; // used in TOML file bool SyncConfig::* option; + std::string_view old_name = {}; }; std::string proxy; @@ -65,49 +46,34 @@ struct SyncConfig { bool tech = false; 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{"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}, }; -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 { @@ -140,13 +106,10 @@ struct ShipFilter { bool all = false; }; -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. + // 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. @@ -263,34 +226,31 @@ 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 { 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; 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 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; @@ -333,18 +293,21 @@ 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 { + // 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. @@ -352,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 { @@ -360,8 +323,14 @@ 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`. 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_; + std::shared_ptr sync_; 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/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/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/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/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/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/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/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/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/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/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/mods/src/patches/parts/sync/battlelogs.cc b/mods/src/patches/parts/sync/battlelogs.cc index aa8e740ae..d6e3a271f 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); + const auto expires_at = Clock::now() + std::chrono::seconds(Config::Sync::Get()->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 @@ -167,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; @@ -175,6 +167,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 +191,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 != SessionGeneration()) + 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 +254,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 +275,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 +283,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 +301,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 +340,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 == SessionGeneration(); } 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 == 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 == 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); + 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..046c4a32e 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)}}, SessionGeneration()); + } + // 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,13 +251,13 @@ namespace records.push_back(std::move(record)); } if (!records.empty()) - Queue(SyncConfig::Type::kFleets, records); + Append(std::move(records), false); } } // 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"); @@ -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..90f785296 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) + void HavenBuildings(const pb::PlanetaryMapData& response, RecordSet& records) { - 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(); + for (const auto& [id, building] : response.buildings()) + records[id] = {{"sid", building.specid()}, + {"id", id}, + {"level", building.level()}, + {"status", building.status()}, + {"position", building.position()}}; } - - void ProcessHavenBase(const std::string& bytes) + void ProcessHavenMap(const Input& input) { - auto response = Parse(bytes, "planetary base"); - if (!response) + pb::PlanetaryMapData response; + if (!response.ParseFromString(input.bytes)) { + spdlog::error("Failed to parse {}", response.GetTypeName()); 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); - } - - void ProcessHavenMap(const std::string& bytes) - { - auto response = Parse(bytes, "planetary map"); - if (!response) - 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) + if (!Config::Sync::Get()->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) - { - 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) + void ProcessCompletedMissions(const Input& input) { - 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,140 @@ 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(); - } - - void ProcessEntitySlotsData(const std::string& bytes) - { - 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& slot : response.entityslots_()) + if (auto record = Slot(slot)) + records[slot.id()] = std::move(*record); + }); } - - void ProcessForbiddenTech(const std::string& bytes) + void ProcessEntitySlotsData(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::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 ProcessOfficerTraits(const std::string& bytes) + void ProcessForbiddenTech(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::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()}}; + }); } - - // 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) + void ProcessOfficerTraits(const Input& input) { - 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); + 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()}}; + }); } - - // The consumable a bundle sells. Faction store offers are favor levels, sold as consumables. - const content::ContentItem* OfferConsumable(const content::BundleContent& bundle) + json Ship(int64_t id, int64_t hull, int32_t tier, int32_t level, double progress, json components) { - for (const auto* list : {&bundle.static_(), &bundle.showcase(), &bundle.gifts()}) - for (const auto& item : list->items()) - if (item.type() == "consumable") - return &item; - return nullptr; + return {{"psid", id}, + {"hull_id", hull}, + {"tier", tier}, + {"level", level}, + {"level_percentage", progress}, + {"components", std::move(components)}}; } - - void AddShip(Batch& batch, int64_t id, int64_t hull_id, const ShipState& state) + void ProcessShips(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}}); + 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 ProcessShips(const std::string& bytes) + void ProcessJson(const Input& input) { - auto response = Parse(bytes, "player ships"); - if (!response) + 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"); 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 +590,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 +803,48 @@ 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; + 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; + 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..b93db361c --- /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 { + 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::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 +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_.*spec.enabled) || 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..79fe63ab5 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 == SessionGeneration()) + 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::Sync::Get()->options.profile; + if (!process && !own_profile) + return; + Input input{SessionGeneration(), + 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, + {SessionGeneration(), + 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, {SessionGeneration(), il2cpp::ToUtf8(realtimeDataPayload->Data())}}); } return original(_this, centrifugoInfo, realtimeDataPayload, finalPayload); } @@ -111,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); @@ -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..faaf0ef1f 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,50 +10,51 @@ #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 // 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 @@ -73,11 +70,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 +85,7 @@ template class BoundedQueue items_.push_back(std::move(item)); } ready_.notify_one(); + return dropped; } // Blocks until an item is available. @@ -107,52 +107,152 @@ template class BoundedQueue std::deque items_; }; -// upload.cc +// 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; -// 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); +// Full lists establish the initial-sync marker; Replace also prunes entities absent from the list. +enum class Update { kMerge, kFull, kReplace }; -// records.cc. Processing thread only. +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; +}; -// 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. +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 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(); +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); +// 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); +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..e5b8dcac3 100644 --- a/mods/src/patches/parts/sync/upload.cc +++ b/mods/src/patches/parts/sync/upload.cc @@ -1,166 +1,179 @@ -// 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 #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; + struct Owner { + 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(std::string name, const SyncTargetConfig& config) - : name_(std::move(name)) - , config_(config) - { - Configure(); - std::thread(&Target::Run, this).detach(); - } - - void Push(Request request) - { requests_.Push(std::move(request)); } - - 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}); + 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 - // 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}); + 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() - { - for (;;) { - auto request = requests_.Pop(); - FollowSettings(); - const auto label = fmt::format("{} ({})", name_, to_string(request.type)); - 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 (!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; } + 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"); + } + Owner& Sync() + { + static auto& owner = *new Owner; + return owner; + } +} // namespace +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 std::shared_ptr applied; + const auto config = Config::Sync::Get(); + if (applied == config) + return; + auto& owner = Sync(); + { + std::scoped_lock lock(owner.mutex); + 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 { - headers.erase("X-PRIME-SYNC"); + 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)); } - 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}; - }; - - // 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) - { - 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; } -} // namespace - -void Queue(SyncConfig::Type type, const nlohmann::json& records, bool first_sync) + 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(); + 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->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/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/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/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..2a7ba3bf3 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, @@ -179,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/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/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/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/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/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">; 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">; 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/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/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/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/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/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/tests/il2cpp_bindings.cc b/tests/il2cpp_bindings.cc index c1da93b59..49e4dad12 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{}; @@ -191,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); } @@ -248,14 +352,35 @@ 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; 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 +496,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*)) @@ -626,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"); @@ -685,5 +840,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"; } diff --git a/tests/mod_logic.cc b/tests/mod_logic.cc index d23dc00a8..b3623fa83 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -1,16 +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/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 @@ -35,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; } @@ -46,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) { @@ -75,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"); } @@ -110,6 +116,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"); @@ -144,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"( @@ -171,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); @@ -180,6 +227,229 @@ 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, "the example documents fleet sync, off by default"); +} + +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 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 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" @@ -208,7 +478,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"); @@ -260,7 +530,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/tests/sync_logic.cc b/tests/sync_logic.cc new file mode 100644 index 000000000..b3ea2aa30 --- /dev/null +++ b/tests/sync_logic.cc @@ -0,0 +1,659 @@ +// Production encoders against independent wire fixtures, and delivery against controlled HTTP outcomes/time. +#include "patches/parts/sync/sync.h" +#include +#include +#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; +auto settings = std::make_shared(); +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 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(); + 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->options.fleets = false; + Process(EntityGroup::Kind::Jobs, jobs); + settings->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 +std::shared_ptr Config::Sync::Get() +{ return settings; } + +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->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); + run("logging", SyncLogging); + if (!failures) + std::cout << "Sync wire and delivery tests passed\n"; + return failures ? 1 : 0; +} 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 8ff4f35a4..a181d344d 100644 --- a/xmake.lua +++ b/xmake.lua @@ -57,6 +57,50 @@ 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/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"), "/") .. '"') + add_defines('STFC_SHORTCUT_DEFAULTS="' .. path.translate(path.absolute("tests/shortcut_defaults.toml"), "/") .. '"') + +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") + +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) + 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 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")