From 6124abc6dd5c713c1dcf0e2e5c59a5940e08cb9c Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 15:00:38 -0700 Subject: [PATCH 01/13] Read settings section by section Each TOML section is read together through one validation helper that keeps the default on bad input. Rejected, renamed and retired values are reported in one format, and the startup dump of effective settings to stdout is gone. Notification cue names parse like other setting choices, so the settings header no longer depends on the playback feature. --- .../example_community_patch_settings_en.toml | 48 +- src/mod/config.cc | 648 ++++++++++-------- src/mod/config.h | 179 +++-- src/mod/features/notification_audio.cc | 117 ++-- src/mod/features/notification_audio.h | 30 +- src/mod/features/notification_audio_mac.mm | 8 +- src/mod/features/toasts.cc | 3 +- tests/config_reload.cc | 5 +- tests/mod_logic.cc | 19 +- 9 files changed, 547 insertions(+), 510 deletions(-) diff --git a/docs/configuration/example_community_patch_settings_en.toml b/docs/configuration/example_community_patch_settings_en.toml index 199061219..cf692d3a3 100644 --- a/docs/configuration/example_community_patch_settings_en.toml +++ b/docs/configuration/example_community_patch_settings_en.toml @@ -63,7 +63,7 @@ trace_events = false # <[=======================================]> [buffs] -# This is the option to always show out of dock power for ships when they are docked. Defaults to false +# This is the option to always show out of dock power for ships when they are docked. Defaults to true use_out_of_dock_power = true # <[===============================================================================================]> @@ -139,10 +139,10 @@ enable_experimental = false # Subgroup: Display # ----------------- -# Frame-rate scaling factor +# Scale of the flat planet sprites in System View; 1.0 keeps the game's size fr_scale = 2.0 -# Amount of time to spend between transitions, increase to slow transitions down +# Amount of time to spend between transitions. Currently read but not applied transition_time = 0.01 # This specifies the amount to scale the UI by, 1.0 being default @@ -186,7 +186,7 @@ zoom_label_player_threshold = 0.5 # This controls the zoom speed when using Q/E in System View, defaults to 350 keyboard_zoom_speed = 350.0 -# Controls the system pan momentum speed. Defaults to 0.4 +# Controls the system pan momentum speed. Defaults to 0.4. Read with enable_experimental, but not applied yet system_pan_momentum = 0.4 # Controls how quickly the momentum falls off when letting go of the mouse. Lower = quicker stop. Defaults to 0.8 @@ -219,7 +219,7 @@ loader_transition_black = false # Allow the system cursor to be replaced? (On by default) allow_cursor = true -# Allow borderless full screen mode, set to false to disable +# Allow borderless full screen mode. Currently has no effect borderless_fullscreen = true # Keep the system camera free when selecting a target. Requires mod 1.1.10 or newer. @@ -363,19 +363,19 @@ zoomhooks = true # Subgroup: Cargo & Locate # ------------------------ -# Whether to show armada cargo by default +# Toggle show_armada_cargo toggle_cargo_armada = "ALT-5" -# Whether to show cargo by default +# Toggle show_cargo_default toggle_cargo_default = "ALT-1" -# Whether to show Hostile cargo by default +# Toggle show_hostile_cargo toggle_cargo_hostile = "ALT-4" -# Whether to show Player cargo by default +# Toggle show_player_cargo toggle_cargo_player = "ALT-2" -# Whether to show Station cargo by default +# Toggle show_station_cargo toggle_cargo_station = "ALT-3" # Cycle instant-warp auto-confirm between none, warp, and jump @@ -800,7 +800,7 @@ show_player_title = true # Subgroup: Chest Purchases # ------------------------- -# Maximum for tagged chest-purchase sliders on Windows; 0 disables the feature and native limits above this value are preserved +# Maximum for chest-purchase sliders, up to 160; 0 disables the feature and native limits above this value are preserved extend_chest_purchase_max = 160 # Subgroup: Daily Goals @@ -839,28 +839,28 @@ pinned_ships = "" # Subgroup: HUD # ------------- -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_field_training = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_missions = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_outposts = "auto" -# HUD visibility mode: auto, show, or hide +# HUD visibility mode: auto, always, or never # "auto" - Use the game's default visibility behaviour -# "show" - Always show this HUD item -# "hide" - Always hide this HUD item +# "always" - Always show this HUD item +# "never" - Always hide this HUD item hud_q_trials = "auto" # Subgroup: Options @@ -1004,7 +1004,7 @@ proxy = "" # Subgroup: Network # ----------------- -# How long to keep cached items (ms) +# How long to keep resolved player and alliance names, in seconds resolver_cache_ttl = 300 # Should SSL Certificates be checked? diff --git a/src/mod/config.cc b/src/mod/config.cc index 6bc85d870..f445b2c84 100644 --- a/src/mod/config.cc +++ b/src/mod/config.cc @@ -1,9 +1,11 @@ +// Owns reading the settings file into Config: defaults, validation, renamed and retired keys, the runtime vars +// file, and reloading when the file changes. + #include "config.h" #include "file.h" #include "shortcuts.h" #include "text.h" -#include "version.h" #include "game/ui.h" @@ -15,16 +17,24 @@ #include #include #include +#include #include -#include #include #include -#include +#include +#include namespace { +// --- Catalogs --- + +template struct Choice { + std::string_view name; + E value; +}; + // Names of ToastState values as written in disabled_banner_types and notify_banner_types. -constexpr std::pair kBannerTypes[] = { +constexpr Choice kBannerTypes[] = { {"Standard", ToastState::Standard}, {"FactionWarning", ToastState::FactionWarning}, {"FactionLevelUp", ToastState::FactionLevelUp}, @@ -66,12 +76,8 @@ constexpr std::pair kBannerTypes[] = { {"SurgeTimeLeft", ToastState::SurgeTimeLeft}, }; -template struct Choice { - std::string_view name; - E value; -}; - -// The first name listed for a value is the one written back; later ones are accepted synonyms. +// For the enum settings, the first name listed for a value is the one written back; later ones are accepted +// synonyms. The empty name keeps a blank setting at the default without a warning. constexpr Choice kHudVisibility[] = { {"auto", MissionHudVisibility::kAuto}, {"always", MissionHudVisibility::kAlways}, @@ -94,95 +100,148 @@ constexpr Choice kFleetLabelDetail[] = { {"", FleetLabelDetail::kNative}, }; -std::string MaskToken(const std::string& token) +// In NotificationSound order, so the canonical names come first. +constexpr Choice kNotificationSounds[] = { + {"none", NotificationSound::kNone}, {"default", NotificationSound::kDefault}, + {"info", NotificationSound::kInfo}, {"success", NotificationSound::kSuccess}, + {"warning", NotificationSound::kWarning}, {"alarm", NotificationSound::kAlarm}, + {"arrival", NotificationSound::kArrival}, {"soft", NotificationSound::kSoft}, + {"ping", NotificationSound::kPing}, {"repair", NotificationSound::kRepair}, + {"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 std::string_view kKnownFactions[] = {"federation", "klingon", "romulan"}; + +// --- Reading --- + +// The text of a setting as written, for messages about values that were rejected. +std::string Raw(toml::node_view node) { - if (token.size() <= 21) - return token; - auto masked = token; - for (size_t i = 9; i < token.size() - 12; ++i) - if (masked[i] != '-') - masked[i] = '*'; - return masked; + if (auto text = node.value()) + return *text; + std::ostringstream out; + out << node; + return out.str(); } -// Reads settings from the user's file and records every effective value in `parsed`, which is written out as the -// runtime vars file so users can see what the mod actually used. +// Tokens are shown with their first 9 and last 12 characters, enough to tell two apart in a log. +std::string MaskToken(std::string token) +{ + if (token.size() > 21) + std::ranges::replace_if(token.begin() + 9, token.end() - 12, [](char c) { return c != '-'; }, '*'); + return token; +} + +// Reads settings from the user's file and records every value used in `effective`, which is written out as the +// runtime vars file so users can see what the mod actually used. A missing or rejected value falls back to the +// default, which is the member's current value; every rejection logs one warning. class Reader { public: - Reader(const toml::table& config, toml::table& parsed, bool log) - : config_(config) - , parsed_(parsed) + Reader(const toml::table& file, toml::table& effective, bool log) + : file_(file) + , effective_(effective) , log_(log) { } - const toml::table& Config() const - { return config_; } + // The value as written, empty when the file does not set it. + toml::node_view Node(std::string_view section, std::string_view key) const + { return file_[section][key]; } - toml::table& Section(std::string_view name) - { return *parsed_.emplace(name).first->second.as_table(); } + // The section of the vars file, created on first use. + toml::table& EffectiveSection(std::string_view section) + { return *effective_.emplace(section).first->second.as_table(); } // The configured value converted to T, or `fallback` when missing or of the wrong type. TOML integers read fine // into float settings; fractions into int settings do not. template T Get(std::string_view section, std::string_view key, T fallback) { - T value = fallback; - auto node = config_[section][key]; - if (auto read = node.template value()) - value = *read; - else if (node) - spdlog::warn("invalid config value {}.{}; using {}", section, key, fallback); + auto value = Convert(section, key, fallback); Record(section, key, value); return value; } - // Reads `key` into `value`, whose current value (the member's initializer) is the default. template void Read(std::string_view section, std::string_view key, T& value) { value = Get(section, key, value); } - // A setting that was renamed: `key`, or `old_key` when only the old name is set. The vars file records `key`. + // Read, keeping the default when `valid` rejects the configured value. + template + void ReadValid(std::string_view section, std::string_view key, T& value, Valid valid) + { + const auto fallback = value; + Read(section, key, value); + if (valid(value)) + return; + Invalid(section, key, value, fallback); + value = fallback; + Record(section, key, value); + } + + // A renamed setting: `key`, or `old_key` when only the old name is set. The vars file records `key`. template void ReadRenamed(std::string_view section, std::string_view key, std::string_view old_key, T& value) { - if (config_[section][key] || !config_[section][old_key]) + if (Node(section, key) || !Node(section, old_key)) return Read(section, key, value); - if (auto read = config_[section][old_key].template value()) - value = *read; - else - spdlog::warn("invalid config value {}.{}; using {}", section, old_key, value); + spdlog::warn("deprecated config value {}.{}; use {}.{}", section, old_key, section, key); + value = Convert(section, old_key, value); Record(section, key, value); } - // A case-insensitive choice from a fixed list of names, read into `value`, whose current value is the default. + // A case-insensitive choice from `choices`. The vars file records the canonical name. template void Choose(std::string_view section, std::string_view key, E& value, const Choice (&choices)[N]) { - const auto fallback = Name(value, choices); - const auto raw = Get(section, key, std::string(fallback)); - const auto normalized = AsciiStrToLower(StripAsciiWhitespace(raw)); - const auto found = std::ranges::find(choices, normalized, &Choice::name); + const auto fallback = CanonicalName(value, choices); + const auto raw = Get(section, key, std::string(fallback)); + const auto found = std::ranges::find(choices, AsciiStrToLower(StripAsciiWhitespace(raw)), &Choice::name); if (found == std::end(choices)) - spdlog::warn("invalid config value {}.{}: '{}'; using {}", section, key, raw, fallback); + Invalid(section, key, raw, fallback); else value = found->value; - Record(section, key, std::string(Name(value, choices))); + Record(section, key, std::string(CanonicalName(value, choices))); + } + + // A setting that no longer exists, at a dotted path. Says what replaced it when the file still sets it. + void Retired(std::string_view path, std::string_view replacement) + { + if (file_.at_path(path)) + spdlog::warn("retired config value {}; {}", path, replacement); } + // Sets the value the vars file shows. A later Record of the same key replaces it. template void Record(std::string_view section, std::string_view key, const T& value) { - Section(section).insert_or_assign(key, value); + EffectiveSection(section).insert_or_assign(key, value); if (log_) - spdlog::debug("config value {}.{} value: {}", section, key, value); + spdlog::debug("config {}.{} = {}", section, key, value); } private: - // The canonical name: the first one listed for the value. - template static std::string_view Name(E value, const Choice (&choices)[N]) + // The value converted to T, or `fallback` when missing or, after a warning, of the wrong type. + template T Convert(std::string_view section, std::string_view key, const T& fallback) const + { + const auto node = Node(section, key); + if (auto read = node.template value()) + return *read; + if (node) + Invalid(section, key, Raw(node), fallback); + return fallback; + } + + template + static void Invalid(std::string_view section, std::string_view key, const R& raw, const F& fallback) + { spdlog::warn("invalid config value {}.{}: '{}'; using {}", section, key, raw, fallback); } + + template static std::string_view CanonicalName(E value, const Choice (&choices)[N]) { return std::ranges::find(choices, value, &Choice::value)->name; } - const toml::table& config_; - toml::table& parsed_; + const toml::table& file_; + toml::table& effective_; bool log_; }; @@ -190,54 +249,53 @@ class Reader ShipFilter ReadShipFilter(Reader& reader, std::string_view key) { const auto value = reader.Get("ui", key, ""); + if (StripAsciiWhitespace(value) == "*") + return {.all = true}; ShipFilter filter; - filter.all = StripAsciiWhitespace(value) == "*"; - if (!filter.all) - for (const auto& token : StrSplit(value, ',')) - if (auto words = ShipNameWords(token); !words.empty()) - filter.names.push_back(std::move(words)); + for (const auto& token : StrSplit(value, ',')) + if (auto words = ShipNameWords(token); !words.empty()) + filter.names.push_back(std::move(words)); return filter; } +// A comma list of kKnownFactions, or "none", which wins over the others. Blank means all of them. std::vector ReadFactions(Reader& reader) { - static constexpr std::string_view kKnownFactions[] = {"federation", "klingon", "romulan"}; - - std::vector factions; - auto value = reader.Get("ui", "daily_bulk_claim_factions", "federation,klingon,romulan"); + const auto all = kKnownFactions | std::views::join_with(',') | std::ranges::to(); + auto value = reader.Get("ui", "daily_bulk_claim_factions", all); if (StripAsciiWhitespace(value).empty()) { - value = "federation,klingon,romulan"; - reader.Section("ui").insert_or_assign("daily_bulk_claim_factions", value); + value = all; + // Replaces the blank that Get recorded, without a second debug line. + reader.EffectiveSection("ui").insert_or_assign("daily_bulk_claim_factions", value); } - bool none = false; + + std::vector factions; + bool none = false; for (const auto& token : StrSplit(value, ',')) { auto faction = AsciiStrToLower(token); - if (faction == "none") { + if (faction == "none") none = true; - continue; - } - if (std::ranges::find(kKnownFactions, faction) == std::end(kKnownFactions)) { - spdlog::warn("Unrecognised faction '{}' in daily_bulk_claim_factions; expected one of: federation, klingon, " - "romulan, or none by itself. Ignoring.", - token); - } else if (std::ranges::find(factions, faction) == factions.end()) { + else if (!std::ranges::contains(kKnownFactions, faction)) + spdlog::warn("invalid config value ui.daily_bulk_claim_factions: '{}'; ignoring it (expected {} or none)", token, + all); + else if (!std::ranges::contains(factions, faction)) factions.push_back(std::move(faction)); - } } - if (none) { - if (!factions.empty()) - spdlog::warn("daily_bulk_claim_factions: none overrides named factions; use none by itself."); - factions = {"none"}; - } - return factions; + if (!none) + return factions; + if (!factions.empty()) + spdlog::warn("invalid config value ui.daily_bulk_claim_factions: '{}'; using none, which overrides the others", + value); + return {"none"}; } -// A comma list of banner names, or "All". The vars file gets the names that matched. +// A case-insensitive comma list of kBannerTypes names, or "All". The vars file gets the names that matched, in +// catalog order. std::vector ReadBannerTypes(Reader& reader, std::string_view key) { - std::vector wanted; - for (const auto& token : StrSplit(reader.Get("ui", key, ""), ',')) - wanted.push_back(AsciiStrToUpper(token)); + auto wanted = StrSplit(reader.Get("ui", key, ""), ',') | std::views::transform(AsciiStrToUpper) + | std::ranges::to(); + // With "All", names after it are still reported as unknown. const bool all = std::erase(wanted, "ALL") > 0; std::vector types; @@ -245,22 +303,24 @@ std::vector ReadBannerTypes(Reader& reader, std::string_view key) for (const auto& [name, state] : kBannerTypes) { if (!all && std::erase(wanted, AsciiStrToUpper(name)) == 0) continue; - types.push_back(static_cast(state)); + types.push_back(std::to_underlying(state)); names += names.empty() ? "" : ", "; names += name; } for (const auto& unknown : wanted) - spdlog::warn("Unknown banner type '{}' in ui.{}", unknown, key); + spdlog::warn("invalid config value ui.{}: '{}'; ignoring it", key, unknown); reader.Record("ui", key, names); return types; } +// [sync.targets.] tables. Each target takes its proxy, verify_ssl and categories from [sync] unless it sets +// its own. Unlike [sync], a target's renamed category keys are read without a deprecation warning. void ReadSyncTargets(Reader& reader, std::map& sync_targets, const SyncConfig& defaults) { // The built-in targets are listed in the vars file as examples; they only sync once given a token. - auto& parsed_targets = - *reader.Section("sync") + auto& effective_targets = + *reader.EffectiveSection("sync") .insert_or_assign( "targets", toml::table{ @@ -270,7 +330,7 @@ void ReadSyncTargets(Reader& reader, std::map& sy }) .first->second.as_table(); - const auto* targets = reader.Config()["sync"]["targets"].as_table(); + const auto* targets = reader.Node("sync", "targets").as_table(); if (!targets) return; @@ -280,84 +340,101 @@ void ReadSyncTargets(Reader& reader, std::map& sy continue; const auto section = "sync.targets." + std::string(name.str()); - if (!values->contains("url") || !values->contains("token")) { - spdlog::warn("Skipping invalid target [{}]. Missing url or token.", section); + const auto url = (*values)["url"].value(); + const auto token = (*values)["token"].value(); + if (!url || !token) { + spdlog::warn("invalid config value {}: url and token are required; skipping the target", section); continue; } - const auto url = (*values)["url"].value(); - const auto token = (*values)["token"].value(); - if (!url || !token || url->empty() || token->empty()) + // Blank values are the example file's placeholders for a target the user has not set up. + if (url->empty() || token->empty()) continue; - SyncTargetConfig target; - target.url = *url; - target.token = *token; - target.proxy = (*values)["proxy"].value().value_or(defaults.proxy); - target.verify_ssl = (*values)["verify_ssl"].value().value_or(defaults.verify_ssl); + SyncTargetConfig target{defaults, *url, *token}; + target.proxy = (*values)["proxy"].value().value_or(target.proxy); + target.verify_ssl = (*values)["verify_ssl"].value().value_or(target.verify_ssl); - toml::table parsed{ + toml::table effective{ {"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) && !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); + for (const auto& [key, member, old_name] : SyncOptions) { + const auto read_as = values->contains(key) || old_name.empty() ? key : old_name; + target.*member = (*values)[read_as].value().value_or(target.*member); + effective.insert(key, target.*member); } - if (sync_targets.emplace(name.str(), target).second) { - parsed_targets.insert_or_assign(name.str(), std::move(parsed)); - spdlog::debug("config value {} url: {}, token: {}", section, target.url, MaskToken(target.token)); - spdlog::info("target [{}] proxy: '{}', verify_ssl: {}", section, target.proxy, target.verify_ssl); - } + // TOML table keys are unique, so every target is new here. + effective_targets.insert_or_assign(name.str(), std::move(effective)); + spdlog::debug("config {} = url {}, token {}, proxy '{}', verify_ssl {}", section, target.url, + MaskToken(target.token), target.proxy, target.verify_ssl); + sync_targets.emplace(name.str(), std::move(target)); } } -void Save(const toml::table& table, const std::string& name, bool warning_header) -{ - std::ofstream file(File::Path(name, true)); - if (warning_header) { - file << "#######################################################################\n"; - file << "#######################################################################\n"; - file << "#### ####\n"; - file << "#### NOTE: This file is not the configuration file that is used ####\n"; - file << "#### by the STFC Community Mod. It is provided to help ####\n"; - file << "#### see what configuration is being used by the runtime ####\n"; - file << "#### and any desired settings should be copied to the same ####\n"; - file << "#### section in: " << File::DefaultConfig() << "\n"; - file << "#### ####\n"; - file << "#### Config in: " << File::Config() << "\n"; - file << "#### ####\n"; - file << "#######################################################################\n"; - file << "#######################################################################\n\n"; - } - file << table; -} -} // namespace +// --- Files and activation --- -namespace -{ 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() +// The settings file, or nullopt after a warning that says why it could not be read and what happens `instead`. +std::optional ReadSettingsFile(std::string_view instead) +{ + try { + // The toml++ library is built without its char8_t overloads, so the UTF-8 path goes in as chars. + const auto path = File::Path(File::Config()).u8string(); + return toml::parse_file(std::string_view(reinterpret_cast(path.data()), path.size())); + } catch (const toml::parse_error& e) { + spdlog::warn("could not parse {}: {}; {}", File::Config(), e.description(), instead); + } catch (const std::exception& e) { + spdlog::warn("could not read {}: {}; {}", File::Config(), e.what(), instead); + } + return std::nullopt; +} + +// Heads the runtime vars file; the placeholders are File::DefaultConfig() and File::Config(). +constexpr std::string_view kVarsHeader = R"(####################################################################### +####################################################################### +#### #### +#### NOTE: This file is not the configuration file that is used #### +#### by the STFC Community Mod. It is provided to help #### +#### see what configuration is being used by the runtime #### +#### and any desired settings should be copied to the same #### +#### section in: {} +#### #### +#### Config in: {} +#### #### +####################################################################### +####################################################################### + +)"; + +void Save(const toml::table& table, const std::string& name, bool warning_header) { - const auto path = File::Path(File::Config()).u8string(); - return toml::parse_file(std::string_view(reinterpret_cast(path.data()), path.size())); + std::ofstream file(File::Path(name, true)); + if (warning_header) + file << std::format(kVarsHeader, File::DefaultConfig(), File::Config()); + file << table; } -// 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) +// Writes `effective` as the runtime vars file. At startup it also becomes the settings file when there is none, so +// a new user starts from a complete list of settings. +void SaveEffective(const toml::table& effective, bool startup) { - if (first && !std::filesystem::exists(File::Path(File::Config()))) { - spdlog::warn("Creating {} (default config file)", File::Config()); + if (startup && !std::filesystem::exists(File::Path(File::Config()))) { + spdlog::info("creating {} with the default settings", File::Config()); Save(effective, File::Config(), false); } - spdlog::info("Creating {} (final config file)", File::Vars()); + spdlog::info("writing the settings in effect to {}", File::Vars()); Save(effective, File::Vars(), true); } } // namespace +std::string_view NotificationSoundName(NotificationSound sound) +{ + const auto found = std::ranges::find(kNotificationSounds, sound, &Choice::value); + return found == std::end(kNotificationSounds) ? "none" : found->name; +} + std::unique_ptr Config::Parse(const toml::table& file, toml::table& effective, bool log_values) { std::unique_ptr config(new Config); @@ -368,71 +445,50 @@ std::unique_ptr Config::Parse(const toml::table& file, toml::table& effe std::shared_ptr Config::Sync::Get() { return std::atomic_load_explicit(&sync_config, std::memory_order_acquire); } +void Config::Activate(std::unique_ptr next) +{ + Shortcut::Set(std::move(next->shortcuts_)); + std::atomic_store_explicit(&sync_config, next->sync_, std::memory_order_release); + current_config = std::move(next); +} + Config& Config::Get() { - // First use, inside il2cpp_init on the game thread. A broken file falls back to the defaults here. - 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; - }(); + // First use is inside il2cpp_init on the game thread. A file that cannot be read gives the defaults; values are + // only logged when they came from the file. + if (!current_config) { + File::MigrateLegacyFiles(); + auto file = ReadSettingsFile("using the default settings"); + const bool loaded = file.has_value(); + toml::table effective; + auto config = Parse(std::move(file).value_or(toml::table{}), effective, loaded); + SaveEffective(effective, true); + Activate(std::move(config)); + } return *current_config; } bool Config::Reload() { - toml::table file; - try { - file = ParseSettingsFile(); - } catch (const toml::parse_error& e) { - spdlog::warn("Settings not reloaded, {} does not parse: {}", File::Config(), e.description()); - return false; - } catch (const std::exception& e) { - spdlog::warn("Settings not reloaded, {} could not be read: {}", File::Config(), e.what()); + const auto file = ReadSettingsFile("keeping the current settings"); + if (!file) return false; - } - spdlog::info("Reloading settings from {}", File::Config()); + spdlog::info("reloading settings from {}", File::Config()); std::unique_ptr next; toml::table effective; try { - next = Parse(file, effective, true); + next = Parse(*file, effective, true); SaveEffective(effective, false); } catch (const std::exception& e) { - spdlog::error("Settings not reloaded: {}", e.what()); + spdlog::error("could not apply {}: {}; keeping the current settings", File::Config(), e.what()); 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"); + if (next->patches != Get().patches) + spdlog::warn("changes to [patches] take effect after a restart"); next->patches = Get().patches; - Shortcut::Set(std::move(next->shortcuts_)); - std::atomic_store_explicit(&sync_config, next->sync_, std::memory_order_release); - current_config = std::move(next); + Activate(std::move(next)); return true; } @@ -453,6 +509,7 @@ void Config::ReloadIfChanged() const auto time = std::filesystem::last_write_time(File::Path(File::Config()), error); if (error) return; + // The first check only learns the time of the file Get already read. if (!applied || time == *applied) { applied = time; pending.reset(); @@ -467,72 +524,64 @@ void Config::ReloadIfChanged() Reload(); } -void Config::Read(const toml::table& config, toml::table& parsed, bool log_values) +void Config::Read(const toml::table& file, toml::table& effective, bool log_values) { - 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); - graphics.interior_zoom_scale = 2.0f; - r.Record("graphics", "interior_zoom_scale", graphics.interior_zoom_scale); - } + Reader r(file, effective, log_values); + + // [patches]. Reload keeps the startup values, since hooks are only installed once. + constexpr std::pair kPatches[] = { + {"actionqueuerecoveryhooks", &Patches::actionqueuerecoveryhooks}, + {"alliancegamesstorehooks", &Patches::alliancegamesstorehooks}, + {"artifactexchangehooks", &Patches::artifactexchangehooks}, + {"audioeventhooks", &Patches::audioeventhooks}, + {"bufffixhooks", &Patches::bufffixhooks}, + {"cargoformathooks", &Patches::cargoformathooks}, + {"chatpatches", &Patches::chatpatches}, + {"chattitledisplayhooks", &Patches::chattitledisplayhooks}, + {"dailyfactionbulkclaimhooks", &Patches::dailyfactionbulkclaimhooks}, + {"doubleclickassignshiphooks", &Patches::doubleclickassignshiphooks}, + {"forbiddentechconfirmhooks", &Patches::forbiddentechconfirmhooks}, + {"forbiddentechshipnamehooks", &Patches::forbiddentechshipnamehooks}, + {"freeresizehooks", &Patches::freeresizehooks}, + {"galacticanomalytimerhooks", &Patches::galacticanomalytimerhooks}, + {"game_version", &Patches::game_version}, + {"giftsbulkclaimhooks", &Patches::giftsbulkclaimhooks}, + {"havenhistoryhooks", &Patches::havenhistoryhooks}, + {"havenzoomhooks", &Patches::havenzoomhooks}, + {"hotkeyhooks", &Patches::hotkeyhooks}, + {"instantcargocounterhooks", &Patches::instantcargocounterhooks}, + {"interiorzoomhooks", &Patches::interiorzoomhooks}, + {"loadingscreenhooks", &Patches::loadingscreenhooks}, + {"loadingtiphooks", &Patches::loadingtiphooks}, + {"miscpatches", &Patches::miscpatches}, + {"missionhudtweakshooks", &Patches::missionhudtweakshooks}, + {"objecttracker", &Patches::objecttracker}, + {"officersorthooks", &Patches::officersorthooks}, + {"panhooks", &Patches::panhooks}, + {"pinnedshipclicktoggle", &Patches::pinnedshipclicktoggle}, + {"pinnedshiphooks", &Patches::pinnedshiphooks}, + {"queueaddressguardhooks", &Patches::queueaddressguardhooks}, + {"revealconfirmhooks", &Patches::revealconfirmhooks}, + {"syncpatches", &Patches::syncpatches}, + {"tempcrashfixes", &Patches::tempcrashfixes}, + {"testpatches", &Patches::testpatches}, + {"thinqueueprotectionhooks", &Patches::thinqueueprotectionhooks}, + {"toastbannerhooks", &Patches::toastbannerhooks}, + {"transitionscreenhooks", &Patches::transitionscreenhooks}, + {"uiscalehooks", &Patches::uiscalehooks}, + {"zoomhooks", &Patches::zoomhooks}, + }; + for (const auto& [key, member] : kPatches) + r.Read("patches", key, patches.*member); + + // [control] comes next: enable_experimental and hotkeys_extended decide which other settings are read. r.Read("control", "kirshara_fixes", control.kirshara_fixes); - if (!config["control"]["kirshara_fixes"] - && (config["control"]["faster_queue_recovery"] || config["control"]["thin_queue_protection"] - || config["control"]["queue_address_guard"] || config["advanced"]["queue"]["thin_queue_protection"])) - spdlog::warn("The separate queue feature settings have been replaced by [control] kirshara_fixes (default=true). " - "Set kirshara_fixes=false to disable the combined fixes; old feature settings are ignored."); + // Once kirshara_fixes is set, leftover queue keys are not worth a warning. + if (!r.Node("control", "kirshara_fixes")) + for (const auto path : {"control.faster_queue_recovery", "control.thin_queue_protection", + "control.queue_address_guard", "advanced.queue.thin_queue_protection"}) + r.Retired(path, "control.kirshara_fixes switches all the queue fixes"); r.Read("control", "primary_requires_visible_target", control.primary_requires_visible_target); - r.Read("ui", "show_player_title", ui.show_player_title); - r.Read("ui", "auto_confirm_reveal_ability", ui.auto_confirm_reveal_ability); - r.Read("ui", "dock_syncmanagement", ui.dock_syncmanagement); - r.Read("patches", "interiorzoomhooks", patches.interiorzoomhooks); - r.Read("patches", "chattitledisplayhooks", patches.chattitledisplayhooks); - r.Read("patches", "revealconfirmhooks", patches.revealconfirmhooks); - r.Read("patches", "actionqueuerecoveryhooks", patches.actionqueuerecoveryhooks); - r.Read("patches", "queueaddressguardhooks", patches.queueaddressguardhooks); - r.Read("patches", "thinqueueprotectionhooks", patches.thinqueueprotectionhooks); - r.Read("patches", "pinnedshipclicktoggle", patches.pinnedshipclicktoggle); - - // [patches] toggles are read in every build: they let users disable hook groups that collide with BepInEx - // plugins hooking the same IL2CPP methods (a native and a managed detour on one prologue crash). - for (auto [key, value] : std::initializer_list>{ - {"uiscalehooks", &patches.uiscalehooks}, - {"zoomhooks", &patches.zoomhooks}, - {"havenzoomhooks", &patches.havenzoomhooks}, - {"havenhistoryhooks", &patches.havenhistoryhooks}, - {"alliancegamesstorehooks", &patches.alliancegamesstorehooks}, - {"bufffixhooks", &patches.bufffixhooks}, - {"toastbannerhooks", &patches.toastbannerhooks}, - {"panhooks", &patches.panhooks}, - {"hotkeyhooks", &patches.hotkeyhooks}, - {"freeresizehooks", &patches.freeresizehooks}, - {"tempcrashfixes", &patches.tempcrashfixes}, - {"testpatches", &patches.testpatches}, - {"miscpatches", &patches.miscpatches}, - {"missionhudtweakshooks", &patches.missionhudtweakshooks}, - {"chatpatches", &patches.chatpatches}, - {"syncpatches", &patches.syncpatches}, - {"game_version", &patches.game_version}, - {"objecttracker", &patches.objecttracker}, - {"loadingscreenhooks", &patches.loadingscreenhooks}, - {"transitionscreenhooks", &patches.transitionscreenhooks}, - {"giftsbulkclaimhooks", &patches.giftsbulkclaimhooks}, - {"dailyfactionbulkclaimhooks", &patches.dailyfactionbulkclaimhooks}, - {"galacticanomalytimerhooks", &patches.galacticanomalytimerhooks}, - {"loadingtiphooks", &patches.loadingtiphooks}, - {"doubleclickassignshiphooks", &patches.doubleclickassignshiphooks}, - {"forbiddentechconfirmhooks", &patches.forbiddentechconfirmhooks}, - {"forbiddentechshipnamehooks", &patches.forbiddentechshipnamehooks}, - {"audioeventhooks", &patches.audioeventhooks}, - {"instantcargocounterhooks", &patches.instantcargocounterhooks}, - {"cargoformathooks", &patches.cargoformathooks}, - {"officersorthooks", &patches.officersorthooks}, - {"pinnedshiphooks", &patches.pinnedshiphooks}, - {"artifactexchangehooks", &patches.artifactexchangehooks}, - }) - r.Read("patches", key, *value); - r.Read("control", "queue_enabled", control.queue_enabled); r.Read("control", "hotkeys_enabled", control.hotkeys_enabled); r.Read("control", "hotkeys_extended", control.hotkeys_extended); @@ -540,32 +589,24 @@ void Config::Read(const toml::table& config, toml::table& parsed, bool log_value r.Read("control", "select_timer", control.select_timer); r.Read("control", "enable_experimental", control.enable_experimental); + // [graphics] auto& g = graphics; r.Read("graphics", "ui_scale", g.ui_scale); r.ReadRenamed("graphics", "ui_scale_step", "ui_scale_adjust", g.ui_scale_step); 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 graphics.haven_zoom {}; using {}", g.haven_zoom, haven_default); - g.haven_zoom = haven_default; - r.Record("graphics", "haven_zoom", haven_default); - } + r.ReadValid("graphics", "haven_zoom", g.haven_zoom, [](float v) { return std::isfinite(v) && v >= 0.0f; }); + r.ReadValid("graphics", "interior_zoom_scale", g.interior_zoom_scale, + [](float v) { return std::isfinite(v) && v >= 1.0f; }); r.Read("graphics", "fr_scale", g.fr_scale); - for (auto [profile, prefix] : {std::pair{&g.zoom_label_player, std::string("zoom_label_player")}, - std::pair{&g.zoom_label_non_player, std::string("zoom_label_non_player")}}) { - const auto fallback = profile->zoom_threshold; - r.Choose("graphics", prefix + "_detail", profile->detail, kFleetLabelDetail); - r.Read("graphics", prefix + "_threshold", profile->zoom_threshold); - if (!std::isfinite(profile->zoom_threshold) || profile->zoom_threshold < 0.0f || profile->zoom_threshold > 1.0f) { - spdlog::warn("invalid config value graphics.{}_threshold: {}; using {}", prefix, profile->zoom_threshold, - fallback); - profile->zoom_threshold = fallback; - r.Record("graphics", prefix + "_threshold", fallback); - } - } + const auto read_label = [&r](const std::string& prefix, FleetLabelProfile& profile) { + r.Choose("graphics", prefix + "_detail", profile.detail, kFleetLabelDetail); + r.ReadValid("graphics", prefix + "_threshold", profile.zoom_threshold, + [](float v) { return std::isfinite(v) && v >= 0.0f && v <= 1.0f; }); + }; + read_label("zoom_label_player", g.zoom_label_player); + read_label("zoom_label_non_player", g.zoom_label_non_player); r.Read("graphics", "free_resize", g.free_resize); r.Read("graphics", "allow_cursor", g.allow_cursor); r.Read("graphics", "keyboard_zoom_speed", g.keyboard_zoom_speed); @@ -601,33 +642,34 @@ void Config::Read(const toml::table& config, toml::table& parsed, bool log_value r.Read("graphics", "loader_tip_enabled", g.loader_tip_enabled); r.Read("graphics", "galactic_anomaly_timer", g.galactic_anomaly_timer); + // [buffs] r.Read("buffs", "use_out_of_dock_power", buffs.use_out_of_dock_power); + // [audio] r.Read("audio", "trace_events", audio.trace_events); - for (const auto& event : StrSplit(r.Get("audio", "disabled_events", ""), ',')) { + for (auto& event : StrSplit(r.Get("audio", "disabled_events", ""), ',')) { if (AsciiStrToLower(event) == "all") audio.disable_all_events = true; else - audio.disabled_events.push_back(event); + audio.disabled_events.push_back(std::move(event)); } + constexpr std::pair kAlerts[] = { + {"alert_victory", &Audio::alert_victory}, + {"alert_defeat", &Audio::alert_defeat}, + {"alert_armada_created", &Audio::alert_armada_created}, + {"alert_armada_battle_won", &Audio::alert_armada_battle_won}, + {"alert_armada_battle_lost", &Audio::alert_armada_battle_lost}, + }; bool has_audio_alert = false; - for (auto [key, value] : std::initializer_list>{ - {"alert_victory", &audio.alert_victory}, - {"alert_defeat", &audio.alert_defeat}, - {"alert_armada_created", &audio.alert_armada_created}, - {"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 = ParseNotificationSound(StripAsciiWhitespace(name)); - if (!sound) - spdlog::warn("invalid config value audio.{}: '{}'; using none", key, name); - *value = sound.value_or(NotificationSound::kNone); - r.Record("audio", key, NotificationSoundName(*value)); - has_audio_alert |= *value != NotificationSound::kNone; + for (const auto& [key, alert] : kAlerts) { + r.Choose("audio", key, audio.*alert, kNotificationSounds); + has_audio_alert |= audio.*alert != NotificationSound::kNone; } + // The alerts play from the toast hooks. if (has_audio_alert && !patches.toastbannerhooks) - spdlog::warn("audio alerts require patches.toastbannerhooks = true"); + spdlog::warn("audio alerts need patches.toastbannerhooks = true; they stay silent"); + // [ui] auto& u = ui; r.Read("ui", "disable_escape_exit", u.disable_escape_exit); r.Read("ui", "disable_escape_exit_timer", u.disable_escape_exit_timer); @@ -637,10 +679,11 @@ void Config::Read(const toml::table& config, toml::table& parsed, bool log_value 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); + r.ReadValid("ui", "default_chat_width", u.default_chat_width, [](float v) { return std::isfinite(v) && v >= 0.0f; }); r.Read("ui", "auto_open_chat", u.auto_open_chat); r.Read("ui", "disable_galaxy_chat", u.disable_galaxy_chat); r.Read("ui", "disable_veil_chat", u.disable_veil_chat); + r.Read("ui", "show_player_title", u.show_player_title); r.Read("ui", "auto_open_bulk_claim_flyout", u.auto_open_bulk_claim_flyout); u.daily_bulk_claim_factions = ReadFactions(r); r.Read("ui", "daily_bulk_claim_toggle_default_on", u.daily_bulk_claim_toggle_default_on); @@ -651,6 +694,7 @@ void Config::Read(const toml::table& config, toml::table& parsed, bool log_value // "*" pins nothing. u.pinned_ships = ReadShipFilter(r, "pinned_ships").names; r.Read("ui", "double_click_to_assign_ship", u.double_click_to_assign_ship); + r.Read("ui", "dock_syncmanagement", u.dock_syncmanagement); 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); @@ -658,6 +702,7 @@ void Config::Read(const toml::table& config, toml::table& parsed, bool log_value r.Read("ui", "auto_confirm_ft_upgrade", u.auto_confirm_ft_upgrade); r.Read("ui", "forbidden_tech_ship_name", u.forbidden_tech_ship_name); r.Read("ui", "auto_confirm_alliance_contribution", u.auto_confirm_alliance_contribution); + r.Read("ui", "auto_confirm_reveal_ability", u.auto_confirm_reveal_ability); r.Read("ui", "extend_chest_purchase_max", u.extend_chest_purchase_max); r.Read("ui", "extend_donation_slider", u.extend_donation_slider); r.Read("ui", "extend_donation_max", u.extend_donation_max); @@ -677,6 +722,7 @@ void Config::Read(const toml::table& config, toml::table& parsed, bool log_value u.disabled_banner_types = ReadBannerTypes(r, "disabled_banner_types"); u.notify_banner_types = ReadBannerTypes(r, "notify_banner_types"); + // [sync] auto sync = std::make_shared(); r.Read("sync", "debug", sync->debug); r.Read("sync", "logging", sync->logging); @@ -684,29 +730,25 @@ void Config::Read(const toml::table& config, toml::table& parsed, bool log_value 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 (option.old_name.empty()) - r.Read("sync", option.option_str, sync_defaults.*option.option); + for (const auto& [key, member, old_name] : SyncOptions) + if (old_name.empty()) + r.Read("sync", key, sync_defaults.*member); else - r.ReadRenamed("sync", option.option_str, option.old_name, sync_defaults.*option.option); + r.ReadRenamed("sync", key, old_name, sync_defaults.*member); 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. " - "For capturing sync output, please use a local HTTP server instead."); - } + r.Retired("sync.file", "capture sync output with a local HTTP server instead"); - // 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; - 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; }); - } + // The global options carry [sync]'s proxy and verify_ssl, and for each category whether any target wants it. + sync->options = {.proxy = sync_defaults.proxy, .verify_ssl = sync_defaults.verify_ssl}; + for (const auto& [key, member, old_name] : SyncOptions) + sync->options.*member = + std::ranges::any_of(sync->targets | std::views::values, [&](const auto& target) { return target.*member; }); + // [config] r.Read("config", "settings_url", game.settings_url); r.Read("config", "assets_url_override", game.assets_url_override); - shortcuts_ = Shortcut::Parse(config, parsed, control.hotkeys_extended, control.enable_experimental); + shortcuts_ = Shortcut::Parse(file, effective, control.hotkeys_extended, control.enable_experimental); sync_ = std::move(sync); } diff --git a/src/mod/config.h b/src/mod/config.h index f79b6b1ef..214ed0a35 100644 --- a/src/mod/config.h +++ b/src/mod/config.h @@ -5,12 +5,12 @@ // startup and again whenever the file changes; a few are also toggled at runtime by hotkeys. Everything runs on // the game thread except sync, whose workers retain immutable snapshots. -#include "features/notification_audio.h" #include "shortcuts.h" #include #include +#include #include #include #include @@ -20,9 +20,10 @@ // Which categories of game data are uploaded, globally ([sync]) or for one target ([sync.targets.]). struct SyncConfig { struct Option { - std::string_view option_str; // used in TOML file + std::string_view key; bool SyncConfig::* option; - std::string_view old_name = {}; + // The key's name in older releases, still read when the new one is missing. + std::string_view old_name = {}; }; std::string proxy; @@ -49,26 +50,27 @@ struct SyncConfig { bool operator==(const SyncConfig&) const = default; }; -constexpr std::array SyncOptions{ - 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}, -}; +// Every category, as [sync] and [sync.targets.] spell it. +constexpr auto SyncOptions = std::to_array({ + {"away_assignments", &SyncConfig::away_assignments}, + {"battlelogs", &SyncConfig::battlelogs}, + {"buffs", &SyncConfig::buffs}, + {"buildings", &SyncConfig::buildings}, + {"fleets", &SyncConfig::fleets}, + {"haven", &SyncConfig::haven}, + {"inventory", &SyncConfig::inventory}, + {"jobs", &SyncConfig::jobs}, + {"missions", &SyncConfig::missions}, + {"officers", &SyncConfig::officers, "officer"}, + {"profile", &SyncConfig::profile}, + {"research", &SyncConfig::research}, + {"resources", &SyncConfig::resources}, + {"ships", &SyncConfig::ships}, + {"slots", &SyncConfig::slots}, + {"store_offers", &SyncConfig::store_offers}, + {"tech", &SyncConfig::tech}, + {"traits", &SyncConfig::traits}, +}); struct SyncTargetConfig : SyncConfig { std::string url; @@ -76,6 +78,24 @@ struct SyncTargetConfig : SyncConfig { bool operator==(const SyncTargetConfig&) const = default; }; +// The cues [audio] alert_* can play; notification_audio.cc generates them. +enum class NotificationSound : uint8_t { + kNone, + kDefault, + kInfo, + kSuccess, + kWarning, + kAlarm, + kArrival, + kSoft, + kPing, + kRepair, + kCount, +}; + +// The cue's name as the settings file writes it: "success". +std::string_view NotificationSoundName(NotificationSound sound); + enum class MissionHudVisibility { kAuto, kAlways, @@ -120,48 +140,49 @@ class Config final Config(const Config&) = delete; Config& operator=(const Config&) = delete; - // Hook groups. Users switch these off when another mod (such as a BepInEx plugin) hooks the same methods. + // Hook groups, one per [patches] key in alphabetical order. Users switch one off when another mod (such as a + // BepInEx plugin) hooks the same methods, or a game update breaks it. Read once: hooks install at startup. struct Patches { - bool pinnedshipclicktoggle = true; - bool thinqueueprotectionhooks = true; - bool queueaddressguardhooks = true; bool actionqueuerecoveryhooks = true; - bool revealconfirmhooks = true; - bool chattitledisplayhooks = true; - bool interiorzoomhooks = true; - bool uiscalehooks = true; - bool zoomhooks = true; - bool havenzoomhooks = true; - bool havenhistoryhooks = true; bool alliancegamesstorehooks = true; + bool artifactexchangehooks = true; + bool audioeventhooks = true; bool bufffixhooks = true; - bool toastbannerhooks = true; - bool panhooks = true; - bool hotkeyhooks = true; - bool freeresizehooks = true; - bool tempcrashfixes = true; - bool testpatches = true; - bool miscpatches = true; - bool missionhudtweakshooks = true; + bool cargoformathooks = true; bool chatpatches = true; - bool syncpatches = true; - bool game_version = true; - bool objecttracker = true; - bool loadingscreenhooks = true; - bool transitionscreenhooks = true; - bool giftsbulkclaimhooks = true; + bool chattitledisplayhooks = true; bool dailyfactionbulkclaimhooks = true; - bool galacticanomalytimerhooks = true; - bool loadingtiphooks = true; bool doubleclickassignshiphooks = true; bool forbiddentechconfirmhooks = true; bool forbiddentechshipnamehooks = true; - bool audioeventhooks = true; + bool freeresizehooks = true; + bool galacticanomalytimerhooks = true; + bool game_version = true; + bool giftsbulkclaimhooks = true; + bool havenhistoryhooks = true; + bool havenzoomhooks = true; + bool hotkeyhooks = true; bool instantcargocounterhooks = true; - bool cargoformathooks = true; + bool interiorzoomhooks = true; + bool loadingscreenhooks = true; + bool loadingtiphooks = true; + bool miscpatches = true; + bool missionhudtweakshooks = true; + bool objecttracker = true; bool officersorthooks = true; + bool panhooks = true; + bool pinnedshipclicktoggle = true; bool pinnedshiphooks = true; - bool artifactexchangehooks = true; + bool queueaddressguardhooks = true; + bool revealconfirmhooks = true; + bool syncpatches = true; + bool tempcrashfixes = true; + bool testpatches = true; + bool thinqueueprotectionhooks = true; + bool toastbannerhooks = true; + bool transitionscreenhooks = true; + bool uiscalehooks = true; + bool zoomhooks = true; bool operator==(const Patches&) const = default; } patches; @@ -179,28 +200,30 @@ class Config final } control; struct Graphics { - float interior_zoom_scale = 2.0f; - float ui_scale = 0.6f; - float ui_scale_step = 0.05f; - float ui_scale_ship = 1.0f; - float ui_scale_viewer = 1.2f; + float ui_scale = 0.6f; + float ui_scale_step = 0.05f; + float ui_scale_ship = 1.0f; + float ui_scale_viewer = 1.2f; // The farthest system zoom; presets and zoom shortcuts are in the same unit. - float zoom = 5000.0f; - float haven_zoom = 4000.0f; - float fr_scale = 2.0f; + float zoom = 5000.0f; + float haven_zoom = 4000.0f; + // A multiple of the station interior's own zoom-out distance, at least 1. + float interior_zoom_scale = 2.0f; + float fr_scale = 2.0f; FleetLabelProfile zoom_label_player; FleetLabelProfile zoom_label_non_player; bool free_resize = true; bool allow_cursor = true; float keyboard_zoom_speed = 350.0f; - // TODO: read (with enable_experimental) but nothing applies it. + // TODO(tashcan): read (with enable_experimental) but nothing applies it. float system_pan_momentum = 0.4f; // How much of the pan speed is kept each frame after a drag or key is released. float system_pan_momentum_falloff = 0.8f; bool disable_target_camera_lock = false; - // TODO: the F11 borderless toggle needs a SetWindowLongW detour that was never installed, so this does nothing. + // TODO(tashcan): the F11 borderless toggle needs a SetWindowLongW detour that was never installed, so this does + // nothing. bool borderless_fullscreen = true; - // TODO: read but nothing applies it. + // TODO(tashcan): read but nothing applies it. float transition_time = 0.01f; float default_system_zoom = 1750.0f; float system_zoom_preset_1 = 50.0f; @@ -237,10 +260,7 @@ class Config final } audio; struct Ui { - bool dock_syncmanagement = true; - bool auto_confirm_reveal_ability = false; - bool show_player_title = true; - bool disable_escape_exit = 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; @@ -251,6 +271,7 @@ class Config final bool disable_move_keys = false; bool disable_galaxy_chat = false; bool disable_veil_chat = false; + bool show_player_title = true; // 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; @@ -267,16 +288,19 @@ class Config final std::vector> pinned_ships; bool double_click_to_assign_ship = false; - bool focus_search = true; + // On the ship management screen, select the managed ship's dock slot as well. + bool dock_syncmanagement = true; + bool focus_search = true; // Abbreviated cargo values use cargo_significant_decimals. bool format_cargo_values = true; // Restores the Below Deck Ability officer sort option. - bool officer_sort = true; - bool arrow_keys_to_select_ship = true; - bool auto_confirm_ft_upgrade = false; + bool officer_sort = true; + bool arrow_keys_to_select_ship = true; + bool auto_confirm_ft_upgrade = false; // Shows the equipped ship below the Forbidden Tech header, including ships without a dock. - bool forbidden_tech_ship_name = true; + bool forbidden_tech_ship_name = true; bool auto_confirm_alliance_contribution = false; + bool auto_confirm_reveal_ability = false; int extend_chest_purchase_max = 160; bool extend_donation_slider = true; @@ -312,12 +336,13 @@ class Config final bool logging = false; // Seconds that resolved player and alliance names stay cached. int resolver_cache_ttl = 300; - // The categories any target wants, so the game hooks can skip the rest. + // The categories any target wants. Only battle logs are skipped when no target wants them; the rest are stored + // regardless, so a target switched on later gets them at once. SyncConfig options; std::map targets; }; - // [config]: overrides for the game's own server settings. + // [config], named Game because Config is taken: overrides for the game's own server settings. struct Game { std::string settings_url; std::string assets_url_override; @@ -329,8 +354,12 @@ class Config final static std::unique_ptr Parse(const toml::table& file, toml::table& effective, bool log_values); private: + // Held until Activate, so Parse can build a candidate without changing the live bindings or the workers' snapshot. Shortcut::Bindings shortcuts_; std::shared_ptr sync_; + Config() = default; void Read(const toml::table& file, toml::table& effective, bool log_values); + // Makes `next` current: binds its shortcuts and publishes its sync snapshot to the workers. + static void Activate(std::unique_ptr next); }; diff --git a/src/mod/features/notification_audio.cc b/src/mod/features/notification_audio.cc index 968ab6e47..fa51a975f 100644 --- a/src/mod/features/notification_audio.cc +++ b/src/mod/features/notification_audio.cc @@ -1,6 +1,6 @@ -// Named notification cues and their generated mono PCM WAV data. +// Notification cues: their tone patterns, the WAV data generated from them, and Windows playback. + #include "features/notification_audio.h" -#include "text.h" #include @@ -14,52 +14,38 @@ #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}, +// Entries follow NotificationSound; unused segments have zero duration. kNone has no tones. +constexpr std::array kPatterns[] = { + {}, + {{{740, 70}, {0, 22}, {880, 85}, {0, 18}}}, + {{{659, 80}, {0, 24}, {880, 110}}}, + {{{587, 70}, {0, 18}, {740, 70}, {0, 18}, {988, 120}}}, + {{{622, 90}, {0, 36}, {466, 110}, {0, 28}, {466, 90}}}, + {{{880, 90}, {0, 42}, {880, 90}, {0, 42}, {698, 160}}}, + {{{523, 65}, {0, 18}, {659, 70}, {0, 18}, {1046, 125}}}, + {{{523, 90}, {0, 26}, {659, 110}}}, + {{{1046, 95}}}, + {{{440, 70}, {0, 18}, {554, 70}, {0, 18}, {740, 140}}}, }; +static_assert(std::size(kPatterns) == static_cast(NotificationSound::kCount)); constexpr int kSampleRate = 44100; constexpr double kTwoPi = 6.28318530717958647692; constexpr double kAmplitude = 0.26; -void append_u16(std::vector& buffer, uint16_t value) +void AppendU16(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) +void AppendU32(std::vector& buffer, uint32_t value) { buffer.push_back(static_cast(value & 0xff)); buffer.push_back(static_cast((value >> 8) & 0xff)); @@ -67,15 +53,14 @@ void append_u32(std::vector& buffer, uint32_t value) buffer.push_back(static_cast((value >> 24) & 0xff)); } -void append_ascii(std::vector& buffer, std::string_view value) +void AppendAscii(std::vector& buffer, std::string_view value) { buffer.insert(buffer.end(), value.begin(), value.end()); } -std::vector build_wav(std::span pattern) +std::vector BuildWav(std::span pattern) { uint32_t sample_count = 0; - for (const auto& segment : pattern) { + 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; @@ -83,19 +68,19 @@ std::vector build_wav(std::span pattern) 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); + AppendAscii(buffer, "RIFF"); + AppendU32(buffer, 36 + data_bytes); + AppendAscii(buffer, "WAVE"); + AppendAscii(buffer, "fmt "); + AppendU32(buffer, 16); + AppendU16(buffer, 1); + AppendU16(buffer, channels); + AppendU32(buffer, kSampleRate); + AppendU32(buffer, kSampleRate * channels * (bits_per_sample / 8)); + AppendU16(buffer, channels * (bits_per_sample / 8)); + AppendU16(buffer, bits_per_sample); + AppendAscii(buffer, "data"); + AppendU32(buffer, data_bytes); double phase = 0.0; for (const auto& segment : pattern) { @@ -105,45 +90,25 @@ std::vector build_wav(std::span pattern) 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))); + AppendU16(buffer, static_cast(static_cast(sample * 32767.0))); phase += step; - if (phase > kTwoPi) { + 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)) + if (index == 0 || index >= std::size(kPatterns)) 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); }); + static std::array ready; + static std::array, std::size(kPatterns)> buffers; + // Only the cues in use are generated. + std::call_once(ready[index], [index] { buffers[index] = BuildWav(kPatterns[index]); }); return buffers[index]; } @@ -151,7 +116,7 @@ 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)); + spdlog::warn("[Toasts] could not play the {} cue", NotificationSoundName(sound)); } #if _WIN32 diff --git a/src/mod/features/notification_audio.h b/src/mod/features/notification_audio.h index 60718ddb0..623412656 100644 --- a/src/mod/features/notification_audio.h +++ b/src/mod/features/notification_audio.h @@ -1,26 +1,18 @@ #pragma once +// The notification cues toasts play for [audio] alert_*: tones generated as WAV data and played without the game. + +#include "config.h" + +#include #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. +// Mono 16-bit PCM WAV data for `sound`, generated on first use and kept for the process: playback is asynchronous +// and reads the buffer after the call returns. None has no data. std::span NotificationSoundBytes(NotificationSound sound); void PlayNotificationSound(NotificationSound sound); + +// Starts asynchronous playback of WAV data that outlives it. Windows plays it in notification_audio.cc, macOS in +// notification_audio_mac.mm; elsewhere nothing plays. +bool PlayNotificationBuffer(const uint8_t* data, size_t size); diff --git a/src/mod/features/notification_audio_mac.mm b/src/mod/features/notification_audio_mac.mm index 56acecc57..163c7dc6b 100644 --- a/src/mod/features/notification_audio_mac.mm +++ b/src/mod/features/notification_audio_mac.mm @@ -1,13 +1,13 @@ -#ifdef __APPLE__ +// macOS playback for the notification cues. -#include -#include +#include "features/notification_audio.h" #import bool PlayNotificationBuffer(const uint8_t* data, size_t size) { @autoreleasepool { + // Kept so a new cue can stop the previous one, and so the sound is not released while it plays. static NSSound* current_sound = nil; NSData* sound_data = [NSData dataWithBytesNoCopy:const_cast(data) length:size freeWhenDone:NO]; @@ -25,5 +25,3 @@ bool PlayNotificationBuffer(const uint8_t* data, size_t size) return [current_sound play]; } } - -#endif diff --git a/src/mod/features/toasts.cc b/src/mod/features/toasts.cc index d4e1655e0..cad2f70a3 100644 --- a/src/mod/features/toasts.cc +++ b/src/mod/features/toasts.cc @@ -3,6 +3,7 @@ #include "config.h" #include "features/features.h" +#include "features/notification_audio.h" #include "features/toast_text.h" #include "text.h" @@ -20,7 +21,7 @@ #include #if _WIN32 -#include +#include #include #include #endif diff --git a/tests/config_reload.cc b/tests/config_reload.cc index d382dcf90..12da40947 100644 --- a/tests/config_reload.cc +++ b/tests/config_reload.cc @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -98,8 +99,8 @@ int main() 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)); + Write(std::format("[patches]\nuiscalehooks = true\n[shortcuts]\nquit = \"F2\"\n[sync]\nresolver_cache_ttl = {}", + 200 + i)); reloads_ok &= Config::Reload(); reloads_ok &= Config::Sync::Get()->resolver_cache_ttl == 200 + i; if (i != 0) diff --git a/tests/mod_logic.cc b/tests/mod_logic.cc index 9bcd9f6cb..6897ada62 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -1,6 +1,7 @@ // Settings parsing and shortcut matching, run against production code with Unity input replaced by a fake. #include "config.h" #include "features/chat_startup.h" +#include "features/notification_audio.h" #include "features/ship_shortcut_hint.h" #include "features/toast_text.h" #include "shortcuts.h" @@ -295,8 +296,11 @@ void NotificationAudio() {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, + toml::table effective; + const auto config = + Config::Parse(toml::parse(std::string("[audio]\nalert_victory = \"") + AsciiStrToUpper(fixture.name) + "\""), + effective, false); + Check(NotificationSoundName(fixture.sound) == fixture.name && config->audio.alert_victory == 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"); @@ -314,6 +318,7 @@ void NotificationAudio() 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"); } + // Settings parsing trims and lower-cases, so " none " is a valid name there. for (const auto& [name, expected] : std::initializer_list>{{"off", NotificationSound::kNone}, {"silent", NotificationSound::kNone}, @@ -322,9 +327,13 @@ void NotificationAudio() {"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"); + {"repaired", NotificationSound::kRepair}, + {"unknown", NotificationSound::kNone}}) { + toml::table effective; + const auto config = Config::Parse(toml::parse(std::string("[audio]\nalert_defeat = \"") + std::string(name) + "\""), + effective, false); + Check(config->audio.alert_defeat == expected, "legacy cue aliases remain valid, unknown names fall back to none"); + } Check(NotificationSoundBytes(NotificationSound::kCount).empty() && NotificationSoundBytes(static_cast(255)).empty(), "invalid cues have no WAV data"); From aef7a99e1c9b6f7dd412efcead47bb4e01c5976f Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 14:45:35 -0700 Subject: [PATCH 02/13] Report shortcut problems like settings problems Invalid and deprecated shortcut values use the same wording and severity as other settings; only a broken built-in default is an error. Shortcut matching spells out the plain and modified cases instead of one dense conditional, and dispatch runs under the same guard as other frame work so an exception cannot unwind into the game. --- src/mod/shortcuts.cc | 117 ++++++++++++++++++++++++------------------ src/mod/shortcuts.def | 7 +-- src/mod/shortcuts.h | 3 +- tests/mod_logic.cc | 27 +++++----- 4 files changed, 87 insertions(+), 67 deletions(-) diff --git a/src/mod/shortcuts.cc b/src/mod/shortcuts.cc index 19bd0790b..07aa4986f 100644 --- a/src/mod/shortcuts.cc +++ b/src/mod/shortcuts.cc @@ -1,3 +1,6 @@ +// Owns the shortcut catalog from shortcuts.def: parsing [shortcuts] into key chords, the live bindings, and +// matching them against this frame's input. + #include "shortcuts.h" #include "text.h" @@ -9,10 +12,16 @@ #include #include #include +#include #include +#include +#include +#include namespace { +// --- Key names --- + // A key name from the settings file, and its keybind badge text when that differs from the name. The badge font // has few glyphs, so long names are shortened and the symbols the modifier notation uses are spelled out. struct KeyName { @@ -140,39 +149,36 @@ std::optional FindKey(std::string_view name) 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)}; + return KeyName{name, static_cast(std::to_underlying(range.key) + number - range.first)}; } const auto it = std::ranges::find(kKeys, name, &KeyName::name); return it == std::end(kKeys) ? std::nullopt : std::optional(*it); } -const KeyChord::Modifier* FindModifier(std::string_view name) -{ - const auto it = std::ranges::find(kModifiers, name, &KeyChord::Modifier::name); - return it == std::end(kModifiers) ? nullptr : it; -} - -Shortcut::Bindings bindings; - std::string DescribeChords(const std::vector& chords) { std::string text; - for (const auto& chord : chords) { - if (!text.empty()) - text += " | "; - text += chord.Text(); - } + for (const auto& chord : chords) + text += (text.empty() ? "" : " | ") + chord.Text(); return text; } +// --- Matching --- + +// The live bindings, replaced whole by Set. Game thread only, like the input they are matched against. +Shortcut::Bindings bindings; + +const std::vector& ChordsOf(Action action) +{ return bindings[std::to_underlying(action)]; } + // 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. +// release, so letting go of CTRL before PGUP does not turn a held CTRL-PGUP into PGUP. At most one entry per key. std::vector used_keys; bool IsUsed(unity::KeyCode key) { std::erase_if(used_keys, [](unity::KeyCode used) { return !unity::Key::Pressed(used); }); - return std::ranges::find(used_keys, key) != used_keys.end(); + return std::ranges::contains(used_keys, key); } bool ModifiersHeld(const KeyChord& chord) @@ -183,18 +189,26 @@ bool ModifiersHeld(const KeyChord& chord) template bool Matches(Action action) { - for (const auto& chord : bindings[static_cast(action)]) { + for (const auto& chord : ChordsOf(action)) { if (chord.key == unity::KeyCode::None || !KeyState(chord.key)) continue; - if (chord.modifiers.empty() ? unity::Key::IsModified() || IsUsed(chord.key) : !ModifiersHeld(chord)) - continue; - if (!chord.modifiers.empty() && std::ranges::find(used_keys, chord.key) == used_keys.end()) - used_keys.push_back(chord.key); + if (chord.modifiers.empty()) { + if (unity::Key::IsModified() || IsUsed(chord.key)) + continue; + } else { + if (!ModifiersHeld(chord)) + continue; + if (!std::ranges::contains(used_keys, chord.key)) + used_keys.push_back(chord.key); + } return true; } return false; } +// --- Settings --- + +// Which [control] switches a shortcut needs: hotkeys_extended, or that and enable_experimental. enum class Tier { kAlways, kExtended, kExperimental }; struct Setting { @@ -211,7 +225,7 @@ constexpr Setting kSettings[] = { #include "shortcuts.def" #undef SHORTCUT }; -static_assert(std::size(kSettings) == static_cast(Action::kCount)); +static_assert(std::size(kSettings) == std::to_underlying(Action::kCount)); // Binds every chord in `value` ("CTRL-G|F8"). Reports whether any was valid. bool BindChords(std::vector& chords, std::string_view key, std::string_view value) @@ -222,43 +236,42 @@ bool BindChords(std::vector& chords, std::string_view key, std::string chords.push_back(std::move(chord)); bound = true; } else { - spdlog::warn("Invalid shortcut token [shortcuts].{} token='{}' value='{}'; ignoring token.", key, text, value); + spdlog::warn("invalid config value shortcuts.{}: '{}'; ignoring the chord '{}'", key, value, text); } } return bound; } -// 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, +// Binds one [shortcuts] setting into `chords`, and records the binding and where it came from (default, config, +// alias: or invalid) for the vars file. +void BindSetting(const toml::table* shortcuts, toml::table& effective, toml::table& sources, const Setting& setting, std::vector& chords) { // The setting itself, else the first deprecated alias present, else the default. std::string_view key = setting.key; const toml::node* node = shortcuts ? shortcuts->get(key) : nullptr; for (const auto alias : setting.aliases) { - if (alias.empty() || !shortcuts || !shortcuts->contains(alias)) + const auto* alias_node = shortcuts && !alias.empty() ? shortcuts->get(alias) : nullptr; + if (!alias_node) continue; if (node) { - spdlog::warn("Ignoring deprecated shortcut alias [shortcuts].{} because [shortcuts].{} is set.", alias, - setting.key); + spdlog::warn("deprecated config value shortcuts.{}; ignored because shortcuts.{} is set", alias, setting.key); continue; } - spdlog::warn("Deprecated shortcut alias [shortcuts].{} is supported for compatibility; use [shortcuts].{}.", alias, - setting.key); + spdlog::warn("deprecated config value shortcuts.{}; use shortcuts.{}", alias, setting.key); key = alias; - node = shortcuts->get(alias); + node = alias_node; break; } - std::optional configured = node ? node->value() : std::nullopt; + const std::optional configured = node ? node->value() : std::nullopt; if (node && !configured) - spdlog::error("Invalid shortcut value [shortcuts].{} must be a string; using default for [shortcuts].{}.", key, - setting.key); - std::string label = !configured ? "default" : key == setting.key ? "config" : "alias:" + std::string(key); + spdlog::warn("invalid config value shortcuts.{}: not a string; using the default {}", key, setting.fallback); + std::string label = !configured ? "default" : key == setting.key ? "config" : std::format("alias:{}", key); const auto record = [&](std::string value, std::string source) { - spdlog::debug("shortcut value shortcuts.{} value: {}", setting.key, value); - out.insert_or_assign(setting.key, std::move(value)); - out_source.insert_or_assign(setting.key, std::move(source)); + spdlog::debug("config shortcuts.{} = {}", setting.key, value); + effective.insert_or_assign(setting.key, std::move(value)); + sources.insert_or_assign(setting.key, std::move(source)); }; // An explicit empty string disables the binding, just like NONE. @@ -268,10 +281,10 @@ void LoadSetting(const toml::table* shortcuts, toml::table& out, toml::table& ou if (!BindChords(chords, key, value)) { if (configured) - spdlog::error("No valid shortcut tokens for [shortcuts].{} value='{}'; using default for [shortcuts].{}.", key, - *configured, setting.key); + spdlog::warn("invalid config value shortcuts.{}: '{}'; using the default {}", key, *configured, setting.fallback); if (!configured || !BindChords(chords, setting.key, setting.fallback)) { - spdlog::error("Default shortcut [shortcuts].{} value='{}' has no valid tokens; action disabled.", setting.key, + // A broken entry in shortcuts.def, not a user mistake. + spdlog::error("default shortcut shortcuts.{} = '{}' has no valid chord; the action is unbound", setting.key, setting.fallback); return record("NONE", "invalid"); } @@ -285,7 +298,8 @@ KeyChord KeyChord::Parse(std::string_view text) { KeyChord chord; for (auto& token : StrSplit(AsciiStrToUpper(text), '-')) { - if (const auto* modifier = FindModifier(token)) { + if (const auto* modifier = std::ranges::find(kModifiers, token, &KeyChord::Modifier::name); + modifier != std::end(kModifiers)) { 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. @@ -325,14 +339,16 @@ Bindings Parse(const toml::table& config, toml::table& parsed, bool extended, bo { 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(); + auto& effective = *parsed.emplace("shortcuts").first->second.as_table(); + auto& sources = *parsed.emplace("shortcuts_source").first->second.as_table(); + // A shortcut whose tier is off stays unbound and is left out of the vars file. for (const auto& setting : kSettings) { - if ((setting.tier == Tier::kExtended && !extended) - || (setting.tier == Tier::kExperimental && !(extended && experimental))) + if (setting.tier == Tier::kExtended && !extended) + continue; + if (setting.tier == Tier::kExperimental && !(extended && experimental)) continue; - LoadSetting(shortcuts, out, source, setting, result[static_cast(setting.action)]); + BindSetting(shortcuts, effective, sources, setting, result[std::to_underlying(setting.action)]); } return result; } @@ -348,8 +364,7 @@ bool Owns(unity::KeyCode key) bool HasBinding(Action action) { - return std::ranges::any_of(bindings[static_cast(action)], - [](const auto& chord) { return chord.key != unity::KeyCode::None; }); + return std::ranges::any_of(ChordsOf(action), [](const auto& chord) { return chord.key != unity::KeyCode::None; }); } bool IsPressed(Action action) @@ -359,12 +374,12 @@ bool IsDown(Action action) { return Matches(action); } std::string Describe(Action action) -{ return DescribeChords(bindings[static_cast(action)]); } +{ return DescribeChords(ChordsOf(action)); } std::string Hint(Action action) { // The game's badges have room for one binding. - const auto& chords = bindings[static_cast(action)]; + const auto& chords = ChordsOf(action); return chords.empty() ? std::string{} : chords.front().Hint(); } } // namespace Shortcut diff --git a/src/mod/shortcuts.def b/src/mod/shortcuts.def index ccc97a199..dd9f139b9 100644 --- a/src/mod/shortcuts.def +++ b/src/mod/shortcuts.def @@ -1,5 +1,6 @@ // Action, TOML key, default chords, optional tier and deprecated aliases. -// Included by shortcuts.h for Action and shortcuts.cc for the settings table. +// Included by shortcuts.h for Action and shortcuts.cc for the settings table. The order is the order of the debug +// log, except that kSelectShip1 to kSelectShip8 must stay contiguous: ship_shortcut_hint.h indexes them. SHORTCUT(kMoveUp, "move_up", "W|UP") SHORTCUT(kMoveDown, "move_down", "S|DOWN") SHORTCUT(kMoveLeft, "move_left", "A|LEFT") @@ -51,14 +52,15 @@ 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(kLogLevelDebug, "log_debug", "CTRL-SHIFT-F9") 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(kToggleShortcutHints, "toggle_shortcut_hints", "HOME") SHORTCUT(kShowAwayTeam, "show_awayteam", "T") SHORTCUT(kShowGifts, "show_gifts", "/") SHORTCUT(kShowArtifacts, "show_artifacts", "SHIFT-I") @@ -73,7 +75,6 @@ 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") diff --git a/src/mod/shortcuts.h b/src/mod/shortcuts.h index c46ca4a88..44a61cc24 100644 --- a/src/mod/shortcuts.h +++ b/src/mod/shortcuts.h @@ -10,6 +10,7 @@ #include #include #include +#include #include // What a shortcut does. Each [shortcuts] setting binds key chords to one action. @@ -47,7 +48,7 @@ struct KeyChord { namespace Shortcut { -using Bindings = std::array, static_cast(Action::kCount)>; +using Bindings = std::array, std::to_underlying(Action::kCount)>; // 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); diff --git a/tests/mod_logic.cc b/tests/mod_logic.cc index 6897ada62..402e2d677 100644 --- a/tests/mod_logic.cc +++ b/tests/mod_logic.cc @@ -12,17 +12,19 @@ #include #include +#include #include #include #include #include #include #include +#include namespace { -std::array(unity::KeyCode::Max)> held{}; -int failures = 0; +std::array held{}; +int failures = 0; void Check(bool condition, const char* what, std::source_location where = std::source_location::current()) { @@ -36,7 +38,7 @@ void Hold(std::initializer_list keys) { held.fill(false); for (auto key : keys) - held[static_cast(key)] = true; + held[std::to_underlying(key)] = true; } toml::table Load(std::string_view settings, bool extended = true, bool experimental = false) @@ -65,7 +67,7 @@ void ShipHints() Check(ShipShortcutHintAction(Action::kSelectShip1, 0, [](std::size_t) { return 0; }) == Action::kCount, "Unavailable fleet context must not advertise a guessed ship shortcut"); for (int index = 0; index < 8; ++index) { - auto action = static_cast(static_cast(Action::kSelectShip1) + index); + const auto action = static_cast(std::to_underlying(Action::kSelectShip1) + index); Check(ShipShortcutHintAction(action, 8, [](std::size_t i) { return static_cast(i); }) == action, "Contiguous eight-dock hints must keep their original shortcuts"); } @@ -81,7 +83,7 @@ void ShipHints() Check(Shortcut::Hint(ship_hint(Action::kSelectShip7)).empty(), "Unbound ship selection must not advertise a working shortcut"); Shortcut::Bindings custom{}; - custom[static_cast(Action::kSelectShip6)].push_back(KeyChord::Parse("CTRL-F8")); + custom[std::to_underlying(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"); @@ -175,6 +177,7 @@ void KeyRanges() 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); @@ -193,6 +196,7 @@ void DefaultBindings() } } } + void Loading() { auto parsed = Load(R"( @@ -499,17 +503,16 @@ void Matching() namespace unity::Key { bool Pressed(unity::KeyCode key) -{ return held[static_cast(key)]; } +{ return held[std::to_underlying(key)]; } bool Down(unity::KeyCode key) { return Pressed(key); } bool IsModified() { - for (auto key : {unity::KeyCode::LeftControl, unity::KeyCode::RightControl, unity::KeyCode::LeftShift, - unity::KeyCode::RightShift, unity::KeyCode::LeftAlt, unity::KeyCode::RightAlt, - unity::KeyCode::LeftCommand, unity::KeyCode::RightCommand}) - if (Pressed(key)) - return true; - return false; + return std::ranges::any_of(std::array{unity::KeyCode::LeftControl, unity::KeyCode::RightControl, + unity::KeyCode::LeftShift, unity::KeyCode::RightShift, unity::KeyCode::LeftAlt, + unity::KeyCode::RightAlt, unity::KeyCode::LeftCommand, + unity::KeyCode::RightCommand}, + Pressed); } } // namespace unity::Key From 07f5b62ad4bcfad8ec8666854e18fb2c01bd108f Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 15:00:38 -0700 Subject: [PATCH 03/13] Own detours in the IL2CPP library and move every hook onto it Detours, checked lookups and failure reporting move into the IL2CPP library. The old hook macros are gone, so every caller moves to hook sets in the same change: a set applies under one suspend window and undoes itself if any step fails, so a feature installs whole or not at all. - Each installer stages its hooks in one set named after its feature and returns whether the set went in. - Hooks two features share get their own set, committed once. - A missing method is reported by the lookup and keeps the whole set out; installers no longer pre-check members. - The tracker installs each constructor with its release hook, so a pooled object is never tracked without its untrack path. Frame work runs only for features whose set went in, and mod.cc logs each feature as installed, skipped or not installed. Static lookups no longer run a class constructor early. --- build.zig | 4 +- src/libs/il2cpp/include/il2cpp/api.h | 8 +- src/libs/il2cpp/include/il2cpp/hook.h | 137 ++-- src/libs/il2cpp/include/il2cpp/il2cpp.h | 265 ++++--- .../il2cpp/include/il2cpp/method_contract.h | 49 -- src/libs/il2cpp/src/api.cc | 12 +- src/libs/il2cpp/src/hook.cc | 82 +++ src/libs/il2cpp/src/il2cpp.cc | 309 ++++---- src/libs/unity/include/unity/core.h | 58 +- src/libs/unity/include/unity/input.h | 649 ++++++++--------- src/libs/unity/include/unity/tracker.h | 26 +- src/libs/unity/include/unity/ui.h | 21 +- src/libs/unity/src/tracker.cc | 13 +- src/mod/features/action_queue.cc | 570 ++++++++++++--- src/mod/features/action_queue.h | 235 +++++- src/mod/features/action_queue_guard_policy.h | 123 ---- src/mod/features/alliance_store.cc | 100 +-- src/mod/features/anomaly_timer.cc | 35 +- src/mod/features/audio.cc | 33 +- src/mod/features/buffs.cc | 26 +- src/mod/features/camera.cc | 107 ++- src/mod/features/cargo.cc | 90 +-- src/mod/features/chat.cc | 231 +++--- src/mod/features/chat_startup.h | 5 +- src/mod/features/chat_titles.cc | 189 ----- src/mod/features/confirmations.cc | 113 +++ src/mod/features/dock.cc | 325 +++++++-- src/mod/features/double_click_assign_ship.h | 6 - src/mod/features/features.h | 90 ++- src/mod/features/fleet_actions.cc | 119 +-- src/mod/features/fleet_labels.cc | 75 +- src/mod/features/forbidden_tech.cc | 91 --- src/mod/features/forbidden_tech_ship_name.cc | 33 +- src/mod/features/game_config.cc | 8 +- src/mod/features/haven.cc | 258 +++---- src/mod/features/hotkeys.cc | 24 +- src/mod/features/instant_warp.cc | 59 +- src/mod/features/interior_zoom.cc | 113 --- src/mod/features/keybind_badges.cc | 37 +- src/mod/features/loading_screens.cc | 53 +- src/mod/features/missions.cc | 85 ++- src/mod/features/officers.cc | 29 +- src/mod/features/pinned_ship_click.cc | 169 ----- src/mod/features/pinned_ship_click.h | 5 - src/mod/features/queue_address_guard.cc | 129 ---- src/mod/features/queue_address_guard_policy.h | 33 - src/mod/features/reveal_confirmation.cc | 49 -- src/mod/features/screens.cc | 42 +- src/mod/features/ship_shortcut_hint.h | 2 + src/mod/features/ship_tile_click.cc | 58 -- src/mod/features/ship_tile_click.h | 8 - src/mod/features/shop.cc | 99 +-- src/mod/features/thin_queue_protection.cc | 141 ---- src/mod/features/toasts.cc | 34 +- src/mod/features/tracked_objects.cc | 17 +- src/mod/features/ui_scale.cc | 45 +- src/mod/features/window.cc | 64 +- src/mod/game/app.h | 12 + src/mod/game/camera.h | 112 +++ src/mod/game/chat.h | 11 +- src/mod/game/fleet.h | 278 +++---- src/mod/game/haven.h | 116 +-- src/mod/game/hud.h | 18 +- src/mod/game/inventory.h | 38 +- src/mod/game/native_hook_extent.cc | 131 ---- src/mod/game/native_hook_extent.h | 14 - src/mod/game/navigation.h | 65 +- src/mod/game/officers.h | 6 +- src/mod/game/queues.h | 686 ++---------------- src/mod/game/server.h | 17 +- src/mod/game/ui.h | 99 +-- src/mod/game/viewers.h | 2 +- src/mod/mod.cc | 102 +-- src/mod/sync/fleet_watch.cc | 4 +- src/mod/sync/fleet_watch.h | 2 +- src/mod/sync/fleets.cc | 10 +- src/mod/sync/sync.cc | 21 +- src/mod/sync/sync.h | 2 +- tests/action_queue.cc | 396 +++++++++- tests/il2cpp_bindings.cc | 193 +++-- tests/il2cpp_gc.cc | 15 +- tests/il2cpp_test_api.h | 2 + tests/queue_address_guard.cc | 86 --- tests/run-action-queue.ps1 | 20 - tests/spud_relocation.cc | 39 +- tests/sync_logic.cc | 16 +- tests/thin_queue_protection.cc | 222 ------ zig/mod.zig | 13 +- zig/tests.zig | 8 +- 89 files changed, 3890 insertions(+), 4656 deletions(-) delete mode 100644 src/libs/il2cpp/include/il2cpp/method_contract.h create mode 100644 src/libs/il2cpp/src/hook.cc delete mode 100644 src/mod/features/action_queue_guard_policy.h delete mode 100644 src/mod/features/chat_titles.cc create mode 100644 src/mod/features/confirmations.cc delete mode 100644 src/mod/features/double_click_assign_ship.h delete mode 100644 src/mod/features/forbidden_tech.cc delete mode 100644 src/mod/features/interior_zoom.cc delete mode 100644 src/mod/features/pinned_ship_click.cc delete mode 100644 src/mod/features/pinned_ship_click.h delete mode 100644 src/mod/features/queue_address_guard.cc delete mode 100644 src/mod/features/queue_address_guard_policy.h delete mode 100644 src/mod/features/reveal_confirmation.cc delete mode 100644 src/mod/features/ship_tile_click.cc delete mode 100644 src/mod/features/ship_tile_click.h delete mode 100644 src/mod/features/thin_queue_protection.cc create mode 100644 src/mod/game/camera.h delete mode 100644 src/mod/game/native_hook_extent.cc delete mode 100644 src/mod/game/native_hook_extent.h delete mode 100644 tests/queue_address_guard.cc delete mode 100644 tests/run-action-queue.ps1 delete mode 100644 tests/thin_queue_protection.cc diff --git a/build.zig b/build.zig index a7a379234..fb46d7d5d 100644 --- a/build.zig +++ b/build.zig @@ -40,10 +40,10 @@ pub fn build(b: *std.Build) void { // Libraries the native fixtures share with the shipped mod. None of them need networking or a platform SDK, // so Linux can build and run the fixtures. - const il2cpp = mod.il2cpp(ctx); + const spud = deps.spud(ctx, deps.zydis(ctx), deps.capstone(ctx)); + const il2cpp = mod.il2cpp(ctx, spud); const spdlog = deps.spdlog(ctx); const toml = deps.tomlplusplus(ctx); - const spud = deps.spud(ctx, deps.zydis(ctx), deps.capstone(ctx)); const proto_runtime = protobuf.protobufLite(ctx, protobuf.abseil(ctx)); // A null result means Zig still has to fetch a lazy dependency; it reruns build() once it has. const proto = protobuf.generate(ctx, proto_runtime) orelse return; diff --git a/src/libs/il2cpp/include/il2cpp/api.h b/src/libs/il2cpp/include/il2cpp/api.h index 1cc56a404..e1ba6364e 100644 --- a/src/libs/il2cpp/include/il2cpp/api.h +++ b/src/libs/il2cpp/include/il2cpp/api.h @@ -1,6 +1,8 @@ #pragma once -#ifdef _WIN32 +// The raw IL2CPP runtime API, for the libraries. Mod code goes through the typed bindings instead. + +#if _WIN32 #pragma comment(lib, "GameAssembly.lib") #endif @@ -9,9 +11,7 @@ #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; @@ -24,9 +24,7 @@ extern "C" { #include "il2cpp-api-functions.h" #undef DO_API #undef DO_API_NO_RETURN -#if defined(__cplusplus) } -#endif // __cplusplus namespace il2cpp { diff --git a/src/libs/il2cpp/include/il2cpp/hook.h b/src/libs/il2cpp/include/il2cpp/hook.h index a264d2bae..472315050 100644 --- a/src/libs/il2cpp/include/il2cpp/hook.h +++ b/src/libs/il2cpp/include/il2cpp/hook.h @@ -1,93 +1,84 @@ #pragma once -// Detours on managed methods and runtime functions. A missing method is already reported by its lookup; these -// report a detour SPUD could not install, which SPUD_STATIC_DETOUR alone would drop without a word. Like it, they -// install permanently: nothing removes them, so their stubs skip SPUD's per-call entry counting. -// -// Hooks take the original as their first parameter: +// Detours on managed methods and native functions, installed through SPUD transactions. Hooks take the original +// first: // void ScreenManager_Update_Hook(auto original, ScreenManager* _this) { ...; original(_this); } -// IL2CPP_HOOK(ScreenManager::Class::Method("Update"), ScreenManager_Update_Hook); -// Each expansion installs one detour on its first call, so expand it once per target: a second call from the same -// place returns the first call's trampoline and installs nothing. +// +// il2cpp::HookSet hooks("Toasts"); +// IL2CPP_STAGE(hooks, ToastObserver::Class::Method("EnqueueToast"), ToastObserver_EnqueueToast_Hook); +// return hooks.Commit(); #include "il2cpp.h" #include -#include +#include +#include #include #include +#include +#include -namespace il2cpp::detail +namespace il2cpp { -void ReportDetourFailure(std::string_view target, const char* error); +// The address SPUD patches for a detour on `method`: its entry, or the body a lone jump there leads to, as an arm64 +// entry that is only `b body` often is. Methods with identical code share one body; compare these to find them. +void* DetourTarget(const Method& method); -constexpr const char* DetourErrorName(spud::detour_error error) +// The hooks one installer puts in, through one SPUD transaction: every one of them, or none. Stage each hook with +// IL2CPP_STAGE or IL2CPP_STAGE_AT, then Commit(). A missing method, already reported by its lookup, or a null address +// makes Commit install nothing. The transaction applies the set under one suspend window and undoes it if any step +// fails, so a hook never runs without the rest of its set. SPUD measures each target from the image's unwind or +// function-start tables and refuses one too short for its patch. Every set that does not go in is reported as +// "[IL2CPP] not installed: ", so features never log hook failures themselves. +// +// SPUD stages only removable hooks, which count calls in and out. After a successful commit they are detached and +// stay for the process. A set is committed once; a hook several features share goes in its own set, installed once. +// +// SPUD chains detours on one function safely, so two hooks may share a target. Identical methods can share +// generated code, though, and a hook on one of them then also runs for the others; DetourTarget finds them. +class HookSet { - using E = spud::detour_error; - switch (error) { - case E::kOk: - return "ok"; - case E::kUnsupportedArch: - return "unsupported architecture"; - case E::kDecodeFailed: - return "prologue could not be decoded"; - case E::kInsufficientBytes: - return "function too short for a detour"; - case E::kProtectionFailed: - return "page protection could not be changed"; - case E::kSuspensionUnavailable: - return "other threads could not be suspended"; - case E::kTargetChanged: - return "target changed while installing"; - case E::kBusy: - return "detour busy"; - case E::kChainFull: - return "too many detours on one function"; - case E::kControlFlowEnforced: - return "control-flow protection"; - case E::kNotFound: - return "not found"; - case E::kUnwindFailed: - return "unwind metadata could not be registered"; - case E::kRollbackFailed: - return "rollback failed"; +public: + // Names the set in reports; the feature's descriptor name reads best. + explicit HookSet(std::string_view name) + : name_(name) + { } - return "unknown error"; -} -inline bool Installed(spud::detour_error error, std::string_view target) -{ - if (error == spud::detour_error::kOk) - return true; - const auto detail = spud::last_failure_detail(); - std::string message = DetourErrorName(error); - if (detail.reason) - message += std::string(": ") + detail.reason; - ReportDetourFailure(target, message.c_str()); - return false; -} + template void Stage(Detour detour) + { + // The transaction holds a reference, so the handle lives on the heap until the commit detaches it. + auto handle = std::make_shared(std::move(detour)); + transaction_.install(*handle); + detach_.push_back([handle] { handle->detach(); }); + } + // Marks a member of the set whose method or address is missing. + void Missing() + { missing_ = true; } + + // True when every staged hook went in. A set with nothing staged installs nothing and is false. + bool Commit(); + +private: + std::string name_; + bool missing_ = false; + spud::transaction transaction_; + std::vector> detach_; +}; +} // namespace il2cpp -inline bool Installed(spud::detour_error error, const Method& target) -{ return error == spud::detour_error::kOk || Installed(error, target.Name()); } -} // namespace il2cpp::detail +// Macros because SPUD_AUTO_HOOK names the hook template itself to instantiate it for the target's signature. -// Detours `address` with `hook`; `name` (a string or an il2cpp::Method) labels a failure. Evaluates to the -// trampoline that calls the original, or null when nothing was installed. -#define IL2CPP_DETOUR(address, hook, name) \ +// Stages a managed method's hook in an il2cpp::HookSet. A missing method marks the set incomplete. +#define IL2CPP_STAGE(set, method, hook) \ + ([&](const ::il2cpp::Method& method_) { IL2CPP_STAGE_AT(set, method_.Pointer(), hook); }(method)) + +// Stages a hook on a native function, such as a runtime export. A null address marks the set incomplete. +#define IL2CPP_STAGE_AT(set, address, hook) \ ([&](void* target_) { \ - using Trampoline_ = decltype(SPUD_AUTO_HOOK(target_, hook).trampoline()); \ - static const Trampoline_ trampoline_ = [&]() -> Trampoline_ { \ - if (!target_) \ - return nullptr; \ - auto detour_ = SPUD_AUTO_HOOK(target_, hook); \ - if (!::il2cpp::detail::Installed(detour_.try_install_permanent(), name)) \ - return nullptr; \ - return detour_.trampoline(); \ - }(); \ - return trampoline_; \ + if (target_) \ + (set).Stage(SPUD_AUTO_HOOK(target_, hook)); \ + else \ + (set).Missing(); \ }(reinterpret_cast(address))) - -// Detours the native code of a managed method from Class::Method. A missing method installs nothing. -#define IL2CPP_HOOK(method, hook) \ - ([&](const ::il2cpp::Method& method_) { return IL2CPP_DETOUR(method_.Pointer(), hook, method_); }(method)) diff --git a/src/libs/il2cpp/include/il2cpp/il2cpp.h b/src/libs/il2cpp/include/il2cpp/il2cpp.h index 033b65455..c4bab95f8 100644 --- a/src/libs/il2cpp/include/il2cpp/il2cpp.h +++ b/src/libs/il2cpp/include/il2cpp/il2cpp.h @@ -1,18 +1,22 @@ #pragma once -// Typed access to the game's managed classes. Bindings name a managed class once, then read -// properties and fields and call methods by name. Member lookups and signature checks happen -// once. After that, calls jump straight to the generated native code. +// Typed access to managed classes. Bindings name a managed class once, then read properties and fields and call +// methods by name. Member lookups and signature checks happen once. After that, calls jump straight to the +// generated native code. Also owns the value, string, collection, delegate and GC-root helpers bindings need. #include "api.h" #include #include #include +#include +#include #include #include #include +#include #include +#include #include #include #include @@ -23,6 +27,8 @@ namespace il2cpp { +// --- Diagnostics --- + // Missing classes and members, and signatures that no longer match the binding, are reported // once per lookup. Without a handler, reports go to stderr. void SetErrorHandler(void (*handler)(std::string_view message)); @@ -31,6 +37,8 @@ void Report(std::string_view message); // "Namespace.Outer/Inner", for diagnostics. std::string ClassName(Il2CppClass* klass); +// --- Bindings --- + // Compile-time member name, used as a template argument: Call<"Show">(). template struct Name { char text[N]; @@ -86,6 +94,7 @@ namespace detail // System.Int32 and friends from corlib, by element type; null for anything else. Il2CppClass* PrimitiveClass(Il2CppTypeEnum type); void ReportFault(const char* what); + void ReportMissingICall(const char* name); template concept Bound = requires { T::Class::Raw(); }; @@ -139,24 +148,21 @@ namespace detail template constexpr Slot SlotOf() { - using U = typename Unref::Type; - Slot slot; - slot.byref = Unref::kByRef; + using U = typename Unref::Type; + constexpr bool byref = Unref::kByRef; if constexpr (std::is_void_v) - return slot; + return {.byref = byref}; else if constexpr (std::is_same_v) - slot.kind = Slot::Kind::kReference; + return {.kind = Slot::Kind::kReference, .byref = byref}; else if constexpr (std::is_pointer_v) { static_assert(!std::is_function_v>, "managed values cannot be function pointers"); - slot.kind = Slot::Kind::kReference; - slot.klass = ClassOf>>(); + return {.kind = Slot::Kind::kReference, + .byref = byref, + .klass = ClassOf>>()}; } else { static_assert(std::is_trivially_copyable_v, "managed values must be trivially copyable"); - slot.kind = Slot::Kind::kValue; - slot.size = sizeof(U); - slot.primitive = Primitive(); + return {.kind = Slot::Kind::kValue, .byref = byref, .size = sizeof(U), .primitive = Primitive()}; } - return slot; } template inline constexpr std::array kParams{SlotOf()...}; @@ -166,11 +172,15 @@ namespace detail Il2CppClass* FindClass(std::string_view assembly, std::string_view namespaze, std::string_view name); Method FindMethod(Il2CppClass* klass, std::string_view name, int count); Method FindMethod(Il2CppClass* klass, std::string_view name, std::initializer_list params); - const MethodInfo* ResolveMethod(Il2CppClass* klass, std::string_view name, const Signature& signature, - bool is_static); + // Resolving a static member runs the class's static constructor, as the first managed access would. Hook + // lookups pass `initialize` false: they run at startup, before the game would have touched the class. + const MethodInfo* ResolveMethod(Il2CppClass* klass, std::string_view name, const Signature& signature, bool is_static, + bool initialize = true); const MethodInfo* ResolveProperty(Il2CppClass* klass, std::string_view name, const Slot& value, bool setter, bool is_static); FieldInfo* ResolveField(Il2CppClass* klass, std::string_view name, const Slot& value, bool is_static); + // Fully shared generic and abstract methods have no code of their own to detour: null, after a report. + Method Hookable(const MethodInfo* method); // Members of bound classes resolve once. Unbound wrappers (generic collections, Object itself) // resolve against the receiver's runtime class, cached per class and thread. @@ -215,9 +225,26 @@ namespace detail return &value; } + // Runs `method` through the runtime's invoker, which boxes value results. A managed exception is reported and + // returns null. Il2CppObject* InvokeBoxed(const MethodInfo* method, void* target, void** args); void ReportException(const MethodInfo* method, Il2CppException* exception); + // An InvokeBoxed result as R: a reference is the object itself, a value is unboxed. R() for a null result. + template R FromBoxed(Il2CppObject* result) + { + if constexpr (std::is_void_v) + return; + else if constexpr (std::is_pointer_v) + return reinterpret_cast(result); + else { + R value{}; + if (result) + std::memcpy(&value, il2cpp_object_unbox(result), sizeof(R)); + return value; + } + } + template R InvokeNative(const MethodInfo* method, Il2CppObject* target, A... args) { // Fully shared generic code takes a different native ABI. Let the runtime marshal it. @@ -225,16 +252,7 @@ namespace detail void* argv[] = {ToBoxedArgument(args)..., nullptr}; // The runtime invoker takes value-type receivers unboxed. void* receiver = target && il2cpp_class_is_valuetype(method->klass) ? il2cpp_object_unbox(target) : target; - auto* result = InvokeBoxed(method, receiver, argv); - if constexpr (std::is_pointer_v) - return reinterpret_cast(result); - else if constexpr (!std::is_void_v) { - R value{}; - if (result) - std::memcpy(&value, il2cpp_object_unbox(result), sizeof(R)); - return value; - } else - return; + return FromBoxed(InvokeBoxed(method, receiver, argv)); } if (method->flags & METHOD_ATTRIBUTE_STATIC) { using Fn = R (*)(Native..., const MethodInfo*); @@ -245,8 +263,8 @@ namespace detail return reinterpret_cast(method->virtualMethodPointer)(target, ToNative(args)..., method); } - // Methods are validated at resolution, so this is a plain native call. Missing members (already - // reported) and missing receivers return a value-initialized R. Managed exceptions stop here: + // Methods are validated at resolution, so this is a plain native call. A missing member (already + // reported) returns a value-initialized R; receivers must not be null. Managed exceptions stop here: // they are reported once per method and the call returns R(), so a throwing getter cannot unwind // through a hook and skip the game's own code. template R Invoke(const MethodInfo* method, Il2CppObject* target, A... args) @@ -297,9 +315,9 @@ namespace detail return {}; } - // Call<"Name", F>: F is either the result type, with parameters taken from the argument types, - // or a full signature R(P...). A signature converts arguments to P without narrowing, and a P of - // T& is a ref/out parameter that binds the caller's lvalue (std::ref in the deduced form). + // Call<"Name", F>: F is either the result type, with parameters taken from the argument types (CallOf), + // or a full signature R(P...). Param converts each argument to P with brace initialization, which rejects + // narrowing, and turns a T& parameter into the std::reference_wrapper the deduced form uses for ref/out. template struct Param { using Type = T; template static T From(A&& value) @@ -313,8 +331,8 @@ namespace detail template struct MethodCall; template struct MethodCall { - static const MethodInfo* Resolve(Il2CppClass* klass, std::string_view name, bool is_static) - { return ResolveMethod(klass, name, SignatureOf::Type...>(), is_static); } + static const MethodInfo* Resolve(Il2CppClass* klass, std::string_view name, bool is_static, bool initialize = true) + { return ResolveMethod(klass, name, SignatureOf::Type...>(), is_static, initialize); } template static R Run(const MethodInfo* method, Il2CppObject* target, A&&... args) { static_assert(sizeof...(A) == sizeof...(P), "argument count does not match the signature"); @@ -377,26 +395,30 @@ template struct Class { return type; } - // Lookups for detours. A count of -1 accepts any arity; parameter names are "Namespace.Type". + // Lookups for detours. A count of -1 accepts any arity; parameter names are "Namespace.Type". A class that does + // not declare the method finds its nearest base's, so the hook covers every class sharing that base code; check + // `Info()->klass` when only this class's override may be detoured. Use Method for nested or generic + // parameter types, which these names cannot spell. static il2cpp::Method Method(std::string_view name, int count = -1) { return detail::FindMethod(Raw(), name, count); } static il2cpp::Method Method(std::string_view name, std::initializer_list params) { return detail::FindMethod(Raw(), name, params); } - // An instance method with its complete C++ signature, for hooks that must reject client ABI drift. + // An instance method with its complete C++ signature, for hooks that must reject client ABI drift. The + // signature leaves out `this`; the hook still takes it after `original`. template static il2cpp::Method Method(std::string_view name) - { - const auto* method = detail::MethodCall::Resolve(Raw(), name, false); - if (method && (method->has_full_generic_sharing_signature || (method->flags & METHOD_ATTRIBUTE_ABSTRACT))) { - Report("[IL2CPP] cannot hook shared or abstract method " + ClassName(Raw()) + "." + std::string(name)); - return {}; - } - return il2cpp::Method(method); - } + { return detail::Hookable(detail::MethodCall::Resolve(Raw(), name, false)); } + // The same for a static method. + template static il2cpp::Method StaticMethod(std::string_view name) + { return detail::Hookable(detail::MethodCall::Resolve(Raw(), name, true, false)); } - // Checks an instance field's schema without reading an object, for hooks that write several related fields. + // Checks an instance field's schema without reading an object. Only for hooks whose decisions would be wrong on a + // value read as zero; a missing member is already reported on first use. template static bool HasField() { return detail::ResolveField(Raw(), N.View(), detail::SlotOf(), false) != nullptr; } + // The same for an instance property's getter. + template static bool HasProperty() + { return detail::ResolveProperty(Raw(), N.View(), detail::SlotOf(), false, false) != nullptr; } template static auto Call(A&&... args) { @@ -429,9 +451,35 @@ template struct Class { // Base for reference-type bindings. A binding pointer is the managed object itself; bindings add // no data members. Receivers must not be null. Missing members return a value-initialized result. struct Object : Il2CppObject { - // Checks a method on the receiver's runtime class, including inflated collection types. - template il2cpp::Method Method(this Self& self, std::string_view name) - { return il2cpp::Method(detail::MethodCall::Resolve(self.klass, name, false)); } + // Whether the receiver's runtime class, including inflated collection types, has this instance method. For + // availability checks; hooks look methods up through Class. + template bool HasMethod(this Self& self, std::string_view name) + { return detail::MethodCall::Resolve(self.klass, name, false) != nullptr; } + + // Calls a parameterless instance method on the value-type field N in place, as `this.N.M()` would, so a method + // that caches into the struct updates this object's copy. T is the field's layout. The runtime invokes the method, + // since the struct has no box for the generated code's adjustor thunk. + template R CallOnField(this Self& self) + { + static_assert(!std::is_pointer_v, "CallOnField is for value-type fields"); + struct Members { + FieldInfo* field = nullptr; + const MethodInfo* method = nullptr; + }; + const auto members = detail::Lookup(self, [](Il2CppClass* klass) { + auto* field = detail::ResolveField(klass, N.View(), detail::SlotOf(), false); + return Members{ + .field = field, + .method = field ? detail::ResolveMethod(il2cpp_class_from_type(field->type), M.View(), + detail::SignatureOf(), false) + : nullptr, + }; + }); + if (!members.method) + return R(); + return detail::FromBoxed( + detail::InvokeBoxed(members.method, reinterpret_cast(&self) + members.field->offset, nullptr)); + } template T Get(this Self& self) { @@ -486,6 +534,8 @@ struct Object : Il2CppObject { { return Is() ? static_cast(this) : nullptr; } }; +// --- Objects and values --- + // Allocate T and run the constructor matching the arguments. Null when the class or constructor // is missing. Native stacks are scanned by the GC; keep a GcHandle to hold the result longer. template T* New(A... args) @@ -523,6 +573,23 @@ template Il2CppObject* Box(T value) return klass ? il2cpp_value_box(klass, &value) : nullptr; } +// Reads a boxed primitive of exactly type T. False, leaving `value` alone, for null or any other type. +template bool Unbox(const Il2CppObject* object, T& value) +{ + if (!object || object->klass != PrimitiveClass()) + return false; + std::memcpy(&value, il2cpp_object_unbox(const_cast(object)), sizeof(T)); + return true; +} + +// System.Nullable as the runtime lays it out, for members that take or return one: the flag, then the value. +template struct Nullable { + bool has_value; + T value; +}; + +// --- Strings --- + // A new managed string. Like any managed object it lives while something managed or the native stack holds it. inline Il2CppString* NewString(std::string_view utf8) { return il2cpp_string_new_len(utf8.data(), static_cast(utf8.size())); } @@ -546,33 +613,11 @@ inline Il2CppString* ToString(Object* object) { if (!object) return nullptr; - if (object->klass == detail::StringClass()) - return reinterpret_cast(object); - return object->Call<"ToString", Il2CppString*>(); + return object->klass == detail::StringClass() ? reinterpret_cast(object) + : object->Call<"ToString", Il2CppString*>(); } -// 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) -{ return detail::InvokeNative(delegate->method, delegate->target, args...); } - -// Runs `read` and, on Windows, survives an access violation inside it: reading game objects whose layout drifted -// between client builds should cost a log line, not the game. False, after reporting `what`, when one was caught. -template bool Guarded(const char* what, Fn&& read) -{ -#if _WIN32 - __try { - read(); - } __except (1 /* EXCEPTION_EXECUTE_HANDLER */) { - detail::ReportFault(what); - return false; - } -#else - (void)what; - read(); -#endif - return true; -} +// --- Collections --- // Managed T[]. Element references are borrowed; use Set to store references with a GC barrier. // Set supports references and primitives only. @@ -620,16 +665,24 @@ template struct List : Object { void Clear() { Call<"Clear">(); } + // A forward iterator, so the list works with std::ranges algorithms. Each element is read through get_Item. struct Iterator { - List* list; - int32_t index; - T operator*() const + using iterator_concept = std::forward_iterator_tag; + using value_type = T; + using difference_type = std::ptrdiff_t; + + List* list{}; + int32_t index{}; + + T operator*() const { return (*list)[index]; } Iterator& operator++() { ++index; return *this; } + Iterator operator++(int) + { return {list, index++}; } bool operator==(const Iterator& other) const { return index == other.index; } }; @@ -640,10 +693,63 @@ template struct List : Object { { return {this, Count()}; } }; +// Game fields often hold a null collection, and indices from the game may not fit one. Items is empty and At reads +// as zero (nullptr for references) in those cases, so callers need no check of their own. +template std::span Items(Array* array) +{ return array ? std::span(array->data(), array->size()) : std::span(); } +template std::ranges::subrange::Iterator> Items(List* list) +{ + return list ? std::ranges::subrange(list->begin(), list->end()) : std::ranges::subrange::Iterator>(); +} + +template T At(Array* array, std::integral auto index) +{ + return array && std::cmp_greater_equal(index, 0) && std::cmp_less(index, array->size()) + ? (*array)[static_cast(index)] + : T{}; +} +template T At(List* list, std::integral auto index) +{ + return list && std::cmp_greater_equal(index, 0) && std::cmp_less(index, list->Count()) + ? (*list)[static_cast(index)] + : T{}; +} + +// --- Delegates --- + // Build a delegate of `delegate_class` bound to a static method. With `native`, invocations jump to // that C++ function instead of the method's generated code; it must match the method's native ABI. Il2CppObject* NewDelegate(Il2CppClass* delegate_class, Method target, Il2CppMethodPointer native = nullptr); +// Runs the method a delegate is bound to on its target directly, skipping the delegate's invoke stub. Single-cast +// delegates only: it ignores invocation lists and native redirects, and calls the bound method without virtual +// dispatch. Unlike Call, a managed exception is not caught here, so the caller can fall back: catch +// Il2CppExceptionWrapper. +template R InvokeBound(Il2CppDelegate* delegate, A... args) +{ return detail::InvokeNative(delegate->method, delegate->target, args...); } + +// --- Faults --- + +// Runs `read` and, on Windows, survives an access violation inside it: reading game objects whose layout drifted +// between client builds should cost a log line, not the game. False, after reporting `what`, when one was caught. +template bool Guarded(const char* what, Fn&& read) +{ +#if _WIN32 + __try { + read(); + } __except (1 /* EXCEPTION_EXECUTE_HANDLER */) { + detail::ReportFault(what); + return false; + } +#else + (void)what; + read(); +#endif + return true; +} + +// --- GC roots --- + // Move-only managed root. Weak handles do not retain the managed object. // Release roots before the runtime shuts down; these do not destroy Unity native resources. class GcHandle @@ -718,10 +824,7 @@ class GcRoots bool healthy_ = true; }; -namespace detail -{ - void ReportMissingICall(const char* name); -} // namespace detail +// --- Internal calls --- // A Unity engine internal call. One that a Unity upgrade renamed is reported once, when resolved, and then returns // the default value instead of calling through null; test it with operator bool to choose another default. @@ -753,8 +856,4 @@ template class ICall R (*fn_)(A...); }; -// Resolves e.g. "UnityEngine.Input::GetKeyInt(UnityEngine.KeyCode)". Keep the result in a static: resolving is a -// lookup by name. -template ICall ResolveICall(const char* name) -{ return ICall(name); } } // namespace il2cpp diff --git a/src/libs/il2cpp/include/il2cpp/method_contract.h b/src/libs/il2cpp/include/il2cpp/method_contract.h deleted file mode 100644 index ca9004941..000000000 --- a/src/libs/il2cpp/include/il2cpp/method_contract.h +++ /dev/null @@ -1,49 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -namespace il2cpp::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 il2cpp::contract diff --git a/src/libs/il2cpp/src/api.cc b/src/libs/il2cpp/src/api.cc index ee42c59b3..af82e1267 100644 --- a/src/libs/il2cpp/src/api.cc +++ b/src/libs/il2cpp/src/api.cc @@ -1,20 +1,18 @@ +// Binds the IL2CPP exports from the loaded GameAssembly image on macOS. Windows links them through GameAssembly.lib. + #include #include #if !_WIN32 #include -#if defined(__cplusplus) extern "C" { -#endif // __cplusplus #define DO_API(r, n, p) n##_t n; #define DO_API_NO_RETURN(r, n, p) DO_API(r, n, p) #include "il2cpp-api-functions.h" #undef DO_API #undef DO_API_NO_RETURN -#if defined(__cplusplus) } -#endif // __cplusplus #endif void il2cpp::Initialize(void* assembly) @@ -28,6 +26,7 @@ void il2cpp::Initialize(void* assembly) #include #undef DO_API #undef DO_API_NO_RETURN + // The exports the libraries call. Failing startup here beats calling through null on first use. #define REQUIRE_API(name) \ if (!name) \ throw std::runtime_error("missing IL2CPP export: " #name); @@ -45,7 +44,6 @@ void il2cpp::Initialize(void* assembly) REQUIRE_API(il2cpp_class_get_declaring_type) REQUIRE_API(il2cpp_class_get_parent) REQUIRE_API(il2cpp_class_get_methods) - REQUIRE_API(il2cpp_class_get_method_from_name) REQUIRE_API(il2cpp_class_get_property_from_name) REQUIRE_API(il2cpp_class_get_field_from_name) REQUIRE_API(il2cpp_class_get_nested_types) @@ -73,6 +71,10 @@ void il2cpp::Initialize(void* assembly) REQUIRE_API(il2cpp_gchandle_get_target) REQUIRE_API(il2cpp_gchandle_free) REQUIRE_API(il2cpp_resolve_icall) + REQUIRE_API(il2cpp_string_new_len) + REQUIRE_API(il2cpp_value_box) + // The object tracker detours it to forget objects the GC frees without OnDestroy. + REQUIRE_API(il2cpp_unity_liveness_finalize) #undef REQUIRE_API #else (void)assembly; diff --git a/src/libs/il2cpp/src/hook.cc b/src/libs/il2cpp/src/hook.cc new file mode 100644 index 000000000..a22826d5b --- /dev/null +++ b/src/libs/il2cpp/src/hook.cc @@ -0,0 +1,82 @@ +// Owns hook set commits and their failure reports. + +#include + +#include + +#include +#include + +namespace il2cpp +{ +namespace +{ + constexpr const char* DetourErrorName(spud::detour_error error) + { + using E = spud::detour_error; + switch (error) { + case E::kOk: + return "ok"; + case E::kUnsupportedArch: + return "unsupported architecture"; + case E::kDecodeFailed: + return "prologue could not be decoded"; + case E::kInsufficientBytes: + return "function too short for a detour"; + case E::kProtectionFailed: + return "page protection could not be changed"; + case E::kSuspensionUnavailable: + return "other threads could not be suspended"; + case E::kTargetChanged: + return "target changed while installing"; + case E::kBusy: + return "detour busy"; + case E::kChainFull: + return "too many detours on one function"; + case E::kControlFlowEnforced: + return "control-flow protection"; + case E::kNotFound: + return "not found"; + case E::kUnwindFailed: + return "unwind metadata could not be registered"; + case E::kRollbackFailed: + return "rollback failed"; + } + return "unknown error"; + } + + void ReportNotInstalled(std::string_view set, std::string_view why) + { Report(std::format("[IL2CPP] {} not installed: {}", set, why)); } +} // namespace + +bool HookSet::Commit() +{ + if (missing_) { + // The lookup has named the method; this names what it costs. + ReportNotInstalled(name_, "a method it hooks is missing"); + return false; + } + if (detach_.empty()) + return false; + if (const auto error = transaction_.commit(); error != spud::detour_error::kOk) { + // SPUD records per thread why the failing step was refused, as a short token. + const auto detail = spud::last_failure_detail(); + ReportNotInstalled(name_, detail.reason ? std::format("{}: {}", DetourErrorName(error), detail.reason) + : DetourErrorName(error)); + detach_.clear(); + return false; + } + for (const auto& detach : detach_) + detach(); + detach_.clear(); + return true; +} + +void* DetourTarget(const Method& method) +{ + void* entry = method.Pointer(); + // The plan resolves the jump without writing anything. It names no target where SPUD cannot hook at all. + const auto target = entry ? spud::inspect_detour(entry).resolved_target : 0; + return target ? reinterpret_cast(target) : entry; +} +} // namespace il2cpp diff --git a/src/libs/il2cpp/src/il2cpp.cc b/src/libs/il2cpp/src/il2cpp.cc index c859395ef..fe95c49c4 100644 --- a/src/libs/il2cpp/src/il2cpp.cc +++ b/src/libs/il2cpp/src/il2cpp.cc @@ -1,30 +1,42 @@ -// Owns metadata lookup and signature checks for typed bindings, and their diagnostics. +// Owns metadata lookup, signature checks and their diagnostics for typed bindings, plus managed string conversion +// and delegate construction. #include +#include #include #include #include +#include +#include #include +#include +#include #include #include +#include namespace il2cpp { -std::string ClassName(Il2CppClass* klass) -{ - if (!klass) - return ""; - if (auto* outer = il2cpp_class_get_declaring_type(klass)) - return ClassName(outer) + "/" + il2cpp_class_get_name(klass); - std::string name = il2cpp_class_get_namespace(klass); - return name.empty() ? il2cpp_class_get_name(klass) : name + "." + il2cpp_class_get_name(klass); -} - namespace { std::atomic error_handler = nullptr; + std::span Parameters(const MethodInfo* method) + { return {method->parameters, method->parameters_count}; } + + // "a, b, c" from each item's text. + std::string Join(auto&& items, auto text) + { + std::string joined; + for (auto&& item : items) { + if (!joined.empty()) + joined += ", "; + joined += text(item); + } + return joined; + } + std::string TypeName(const Il2CppType* type) { auto* raw = type ? il2cpp_type_get_name(type) : nullptr; @@ -35,31 +47,24 @@ namespace } std::string Describe(const MethodInfo* method) - { - std::string text = TypeName(method->return_type) + " " + method->name + "("; - for (uint8_t i = 0; i < method->parameters_count; ++i) - text += (i ? ", " : "") + TypeName(method->parameters[i]); - return text + ")"; - } + { return std::format("{} {}({})", TypeName(method->return_type), method->name, Join(Parameters(method), TypeName)); } std::string Describe(const detail::Slot& slot) { - using Kind = detail::Slot::Kind; - std::string text = slot.byref ? "ref " : ""; + using Kind = detail::Slot::Kind; + const auto* byref = slot.byref ? "ref " : ""; if (slot.kind == Kind::kVoid) - return text + "void"; + return std::format("{}void", byref); if (slot.kind == Kind::kValue) - return text + std::to_string(slot.size) + "-byte value"; + return std::format("{}{}-byte value", byref, slot.size); auto* expected = slot.klass ? slot.klass() : nullptr; - return text + (expected ? ClassName(expected) : "reference"); + return std::format("{}{}", byref, expected ? ClassName(expected) : "reference"); } std::string Describe(const detail::Signature& signature) { - std::string text = Describe(signature.result) + " ("; - for (size_t i = 0; i < signature.params.size(); ++i) - text += (i ? ", " : "") + Describe(signature.params[i]); - return text + ")"; + return std::format("{} ({})", Describe(signature.result), + Join(signature.params, [](const detail::Slot& slot) { return Describe(slot); })); } bool IsIntegral(Il2CppTypeEnum type) @@ -105,13 +110,9 @@ namespace bool Matches(const MethodInfo* method, const detail::Signature& signature) { - if (method->parameters_count != signature.params.size() || !Matches(method->return_type, signature.result)) - return false; - for (size_t i = 0; i < signature.params.size(); ++i) { - if (!Matches(method->parameters[i], signature.params[i])) - return false; - } - return true; + return method->parameters_count == signature.params.size() && Matches(method->return_type, signature.result) + && std::ranges::equal(Parameters(method), signature.params, + [](const Il2CppType* type, const detail::Slot& slot) { return Matches(type, slot); }); } bool IsStatic(const MethodInfo* method) @@ -122,19 +123,25 @@ namespace bool Callable(const MethodInfo* method) { return !method->is_generic; } - // Constructors are not inherited, so they are only looked up on the class itself. - template void ForEachMethod(Il2CppClass* klass, std::string_view name, Visit visit) + // Abstract methods and generic definitions only have shared stubs, which must never be detoured. + bool Detourable(const MethodInfo* method) + { return Callable(method) && !(method->flags & METHOD_ATTRIBUTE_ABSTRACT); } + + // The first method called `name` that `accept` takes, searching the class before its bases. Constructors are not + // inherited, so they are only looked up on the class itself. + const MethodInfo* FirstMethod(Il2CppClass* klass, std::string_view name, auto accept) { const bool inherited = name != ".ctor" && name != ".cctor"; for (; klass; klass = inherited ? il2cpp_class_get_parent(klass) : nullptr) { void* iter = nullptr; - while (const auto* method = il2cpp_class_get_methods(klass, &iter)) { - if (name == method->name && !visit(method)) - return; - } + while (const auto* method = il2cpp_class_get_methods(klass, &iter)) + if (name == method->name && accept(method)) + return method; } + return nullptr; } + // Parameter names are "Namespace.Type", or just "Type" for any namespace. bool TypeNamed(const Il2CppType* type, std::string_view expected) { auto* klass = type ? il2cpp_class_from_type(type) : nullptr; @@ -146,6 +153,15 @@ namespace return expected.substr(0, dot) == il2cpp_class_get_namespace(klass) && expected.substr(dot + 1) == il2cpp_class_get_name(klass); } + + Il2CppClass* NestedClass(Il2CppClass* outer, std::string_view name) + { + void* iter = nullptr; + while (auto* candidate = il2cpp_class_get_nested_types(outer, &iter)) + if (name == il2cpp_class_get_name(candidate)) + return candidate; + return nullptr; + } } // namespace void SetErrorHandler(void (*handler)(std::string_view message)) @@ -159,8 +175,19 @@ void Report(std::string_view message) std::fprintf(stderr, "%.*s\n", static_cast(message.size()), message.data()); } +std::string ClassName(Il2CppClass* klass) +{ + if (!klass) + return ""; + if (auto* outer = il2cpp_class_get_declaring_type(klass)) + return std::format("{}/{}", ClassName(outer), il2cpp_class_get_name(klass)); + const std::string_view namespaze = il2cpp_class_get_namespace(klass); + return namespaze.empty() ? il2cpp_class_get_name(klass) + : std::format("{}.{}", namespaze, il2cpp_class_get_name(klass)); +} + std::string Method::Name() const -{ return info_ ? ClassName(info_->klass) + "." + info_->name : ""; } +{ return info_ ? std::format("{}.{}", ClassName(info_->klass), info_->name) : ""; } Il2CppClass* ObjectClass() { @@ -173,15 +200,17 @@ std::string ToUtf8(const Il2CppString* str) std::string result; if (!str) return result; - result.reserve(static_cast(str->length)); - const auto* chars = str->chars; - for (int32_t i = 0; i < str->length; ++i) { + const std::span chars(str->chars, static_cast(str->length)); + result.reserve(chars.size()); + const auto in = [](uint32_t c, uint32_t low, uint32_t high) { return c >= low && c <= high; }; + for (size_t i = 0; i < chars.size(); ++i) { uint32_t c = chars[i]; // A surrogate pair is one code point; a lone surrogate becomes U+FFFD. - if (c >= 0xD800 && c <= 0xDBFF && i + 1 < str->length && chars[i + 1] >= 0xDC00 && chars[i + 1] <= 0xDFFF) + if (in(c, 0xD800, 0xDBFF) && i + 1 < chars.size() && in(chars[i + 1], 0xDC00, 0xDFFF)) c = 0x10000 + ((c - 0xD800) << 10) + (chars[++i] - 0xDC00); - else if (c >= 0xD800 && c <= 0xDFFF) + else if (in(c, 0xD800, 0xDFFF)) c = 0xFFFD; + if (c < 0x80) { result += static_cast(c); } else if (c < 0x800) { @@ -204,7 +233,7 @@ std::string ToUtf8(const Il2CppString* str) namespace detail { void ReportMissingICall(const char* name) - { Report(std::string("[IL2CPP] missing internal call ") + name); } + { Report(std::format("[IL2CPP] missing internal call {}", name)); } Il2CppClass* StringClass() { @@ -220,59 +249,46 @@ namespace detail Il2CppClass* PrimitiveClass(Il2CppTypeEnum type) { - static constexpr std::pair kNames[] = { + using Entry = std::pair; + static constexpr Entry kNames[] = { {IL2CPP_TYPE_BOOLEAN, "Boolean"}, {IL2CPP_TYPE_CHAR, "Char"}, {IL2CPP_TYPE_I1, "SByte"}, {IL2CPP_TYPE_U1, "Byte"}, {IL2CPP_TYPE_I2, "Int16"}, {IL2CPP_TYPE_U2, "UInt16"}, {IL2CPP_TYPE_I4, "Int32"}, {IL2CPP_TYPE_U4, "UInt32"}, {IL2CPP_TYPE_I8, "Int64"}, {IL2CPP_TYPE_U8, "UInt64"}, {IL2CPP_TYPE_R4, "Single"}, {IL2CPP_TYPE_R8, "Double"}, }; static std::array, std::size(kNames)> classes{}; - for (size_t i = 0; i < std::size(kNames); ++i) { - if (kNames[i].first != type) - continue; - auto* klass = classes[i].load(); - if (!klass) { - klass = il2cpp_class_from_name(il2cpp_get_corlib(), "System", kNames[i].second); - classes[i].store(klass); - } - return klass; + const auto* entry = std::ranges::find(kNames, type, &Entry::first); + if (entry == std::end(kNames)) + return nullptr; + auto& cached = classes[entry - std::begin(kNames)]; + auto* klass = cached.load(); + if (!klass) { + klass = il2cpp_class_from_name(il2cpp_get_corlib(), "System", entry->second); + cached.store(klass); } - return nullptr; + return klass; } void ReportFault(const char* what) - { Report(std::string("[IL2CPP] access violation while reading ") + what); } - - void ReportDetourFailure(std::string_view target, const char* error) - { Report("[IL2CPP] could not detour " + std::string(target) + ": " + error); } + { Report(std::format("[IL2CPP] access violation while reading {}", what)); } Il2CppClass* FindClass(std::string_view assembly, std::string_view namespaze, std::string_view name) { - const std::string assembly_name(assembly), namespace_name(namespaze); + const std::string namespace_name(namespaze); auto* domain = il2cpp_domain_get(); - auto* loaded = domain ? il2cpp_domain_assembly_open(domain, assembly_name.c_str()) : nullptr; + auto* loaded = domain ? il2cpp_domain_assembly_open(domain, std::string(assembly).c_str()) : nullptr; auto* image = loaded ? il2cpp_assembly_get_image(loaded) : nullptr; // Nested types are reached through their declaring type: "Outer/Inner". - const auto slash = name.find('/'); - const auto outer = std::string(name.substr(0, slash)); - Il2CppClass* klass = image ? il2cpp_class_from_name(image, namespace_name.c_str(), outer.c_str()) : nullptr; - for (auto rest = slash; klass && rest != std::string_view::npos;) { - const auto next = name.find('/', rest + 1); - const auto nested = name.substr(rest + 1, next == std::string_view::npos ? next : next - rest - 1); - void* iter = nullptr; - auto* parent = std::exchange(klass, nullptr); - while (auto* candidate = il2cpp_class_get_nested_types(parent, &iter)) { - if (nested == il2cpp_class_get_name(candidate)) { - klass = candidate; - break; - } - } - rest = next; - } + const auto slash = name.find('/'); + auto* klass = + image ? il2cpp_class_from_name(image, namespace_name.c_str(), std::string(name.substr(0, slash)).c_str()) + : nullptr; + if (slash != std::string_view::npos) + for (const auto nested : name.substr(slash + 1) | std::views::split('/')) + klass = klass ? NestedClass(klass, std::string_view(nested.begin(), nested.end())) : nullptr; if (!klass) - Report("[IL2CPP] missing class " + assembly_name + ":" + (namespace_name.empty() ? "" : namespace_name + ".") - + std::string(name)); + Report(std::format("[IL2CPP] missing class {}:{}{}{}", assembly, namespaze, namespaze.empty() ? "" : ".", name)); return klass; } @@ -280,18 +296,12 @@ namespace detail { if (!klass) return {}; - // Abstract methods and generic definitions only have shared stubs, which must never be detoured. - const MethodInfo* found = nullptr; - ForEachMethod(klass, name, [&](const MethodInfo* method) { - if (!Callable(method) || (method->flags & METHOD_ATTRIBUTE_ABSTRACT) - || (count >= 0 && method->parameters_count != count)) - return true; - found = method; - return false; + const auto* found = FirstMethod(klass, name, [count](const MethodInfo* method) { + return Detourable(method) && (count < 0 || method->parameters_count == count); }); if (!found) - Report("[IL2CPP] missing method " + ClassName(klass) + "." + std::string(name) - + (count >= 0 ? " with " + std::to_string(count) + " parameters" : "")); + Report(std::format("[IL2CPP] missing method {}.{}{}", ClassName(klass), name, + count >= 0 ? std::format(" with {} parameters", count) : "")); return Method(found); } @@ -299,54 +309,39 @@ namespace detail { if (!klass) return {}; - const MethodInfo* found = nullptr; - ForEachMethod(klass, name, [&](const MethodInfo* method) { - if (!Callable(method) || (method->flags & METHOD_ATTRIBUTE_ABSTRACT) || method->parameters_count != params.size()) - return true; - size_t index = 0; - for (auto param : params) { - if (!TypeNamed(method->parameters[index++], param)) - return true; - } - found = method; - return false; + const auto* found = FirstMethod(klass, name, [params](const MethodInfo* method) { + return Detourable(method) && std::ranges::equal(Parameters(method), params, TypeNamed); }); - if (!found) { - std::string list; - for (auto param : params) - list += (list.empty() ? "" : ", ") + std::string(param); - Report("[IL2CPP] missing method " + ClassName(klass) + "." + std::string(name) + "(" + list + ")"); - } + if (!found) + Report(std::format("[IL2CPP] missing method {}.{}({})", ClassName(klass), name, Join(params, std::identity{}))); return Method(found); } - const MethodInfo* ResolveMethod(Il2CppClass* klass, std::string_view name, const Signature& signature, bool is_static) + const MethodInfo* ResolveMethod(Il2CppClass* klass, std::string_view name, const Signature& signature, bool is_static, + bool initialize) { if (!klass) return nullptr; // The most derived match wins; virtual calls still dispatch on the receiver. - const MethodInfo* found = nullptr; - std::string candidates; - ForEachMethod(klass, name, [&](const MethodInfo* method) { + std::string candidates; + const auto* found = FirstMethod(klass, name, [&](const MethodInfo* method) { if (!Callable(method) || IsStatic(method) != is_static) - return true; - if (Matches(method, signature)) { - found = method; return false; - } + if (Matches(method, signature)) + return true; candidates += "\n " + Describe(method); - return true; + return false; }); if (found) { - if (is_static) + if (is_static && initialize) il2cpp_runtime_class_init(found->klass); return found; } - const auto member = ClassName(klass) + "." + std::string(name); if (candidates.empty()) - Report("[IL2CPP] missing " + std::string(is_static ? "static " : "") + "method " + member); + Report(std::format("[IL2CPP] missing {}method {}.{}", is_static ? "static " : "", ClassName(klass), name)); else - Report("[IL2CPP] no overload of " + member + " matches " + Describe(signature) + "; found:" + candidates); + Report(std::format("[IL2CPP] no overload of {}.{} matches {}; found:{}", ClassName(klass), name, + Describe(signature), candidates)); return nullptr; } @@ -356,7 +351,7 @@ namespace detail if (!klass) return nullptr; const std::string property_name(name); - const auto member = ClassName(klass) + "." + property_name; + const auto member = std::format("{}.{}", ClassName(klass), name); const auto* property = il2cpp_class_get_property_from_name(klass, property_name.c_str()); if (!property) { Report("[IL2CPP] missing property " + member); @@ -364,30 +359,27 @@ namespace detail } // An override may redeclare only one accessor; the other is inherited from a base property. const MethodInfo* method = nullptr; - for (; property; property = property->parent && il2cpp_class_get_parent(property->parent) - ? il2cpp_class_get_property_from_name(il2cpp_class_get_parent(property->parent), - property_name.c_str()) - : nullptr) { - method = setter ? il2cpp_property_get_set_method(const_cast(property)) - : il2cpp_property_get_get_method(const_cast(property)); - if (method) + while (property) { + auto* raw = const_cast(property); + if ((method = setter ? il2cpp_property_get_set_method(raw) : il2cpp_property_get_get_method(raw))) break; + auto* base = property->parent ? il2cpp_class_get_parent(property->parent) : nullptr; + property = base ? il2cpp_class_get_property_from_name(base, property_name.c_str()) : nullptr; } if (!method) { - Report("[IL2CPP] property " + member + " has no " + (setter ? "setter" : "getter")); + Report(std::format("[IL2CPP] property {} has no {}", member, setter ? "setter" : "getter")); return nullptr; } if (IsStatic(method) != is_static) { - Report("[IL2CPP] property " + member + (is_static ? " is not static" : " is static")); + Report(std::format("[IL2CPP] property {} {}", member, is_static ? "is not static" : "is static")); return nullptr; } - const Slot void_slot; const bool matches = setter ? method->parameters_count == 1 && Matches(method->parameters[0], value) - && Matches(method->return_type, void_slot) + && Matches(method->return_type, Slot{}) : method->parameters_count == 0 && Matches(method->return_type, value); if (!matches) { - Report("[IL2CPP] property " + member + " is " + TypeName(setter ? method->parameters[0] : method->return_type) - + ", binding expects " + Describe(value)); + Report(std::format("[IL2CPP] property {} is {}, binding expects {}", member, + TypeName(setter ? method->parameters[0] : method->return_type), Describe(value))); return nullptr; } if (is_static) @@ -399,23 +391,23 @@ namespace detail { if (!klass) return nullptr; - const std::string field_name(name); - const auto member = ClassName(klass) + "." + field_name; - auto* field = il2cpp_class_get_field_from_name(klass, field_name.c_str()); + const auto member = std::format("{}.{}", ClassName(klass), name); + auto* field = il2cpp_class_get_field_from_name(klass, std::string(name).c_str()); if (!field) { Report("[IL2CPP] missing field " + member); return nullptr; } if (((field->type->attrs & FIELD_ATTRIBUTE_STATIC) != 0) != is_static) { - Report("[IL2CPP] field " + member + (is_static ? " is not static" : " is static")); + Report(std::format("[IL2CPP] field {} {}", member, is_static ? "is not static" : "is static")); return nullptr; } if (field->offset == THREAD_STATIC_FIELD_OFFSET) { - Report("[IL2CPP] field " + member + " is thread-static"); + Report(std::format("[IL2CPP] field {} is thread-static", member)); return nullptr; } if (!Matches(field->type, value)) { - Report("[IL2CPP] field " + member + " is " + TypeName(field->type) + ", binding expects " + Describe(value)); + Report( + std::format("[IL2CPP] field {} is {}, binding expects {}", member, TypeName(field->type), Describe(value))); return nullptr; } if (is_static) @@ -423,23 +415,31 @@ namespace detail return field; } + Method Hookable(const MethodInfo* method) + { + if (method && (method->has_full_generic_sharing_signature || (method->flags & METHOD_ATTRIBUTE_ABSTRACT))) { + Report("[IL2CPP] cannot hook shared or abstract method " + Method(method).Name()); + return {}; + } + return Method(method); + } + void ReportException(const MethodInfo* method, Il2CppException* exception) { // Once per method: a getter that throws every frame would otherwise flood the log. static std::mutex mutex; static std::unordered_set reported; { - std::lock_guard lock(mutex); + std::scoped_lock lock(mutex); if (!reported.insert(method).second) return; } - std::string message = "[IL2CPP] " + ClassName(method->klass) + "." + method->name + " threw"; - if (exception) { - char text[512]{}; - il2cpp_format_exception(exception, text, sizeof(text)); - message += std::string(": ") + text; - } - Report(message + " (further exceptions from this method are not logged)"); + // The runtime copies with strncpy, which leaves a full buffer unterminated. Keep the last byte NUL. + char text[512]{}; + if (exception) + il2cpp_format_exception(exception, text, sizeof(text) - 1); + Report(std::format("[IL2CPP] {}.{} threw{}{} (further exceptions from this method are not logged)", + ClassName(method->klass), method->name, exception ? ": " : "", text)); } Il2CppObject* InvokeBoxed(const MethodInfo* method, void* target, void** args) @@ -462,15 +462,12 @@ Il2CppObject* NewDelegate(Il2CppClass* delegate_class, Method target, Il2CppMeth return nullptr; // Delegate(object target, IntPtr method). Runtime invocation rejects static targets for this // constructor; its generated invoker accepts a null target and a MethodInfo address. - const MethodInfo* ctor = nullptr; - ForEachMethod(delegate_class, ".ctor", [&](const MethodInfo* method) { - if (method->parameters_count == 2 && method->parameters[0]->type == IL2CPP_TYPE_OBJECT - && method->parameters[1]->type == IL2CPP_TYPE_I && method->invoker_method) - ctor = method; - return ctor == nullptr; + const auto* ctor = FirstMethod(delegate_class, ".ctor", [](const MethodInfo* method) { + return method->parameters_count == 2 && method->parameters[0]->type == IL2CPP_TYPE_OBJECT + && method->parameters[1]->type == IL2CPP_TYPE_I && method->invoker_method; }); if (!ctor) { - Report("[IL2CPP] " + ClassName(delegate_class) + " has no (object, IntPtr) delegate constructor"); + Report(std::format("[IL2CPP] {} has no (object, IntPtr) delegate constructor", ClassName(delegate_class))); return nullptr; } auto* delegate = il2cpp_object_new(delegate_class); diff --git a/src/libs/unity/include/unity/core.h b/src/libs/unity/include/unity/core.h index 3a3992fc0..977c8c128 100644 --- a/src/libs/unity/include/unity/core.h +++ b/src/libs/unity/include/unity/core.h @@ -37,28 +37,31 @@ struct TimeSpan { int64_t ticks; }; -using ObjectClass = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Object">; +// UnityEngine.Object's static members. il2cpp::ObjectClass() is System.Object, a different class. +struct UnityObject { + using Class = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Object">; +}; // Unity's bool(Object): false for null, and for objects whose native side was destroyed even while a GC handle // still holds the managed wrapper. inline bool Alive(Il2CppObject* object) -{ return object && ObjectClass::Call<"op_Implicit", bool>(object); } +{ return object && UnityObject::Class::Call<"op_Implicit", bool>(object); } inline void Destroy(Il2CppObject* object) -{ ObjectClass::Call<"Destroy">(object); } +{ UnityObject::Class::Call<"Destroy">(object); } // The first loaded object of T. Scans every loaded object; keep it off per-frame paths. template T* FindObject() { auto* type = T::Class::TypeObject(); - return type ? static_cast(ObjectClass::Call<"FindObjectOfType", Il2CppObject*>(type)) : nullptr; + return type ? static_cast(UnityObject::Class::Call<"FindObjectOfType", Il2CppObject*>(type)) : nullptr; } // Every active loaded T. Scans every loaded object; keep it off per-frame paths. template il2cpp::Array* FindObjectsOfType() { auto* type = T::Class::TypeObject(); - return type ? ObjectClass::Call<"FindObjectsOfType", il2cpp::Array*>(type) : nullptr; + return type ? UnityObject::Class::Call<"FindObjectsOfType", il2cpp::Array*>(type) : nullptr; } // Every loaded T, active or not, including prefabs and other assets. Scans every loaded object; keep it off @@ -70,12 +73,16 @@ template il2cpp::Array* FindObjectsOfTypeAll() return type ? Resources::Call<"FindObjectsOfTypeAll", il2cpp::Array*>(type) : nullptr; } -// Behaviour.isActiveAndEnabled through its internal call, for bindings whose managed property may have drifted: -// without the call everything counts as active. +// Behaviour.isActiveAndEnabled for any MonoBehaviour. The getter resolves once on Behaviour itself, so bindings +// resolved on their runtime class, such as Widget`1, can share it. It goes through the managed getter rather than an +// internal call: Unity 6 registers only get_isActiveAndEnabled_Injected, which takes the native object pointer. False +// when the getter is missing, like any other binding. inline bool ActiveAndEnabled(il2cpp::Object* behaviour) { - static const il2cpp::ICall call{"UnityEngine.Behaviour::get_isActiveAndEnabled()"}; - return !call || call(behaviour); + using BehaviourClass = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Behaviour">; + static const auto* getter = il2cpp::detail::ResolveProperty(BehaviourClass::Raw(), "isActiveAndEnabled", + il2cpp::detail::SlotOf(), false, false); + return il2cpp::detail::Invoke(getter, behaviour); } struct Transform; @@ -96,6 +103,8 @@ struct GameObject : il2cpp::Object { static GameObject* Create(std::string_view name) { return il2cpp::New(il2cpp::NewString(name)); } + // The name converted to UTF-8. Accessors named exactly after a member, like `name()` elsewhere, return the managed + // value itself; capitalized helpers such as this one convert it. std::string Name() { return il2cpp::ToUtf8(Get<"name", Il2CppString*>()); } bool NameIs(std::string_view ascii) @@ -168,6 +177,18 @@ struct Transform : il2cpp::Object { return nullptr; } + // The first T on this transform's GameObject or its ancestors, active or not. The walk is bounded so a cycle in + // a half-destroyed hierarchy cannot hang the game; real UI hierarchies are far shallower. + template T* FindInParents(int32_t limit = 32) + { + auto* transform = this; + for (int32_t depth = 0; transform && depth < limit; ++depth, transform = transform->parent()) + if (auto* object = transform->gameObject()) + if (auto* found = object->GetComponent()) + return found; + return nullptr; + } + // The direct child whose GameObject is called `name`. Transform* ChildNamed(std::string_view name) { @@ -218,6 +239,14 @@ struct Component : il2cpp::Object { { return Get<"transform", Transform*>(); } GameObject* gameObject() { return Get<"gameObject", GameObject*>(); } + + // The GameObject of this component's parent transform, or null at the root. + GameObject* ParentObject() + { + auto* own = transform(); + auto* parent = own ? own->parent() : nullptr; + return parent ? parent->gameObject() : nullptr; + } }; // A copy of `original` (a component copies its whole GameObject) under `parent`. Uses the non-generic @@ -225,17 +254,19 @@ struct Component : il2cpp::Object { // would need generic inflation. template T* Instantiate(T* original, Transform* parent, bool world_position_stays = false) { - return static_cast(ObjectClass::Call<"Instantiate", il2cpp::Object*(il2cpp::Object*, Transform*, bool)>( + return static_cast(UnityObject::Class::Call<"Instantiate", il2cpp::Object*(il2cpp::Object*, Transform*, bool)>( original, parent, world_position_stays)); } +// The base of every MonoBehaviour. Bindings for MonoBehaviours derive from it and name their own Class; one without +// a Class would bind to Behaviour's. struct Behaviour : Component { using Class = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Behaviour">; void SetEnabled(bool value) { Set<"enabled">(value); } bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } + { return ActiveAndEnabled(this); } }; struct Camera : il2cpp::Object { @@ -284,11 +315,11 @@ struct Material : il2cpp::Object { struct Renderer : Component { using Class = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Renderer">; - il2cpp::Array* SharedMaterials() + il2cpp::Array* sharedMaterials() { return Get<"sharedMaterials", il2cpp::Array*>(); } }; -struct Canvas : il2cpp::Object { +struct Canvas : Behaviour { using Class = il2cpp::Class<"UnityEngine.UIModule", "UnityEngine", "Canvas">; }; @@ -301,6 +332,7 @@ struct Screen { { return Class::Get<"height", int32_t>(); } }; +// Only hooked, so a bare Class. Classes whose static members are called get a struct, like Screen. using Cursor = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Cursor">; // UnityEngine.Time through its internal calls, which skip the managed wrappers. diff --git a/src/libs/unity/include/unity/input.h b/src/libs/unity/include/unity/input.h index 1d734bbb3..f304083a6 100644 --- a/src/libs/unity/include/unity/input.h +++ b/src/libs/unity/include/unity/input.h @@ -12,333 +12,333 @@ namespace unity { // UnityEngine.KeyCode. -enum class KeyCode { +enum class KeyCode : int32_t { None = 0, Backspace = 8, Tab = 9, - Clear = 12, // 0x0000000C - Return = 13, // 0x0000000D - Pause = 19, // 0x00000013 - Escape = 27, // 0x0000001B - Space = 32, // 0x00000020 - Exclaim = 33, // 0x00000021 - DoubleQuote = 34, // 0x00000022 - Hash = 35, // 0x00000023 - Dollar = 36, // 0x00000024 - Percent = 37, // 0x00000025 - Ampersand = 38, // 0x00000026 - Quote = 39, // 0x00000027 - LeftParen = 40, // 0x00000028 - RightParen = 41, // 0x00000029 - Asterisk = 42, // 0x0000002A - Plus = 43, // 0x0000002B - Comma = 44, // 0x0000002C - Minus = 45, // 0x0000002D - Period = 46, // 0x0000002E - Slash = 47, // 0x0000002F - Alpha0 = 48, // 0x00000030 - Alpha1 = 49, // 0x00000031 - Alpha2 = 50, // 0x00000032 - Alpha3 = 51, // 0x00000033 - Alpha4 = 52, // 0x00000034 - Alpha5 = 53, // 0x00000035 - Alpha6 = 54, // 0x00000036 - Alpha7 = 55, // 0x00000037 - Alpha8 = 56, // 0x00000038 - Alpha9 = 57, // 0x00000039 - Colon = 58, // 0x0000003A - Semicolon = 59, // 0x0000003B - Less = 60, // 0x0000003C - Equals = 61, // 0x0000003D - Greater = 62, // 0x0000003E - Question = 63, // 0x0000003F - At = 64, // 0x00000040 - LeftBracket = 91, // 0x0000005B - Backslash = 92, // 0x0000005C - RightBracket = 93, // 0x0000005D - Caret = 94, // 0x0000005E - Underscore = 95, // 0x0000005F - BackQuote = 96, // 0x00000060 - A = 97, // 0x00000061 - B = 98, // 0x00000062 - C = 99, // 0x00000063 - D = 100, // 0x00000064 - E = 101, // 0x00000065 - F = 102, // 0x00000066 - G = 103, // 0x00000067 - H = 104, // 0x00000068 - I = 105, // 0x00000069 - J = 106, // 0x0000006A - K = 107, // 0x0000006B - L = 108, // 0x0000006C - M = 109, // 0x0000006D - N = 110, // 0x0000006E - O = 111, // 0x0000006F - P = 112, // 0x00000070 - Q = 113, // 0x00000071 - R = 114, // 0x00000072 - S = 115, // 0x00000073 - T = 116, // 0x00000074 - U = 117, // 0x00000075 - V = 118, // 0x00000076 - W = 119, // 0x00000077 - X = 120, // 0x00000078 - Y = 121, // 0x00000079 - Z = 122, // 0x0000007A - LeftCurlyBracket = 123, // 0x0000007B - Pipe = 124, // 0x0000007C - RightCurlyBracket = 125, // 0x0000007D - Tilde = 126, // 0x0000007E - Delete = 127, // 0x0000007F - Keypad0 = 256, // 0x00000100 - Keypad1 = 257, // 0x00000101 - Keypad2 = 258, // 0x00000102 - Keypad3 = 259, // 0x00000103 - Keypad4 = 260, // 0x00000104 - Keypad5 = 261, // 0x00000105 - Keypad6 = 262, // 0x00000106 - Keypad7 = 263, // 0x00000107 - Keypad8 = 264, // 0x00000108 - Keypad9 = 265, // 0x00000109 - KeypadPeriod = 266, // 0x0000010A - KeypadDivide = 267, // 0x0000010B - KeypadMultiply = 268, // 0x0000010C - KeypadMinus = 269, // 0x0000010D - KeypadPlus = 270, // 0x0000010E - KeypadEnter = 271, // 0x0000010F - KeypadEquals = 272, // 0x00000110 - UpArrow = 273, // 0x00000111 - DownArrow = 274, // 0x00000112 - RightArrow = 275, // 0x00000113 - LeftArrow = 276, // 0x00000114 - Insert = 277, // 0x00000115 - Home = 278, // 0x00000116 - End = 279, // 0x00000117 - PageUp = 280, // 0x00000118 - PageDown = 281, // 0x00000119 - F1 = 282, // 0x0000011A - F2 = 283, // 0x0000011B - F3 = 284, // 0x0000011C - F4 = 285, // 0x0000011D - F5 = 286, // 0x0000011E - F6 = 287, // 0x0000011F - F7 = 288, // 0x00000120 - F8 = 289, // 0x00000121 - F9 = 290, // 0x00000122 - F10 = 291, // 0x00000123 - F11 = 292, // 0x00000124 - F12 = 293, // 0x00000125 - F13 = 294, // 0x00000126 - F14 = 295, // 0x00000127 - F15 = 296, // 0x00000128 - Numlock = 300, // 0x0000012C - CapsLock = 301, // 0x0000012D - ScrollLock = 302, // 0x0000012E - RightShift = 303, // 0x0000012F - LeftShift = 304, // 0x00000130 - RightControl = 305, // 0x00000131 - LeftControl = 306, // 0x00000132 - RightAlt = 307, // 0x00000133 - LeftAlt = 308, // 0x00000134 - RightApple = 309, // 0x00000135 - RightCommand = 309, // 0x00000135 - LeftApple = 310, // 0x00000136 - LeftCommand = 310, // 0x00000136 - LeftWindows = 311, // 0x00000137 - RightWindows = 312, // 0x00000138 - AltGr = 313, // 0x00000139 - Help = 315, // 0x0000013B - Print = 316, // 0x0000013C - SysReq = 317, // 0x0000013D - Break = 318, // 0x0000013E - Menu = 319, // 0x0000013F - Mouse0 = 323, // 0x00000143 - Mouse1 = 324, // 0x00000144 - Mouse2 = 325, // 0x00000145 - Mouse3 = 326, // 0x00000146 - Mouse4 = 327, // 0x00000147 - Mouse5 = 328, // 0x00000148 - Mouse6 = 329, // 0x00000149 - JoystickButton0 = 330, // 0x0000014A - JoystickButton1 = 331, // 0x0000014B - JoystickButton2 = 332, // 0x0000014C - JoystickButton3 = 333, // 0x0000014D - JoystickButton4 = 334, // 0x0000014E - JoystickButton5 = 335, // 0x0000014F - JoystickButton6 = 336, // 0x00000150 - JoystickButton7 = 337, // 0x00000151 - JoystickButton8 = 338, // 0x00000152 - JoystickButton9 = 339, // 0x00000153 - JoystickButton10 = 340, // 0x00000154 - JoystickButton11 = 341, // 0x00000155 - JoystickButton12 = 342, // 0x00000156 - JoystickButton13 = 343, // 0x00000157 - JoystickButton14 = 344, // 0x00000158 - JoystickButton15 = 345, // 0x00000159 - JoystickButton16 = 346, // 0x0000015A - JoystickButton17 = 347, // 0x0000015B - JoystickButton18 = 348, // 0x0000015C - JoystickButton19 = 349, // 0x0000015D - Joystick1Button0 = 350, // 0x0000015E - Joystick1Button1 = 351, // 0x0000015F - Joystick1Button2 = 352, // 0x00000160 - Joystick1Button3 = 353, // 0x00000161 - Joystick1Button4 = 354, // 0x00000162 - Joystick1Button5 = 355, // 0x00000163 - Joystick1Button6 = 356, // 0x00000164 - Joystick1Button7 = 357, // 0x00000165 - Joystick1Button8 = 358, // 0x00000166 - Joystick1Button9 = 359, // 0x00000167 - Joystick1Button10 = 360, // 0x00000168 - Joystick1Button11 = 361, // 0x00000169 - Joystick1Button12 = 362, // 0x0000016A - Joystick1Button13 = 363, // 0x0000016B - Joystick1Button14 = 364, // 0x0000016C - Joystick1Button15 = 365, // 0x0000016D - Joystick1Button16 = 366, // 0x0000016E - Joystick1Button17 = 367, // 0x0000016F - Joystick1Button18 = 368, // 0x00000170 - Joystick1Button19 = 369, // 0x00000171 - Joystick2Button0 = 370, // 0x00000172 - Joystick2Button1 = 371, // 0x00000173 - Joystick2Button2 = 372, // 0x00000174 - Joystick2Button3 = 373, // 0x00000175 - Joystick2Button4 = 374, // 0x00000176 - Joystick2Button5 = 375, // 0x00000177 - Joystick2Button6 = 376, // 0x00000178 - Joystick2Button7 = 377, // 0x00000179 - Joystick2Button8 = 378, // 0x0000017A - Joystick2Button9 = 379, // 0x0000017B - Joystick2Button10 = 380, // 0x0000017C - Joystick2Button11 = 381, // 0x0000017D - Joystick2Button12 = 382, // 0x0000017E - Joystick2Button13 = 383, // 0x0000017F - Joystick2Button14 = 384, // 0x00000180 - Joystick2Button15 = 385, // 0x00000181 - Joystick2Button16 = 386, // 0x00000182 - Joystick2Button17 = 387, // 0x00000183 - Joystick2Button18 = 388, // 0x00000184 - Joystick2Button19 = 389, // 0x00000185 - Joystick3Button0 = 390, // 0x00000186 - Joystick3Button1 = 391, // 0x00000187 - Joystick3Button2 = 392, // 0x00000188 - Joystick3Button3 = 393, // 0x00000189 - Joystick3Button4 = 394, // 0x0000018A - Joystick3Button5 = 395, // 0x0000018B - Joystick3Button6 = 396, // 0x0000018C - Joystick3Button7 = 397, // 0x0000018D - Joystick3Button8 = 398, // 0x0000018E - Joystick3Button9 = 399, // 0x0000018F - Joystick3Button10 = 400, // 0x00000190 - Joystick3Button11 = 401, // 0x00000191 - Joystick3Button12 = 402, // 0x00000192 - Joystick3Button13 = 403, // 0x00000193 - Joystick3Button14 = 404, // 0x00000194 - Joystick3Button15 = 405, // 0x00000195 - Joystick3Button16 = 406, // 0x00000196 - Joystick3Button17 = 407, // 0x00000197 - Joystick3Button18 = 408, // 0x00000198 - Joystick3Button19 = 409, // 0x00000199 - Joystick4Button0 = 410, // 0x0000019A - Joystick4Button1 = 411, // 0x0000019B - Joystick4Button2 = 412, // 0x0000019C - Joystick4Button3 = 413, // 0x0000019D - Joystick4Button4 = 414, // 0x0000019E - Joystick4Button5 = 415, // 0x0000019F - Joystick4Button6 = 416, // 0x000001A0 - Joystick4Button7 = 417, // 0x000001A1 - Joystick4Button8 = 418, // 0x000001A2 - Joystick4Button9 = 419, // 0x000001A3 - Joystick4Button10 = 420, // 0x000001A4 - Joystick4Button11 = 421, // 0x000001A5 - Joystick4Button12 = 422, // 0x000001A6 - Joystick4Button13 = 423, // 0x000001A7 - Joystick4Button14 = 424, // 0x000001A8 - Joystick4Button15 = 425, // 0x000001A9 - Joystick4Button16 = 426, // 0x000001AA - Joystick4Button17 = 427, // 0x000001AB - Joystick4Button18 = 428, // 0x000001AC - Joystick4Button19 = 429, // 0x000001AD - Joystick5Button0 = 430, // 0x000001AE - Joystick5Button1 = 431, // 0x000001AF - Joystick5Button2 = 432, // 0x000001B0 - Joystick5Button3 = 433, // 0x000001B1 - Joystick5Button4 = 434, // 0x000001B2 - Joystick5Button5 = 435, // 0x000001B3 - Joystick5Button6 = 436, // 0x000001B4 - Joystick5Button7 = 437, // 0x000001B5 - Joystick5Button8 = 438, // 0x000001B6 - Joystick5Button9 = 439, // 0x000001B7 - Joystick5Button10 = 440, // 0x000001B8 - Joystick5Button11 = 441, // 0x000001B9 - Joystick5Button12 = 442, // 0x000001BA - Joystick5Button13 = 443, // 0x000001BB - Joystick5Button14 = 444, // 0x000001BC - Joystick5Button15 = 445, // 0x000001BD - Joystick5Button16 = 446, // 0x000001BE - Joystick5Button17 = 447, // 0x000001BF - Joystick5Button18 = 448, // 0x000001C0 - Joystick5Button19 = 449, // 0x000001C1 - Joystick6Button0 = 450, // 0x000001C2 - Joystick6Button1 = 451, // 0x000001C3 - Joystick6Button2 = 452, // 0x000001C4 - Joystick6Button3 = 453, // 0x000001C5 - Joystick6Button4 = 454, // 0x000001C6 - Joystick6Button5 = 455, // 0x000001C7 - Joystick6Button6 = 456, // 0x000001C8 - Joystick6Button7 = 457, // 0x000001C9 - Joystick6Button8 = 458, // 0x000001CA - Joystick6Button9 = 459, // 0x000001CB - Joystick6Button10 = 460, // 0x000001CC - Joystick6Button11 = 461, // 0x000001CD - Joystick6Button12 = 462, // 0x000001CE - Joystick6Button13 = 463, // 0x000001CF - Joystick6Button14 = 464, // 0x000001D0 - Joystick6Button15 = 465, // 0x000001D1 - Joystick6Button16 = 466, // 0x000001D2 - Joystick6Button17 = 467, // 0x000001D3 - Joystick6Button18 = 468, // 0x000001D4 - Joystick6Button19 = 469, // 0x000001D5 - Joystick7Button0 = 470, // 0x000001D6 - Joystick7Button1 = 471, // 0x000001D7 - Joystick7Button2 = 472, // 0x000001D8 - Joystick7Button3 = 473, // 0x000001D9 - Joystick7Button4 = 474, // 0x000001DA - Joystick7Button5 = 475, // 0x000001DB - Joystick7Button6 = 476, // 0x000001DC - Joystick7Button7 = 477, // 0x000001DD - Joystick7Button8 = 478, // 0x000001DE - Joystick7Button9 = 479, // 0x000001DF - Joystick7Button10 = 480, // 0x000001E0 - Joystick7Button11 = 481, // 0x000001E1 - Joystick7Button12 = 482, // 0x000001E2 - Joystick7Button13 = 483, // 0x000001E3 - Joystick7Button14 = 484, // 0x000001E4 - Joystick7Button15 = 485, // 0x000001E5 - Joystick7Button16 = 486, // 0x000001E6 - Joystick7Button17 = 487, // 0x000001E7 - Joystick7Button18 = 488, // 0x000001E8 - Joystick7Button19 = 489, // 0x000001E9 - Joystick8Button0 = 490, // 0x000001EA - Joystick8Button1 = 491, // 0x000001EB - Joystick8Button2 = 492, // 0x000001EC - Joystick8Button3 = 493, // 0x000001ED - Joystick8Button4 = 494, // 0x000001EE - Joystick8Button5 = 495, // 0x000001EF - Joystick8Button6 = 496, // 0x000001F0 - Joystick8Button7 = 497, // 0x000001F1 - Joystick8Button8 = 498, // 0x000001F2 - Joystick8Button9 = 499, // 0x000001F3 - Joystick8Button10 = 500, // 0x000001F4 - Joystick8Button11 = 501, // 0x000001F5 - Joystick8Button12 = 502, // 0x000001F6 - Joystick8Button13 = 503, // 0x000001F7 - Joystick8Button14 = 504, // 0x000001F8 - Joystick8Button15 = 505, // 0x000001F9 - Joystick8Button16 = 506, // 0x000001FA - Joystick8Button17 = 507, // 0x000001FB - Joystick8Button18 = 508, // 0x000001FC - Joystick8Button19 = 509, // 0x000001FD + Clear = 12, + Return = 13, + Pause = 19, + Escape = 27, + Space = 32, + Exclaim = 33, + DoubleQuote = 34, + Hash = 35, + Dollar = 36, + Percent = 37, + Ampersand = 38, + Quote = 39, + LeftParen = 40, + RightParen = 41, + Asterisk = 42, + Plus = 43, + Comma = 44, + Minus = 45, + Period = 46, + Slash = 47, + Alpha0 = 48, + Alpha1 = 49, + Alpha2 = 50, + Alpha3 = 51, + Alpha4 = 52, + Alpha5 = 53, + Alpha6 = 54, + Alpha7 = 55, + Alpha8 = 56, + Alpha9 = 57, + Colon = 58, + Semicolon = 59, + Less = 60, + Equals = 61, + Greater = 62, + Question = 63, + At = 64, + LeftBracket = 91, + Backslash = 92, + RightBracket = 93, + Caret = 94, + Underscore = 95, + BackQuote = 96, + A = 97, + B = 98, + C = 99, + D = 100, + E = 101, + F = 102, + G = 103, + H = 104, + I = 105, + J = 106, + K = 107, + L = 108, + M = 109, + N = 110, + O = 111, + P = 112, + Q = 113, + R = 114, + S = 115, + T = 116, + U = 117, + V = 118, + W = 119, + X = 120, + Y = 121, + Z = 122, + LeftCurlyBracket = 123, + Pipe = 124, + RightCurlyBracket = 125, + Tilde = 126, + Delete = 127, + Keypad0 = 256, + Keypad1 = 257, + Keypad2 = 258, + Keypad3 = 259, + Keypad4 = 260, + Keypad5 = 261, + Keypad6 = 262, + Keypad7 = 263, + Keypad8 = 264, + Keypad9 = 265, + KeypadPeriod = 266, + KeypadDivide = 267, + KeypadMultiply = 268, + KeypadMinus = 269, + KeypadPlus = 270, + KeypadEnter = 271, + KeypadEquals = 272, + UpArrow = 273, + DownArrow = 274, + RightArrow = 275, + LeftArrow = 276, + Insert = 277, + Home = 278, + End = 279, + PageUp = 280, + PageDown = 281, + F1 = 282, + F2 = 283, + F3 = 284, + F4 = 285, + F5 = 286, + F6 = 287, + F7 = 288, + F8 = 289, + F9 = 290, + F10 = 291, + F11 = 292, + F12 = 293, + F13 = 294, + F14 = 295, + F15 = 296, + Numlock = 300, + CapsLock = 301, + ScrollLock = 302, + RightShift = 303, + LeftShift = 304, + RightControl = 305, + LeftControl = 306, + RightAlt = 307, + LeftAlt = 308, + RightApple = 309, + RightCommand = 309, + LeftApple = 310, + LeftCommand = 310, + LeftWindows = 311, + RightWindows = 312, + AltGr = 313, + Help = 315, + Print = 316, + SysReq = 317, + Break = 318, + Menu = 319, + Mouse0 = 323, + Mouse1 = 324, + Mouse2 = 325, + Mouse3 = 326, + Mouse4 = 327, + Mouse5 = 328, + Mouse6 = 329, + JoystickButton0 = 330, + JoystickButton1 = 331, + JoystickButton2 = 332, + JoystickButton3 = 333, + JoystickButton4 = 334, + JoystickButton5 = 335, + JoystickButton6 = 336, + JoystickButton7 = 337, + JoystickButton8 = 338, + JoystickButton9 = 339, + JoystickButton10 = 340, + JoystickButton11 = 341, + JoystickButton12 = 342, + JoystickButton13 = 343, + JoystickButton14 = 344, + JoystickButton15 = 345, + JoystickButton16 = 346, + JoystickButton17 = 347, + JoystickButton18 = 348, + JoystickButton19 = 349, + Joystick1Button0 = 350, + Joystick1Button1 = 351, + Joystick1Button2 = 352, + Joystick1Button3 = 353, + Joystick1Button4 = 354, + Joystick1Button5 = 355, + Joystick1Button6 = 356, + Joystick1Button7 = 357, + Joystick1Button8 = 358, + Joystick1Button9 = 359, + Joystick1Button10 = 360, + Joystick1Button11 = 361, + Joystick1Button12 = 362, + Joystick1Button13 = 363, + Joystick1Button14 = 364, + Joystick1Button15 = 365, + Joystick1Button16 = 366, + Joystick1Button17 = 367, + Joystick1Button18 = 368, + Joystick1Button19 = 369, + Joystick2Button0 = 370, + Joystick2Button1 = 371, + Joystick2Button2 = 372, + Joystick2Button3 = 373, + Joystick2Button4 = 374, + Joystick2Button5 = 375, + Joystick2Button6 = 376, + Joystick2Button7 = 377, + Joystick2Button8 = 378, + Joystick2Button9 = 379, + Joystick2Button10 = 380, + Joystick2Button11 = 381, + Joystick2Button12 = 382, + Joystick2Button13 = 383, + Joystick2Button14 = 384, + Joystick2Button15 = 385, + Joystick2Button16 = 386, + Joystick2Button17 = 387, + Joystick2Button18 = 388, + Joystick2Button19 = 389, + Joystick3Button0 = 390, + Joystick3Button1 = 391, + Joystick3Button2 = 392, + Joystick3Button3 = 393, + Joystick3Button4 = 394, + Joystick3Button5 = 395, + Joystick3Button6 = 396, + Joystick3Button7 = 397, + Joystick3Button8 = 398, + Joystick3Button9 = 399, + Joystick3Button10 = 400, + Joystick3Button11 = 401, + Joystick3Button12 = 402, + Joystick3Button13 = 403, + Joystick3Button14 = 404, + Joystick3Button15 = 405, + Joystick3Button16 = 406, + Joystick3Button17 = 407, + Joystick3Button18 = 408, + Joystick3Button19 = 409, + Joystick4Button0 = 410, + Joystick4Button1 = 411, + Joystick4Button2 = 412, + Joystick4Button3 = 413, + Joystick4Button4 = 414, + Joystick4Button5 = 415, + Joystick4Button6 = 416, + Joystick4Button7 = 417, + Joystick4Button8 = 418, + Joystick4Button9 = 419, + Joystick4Button10 = 420, + Joystick4Button11 = 421, + Joystick4Button12 = 422, + Joystick4Button13 = 423, + Joystick4Button14 = 424, + Joystick4Button15 = 425, + Joystick4Button16 = 426, + Joystick4Button17 = 427, + Joystick4Button18 = 428, + Joystick4Button19 = 429, + Joystick5Button0 = 430, + Joystick5Button1 = 431, + Joystick5Button2 = 432, + Joystick5Button3 = 433, + Joystick5Button4 = 434, + Joystick5Button5 = 435, + Joystick5Button6 = 436, + Joystick5Button7 = 437, + Joystick5Button8 = 438, + Joystick5Button9 = 439, + Joystick5Button10 = 440, + Joystick5Button11 = 441, + Joystick5Button12 = 442, + Joystick5Button13 = 443, + Joystick5Button14 = 444, + Joystick5Button15 = 445, + Joystick5Button16 = 446, + Joystick5Button17 = 447, + Joystick5Button18 = 448, + Joystick5Button19 = 449, + Joystick6Button0 = 450, + Joystick6Button1 = 451, + Joystick6Button2 = 452, + Joystick6Button3 = 453, + Joystick6Button4 = 454, + Joystick6Button5 = 455, + Joystick6Button6 = 456, + Joystick6Button7 = 457, + Joystick6Button8 = 458, + Joystick6Button9 = 459, + Joystick6Button10 = 460, + Joystick6Button11 = 461, + Joystick6Button12 = 462, + Joystick6Button13 = 463, + Joystick6Button14 = 464, + Joystick6Button15 = 465, + Joystick6Button16 = 466, + Joystick6Button17 = 467, + Joystick6Button18 = 468, + Joystick6Button19 = 469, + Joystick7Button0 = 470, + Joystick7Button1 = 471, + Joystick7Button2 = 472, + Joystick7Button3 = 473, + Joystick7Button4 = 474, + Joystick7Button5 = 475, + Joystick7Button6 = 476, + Joystick7Button7 = 477, + Joystick7Button8 = 478, + Joystick7Button9 = 479, + Joystick7Button10 = 480, + Joystick7Button11 = 481, + Joystick7Button12 = 482, + Joystick7Button13 = 483, + Joystick7Button14 = 484, + Joystick7Button15 = 485, + Joystick7Button16 = 486, + Joystick7Button17 = 487, + Joystick7Button18 = 488, + Joystick7Button19 = 489, + Joystick8Button0 = 490, + Joystick8Button1 = 491, + Joystick8Button2 = 492, + Joystick8Button3 = 493, + Joystick8Button4 = 494, + Joystick8Button5 = 495, + Joystick8Button6 = 496, + Joystick8Button7 = 497, + Joystick8Button8 = 498, + Joystick8Button9 = 499, + Joystick8Button10 = 500, + Joystick8Button11 = 501, + Joystick8Button12 = 502, + Joystick8Button13 = 503, + Joystick8Button14 = 504, + Joystick8Button15 = 505, + Joystick8Button16 = 506, + Joystick8Button17 = 507, + Joystick8Button18 = 508, + Joystick8Button19 = 509, // One past the largest code, for tables indexed by key. Max @@ -419,6 +419,7 @@ struct InputActionCallbackContext { static_assert(sizeof(InputActionCallbackContext) == 16); // This frame's keyboard. Idle frames skip key queries; otherwise each key/state is read from Unity at most once. +// Game thread only: the snapshot is a plain global. 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/src/libs/unity/include/unity/tracker.h b/src/libs/unity/include/unity/tracker.h index 1b582fcc3..6eaab79d5 100644 --- a/src/libs/unity/include/unity/tracker.h +++ b/src/libs/unity/include/unity/tracker.h @@ -14,11 +14,8 @@ namespace unity { namespace detail { - void Constructed(Il2CppObject* object); - void Destroyed(Il2CppObject* object); - // True the first time a native method is seen: constructors and OnDestroy often resolve to one shared base - // implementation, which must be detoured only once. - bool FirstDetour(void* native); + void Constructed(Il2CppObject* object); + void Destroyed(Il2CppObject* object); void StartTracking(); Il2CppObject* Newest(Il2CppClass* klass); std::vector Instances(Il2CppClass* klass); @@ -38,14 +35,21 @@ namespace detail // Starts tracking T and its subclasses. Call while installing hooks, before T's instances are created: instances // that already exist are not found. Alternate parameterless lifecycle methods support pooled components. +// Tracking a class again does nothing; the first call's lifecycle methods stay. template void Track(std::string_view created = ".ctor", std::string_view released = "OnDestroy") { - detail::StartTracking(); - // Both lifecycle methods are parameterless; Unity's constructor and OnDestroy are the defaults. - if (auto ctor = T::Class::Method(created, 0); ctor && detail::FirstDetour(ctor.Pointer())) - IL2CPP_HOOK(ctor, detail::Ctor_Hook); - if (auto on_destroy = T::Class::Method(released, 0); on_destroy && detail::FirstDetour(on_destroy.Pointer())) - IL2CPP_HOOK(on_destroy, detail::OnDestroy_Hook); + static const bool tracked = [&] { + detail::StartTracking(); + // Both lifecycle methods are parameterless; Unity's constructor and OnDestroy are the defaults. They go in as + // one set: without its release hook, a pooled component would stay tracked after the pool took it back. + // Classes often share one constructor or OnDestroy body, which then carries a chained hook per tracked class. + // Tracking, untracking and the finalizer registration all tolerate running more than once for one object. + il2cpp::HookSet hooks("tracking " + il2cpp::ClassName(T::Class::Raw())); + IL2CPP_STAGE(hooks, T::Class::Method(created, 0), detail::Ctor_Hook); + IL2CPP_STAGE(hooks, T::Class::Method(released, 0), detail::OnDestroy_Hook); + return hooks.Commit(); + }(); + (void)tracked; } // The newest live instance of T, or null. Only finds classes passed to Track. diff --git a/src/libs/unity/include/unity/ui.h b/src/libs/unity/include/unity/ui.h index b56b0c4a2..b8610f3af 100644 --- a/src/libs/unity/include/unity/ui.h +++ b/src/libs/unity/include/unity/ui.h @@ -12,7 +12,7 @@ namespace unity { -struct CanvasScaler : il2cpp::Object { +struct CanvasScaler : Behaviour { using Class = il2cpp::Class<"UnityEngine.UI", "UnityEngine.UI", "CanvasScaler">; // The property, which the scaler applies on its next Update; not the managed SetScaleFactor method, which writes @@ -22,7 +22,7 @@ struct CanvasScaler : il2cpp::Object { }; // UnityEngine.UI.Image; color and raycastTarget come from Graphic. -struct Image : Component { +struct Image : Behaviour { using Class = il2cpp::Class<"UnityEngine.UI", "UnityEngine.UI", "Image">; enum class Type : int32_t { @@ -54,14 +54,14 @@ struct Image : Component { { Call<"SetVerticesDirty">(); } }; -struct Button : il2cpp::Object { +struct Button : Behaviour { using Class = il2cpp::Class<"UnityEngine.UI", "UnityEngine.UI", "Button">; void Press() { Call<"Press">(); } }; -struct EventSystem : il2cpp::Object { +struct EventSystem : Behaviour { using Class = il2cpp::Class<"UnityEngine.UI", "UnityEngine.EventSystems", "EventSystem">; static EventSystem* current() @@ -77,7 +77,7 @@ struct PointerEventData : il2cpp::Object { using Class = il2cpp::Class<"UnityEngine.UI", "UnityEngine.EventSystems", "PointerEventData">; }; -struct Toggle : Component { +struct Toggle : Behaviour { using Class = il2cpp::Class<"UnityEngine.UI", "UnityEngine.UI", "Toggle">; bool isOn() @@ -92,7 +92,7 @@ struct Toggle : Component { } }; -struct TMP_Text : Component { +struct TMP_Text : Behaviour { using Class = il2cpp::Class<"Unity.TextMeshPro", "TMPro", "TMP_Text">; // TMPro.TextAlignmentOptions, the ones in use. @@ -134,7 +134,7 @@ struct TextMeshProUGUI : TMP_Text { using Class = il2cpp::Class<"Unity.TextMeshPro", "TMPro", "TextMeshProUGUI">; }; -struct TMP_InputField : il2cpp::Object { +struct TMP_InputField : Behaviour { using Class = il2cpp::Class<"Unity.TextMeshPro", "TMPro", "TMP_InputField">; bool isFocused() @@ -147,10 +147,9 @@ struct TMP_InputField : il2cpp::Object { // `transform` when there is none. inline Transform* CanvasRoot(Transform* transform) { - for (auto* current = transform; current; current = current->parent()) - if (auto* object = current->gameObject(); object && object->GetComponent()) - return current; - return transform; + auto* canvas = transform ? transform->FindInParents() : nullptr; + auto* root = canvas ? canvas->transform() : nullptr; + return root ? root : transform; } // An inactive GameObject with a RectTransform under `parent`, for building UI. `root` keeps it alive while the diff --git a/src/libs/unity/src/tracker.cc b/src/libs/unity/src/tracker.cc index 0bd1a6258..70b6c6de5 100644 --- a/src/libs/unity/src/tracker.cc +++ b/src/libs/unity/src/tracker.cc @@ -8,7 +8,6 @@ #include #include #include -#include #include #include @@ -132,14 +131,13 @@ namespace "GameAssembly.dylib"); #endif if (matches.empty()) { - il2cpp::Report("[Unity] GC_register_finalizer_inner not found; tracked objects are only dropped on OnDestroy " - "and Unity's liveness pass"); + il2cpp::Report("[Unity] GC_register_finalizer_inner not found; an object the GC frees without OnDestroy " + "stays tracked, and can be read after it is freed"); return nullptr; } return reinterpret_cast(matches.front().address()); } - std::unordered_set detoured; } // namespace void Constructed(Il2CppObject* object) @@ -159,15 +157,14 @@ void Destroyed(Il2CppObject* object) Untrack(object); } -bool FirstDetour(void* native) -{ return detoured.insert(native).second; } - void StartTracking() { static bool started = false; if (std::exchange(started, true)) return; - IL2CPP_DETOUR(il2cpp_unity_liveness_finalize, LivenessFinalize_Hook, "il2cpp_unity_liveness_finalize"); + il2cpp::HookSet hooks("object tracking"); + IL2CPP_STAGE_AT(hooks, il2cpp_unity_liveness_finalize, LivenessFinalize_Hook); + hooks.Commit(); register_finalizer = FindRegisterFinalizer(); } diff --git a/src/mod/features/action_queue.cc b/src/mod/features/action_queue.cc index 004cfc63d..9155238d5 100644 --- a/src/mod/features/action_queue.cc +++ b/src/mod/features/action_queue.cc @@ -1,159 +1,497 @@ -#include "action_queue.h" +// [control] kirshara_fixes: three fixes for the Kir'Shara action queue, each with its own [patches] group. +// +// - ActionQueueRecovery hands a failed course response straight to the native planner when its target is gone. +// - ThinQueueProtection resumes a fleet the native planner or watchdog left idle after pruning its queue. +// - QueueAddressGuard keeps a queue from being cleared over a misreported fleet address. +// +// The decisions live in action_queue.h. The hooks here gather evidence around the game's own handlers, and only +// ever call back into the game's own entry points; they never edit queues, flags or retry counts. + +#include "features/action_queue.h" +#include "config.h" #include "features/features.h" -#include -// Use the host ABI for the 24-byte value-type callback on Windows and both Mac architectures. -// Validate the managed layout before installing. #include "game/queues.h" -#include -#include -#include + #include -#include +#include #include -#include -using namespace queue_bindings::recovery; +#include +#include +#include +#include namespace { using action_queue::Clock; -using action_queue::QueueState; -action_queue::Requests requests; -std::mutex requestsMutex; -std::atomic_bool ready{false}; -bool Enabled() -{ return ready.load() && Config::Get().control.kirshara_fixes && Config::Get().control.queue_enabled; } -void ClearRequests() +// The only check a hook needs: each fix installs all of its hooks or none, so a hook that runs has its partners. +bool QueueFixesEnabled() { - std::lock_guard lock(requestsMutex); - requests.Clear(); + const auto& control = Config::Get().control; + return control.kirshara_fixes && control.queue_enabled; } + +// Sets a thread-local for the length of one native call, so hooks the game reaches from inside it can see it. +template class Scoped +{ +public: + Scoped(T& slot, T value) + : slot_(slot) + , previous_(std::exchange(slot, value)) + { + } + ~Scoped() + { slot_ = previous_; } + Scoped(const Scoped&) = delete; + Scoped& operator=(const Scoped&) = delete; + +private: + T& slot_; + T previous_; +}; + +// A queue's actions and their count, or null when the queue has no list. +il2cpp::List* Actions(ActionQueueInstance* queue, int32_t& count) +{ + auto* list = queue ? queue->actionQueue() : nullptr; + count = list ? list->Count() : 0; + return list; +} + +ActionQueueInstance* FindQueue(ActionQueueManager* manager, int64_t fleet_id) +{ + if (auto* queues = manager->battleQueue()) + for (auto* queue : *queues) + if (queue && queue->PlayerFleetId() == fleet_id) + return queue; + return nullptr; +} + +action_queue::QueueSnapshot SnapshotOf(ActionQueueInstance* queue) +{ + action_queue::QueueSnapshot snapshot; + int32_t count = 0; + auto* actions = Actions(queue, count); + if (!actions) + return {}; + snapshot.count = count; + snapshot.player_fleet_id = queue->PlayerFleetId(); + snapshot.is_engaging = queue->IsEngaging(); + snapshot.last_engaged_target_id = queue->LastEngagedTargetId(); + snapshot.pending_target_id = queue->PendingEngageTargetId(); + snapshot.captured_target_count = std::min(count, static_cast(snapshot.target_ids.size())); + snapshot.targets_truncated = count > snapshot.captured_target_count; + for (int32_t i = 0; i < snapshot.captured_target_count; ++i) { + auto* action = (*actions)[i]; + if (!action || !(snapshot.target_ids[i] = action->FleetId())) + return {}; + } + snapshot.head_target_id = count ? snapshot.target_ids[0] : 0; + snapshot.present = snapshot.player_fleet_id != 0; + return snapshot; +} + +// --- Faster queue recovery --- + +// Shared by four hooks; locked rather than assuming they all run on the game thread. +std::mutex requests_mutex; +// Never destroyed: freeing a GC handle in a static destructor can run after the runtime has shut down. +auto& requests = *new action_queue::Requests; + +// What the course response handler knew on entry, for the retry decision it makes inside. struct CourseContext { - std::int64_t fleet{}, target{}; - Il2CppObject* queue{}; // Borrowed only inside the synchronous native - // ActionQueueManager_OnSetCourseResponseEventHandler_Hook call. - bool outstandingAtEntry{}; + int64_t fleet_id{}, target_id{}; + ActionQueueInstance* queue{}; + bool engaging_at_entry{}; }; -thread_local CourseContext* currentCourse{}; - -int ActionQueueManager_TryPlanPathAndEngageTarget_Hook(auto original, Il2CppObject* manager, Il2CppObject* player, - Il2CppObject* queue) -{ - std::uint64_t serial{}; - if (Enabled()) { - try { - const auto s = Inspect(queue); - std::lock_guard lock(requestsMutex); - serial = requests.Remember(queue, s, Clock::now()); - } catch (...) { - ClearRequests(); - } +thread_local CourseContext* current_course = nullptr; + +void ClearRequests() +{ + std::lock_guard lock(requests_mutex); + requests.Clear(); +} + +// `target`, when given, is checked against every queued action; otherwise only the head is read. +action_queue::RetryState RetryStateOf(ActionQueueInstance* queue, int64_t target = 0) +{ + action_queue::RetryState state; + if (!queue) + return state; + state.fleet = queue->PlayerFleetId(); + state.attempt = queue->LastEngageAttemptTime(); + state.engaging = queue->IsEngaging(); + int32_t count = 0; + auto* actions = Actions(queue, count); + if (!actions) + return state; + state.count = count; + for (int32_t i = 0; i < (target ? count : std::min(count, 1)); ++i) { + auto* action = (*actions)[i]; + if (!action) + return state; + const auto id = action->FleetId(); + if (i == 0) + state.front = id; + state.contains_target |= target && id == target; + } + state.valid = state.fleet != 0; + return state; +} + +// An unreadable queue might hold the target, so it counts as holding it. +bool AbsentFromAllQueues(ActionQueueManager* manager, int64_t target) +{ + auto* queues = manager->battleQueue(); + if (!queues) + return false; + for (auto* queue : *queues) { + if (!queue) + continue; + const auto state = RetryStateOf(queue, target); + if (!state.valid || state.contains_target) + return false; + } + return true; +} + +EngageResult ActionQueueManager_TryPlanPathAndEngageTarget_Hook(auto original, ActionQueueManager* _this, + FleetPlayerData* fleet, ActionQueueInstance* queue) +{ + uint64_t serial = 0; + if (QueueFixesEnabled()) { + const auto state = RetryStateOf(queue); + std::lock_guard lock(requests_mutex); + serial = requests.Remember(queue, state, Clock::now()); } else { ClearRequests(); } - const auto result = original(manager, player, queue); - // Result zero means native dispatch succeeded. Do not cancel a newer reentrant request. - if (serial && result != 0) { - std::lock_guard lock(requestsMutex); + const auto result = original(_this, fleet, queue); + // Only a dispatched attempt can be answered by a course response. + if (serial && result != EngageResult::Success) { + std::lock_guard lock(requests_mutex); requests.Cancel(serial); } return result; } -bool ActionQueueManager_ShouldRetryFailedSetCourse_Hook(auto original, Il2CppObject* manager, std::int64_t target, - Il2CppObject* queue) + +// Called by the game from inside OnSetCourseResponseEventHandler. +bool ActionQueueManager_ShouldRetryFailedSetCourse_Hook(auto original, ActionQueueManager* _this, int64_t target, + ActionQueueInstance* queue) { - if (!Enabled()) + const bool enabled = QueueFixesEnabled(); + if (!enabled) ClearRequests(); - const bool retry = original(manager, target, queue); - if (retry || !Enabled() || !currentCourse) + const bool retry = original(_this, target, queue); + if (retry || !enabled || !current_course) return retry; - try { - const auto& c = *currentCourse; - const auto s = Inspect(queue, target); - if (c.target != target - || !action_queue::CanAdvance(s, c.fleet, target, c.outstandingAtEntry, c.queue == queue, true)) - return retry; - // The native false branch processes this target across all fleets. Never suppress that work - // while any other queue still contains it or has contents we cannot verify. - if (!AbsentFromAllQueues(manager, target)) - return retry; - std::lock_guard lock(requestsMutex); - if (!requests.Consume(queue, s, target, Clock::now())) - return retry; - // The enclosing ActionQueueManager_OnSetCourseResponseEventHandler_Hook handler's true branch calls its normal - // planner for this same fleet. That planner retains all eligibility checks. No flags, targets or retry counters are - // changed here. - return true; - } catch (...) { + const auto& course = *current_course; + const auto state = RetryStateOf(queue, target); + if (course.target_id != target + || !action_queue::CanAdvance(state, course.fleet_id, target, course.engaging_at_entry, course.queue == queue, + true)) return retry; - } + // The native false branch removes the target from every fleet's queue. Leave that to it while any queue might + // still hold the target. + if (!AbsentFromAllQueues(_this, target)) + return retry; + std::lock_guard lock(requests_mutex); + if (!requests.Consume(queue, state, target, Clock::now())) + return retry; + // True takes the handler's retry branch, which runs the native planner for this fleet with all of its checks. + return true; } -void ActionQueueManager_OnSetCourseResponseEventHandler_Hook(auto original, Il2CppObject* manager, CourseResponse args) + +void ActionQueueManager_OnSetCourseResponseEventHandler_Hook(auto original, ActionQueueManager* _this, + SetCourseResponse response) { - if (!Enabled()) + const bool enabled = QueueFixesEnabled(); + if (!enabled) ClearRequests(); - CourseContext context{args.fleet}; - if (Enabled() && !args.success && !args.recall) { - try { - if (ReadCourseTarget(args.target, context.target)) { - context.queue = FindQueue(manager, args.fleet); - context.outstandingAtEntry = context.queue && Read(context.queue, 0x10); - } - } catch (...) { - context = {}; - } + CourseContext course{response.fleet_id}; + if (enabled && !response.success && !response.is_recall && il2cpp::Unbox(response.target_id, course.target_id)) { + course.queue = FindQueue(_this, response.fleet_id); + course.engaging_at_entry = course.queue && course.queue->IsEngaging(); } - struct Scope { - CourseContext* previous{currentCourse}; - explicit Scope(CourseContext* value) - { currentCourse = value; } - ~Scope() - { currentCourse = previous; } - } scope(&context); - original(manager, args); -} -void ActionQueueManager_StopWatchdogAndClearAllQueues_Hook(auto original, Il2CppObject* manager) -{ - // Native session end/invalidation/quit share this seam; release weak handles while IL2CPP is alive. + Scoped scope(current_course, &course); + original(_this, response); +} + +// Session end, invalidation and quit all pass through here, while the runtime can still free the weak handles. +void ActionQueueManager_StopWatchdogAndClearAllQueues_Hook(auto original, ActionQueueManager* _this) +{ ClearRequests(); - original(manager); + original(_this); } -} // namespace +// --- Thin queue protection --- -void ResetQueueRecoveryTracking() -{ ClearRequests(); } +ActionQueueInstance* FindQueueWithHead(ActionQueueManager* manager, int64_t target) +{ + if (auto* queues = manager->battleQueue()) + for (auto* queue : *queues) + if (const auto snapshot = SnapshotOf(queue); snapshot.present && snapshot.head_target_id == target) + return queue; + return nullptr; +} -namespace +// Asks the fleet to engage its new head after a native call pruned its queue, when the evidence allows it. +bool ResumeAfterPrune(ActionQueueManager* manager, ActionQueueInstance* queue, FleetPlayerData* fleet, bool idle, + const action_queue::QueueSnapshot& before, const action_queue::QueueSnapshot& after, + const char* source) { -void InstallActionQueueRecovery() + if (!action_queue::IsNativePruneResumeCandidate(QueueFixesEnabled(), idle, before, after)) + return false; + const auto confirmed = SnapshotOf(queue); + if (!action_queue::IsStableResumePostcondition(after, confirmed)) + return false; + // The game's own engage entry, so faster recovery sees this attempt like any other. If it throws, the binding + // contains the exception and reports it, but the result reads as Success. + // TODO(tashcan): let the binding tell a contained exception apart, so a throw is not taken for an engage. + const auto result = manager->TryPlanPathAndEngageTarget(fleet, queue); + spdlog::info("[ThinQueueProtection] resumed fleet {} after the {} pruned it: head {} -> {} of {}, {}", + confirmed.player_fleet_id, source, before.head_target_id, confirmed.head_target_id, confirmed.count, + action_queue::EngageResultName(static_cast(result))); + return result == EngageResult::Success; +} + +bool ActionQueueManager_DoPlanPathAndEngageTarget_Hook(auto original, ActionQueueManager* _this, FleetPlayerData* fleet) +{ + if (!QueueFixesEnabled() || !fleet) + return original(_this, fleet); + const auto id = fleet->Id(); + auto* queue = FindQueue(_this, id); + const auto before = SnapshotOf(queue); + const bool result = original(_this, fleet); + // A replaced queue (a new session) is not evidence of pruning. + if (FindQueue(_this, id) != queue) + return result; + const auto after = SnapshotOf(queue); + const bool idle = !result && before.player_fleet_id == id && fleet->CurrentState() == FleetState::IdleInSpace; + return ResumeAfterPrune(_this, queue, fleet, idle, before, after, "planner") || result; +} + +void ActionQueueManager_HandleStall_Hook(auto original, ActionQueueManager* _this, ActionQueueInstance* queue, + FleetPlayerData* fleet, FleetDeployedData* deployed) { - if (!ResolveBindings()) + if (!QueueFixesEnabled() || !fleet || !deployed) + return original(_this, queue, fleet, deployed); + const auto before = SnapshotOf(queue); + original(_this, queue, fleet, deployed); + if (FindQueue(_this, before.player_fleet_id) != queue) return; - const bool a = IL2CPP_DETOUR(engage, ActionQueueManager_TryPlanPathAndEngageTarget_Hook, - "ActionQueueManager_TryPlanPathAndEngageTarget_Hook") - != nullptr; - const bool b = IL2CPP_DETOUR(retry, ActionQueueManager_ShouldRetryFailedSetCourse_Hook, - "ActionQueueManager_ShouldRetryFailedSetCourse_Hook") - != nullptr; - const bool c = IL2CPP_DETOUR(course, ActionQueueManager_OnSetCourseResponseEventHandler_Hook, - "ActionQueueManager_OnSetCourseResponseEventHandler_Hook") - != nullptr; - const bool d = IL2CPP_DETOUR(clear, ActionQueueManager_StopWatchdogAndClearAllQueues_Hook, - "ActionQueueManager_StopWatchdogAndClearAllQueues_Hook") - != nullptr; - for (const auto& [installed, name] : std::array{std::pair{a, "ActionQueueManager.TryPlanPathAndEngageTarget"}, - std::pair{b, "ActionQueueManager.ShouldRetryFailedSetCourse"}, - std::pair{c, "ActionQueueManager.OnSetCourseResponseEventHandler"}, - std::pair{d, "ActionQueueManager.StopWatchdogAndClearAllQueues"}}) - if (!installed) - spdlog::warn("[FasterQueueRecovery] hook installation failed: {}; feature unavailable", name); - ready.store(a && b && c && d); - spdlog::info("[FasterQueueRecovery] ready={}", ready.load()); + const auto after = SnapshotOf(queue); + const bool idle = before.player_fleet_id == fleet->Id() && before.player_fleet_id == deployed->Id() + && fleet->CurrentState() == FleetState::IdleInSpace + && deployed->CurrentState() == DeployedFleetState::Idle && !deployed->IsDestroyed() + && !deployed->CurrentlyBattling(); + ResumeAfterPrune(_this, queue, fleet, idle, before, after, "watchdog"); +} + +// A destroyed target can stay at the head of an idle queue after disposal; the game's ProcessQueue removes it. +void ActionQueueManager_OnFleetsDisposedEventHandler_Hook(auto original, ActionQueueManager* _this, + il2cpp::List* fleets) +{ + // Read before the game disposes of them. A wave destroys a handful at a time; 64 is plenty. + std::array destroyed{}; + size_t count = 0; + const auto total = QueueFixesEnabled() && fleets ? fleets->Count() : 0; + for (int32_t i = 0; i < std::min(total, destroyed.size()); ++i) { + auto* fleet = (*fleets)[i]; + if (!fleet || !fleet->IsDestroyed() || fleet->RemovalReason() != FleetRemovalReason::Destroyed) + continue; + const auto id = fleet->Id(); + if (id && std::find(destroyed.begin(), destroyed.begin() + count, id) == destroyed.begin() + count) + destroyed[count++] = id; + } + original(_this, fleets); + for (size_t i = 0; i < count; ++i) { + const auto target = destroyed[i]; + const auto queue = SnapshotOf(FindQueueWithHead(_this, target)); + if (!action_queue::ShouldProcessDestroyedHead(true, true, target, queue)) + continue; + _this->ProcessQueue(target, false); + spdlog::info("[ThinQueueProtection] removed destroyed target {} from the head of fleet {}'s queue", target, + queue.player_fleet_id); + } +} + +// --- Queue address guard --- + +// Set while the game handles a player-state event, the only time a queue is cleared over a misreported address. +thread_local bool in_player_state_event = false; + +// What the queue's address check has seen so far: the fleet's own deployment, and the target being compared. +struct AddressCheck { + FleetPlayerData* fleet{}; + int64_t fleet_id{}; + bool own_resolved{}; + FleetDeployedData* own_deployment{}; + FleetDeployedData* target{}; +}; +// Set only while the fixes are on, so the hooks that read it need no setting check of their own. +thread_local AddressCheck* current_address_check = nullptr; + +action_queue::Address AddressOf(NodeAddress* address) +{ + if (!address) + return {}; + action_queue::Address value{true, address->Galaxy(), address->System(), address->Planet(), address->Instance()}; + value.valid = value.galaxy >= 0 && value.system > 0 && value.planet >= 0 && value.instance >= 0; + return value; +} + +// The fleet's own slot holds its queue, and every queued action names a target. +bool OwnsNonemptyQueue(ActionQueueManager* manager, FleetPlayerData* fleet, int64_t fleet_id) +{ + auto* queues = manager->battleQueue(); + const auto index = fleet->Index(); + if (!queues || !fleet_id || index < 0 || static_cast(index) >= queues->size()) + return false; + auto* queue = (*queues)[index]; + if (!queue || queue->PlayerFleetId() != fleet_id) + return false; + int32_t count = 0; + auto* actions = Actions(queue, count); + if (!actions || count == 0) + return false; + for (auto* action : *actions) + if (!action || !action->FleetId()) + return false; + return true; +} + +bool IsOwnDeployment(FleetDeployedData* deployment, int64_t fleet_id) +{ + auto* model = deployment->deploymentFleet(); + return deployment->localPlayerFleet() && model && model->FleetId() == fleet_id; +} + +void ActionQueueManager_OnPlayerFleetStateChangedEventHandler_Hook(auto original, ActionQueueManager* _this, + il2cpp::List* fleets) +{ + if (!QueueFixesEnabled()) + return original(_this, fleets); + Scoped scope(in_player_state_event, true); + original(_this, fleets); +} + +bool ActionQueueManager_DoesFleetQueueContainMismatchedAddressTargets_Hook(auto original, ActionQueueManager* _this, + FleetPlayerData* fleet) +{ + if (!QueueFixesEnabled() || !in_player_state_event || !fleet) + return original(_this, fleet); + const auto fleet_id = fleet->Id(); + if (!OwnsNonemptyQueue(_this, fleet, fleet_id)) + return original(_this, fleet); + AddressCheck check{fleet, fleet_id}; + Scoped scope(current_address_check, &check); + return original(_this, fleet); +} + +// The check reads the fleet's address once per target. Inside it, and only there, a fleet whose evidence matches +// the observed fault reports its own deployment's address. Nothing is written or remembered. +NodeAddress* FleetPlayerData_get_Address_Hook(auto original, FleetPlayerData* _this) +{ + auto* reported = original(_this); + auto* check = current_address_check; + if (!check || _this != check->fleet || !check->own_deployment || !check->target) + return reported; + auto* own = check->own_deployment; + auto* deployed = own->address(); + const action_queue::AddressEvidence evidence{ + .enabled = true, + .player_state_event = in_player_state_event, + .owned_queue = true, + .owned_deployment = IsOwnDeployment(own, check->fleet_id), + .planning_recall = own->IsPlanningRecallCourse(), + .player_state = static_cast(_this->CurrentState()), + .deployed_state = static_cast(own->CurrentState()), + .removal_reason = static_cast(own->RemovalReason()), + .reported = AddressOf(reported), + .deployed = AddressOf(deployed), + .target = AddressOf(check->target->address()), + }; + if (!action_queue::UseDeployedAddress(evidence)) + return reported; + spdlog::debug("[QueueAddressGuard] fleet {} kept its queue: reported in system {}, deployed in system {}", + check->fleet_id, evidence.reported.system, evidence.deployed.system); + return deployed; +} + +// The check looks up each queued target here. The fleet's own deployment is looked up once, alongside. +FleetDeployedData* DeploymentService_GetDeployedFleet_Hook(auto original, DeploymentService* _this, int64_t fleet_id) +{ + auto* result = original(_this, fleet_id); + if (auto* check = current_address_check; check) { + check->target = result; + if (!std::exchange(check->own_resolved, true)) + check->own_deployment = original(_this, check->fleet_id); + } + return result; +} + +// --- Installation --- +// +// Each fix needs all of its hooks, so each installs as one HookSet: every hook or none. + +bool InstallActionQueueRecovery() +{ + using Manager = ActionQueueManager::Class; + il2cpp::HookSet hooks("ActionQueueRecovery"); + IL2CPP_STAGE(hooks, + Manager::Method("TryPlanPathAndEngageTarget"), + ActionQueueManager_TryPlanPathAndEngageTarget_Hook); + IL2CPP_STAGE(hooks, Manager::Method("ShouldRetryFailedSetCourse"), + ActionQueueManager_ShouldRetryFailedSetCourse_Hook); + IL2CPP_STAGE(hooks, Manager::Method("OnSetCourseResponseEventHandler"), + ActionQueueManager_OnSetCourseResponseEventHandler_Hook); + IL2CPP_STAGE(hooks, Manager::Method("StopWatchdogAndClearAllQueues"), + ActionQueueManager_StopWatchdogAndClearAllQueues_Hook); + return hooks.Commit(); +} + +bool InstallThinQueueProtection() +{ + using Manager = ActionQueueManager::Class; + // Called rather than hooked: a missing one would read as a successful engage. + if (!Manager::Method("TryPlanPathAndEngageTarget")) + return false; + il2cpp::HookSet hooks("ThinQueueProtection"); + IL2CPP_STAGE(hooks, Manager::Method("DoPlanPathAndEngageTarget"), + ActionQueueManager_DoPlanPathAndEngageTarget_Hook); + IL2CPP_STAGE(hooks, Manager::Method("HandleStall"), + ActionQueueManager_HandleStall_Hook); + IL2CPP_STAGE(hooks, Manager::Method*)>("OnFleetsDisposedEventHandler"), + ActionQueueManager_OnFleetsDisposedEventHandler_Hook); + return hooks.Commit(); +} + +bool InstallQueueAddressGuard() +{ + using Manager = ActionQueueManager::Class; + il2cpp::HookSet hooks("QueueAddressGuard"); + IL2CPP_STAGE(hooks, Manager::Method*)>("OnPlayerFleetStateChangedEventHandler"), + ActionQueueManager_OnPlayerFleetStateChangedEventHandler_Hook); + IL2CPP_STAGE(hooks, Manager::Method("DoesFleetQueueContainMismatchedAddressTargets"), + ActionQueueManager_DoesFleetQueueContainMismatchedAddressTargets_Hook); + IL2CPP_STAGE(hooks, FleetPlayerData::Class::Method("get_Address"), FleetPlayerData_get_Address_Hook); + IL2CPP_STAGE(hooks, DeploymentService::Class::Method("GetDeployedFleet"), + DeploymentService_GetDeployedFleet_Hook); + return hooks.Commit(); } } // namespace +void ResetQueueRecoveryTracking() +{ ClearRequests(); } + +// Recovery and thin protection run on Windows and both Mac architectures: the host ABI passes the 24-byte +// SetCourseResponse as the game does, and the method lookups reject a client whose managed layout differs. const Feature kActionQueueRecovery{"ActionQueueRecovery", &Config::Patches::actionqueuerecoveryhooks, InstallActionQueueRecovery}; +const Feature kThinQueueProtection{"ThinQueueProtection", &Config::Patches::thinqueueprotectionhooks, + InstallThinQueueProtection}; +const Feature kQueueAddressGuard{"QueueAddressGuard", &Config::Patches::queueaddressguardhooks, + InstallQueueAddressGuard}; diff --git a/src/mod/features/action_queue.h b/src/mod/features/action_queue.h index 35a323aa7..0851fd49a 100644 --- a/src/mod/features/action_queue.h +++ b/src/mod/features/action_queue.h @@ -1,47 +1,59 @@ #pragma once +// The decisions behind the Kir'Shara queue fixes in action_queue.cc, kept free of game types so tests can drive +// them. Each fix acts only on narrow, observed evidence and otherwise leaves the game's own handling alone. + #include #include #include - -// Forget the mod's pending response correlations at both queue toggle transitions. -// Releases weak tracking handles; never clears or modifies native target queues. -void ResetQueueRecoveryTracking(); +#include +#include namespace action_queue { using Clock = std::chrono::steady_clock; -struct QueueState { +// --- Faster queue recovery --- +// +// A target removed while its course request is outstanding can make the failed response retry nothing, which leaves +// the next target waiting for the watchdog. A failed response may hand straight over to the native planner when it +// answers the latest attempt this queue made. + +// A queue as the course response sees it. `contains_target` is relative to the failed target. +struct RetryState { bool valid{}; std::int64_t fleet{}, front{}; float attempt{}; int count{}; - bool engaging{}, containsTarget{}; + bool engaging{}, contains_target{}; }; -// The pending-target field describes the last successful response, not necessarily the outstanding request. -inline bool CanAdvance(const QueueState& state, std::int64_t failedFleet, std::int64_t failedTarget, - bool outstandingAtEntry, bool sameQueue, bool absentFromAllQueues) +// The pending-target field describes the last successful response, not necessarily the outstanding request, so +// it is deliberately not an input. +inline bool CanAdvance(const RetryState& state, std::int64_t failed_fleet, std::int64_t failed_target, + bool outstanding_at_entry, bool same_queue, bool absent_from_all_queues) { - return state.valid && sameQueue && outstandingAtEntry && failedTarget != 0 && state.fleet == failedFleet - && state.count > 0 && state.front != failedTarget && !state.engaging && !state.containsTarget - && absentFromAllQueues; + return state.valid && same_queue && outstanding_at_entry && failed_target != 0 && state.fleet == failed_fleet + && state.count > 0 && state.front != failed_target && !state.engaging && !state.contains_target + && absent_from_all_queues; } -// Handles provides weak identity without keeping native queues alive. Calls are serialized by the adapter. -template class Requests +// The latest engagement attempt per fleet. Queues are held weakly, so a collected queue cannot be confused with a +// new one at the same address. `Handle` is il2cpp::GcHandle in the game: move-only, `Weak(object)`, `Get()`. +// Callers serialize access. +template class Requests { public: - using Object = typename Handles::Object; - using Handle = typename Handles::Handle; + using Object = decltype(std::declval().Get()); - std::uint64_t Remember(Object queue, const QueueState& state, Clock::time_point now) + // Records an attempt and returns its serial, for Cancel. + std::uint64_t Remember(Object queue, const RetryState& state, Clock::time_point now) { + // Reuse this fleet's record, else the oldest one; expired records are dropped on the way. auto* slot = &records_.front(); for (auto& record : records_) { - if (record.handle && (now - record.started >= lifetime || !Handles::Get(record.handle))) - Release(record); + if (record.handle && (now - record.started >= kLifetime || !record.handle.Get())) + record = {}; if (record.fleet == state.fleet) { slot = &record; break; @@ -49,43 +61,42 @@ template class Requests if (record.started < slot->started) slot = &record; } - Release(*slot); + *slot = {}; const auto serial = ++serial_; if (state.valid && state.count > 0 && state.front != 0) - *slot = {Handles::New(queue), state.fleet, state.front, state.attempt, now, serial}; + *slot = {Handle::Weak(queue), state.fleet, state.front, state.attempt, now, serial}; return serial; } - // A native attempt that did not dispatch must not authorize a later failure response. + // An attempt that did not dispatch must not authorize a later failure response. Only that attempt's own record + // goes, so an older reentrant call cannot cancel a newer one. void Cancel(std::uint64_t serial) { for (auto& record : records_) if (record.serial == serial) - Release(record); + record = {}; } - bool Consume(Object queue, const QueueState& state, std::int64_t target, Clock::time_point now) + // True once for a failure that answers the remembered attempt of the same live queue. + bool Consume(Object queue, const RetryState& state, std::int64_t target, Clock::time_point now) { for (auto& record : records_) { if (record.fleet != state.fleet) continue; - if (!record.handle || now - record.started >= lifetime || !Handles::Get(record.handle)) { - Release(record); + if (!record.handle || now - record.started >= kLifetime || !record.handle.Get()) { + record = {}; return false; } - if (Handles::Get(record.handle) != queue || record.target != target || record.attempt != state.attempt) + if (record.handle.Get() != queue || record.target != target || record.attempt != state.attempt) return false; - Release(record); + record = {}; return true; } return false; } void Clear() - { - for (auto& record : records_) - Release(record); - } + { records_ = {}; } private: struct Record { @@ -95,14 +106,160 @@ template class Requests Clock::time_point started{}; std::uint64_t serial{}; }; - static void Release(Record& record) - { - if (record.handle) - Handles::Free(record.handle); - record = {}; - } - static constexpr auto lifetime = std::chrono::seconds(30); + // A response later than this belongs to some other attempt. + static constexpr auto kLifetime = std::chrono::seconds(30); + // One per dock slot, with room to spare. std::array records_{}; std::uint64_t serial_{}; }; + +// --- Thin queue protection --- +// +// The native planner and watchdog can drop a prefix of targets and then leave the fleet idle with the rest still +// queued. When that is exactly what happened, the fleet is asked to engage its new head. + +// Digit.Prime.Combat.EngageResult. +constexpr int kEngageSuccess = 0; +constexpr int kEngageSkipTarget = 1; +constexpr int kEngageStop = 2; + +constexpr std::string_view EngageResultName(int result) +{ + switch (result) { + case kEngageSuccess: + return "success"; + case kEngageSkipTarget: + return "skip-target"; + case kEngageStop: + return "stop"; + default: + return result < 0 ? "not-attempted" : "unknown"; + } +} + +// A queue's targets and engagement latches. Only the first eight targets are captured; a longer queue is marked +// truncated and never treated as evidence. +struct QueueSnapshot { + bool present = false; + std::int64_t player_fleet_id = 0; + int count = -1; + std::int64_t head_target_id = 0; + std::array target_ids{}; + int captured_target_count = 0; + bool targets_truncated = false; + bool is_engaging = false; + std::int64_t last_engaged_target_id = 0; + std::int64_t pending_target_id = 0; +}; + +// A destroyed target the native disposal left at the head of an idle queue. +constexpr bool ShouldProcessDestroyedHead(bool enabled, bool target_destroyed, std::int64_t target_id, + const QueueSnapshot& after_native) +{ + return enabled && target_destroyed && target_id != 0 && after_native.present && after_native.count > 0 + && after_native.head_target_id == target_id && !after_native.is_engaging + && after_native.last_engaged_target_id != target_id && after_native.pending_target_id != target_id; +} + +constexpr bool IsNoTargetOrRemovedPrefix(std::int64_t target_id, const QueueSnapshot& before_native, int removed_prefix) +{ + if (target_id <= 0) + return true; + for (int index = 0; index < removed_prefix; ++index) + if (before_native.target_ids[index] == target_id) + return true; + return false; +} + +constexpr bool LatchNamesSurvivingTarget(std::int64_t target_id, const QueueSnapshot& queue) +{ + if (target_id <= 0) + return false; + for (int index = 0; index < queue.count; ++index) + if (queue.target_ids[index] == target_id) + return true; + return false; +} + +// The native call removed an exact prefix and left the same fleet idle with an unchanged remainder, and neither +// latch names a target that is still queued. +constexpr bool IsNativePruneResumeCandidate(bool enabled, bool player_fleet_idle, const QueueSnapshot& before_native, + const QueueSnapshot& after_native) +{ + if (!enabled || !player_fleet_idle || !before_native.present || !after_native.present + || before_native.player_fleet_id == 0 || before_native.player_fleet_id != after_native.player_fleet_id + || before_native.count <= 0 || after_native.count <= 0 || after_native.count >= before_native.count + || before_native.head_target_id == 0 || after_native.head_target_id == 0 + || after_native.head_target_id == before_native.head_target_id || after_native.is_engaging + || before_native.targets_truncated || after_native.targets_truncated + || before_native.captured_target_count != before_native.count + || after_native.captured_target_count != after_native.count) + return false; + + const auto removed_prefix = before_native.count - after_native.count; + for (int index = 0; index < after_native.count; ++index) + if (after_native.target_ids[index] != before_native.target_ids[index + removed_prefix]) + return false; + + return IsNoTargetOrRemovedPrefix(after_native.last_engaged_target_id, before_native, removed_prefix) + && IsNoTargetOrRemovedPrefix(after_native.pending_target_id, before_native, removed_prefix); +} + +// The queue read again just before resuming still matches what the decision was made on. +constexpr bool IsStableResumePostcondition(const QueueSnapshot& expected, const QueueSnapshot& confirmed) +{ + if (!expected.present || !confirmed.present || expected.player_fleet_id == 0 + || confirmed.player_fleet_id != expected.player_fleet_id || expected.count <= 0 + || confirmed.count != expected.count || expected.head_target_id == 0 + || confirmed.head_target_id != expected.head_target_id || expected.targets_truncated + || confirmed.targets_truncated || expected.captured_target_count != expected.count + || confirmed.captured_target_count != confirmed.count || confirmed.is_engaging || expected.is_engaging + || confirmed.last_engaged_target_id != expected.last_engaged_target_id + || confirmed.pending_target_id != expected.pending_target_id + || LatchNamesSurvivingTarget(confirmed.last_engaged_target_id, confirmed) + || LatchNamesSurvivingTarget(confirmed.pending_target_id, confirmed)) + return false; + + for (int index = 0; index < expected.count; ++index) + if (confirmed.target_ids[index] != expected.target_ids[index]) + return false; + return true; +} + +// --- Queue address guard --- +// +// Moving or recalling another ship can briefly report a deployed ship's address as its starbase planet. The next +// player-state event then sees that ship's same-system targets as foreign and clears its queue. + +struct Address { + bool valid{}; + std::int64_t galaxy{}, system{}, planet{}; + int instance{}; + bool operator==(const Address&) const = default; +}; + +// FleetState and DeployedFleetState values, as the game defines them. +constexpr int kFleetIdleInSpace = 1, kFleetBattling = 64, kFleetImpulsing = 512; +constexpr int kDeployedIdle = 0, kDeployedMoving = 1, kDeployedBattling = 3, kDeployedPreBattle = 6; + +struct AddressEvidence { + bool enabled{}, player_state_event{}, owned_queue{}, owned_deployment{}, planning_recall{}; + int player_state{-1}, deployed_state{-1}, removal_reason{-1}; + Address reported, deployed, target; +}; + +// Only the observed substitution is corrected: an active ship reported at a planet in another system while its own +// deployment, and its target, are both still at the deployment's address. +inline bool UseDeployedAddress(const AddressEvidence& e) +{ + const bool player_active = + e.player_state == kFleetIdleInSpace || e.player_state == kFleetBattling || e.player_state == kFleetImpulsing; + const bool deployed_active = e.deployed_state == kDeployedIdle || e.deployed_state == kDeployedMoving + || e.deployed_state == kDeployedBattling || e.deployed_state == kDeployedPreBattle; + return e.enabled && e.player_state_event && e.owned_queue && e.owned_deployment && !e.planning_recall + && e.removal_reason == 0 && player_active && deployed_active && e.reported.valid && e.deployed.valid + && e.target.valid && e.reported.planet > 0 && e.deployed.planet == 0 && e.reported.system != e.deployed.system + && e.reported.galaxy == e.deployed.galaxy && e.reported.instance == e.deployed.instance + && e.deployed == e.target; +} } // namespace action_queue diff --git a/src/mod/features/action_queue_guard_policy.h b/src/mod/features/action_queue_guard_policy.h deleted file mode 100644 index 1bfc3eab9..000000000 --- a/src/mod/features/action_queue_guard_policy.h +++ /dev/null @@ -1,123 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace action_queue_guard -{ -constexpr int kEngageResultSuccess = 0; -constexpr int kEngageResultSkipTarget = 1; -constexpr int kEngageResultStop = 2; - -constexpr std::string_view EngageResultName(int result) -{ - switch (result) { - case kEngageResultSuccess: - return "success"; - case kEngageResultSkipTarget: - return "skip-target"; - case kEngageResultStop: - return "stop"; - default: - return result < 0 ? "not-attempted" : "unknown"; - } -} - -struct QueueState { - bool present = false; - std::int64_t player_fleet_id = 0; - int count = -1; - std::int64_t head_target_id = 0; - std::array target_ids{}; - int captured_target_count = 0; - bool targets_truncated = false; - bool is_engaging = false; - std::int64_t last_engaged_target_id = 0; - std::int64_t pending_target_id = 0; -}; - -constexpr bool ShouldProcessDestroyedHead(bool enabled, bool target_destroyed, std::int64_t target_id, - const QueueState& after_native) -{ - return enabled && target_destroyed && target_id != 0 && after_native.present && after_native.count > 0 - && after_native.head_target_id == target_id && !after_native.is_engaging - && after_native.last_engaged_target_id != target_id && after_native.pending_target_id != target_id; -} - -constexpr bool IsNoTargetOrRemovedPrefix(std::int64_t target_id, const QueueState& before_native, int removed_prefix) -{ - if (target_id <= 0) { - return true; - } - - for (int index = 0; index < removed_prefix; ++index) { - if (before_native.target_ids[index] == target_id) { - return true; - } - } - return false; -} - -constexpr bool LatchNamesSurvivingTarget(std::int64_t target_id, const QueueState& queue) -{ - if (target_id <= 0) { - return false; - } - - for (int index = 0; index < queue.count; ++index) { - if (queue.target_ids[index] == target_id) { - return true; - } - } - return false; -} - -constexpr bool IsNativePruneResumeCandidate(bool enabled, bool player_fleet_idle, const QueueState& before_native, - const QueueState& after_native) -{ - if (!enabled || !player_fleet_idle || !before_native.present || !after_native.present - || before_native.player_fleet_id == 0 || before_native.player_fleet_id != after_native.player_fleet_id - || before_native.count <= 0 || after_native.count <= 0 || after_native.count >= before_native.count - || before_native.head_target_id == 0 || after_native.head_target_id == 0 - || after_native.head_target_id == before_native.head_target_id || after_native.is_engaging - || before_native.targets_truncated || after_native.targets_truncated - || before_native.captured_target_count != before_native.count - || after_native.captured_target_count != after_native.count) { - return false; - } - - const auto removed_prefix = before_native.count - after_native.count; - for (int index = 0; index < after_native.count; ++index) { - if (after_native.target_ids[index] != before_native.target_ids[index + removed_prefix]) { - return false; - } - } - - return IsNoTargetOrRemovedPrefix(after_native.last_engaged_target_id, before_native, removed_prefix) - && IsNoTargetOrRemovedPrefix(after_native.pending_target_id, before_native, removed_prefix); -} - -constexpr bool IsStableResumePostcondition(const QueueState& expected, const QueueState& confirmed) -{ - if (!expected.present || !confirmed.present || expected.player_fleet_id == 0 - || confirmed.player_fleet_id != expected.player_fleet_id || expected.count <= 0 - || confirmed.count != expected.count || expected.head_target_id == 0 - || confirmed.head_target_id != expected.head_target_id || expected.targets_truncated - || confirmed.targets_truncated || expected.captured_target_count != expected.count - || confirmed.captured_target_count != confirmed.count || confirmed.is_engaging || expected.is_engaging - || confirmed.last_engaged_target_id != expected.last_engaged_target_id - || confirmed.pending_target_id != expected.pending_target_id - || LatchNamesSurvivingTarget(confirmed.last_engaged_target_id, confirmed) - || LatchNamesSurvivingTarget(confirmed.pending_target_id, confirmed)) { - return false; - } - - for (int index = 0; index < expected.count; ++index) { - if (confirmed.target_ids[index] != expected.target_ids[index]) { - return false; - } - } - return true; -} -} // namespace action_queue_guard diff --git a/src/mod/features/alliance_store.cc b/src/mod/features/alliance_store.cc index d6c4656b6..931056206 100644 --- a/src/mod/features/alliance_store.cc +++ b/src/mod/features/alliance_store.cc @@ -5,19 +5,12 @@ #include "game/app.h" #include "game/inventory.h" -#include "game/native_hook_extent.h" #include #include -#include #include -#include #include -#if _WIN32 -#include -#endif - #include #include #include @@ -31,10 +24,10 @@ namespace { using Clock = std::chrono::steady_clock; -bool alliance_store_active = false; +// The thread that installed the hooks, the game's main thread. The hooks leave calls from any other thread alone. std::thread::id alliance_store_thread; bool StoreThread() -{ return alliance_store_active && std::this_thread::get_id() == alliance_store_thread; } +{ return std::this_thread::get_id() == alliance_store_thread; } struct AllianceStoreWork { il2cpp::GcHandle source; @@ -466,73 +459,44 @@ void AllianceGamesStoreFrame() StoreCount(currency_after), StoreCount(visible), StoreCount(position)); } -bool StoreHookFits(const MethodInfo* method) -{ - if (!method || !method->methodPointer) - return false; - auto* entry = reinterpret_cast(method->methodPointer); -#if _WIN32 && defined(_M_X64) - DWORD64 base = 0; - auto* function = RtlLookupFunctionEntry(reinterpret_cast(entry), &base, nullptr); - if (!function || base + function->BeginAddress != reinterpret_cast(entry) - || function->EndAddress <= function->BeginAddress) - return false; - const size_t extent = function->EndAddress - function->BeginAddress; - const auto plan = spud::inspect_detour(entry, spud::Arch::kHost, extent); - return plan.ok() && plan.resolved_target == reinterpret_cast(entry) && plan.far_jump_size != 0 - && plan.far_required_patch_bytes >= plan.far_jump_size && plan.far_required_patch_bytes <= extent; -#elif __APPLE__ - return native_hooks::MacHookFits(entry); -#else - return false; -#endif -} - -void InstallAllianceGamesStore() +bool InstallAllianceGamesStore() { - using il2cpp::contract::Resolve; - auto* store = AllianceGamesStoreScrollerViewController::Class::Raw(); - auto* shop = ShopListScrollerViewController::Class::Raw(); - auto* card = BundleDataWidget::Class::Raw(); - const std::array targets{Resolve(store, "OnDidBindCanvasContext", false, "System.Void", {}), - Resolve(store, "OnAboutToReleaseCanvasContext", false, "System.Void", {}), - Resolve(shop, "RegenerateSortedBundles", false, "System.Void", {"System.Boolean"}), - Resolve(card, "RefreshBundle", false, "System.Void", {"Digit.PrimePlatform.Content.Bundle"}), - Resolve(card, "OnAboutToReleaseContext", false, "System.Void", {}), - Resolve(card, "OnDidBindContext", false, "System.Void", {})}; - if (!AllianceGamesStoreFieldsPresent()) { - spdlog::warn("[AllianceGamesStore] unavailable: native field contract changed"); - return; - } + using Store = AllianceGamesStoreScrollerViewController::Class; + using Shop = ShopListScrollerViewController::Class; + using Card = BundleDataWidget::Class; + const std::array targets{ + Store::Method("OnDidBindCanvasContext"), Store::Method("OnAboutToReleaseCanvasContext"), + Shop::Method("RegenerateSortedBundles"), Card::Method("RefreshBundle"), + Card::Method("OnAboutToReleaseContext"), Card::Method("OnDidBindContext")}; + const std::array owners{Store::Raw(), Store::Raw(), Shop::Raw(), Card::Raw(), Card::Raw(), Card::Raw()}; + // Each target must be the class's own method: the lookup falls back to a base class's, and a detour there would run + // for every subclass. Nor may two share the body SPUD patches, or one hook would also run for the other's receiver. + std::array bodies{}; for (size_t i = 0; i < targets.size(); ++i) { - if (!StoreHookFits(targets[i])) { - spdlog::warn("[AllianceGamesStore] unavailable: hook signature/extent {}", i); - return; + if (!targets[i]) + continue; // Reported by the lookup, and keeps the set out. + if (targets[i].Info()->klass != owners[i]) { + spdlog::warn("[AllianceGamesStore] unavailable: {} is inherited", targets[i].Name()); + return false; } + bodies[i] = il2cpp::DetourTarget(targets[i]); for (size_t j = 0; j < i; ++j) - if (targets[i]->methodPointer == targets[j]->methodPointer) { + if (bodies[i] == bodies[j]) { spdlog::warn("[AllianceGamesStore] unavailable: shared hook target"); - return; + return false; } } - // Every target is checked first; the detours are attempted once, and the hooks stay native-only unless all went - // in. The frame work runs from the frame tick; without the tick the hooks only record pending work. - const bool complete = - IL2CPP_DETOUR(targets[0]->methodPointer, AllianceStoreScroller_OnDidBindCanvasContext_Hook, - "AllianceGamesStoreScrollerViewController.OnDidBindCanvasContext") - && IL2CPP_DETOUR(targets[1]->methodPointer, AllianceStoreScroller_OnAboutToReleaseCanvasContext_Hook, - "AllianceGamesStoreScrollerViewController.OnAboutToReleaseCanvasContext") - && IL2CPP_DETOUR(targets[2]->methodPointer, ShopListScrollerViewController_RegenerateSortedBundles_Hook, - "ShopListScrollerViewController.RegenerateSortedBundles") - && IL2CPP_DETOUR(targets[3]->methodPointer, BundleDataWidget_RefreshBundle_Hook, "BundleDataWidget.RefreshBundle") - && IL2CPP_DETOUR(targets[4]->methodPointer, BundleDataWidget_OnAboutToReleaseContext_Hook, - "BundleDataWidget.OnAboutToReleaseContext") - && IL2CPP_DETOUR(targets[5]->methodPointer, BundleDataWidget_OnDidBindContext_Hook, - "BundleDataWidget.OnDidBindContext"); alliance_store_thread = std::this_thread::get_id(); - alliance_store_active = complete; - if (!complete) - spdlog::warn("[AllianceGamesStore] unavailable: incomplete detour installation; adapters inactive"); + // One transaction: every hook or none. The frame work runs from the frame tick; without the tick the hooks only + // record pending work. + il2cpp::HookSet hooks("AllianceGamesStore"); + IL2CPP_STAGE(hooks, targets[0], AllianceStoreScroller_OnDidBindCanvasContext_Hook); + IL2CPP_STAGE(hooks, targets[1], AllianceStoreScroller_OnAboutToReleaseCanvasContext_Hook); + IL2CPP_STAGE(hooks, targets[2], ShopListScrollerViewController_RegenerateSortedBundles_Hook); + IL2CPP_STAGE(hooks, targets[3], BundleDataWidget_RefreshBundle_Hook); + IL2CPP_STAGE(hooks, targets[4], BundleDataWidget_OnAboutToReleaseContext_Hook); + IL2CPP_STAGE(hooks, targets[5], BundleDataWidget_OnDidBindContext_Hook); + return hooks.Commit(); } } // namespace diff --git a/src/mod/features/anomaly_timer.cc b/src/mod/features/anomaly_timer.cc index 547736e79..54cdd772f 100644 --- a/src/mod/features/anomaly_timer.cc +++ b/src/mod/features/anomaly_timer.cc @@ -1,4 +1,4 @@ -// galactic_anomaly_timer: a countdown for the active galactic anomaly in the viewed system, under the HUD's +// [graphics] galactic_anomaly_timer: a countdown for the active galactic anomaly in the viewed system, under the HUD's // Help All / Engage / Claim drawer. All Unity work runs on the frame tick; there are no detours of its own. #include "config.h" @@ -72,14 +72,14 @@ class AnomalyTimer // Remaining ticks of the active anomaly in the viewed system. const int64_t ticks = nav && nav->isActiveAndEnabled() && nav->CanShowGalacticAnomaly() ? RemainingTicks(nav) : 0; if (ticks <= 0) { - if (auto* label = Label()) - label->transform()->parent()->gameObject()->SetActive(false); + if (auto* panel = Label() ? Label()->ParentObject() : nullptr) + panel->SetActive(false); return; } if (!Label() && !Create(nav)) { static bool warned = false; if (!std::exchange(warned, true)) - spdlog::warn("Galactic anomaly countdown: HUD creation failed; leaving the native HUD unchanged"); + spdlog::warn("[AnomalyTimer] could not build the countdown panel; leaving the HUD unchanged"); DestroyPanel(); next_update_ = now + std::chrono::seconds(5); return; @@ -93,7 +93,8 @@ class AnomalyTimer : fmt::format("{:02}m {:02}s", seconds / 60, seconds % 60); Label()->SetText(text); } - Label()->transform()->parent()->gameObject()->SetActive(true); + if (auto* panel = Label()->ParentObject()) + panel->SetActive(true); } private: @@ -131,8 +132,7 @@ class AnomalyTimer void DestroyPanel() { - if (auto* label = Label()) { - auto* panel = label->transform()->parent()->gameObject(); + if (auto* panel = Label() ? Label()->ParentObject() : nullptr) { panel->SetActive(false); unity::Destroy(panel); } @@ -215,20 +215,19 @@ class AnomalyTimer int64_t shown_seconds_ = -1; }; -AnomalyTimer* timer = nullptr; -void AnomalyTimerUpdate() -{ - if (timer) - timer->Update(); -} +// Leaked on purpose: its GC handles must not be released by static destructors after the runtime is gone. +AnomalyTimer* anomaly_timer = nullptr; + +void AnomalyTimerFrame() +{ anomaly_timer->Update(); } -void InstallAnomalyTimer() +// No hooks: the countdown is all frame work. +bool InstallAnomalyTimer() { - // Never destroyed: GC handles must not be released by static destructors after the runtime is gone. - timer = new AnomalyTimer; - spdlog::info("Galactic anomaly countdown enabled (below Help All / Engage / Claim)"); + anomaly_timer = new AnomalyTimer; + return true; } } // namespace const Feature kAnomalyTimer{"AnomalyTimer", &Config::Patches::galacticanomalytimerhooks, InstallAnomalyTimer, - AnomalyTimerUpdate}; + AnomalyTimerFrame}; diff --git a/src/mod/features/audio.cc b/src/mod/features/audio.cc index bba24de17..f216afede 100644 --- a/src/mod/features/audio.cc +++ b/src/mod/features/audio.cc @@ -1,8 +1,8 @@ -// [audio]: logs the game's sound events by name, and mutes the ones in disabled_events. +// [audio] trace_events, disabled_events and disable_all_events: logs the game's sound events by name, and mutes the +// chosen ones. #include "config.h" #include "features/features.h" -#include "text.h" #include "game/app.h" @@ -31,28 +31,27 @@ bool AudioEventManager_PostEvent_Hook(auto original, Il2CppObject* _this, Il2Cpp { 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. + 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("[Audio] muted {}", event); + return false; } } return original(_this, event_name, event_action, parameter, parent_game_object, initialise_parameters, add_to_queue, on_event_notify); } -void InstallAudio() +bool InstallAudio() { - IL2CPP_HOOK(AudioEventManager::Method("PostEvent"), - AudioEventManager_PostEvent_Hook); + il2cpp::HookSet hooks("Audio"); + IL2CPP_STAGE(hooks, + AudioEventManager::Method("PostEvent"), + AudioEventManager_PostEvent_Hook); + return hooks.Commit(); } } // namespace diff --git a/src/mod/features/buffs.cc b/src/mod/features/buffs.cc index e3f7a6586..8b73c7aa7 100644 --- a/src/mod/features/buffs.cc +++ b/src/mod/features/buffs.cc @@ -12,13 +12,13 @@ namespace { // Leaves out the buffs that only apply while docked at the station. -bool BuffService_IsBuffConditionMet_Hook(auto original, void* _this, BuffCondition currentCondition, - IBuffComparer* buffComparer, IBuffData* buffToCompare, - bool excludeFactionBuffs, bool isAllianceLoyalty) +bool BuffService_IsBuffConditionMet_Hook(auto original, il2cpp::Object* _this, BuffCondition condition, + IBuffComparer* comparer, IBuffData* buff, bool exclude_faction_buffs, + bool is_alliance_loyalty) { - if (currentCondition == BuffCondition::CondSelfAtStation && Config::Get().buffs.use_out_of_dock_power) + if (condition == BuffCondition::CondSelfAtStation && Config::Get().buffs.use_out_of_dock_power) return false; - return original(_this, currentCondition, buffComparer, buffToCompare, excludeFactionBuffs, isAllianceLoyalty); + return original(_this, condition, comparer, buff, exclude_faction_buffs, is_alliance_loyalty); } // List in and out, read untyped so a drifted IBuffData binding cannot make every buff look null. A list @@ -33,11 +33,19 @@ il2cpp::List* BuffService_ExtractBuffsOfType_Hook(auto original return original(modifier, list); } -void InstallOutOfDockPower() -{ IL2CPP_HOOK(BuffService::Method("IsBuffConditionMet"), BuffService_IsBuffConditionMet_Hook); } +bool InstallOutOfDockPower() +{ + il2cpp::HookSet hooks("OutOfDockPower"); + IL2CPP_STAGE(hooks, BuffService::Method("IsBuffConditionMet"), BuffService_IsBuffConditionMet_Hook); + return hooks.Commit(); +} -void InstallBuffCrashGuard() -{ IL2CPP_HOOK(BuffService::Method("ExtractBuffsOfType"), BuffService_ExtractBuffsOfType_Hook); } +bool InstallBuffCrashGuard() +{ + il2cpp::HookSet hooks("BuffCrashGuard"); + IL2CPP_STAGE(hooks, BuffService::Method("ExtractBuffsOfType"), BuffService_ExtractBuffsOfType_Hook); + return hooks.Commit(); +} } // namespace const Feature kOutOfDockPower{"OutOfDockPower", &Config::Patches::bufffixhooks, InstallOutOfDockPower}; diff --git a/src/mod/features/camera.cc b/src/mod/features/camera.cc index 0a87732b6..bdd9f1582 100644 --- a/src/mod/features/camera.cc +++ b/src/mod/features/camera.cc @@ -1,12 +1,14 @@ // The camera in system view and at the station: the extended zoom range, keyboard zoom and its presets, planet -// sprite scaling, keyboard panning, pan momentum and the target camera lock, keyboard rotation around the station, and -// held touches that keep panning. Fleet label detail, which follows the zoom, is in fleet_labels.cc. +// sprite scaling, keyboard panning, pan momentum and the target camera lock, keyboard rotation around the station, +// held touches that keep panning, and [graphics] interior_zoom_scale inside the station. Fleet label detail, which +// follows the zoom, is in fleet_labels.cc; Haven's camera is in haven.cc. #include "config.h" #include "features/features.h" #include "shortcuts.h" #include "game/app.h" +#include "game/camera.h" #include "game/navigation.h" #include "game/ui.h" @@ -37,7 +39,7 @@ void StorePreset(const char* label, float& preset, NavigationZoom* zoom) { const auto old = preset; preset = CurrentZoom(zoom); - spdlog::info("Changing {} from {} to {}", label, old, preset); + spdlog::info("[Zoom] {} set from {} to {}", label, old, preset); } struct Preset { @@ -226,8 +228,7 @@ void PlanetViewUtils_CameraZoomedEventHandler_Hook(auto original, PlanetViewUtil float normalized) { original(_this, distance, normalized); - if (_this) - _this->FlatRenderable(); + _this->FlatRenderable(); } void ScaleAuthoredBackdrop(PlanetViewUtils* view) @@ -241,7 +242,7 @@ void PlanetViewUtils_CreateBackdrop_Hook(auto original, PlanetViewUtils* _this, Il2CppObject* resource, bool using_default) { original(_this, backdrop, resource, using_default); - if (_this && resource) { + if (resource) { ++backdrop_revision; ScaleAuthoredBackdrop(_this); } @@ -250,10 +251,10 @@ void PlanetViewUtils_CreateBackdrop_Hook(auto original, PlanetViewUtils* _this, void PlanetViewUtils_Populate_Hook(auto original, PlanetViewUtils* _this, Il2CppObject* system, int64_t node_id, NodeDepth depth) { - auto* previous = _this ? _this->Population() : nullptr; + auto* previous = _this->popData(); const auto revision = backdrop_revision; original(_this, system, node_id, depth); - auto* population = _this ? _this->Population() : nullptr; + auto* population = _this->popData(); // Cached variation backdrops reload authored scale without calling CreateBackdrop. if (depth == NodeDepth::SolarSystem && population && population != previous && population->VariationCollection() && revision == backdrop_revision) @@ -348,9 +349,9 @@ void OrbitFrameProvider_UpdateInputData_Hook(auto original, OrbitFrameProvider* constexpr float kDegreesPerSecond = 45.0f; const float step = kDegreesPerSecond * unity::Time::deltaTime(); if (Shortcut::IsPressed(Action::kMoveLeft)) - _this->SetRotationAngleDelta(_this->RotationAngleDelta() - step); + _this->SetRotationAngleDelta(_this->rotationAngleDelta() - step); if (Shortcut::IsPressed(Action::kMoveRight)) - _this->SetRotationAngleDelta(_this->RotationAngleDelta() + step); + _this->SetRotationAngleDelta(_this->rotationAngleDelta() + step); } // A held touch reports Stationary, which the game's pan ignores; report it as moving so drags keep panning. @@ -362,34 +363,84 @@ TKTouch* TKTouch_populateWithPosition_Hook(auto original, TKTouch* _this, uintpt return touch; } -void InstallZoom() +// --- Station interior zoom --- +// +// The interior view blends from a fixed close frame out to an orbit pinned at one radius, so its far stop is that +// orbit's radius. Widening the orbit's [min, max] lets the zoom go further. The same radius bounds the exterior station +// camera, so it is widened only inside the interior and put back everywhere else. + +// Each radius's own range, as found before widening. Keyed by the radius constraint, not the orbit: the orbit is +// recreated across transitions while its radius persists, so an orbit key left the exterior at a widened radius it +// never restored. The interior has one radius, so this stays tiny; keys are compared only, never dereferenced. +std::unordered_map> native_interior_radius; + +void OrbitFrameProvider_UpdateConstrains_Hook(auto original, OrbitFrameProvider* _this, unity::Camera* primary_camera) { - 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); - IL2CPP_HOOK(NavigationZoom::Class::Method("Update"), NavigationZoom_Update_Hook); + auto* constraint = _this->constraint(); + if (auto* radius = constraint ? constraint->radius() : nullptr) { + auto* sections = Hub::Sections(); + const bool interior = sections && sections->CurrentSection() == SectionID::Starbase_Interior; + const float scale = Config::Get().graphics.interior_zoom_scale; + auto native = native_interior_radius.find(radius); + if (interior && std::isfinite(scale) && scale > 1.0f) { + if (native == native_interior_radius.end()) + native = native_interior_radius.emplace(radius, std::pair{radius->min(), radius->max()}).first; + // Always scaled from the native range, so doing this every frame cannot compound. + radius->SetMin(native->second.first * scale); + radius->SetMax(native->second.second * scale); + } else if (native != native_interior_radius.end()) { + // Left wider, the exterior view stays zoomed out and its station labels drop out of range. + radius->SetMin(native->second.first); + radius->SetMax(native->second.second); + } + } + // The game clamps the camera against the widened or restored range. + original(_this, primary_camera); +} + +bool InstallZoom() +{ + il2cpp::HookSet hooks("Zoom"); + IL2CPP_STAGE(hooks, PlanetViewUtils::Class::Method("Populate", 3), PlanetViewUtils_Populate_Hook); + IL2CPP_STAGE(hooks, + PlanetViewUtils::Class::Method( + "CreateBackdrop", {"Digit.PrimeServer.Models.ITreeNode", "UnityEngine.Object", "System.Boolean"}), + PlanetViewUtils_CreateBackdrop_Hook); + IL2CPP_STAGE(hooks, + PlanetViewUtils::Class::Method("CreateBackdrop", {"Digit.PrimeServer.Models.ITreeNode", + "SystemVariationConfigCollection", "System.Boolean"}), + PlanetViewUtils_CreateBackdrop_Hook); + IL2CPP_STAGE(hooks, PlanetViewUtils::Class::Method("CameraZoomedEventHandler"), + PlanetViewUtils_CameraZoomedEventHandler_Hook); + IL2CPP_STAGE(hooks, PlanetViewUtils::Class::Method("get_FlatRenderable"), PlanetViewUtils_get_FlatRenderable_Hook); + IL2CPP_STAGE(hooks, NavigationZoom::Class::Method("Update"), NavigationZoom_Update_Hook); // TODO: SetDepth has only ever been detoured on Windows and the reason was not recorded; check its prologue // against SPUD's overwrite size before enabling it on macOS. #if _WIN32 - IL2CPP_HOOK(NavigationZoom::Class::Method("SetDepth"), NavigationZoom_SetDepth_Hook); + IL2CPP_STAGE(hooks, NavigationZoom::Class::Method("SetDepth"), NavigationZoom_SetDepth_Hook); #endif - IL2CPP_HOOK(NavigationZoom::Class::Method("SetViewParameters"), NavigationZoom_SetViewParameters_Hook); + IL2CPP_STAGE(hooks, NavigationZoom::Class::Method("SetViewParameters"), NavigationZoom_SetViewParameters_Hook); + return hooks.Commit(); +} + +bool InstallPan() +{ + il2cpp::HookSet hooks("Pan"); + IL2CPP_STAGE(hooks, OrbitFrameProvider::Class::Method("UpdateInputData"), OrbitFrameProvider_UpdateInputData_Hook); + IL2CPP_STAGE(hooks, TKTouch::Class::Method("populateWithPosition"), TKTouch_populateWithPosition_Hook); + IL2CPP_STAGE(hooks, NavigationPan::Class::Method("LateUpdate"), NavigationPan_LateUpdate_Hook); + return hooks.Commit(); } -void InstallPan() +bool InstallInteriorZoom() { - IL2CPP_HOOK(OrbitFrameProvider::Class::Method("UpdateInputData"), OrbitFrameProvider_UpdateInputData_Hook); - IL2CPP_HOOK(TKTouch::Class::Method("populateWithPosition"), TKTouch_populateWithPosition_Hook); - IL2CPP_HOOK(NavigationPan::Class::Method("LateUpdate"), NavigationPan_LateUpdate_Hook); + il2cpp::HookSet hooks("InteriorZoom"); + IL2CPP_STAGE(hooks, OrbitFrameProvider::Class::Method("UpdateConstrains"), + OrbitFrameProvider_UpdateConstrains_Hook); + return hooks.Commit(); } } // namespace const Feature kZoom{"Zoom", &Config::Patches::zoomhooks, InstallZoom}; const Feature kPan{"Pan", &Config::Patches::panhooks, InstallPan}; +const Feature kInteriorZoom{"InteriorZoom", &Config::Patches::interiorzoomhooks, InstallInteriorZoom}; diff --git a/src/mod/features/cargo.cc b/src/mod/features/cargo.cc index 7411da88a..e7ed1b88d 100644 --- a/src/mod/features/cargo.cc +++ b/src/mod/features/cargo.cc @@ -1,5 +1,5 @@ -// Cargo labels: the precision of abbreviated totals (1.25M) and showing the real total at once instead of the -// count-up animation. +// Cargo labels: [ui] format_cargo_values and cargo_significant_decimals, the precision of abbreviated totals (1.25M), +// and on Windows [ui] instant_cargo_counter, which shows the real total at once instead of the count-up animation. #include "config.h" #include "features/features.h" @@ -12,6 +12,8 @@ #include #include +#include +#include namespace { @@ -22,69 +24,69 @@ int SignificantDecimals() { return std::clamp(Config::Get().ui.cargo_significant_decimals, 0, 6); } // m_significantDecimals is read by Localize(), which runs after SetLocalTextParameters, so set it here for good. -void ColourTextLocalizer_SetLocalTextParameters_Hook(auto original, ColourTextLocalizer* _this, bool parseID, +void ColourTextLocalizer_SetLocalTextParameters_Hook(auto original, ColourTextLocalizer* _this, bool parse_id, void* args) { - if (_this && Config::Get().ui.format_cargo_values && _this->IdentifierIs(kCargoIdentifier)) + if (Config::Get().ui.format_cargo_values && _this->IdentifierIs(kCargoIdentifier)) _this->SetSignificantDecimals(SignificantDecimals()); - original(_this, parseID, args); + original(_this, parse_id, args); } -#if defined(_WIN32) && defined(_M_X64) -bool IsCargoText(LegacyComparableProgressBar* self) +// --- Instant cargo counter --- +// +// TODO: Windows only. Enabling it on macOS needs SetWidgetData's prologue checked against SPUD's detour size. +#if _WIN32 +bool IsCargoText(LegacyComparableProgressBar* bar) { - auto* label = self->currentValue_X_of_Y(); + auto* label = bar->currentValue_X_of_Y(); if (!label || !label->IdentifierIs(kCargoIdentifier)) return false; - auto* context = self->context(); - if (!context || context->klass != CargoProgressData::Raw()) { - static bool reported = false; - if (!reported) { - reported = true; - spdlog::warn("[InstantCargoText] cargo label has unexpected context {}; native text retained", - context ? il2cpp::ClassName(context->klass) : "null"); - } - return false; - } - return true; + auto* context = bar->context(); + if (context && context->klass == CargoProgressData::Raw()) + return true; + static bool reported = false; + if (!std::exchange(reported, true)) + spdlog::warn("[InstantCargo] the cargo label shows a {}, not cargo; keeping the game's count-up", + context ? il2cpp::ClassName(context->klass) : "null"); + return false; } -void SetWidgetData_Hook(auto original, LegacyComparableProgressBar* self) +void LegacyComparableProgressBar_SetWidgetData_Hook(auto original, LegacyComparableProgressBar* _this) { - if (!Config::Get().ui.instant_cargo_counter || !self || !IsCargoText(self)) { - original(self); - return; - } + if (!Config::Get().ui.instant_cargo_counter || !IsCargoText(_this)) + return original(_this); // Render the current total through the game's own formatting with the prefab's snap flag set, then restore // the flag so reward updates and interpolation carry on normally. - const bool previous = self->snapToValue(); - self->SetSnapToValue(true); - original(self); - self->SetSnapToValue(previous); + const bool previous = _this->snapToValue(); + _this->SetSnapToValue(true); + original(_this); + _this->SetSnapToValue(previous); } -#endif -void InstallInstantCargo() + +bool InstallInstantCargo() { - // TODO: Windows x64 only. Enabling it on macOS needs SetWidgetData's prologue checked against SPUD's detour size. -#if defined(_WIN32) && defined(_M_X64) auto method = LegacyComparableProgressBar::Class::Method("SetWidgetData", 0); // The lookup walks parents; only detour the bar's own override, never the shared Widget`1.SetWidgetData. - if (!method || method.Info()->klass != LegacyComparableProgressBar::Class::Raw()) - return; - const bool installed = IL2CPP_HOOK(method, SetWidgetData_Hook) != nullptr; - spdlog::info("[InstantCargoText] native text hook {}", installed ? "installed" : "unavailable"); -#else - spdlog::info("[InstantCargoText] native text snap is not supported on this platform"); -#endif + if (method && method.Info()->klass != LegacyComparableProgressBar::Class::Raw()) { + spdlog::warn("[InstantCargo] the progress bar no longer overrides SetWidgetData; not hooking the shared one"); + return false; + } + il2cpp::HookSet hooks("InstantCargo"); + IL2CPP_STAGE(hooks, method, LegacyComparableProgressBar_SetWidgetData_Hook); + return hooks.Commit(); } +#endif -void InstallCargoFormat() +bool InstallCargoFormat() { - if (IL2CPP_HOOK(ColourTextLocalizer::Class::Method("SetLocalTextParameters"), - ColourTextLocalizer_SetLocalTextParameters_Hook)) - spdlog::info("Cargo format: significant decimals = {}", SignificantDecimals()); + il2cpp::HookSet hooks("CargoFormat"); + IL2CPP_STAGE(hooks, ColourTextLocalizer::Class::Method("SetLocalTextParameters"), + ColourTextLocalizer_SetLocalTextParameters_Hook); + return hooks.Commit(); } } // namespace -const Feature kInstantCargo{"InstantCargo", &Config::Patches::instantcargocounterhooks, InstallInstantCargo}; const Feature kCargoFormat{"CargoFormat", &Config::Patches::cargoformathooks, InstallCargoFormat}; +#if _WIN32 +const Feature kInstantCargo{"InstantCargo", &Config::Patches::instantcargocounterhooks, InstallInstantCargo}; +#endif diff --git a/src/mod/features/chat.cc b/src/mod/features/chat.cc index f8487d9ce..91ef5a134 100644 --- a/src/mod/features/chat.cc +++ b/src/mod/features/chat.cc @@ -1,6 +1,7 @@ -// Chat: the shortcuts that open it and switch channel; disable_galaxy_chat and disable_veil_chat, which grey out -// those tabs in full-screen chat, keep the chat preview off them, and drop their messages from the preview; and -// default_chat_width and auto_open_chat, which size side chat and open alliance side chat once per login. +// Chat: the shortcuts that open it and switch channel; [ui] disable_galaxy_chat and disable_veil_chat, which grey +// out those tabs in full-screen chat, keep the chat preview off them, and drop their messages from the preview; +// [ui] show_player_title, which can hide the title row under each sender's name; and [ui] default_chat_width and +// auto_open_chat, which size side chat and open alliance side chat once per login. #include "config.h" #include "features/chat_startup.h" @@ -13,99 +14,77 @@ #include #include +#include #include #include -#include -#include namespace { -il2cpp::GcHandle preview_handle; -ChatStartup::Gate startup; -bool width_applied = false; -bool startup_failed = false; +// --- Side chat on login --- +// +// Both options act once per login: the width while side chat is open, and the open once the station and chat have +// settled. Login and AppInit start the next login over. + +il2cpp::GcHandle chat_preview; // Weak: the preview the hooks last saw. +ChatStartup::Gate chat_startup; +bool chat_width_applied = false; void ObserveChatPreview(ChatPreviewController* preview) { - if (!Config::Get().ui.auto_open_chat) - return; - if (preview_handle && preview_handle.Get() == preview) + if (!Config::Get().ui.auto_open_chat || chat_preview.Get() == preview) return; - preview_handle = il2cpp::GcHandle::Weak(preview); - startup.ready_since = -1; - spdlog::info("[ChatOptions] observed chat preview"); + chat_preview = il2cpp::GcHandle::Weak(preview); + chat_startup.ready_since = -1; } -void UpdateChatOptions() +void ChatFrame() { - const float configured_width = Config::Get().ui.default_chat_width; - const bool custom_width = std::isfinite(configured_width) && configured_width > 0; - if (startup_failed || (!Config::Get().ui.auto_open_chat && !custom_width)) + const auto& ui = Config::Get().ui; + const bool custom_width = ui.default_chat_width > 0; + if (!ui.auto_open_chat && !custom_width) return; - try { - auto* sections = Hub::Sections(); - if (!sections) - return; - const auto section = sections->CurrentSection(); - if (section == SectionID::Login || section == SectionID::AppInit) { - startup = {}; - width_applied = false; - // The preview may initialize while CurrentSection is still Login/AppInit. - // Keep its weak handle: clearing it here can lose the only AboutToShow - // notification. A destroyed preview naturally resolves to null. - return; - } - if ((!Config::Get().ui.auto_open_chat || startup.completed) && (!custom_width || width_applied)) - return; - auto* manager = ChatManager::Instance(); - if (!manager) - return; - const bool side_open = manager->IsSideChatOpen(); - float width = configured_width; - // Apply once per login, then leave manual resizing to the game. - if (side_open && !width_applied && std::isfinite(width) && width > 0) { - auto* screen = ScreenManager::Instance(); - if (auto* frame = screen ? screen->frameManager() : nullptr) { - if (!frame->ResizeSideFrame(width)) - throw std::runtime_error("side frame resize failed"); - width_applied = true; - } - } - if (!Config::Get().ui.auto_open_chat || startup.completed) - return; - auto* preview = preview_handle.Get(); - const bool station = section == SectionID::Starbase_Interior || section == SectionID::Starbase_Exterior; - // Section completion is set by the game after its visibility refresh. - const bool section_ready = preview && preview->isSectionChangedCompleted(); - const bool context_ready = manager->isContextInitialised(); - const bool side_allowed = manager->IsSideChatAllowed(); - const bool ready = station && section_ready && context_ready && side_allowed; - // Log changes only, so a pending startup identifies its exact blocking gate. - const int status = (station ? 1 : 0) | (preview ? 2 : 0) | (section_ready ? 4 : 0) - | (context_ready ? 8 : 0) | (side_allowed ? 16 : 0) | (side_open ? 32 : 0); - static int last_status = -1; - static SectionID last_section = SectionID::AppInit; - if (status != last_status || section != last_section) { - last_status = status; - last_section = section; - spdlog::info("[ChatOptions] startup section={} station={} preview={} section_ready={} context_ready={} " - "side_allowed={} side_open={}", static_cast(section), station, preview != nullptr, - section_ready, context_ready, side_allowed, side_open); - } - const double now = std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); - if (startup.Tick(ready, side_open || Hub::IsInChat(), now)) { - spdlog::info("[ChatOptions] readiness settled; opening alliance side chat"); - manager->OpenChannel(ChatChannelCategory::Alliance, ChatViewMode::Side); - } else if (startup.completed) { - spdlog::info("[ChatOptions] startup consumed by existing/manual chat"); + auto* sections = Hub::Sections(); + if (!sections) + return; + const auto section = sections->CurrentSection(); + if (section == SectionID::Login || section == SectionID::AppInit) { + chat_startup = {}; + chat_width_applied = false; + // The preview may initialize while the section is still Login or AppInit. Keep its weak handle: clearing it + // here can lose the only AboutToShow it gets. A destroyed preview reads as null. + return; + } + if ((!ui.auto_open_chat || chat_startup.completed) && (!custom_width || chat_width_applied)) + return; + auto* chat = ChatManager::Instance(); + if (!chat) + return; + const bool side_open = chat->IsSideChatOpen(); + // Once per login; after that, resizing is left to the player. + if (side_open && custom_width && !chat_width_applied) { + auto* screens = ScreenManager::Instance(); + if (auto* frames = screens ? screens->frameManager() : nullptr) { + frames->ResizeSideFrame(ui.default_chat_width); + chat_width_applied = true; } - } catch (const std::exception& error) { - startup_failed = true; - spdlog::warn("[ChatOptions] disabled after metadata/invocation failure: {}", error.what()); + } + if (!ui.auto_open_chat || chat_startup.completed) + return; + auto* preview = chat_preview.Get(); + const bool station = section == SectionID::Starbase_Interior || section == SectionID::Starbase_Exterior; + // The preview sets section completion after its visibility refresh. + const bool ready = station && preview && preview->isSectionChangedCompleted() && chat->isContextInitialised() + && chat->IsSideChatAllowed(); + const double now = std::chrono::duration(std::chrono::steady_clock::now().time_since_epoch()).count(); + if (chat_startup.Tick(ready, side_open || Hub::IsInChat(), now)) { + spdlog::info("[Chat] readiness settled; opening alliance side chat"); + chat->OpenChannel(ChatChannelCategory::Alliance, ChatViewMode::Side); } } +// --- Disabled channels --- + // Tab positions in both the full-screen chat and the preview. Cadet and veil chat only exist for some players, // and shift the tabs after them; a missing tab is -1. struct ChatTabs { @@ -177,7 +156,7 @@ void ChatPreviewController_AboutToShow_Hook(auto original, ChatPreviewController original(_this); ObserveChatPreview(_this); // The preview reopens on the last panel; move off it only if that channel is disabled. - auto* scroller = _this ? _this->swipeScroller() : nullptr; + auto* scroller = _this->swipeScroller(); const auto tabs = ChatTabs::Current(); if (scroller && tabs.Disabled(scroller->currentContentIndex())) { _this->SetFocusedPanel(ChatChannelCategory::Alliance); @@ -209,20 +188,85 @@ void ChatPreviewController_OnRegionalMessageReceived_Hook(auto original, ChatPre original(_this, message); } -void InstallChat() +// --- Player titles --- +// +// Only chat rows lose their title: the same profile widget appears on other screens. + +void HideChatTitle(UserProfileWidget* profile) +{ + auto* title = profile ? profile->playerTitleWidget() : nullptr; + auto* transform = title ? profile->transform() : nullptr; + if (!transform || !transform->FindInParents()) + return; + if (auto* object = title->gameObject()) + object->SetActive(false); +} + +// The game shows the title again on every update, so it is hidden after each one. +void UserProfileWidget_UpdatePlayerTitle_Hook(auto original, UserProfileWidget* _this) { - spdlog::info("[ChatOptions] lifecycle v2 registered: auto_open={} width={}", Config::Get().ui.auto_open_chat, - Config::Get().ui.default_chat_width); - IL2CPP_HOOK(FullScreenChatViewController::Class::Method("AboutToShow"), - FullScreenChatViewController_AboutToShow_Hook); - IL2CPP_HOOK(FullScreenChatViewController::Class::Method("OnDidChangeSelectedTab"), - FullScreenChatViewController_OnDidChangeSelectedTab_Hook); - IL2CPP_HOOK(ChatPreviewController::Class::Method("AboutToShow"), ChatPreviewController_AboutToShow_Hook); - IL2CPP_HOOK(ChatPreviewController::Class::Method("OnPanelFocused"), ChatPreviewController_OnPanelFocused_Hook); - IL2CPP_HOOK(ChatPreviewController::Class::Method("OnGlobalMessageReceived"), - ChatPreviewController_OnGlobalMessageReceived_Hook); - IL2CPP_HOOK(ChatPreviewController::Class::Method("OnRegionalMessageReceived"), - ChatPreviewController_OnRegionalMessageReceived_Hook); + original(_this); + if (!Config::Get().ui.show_player_title) + HideChatTitle(_this); +} + +// SetWidgetData and OnDidBindContext both bind a title from the title widget's side; one hook serves both. +void PlayerTitleWidget_Bind_Hook(auto original, PlayerTitleWidget* _this) +{ + original(_this); + if (Config::Get().ui.show_player_title) + return; + if (auto* transform = _this->transform()) + HideChatTitle(transform->FindInParents()); +} + +bool InstallChat() +{ + il2cpp::HookSet hooks("Chat"); + IL2CPP_STAGE(hooks, FullScreenChatViewController::Class::Method("AboutToShow"), + FullScreenChatViewController_AboutToShow_Hook); + IL2CPP_STAGE(hooks, FullScreenChatViewController::Class::Method("OnDidChangeSelectedTab"), + FullScreenChatViewController_OnDidChangeSelectedTab_Hook); + IL2CPP_STAGE(hooks, ChatPreviewController::Class::Method("AboutToShow"), ChatPreviewController_AboutToShow_Hook); + IL2CPP_STAGE(hooks, ChatPreviewController::Class::Method("OnPanelFocused"), + ChatPreviewController_OnPanelFocused_Hook); + IL2CPP_STAGE(hooks, ChatPreviewController::Class::Method("OnGlobalMessageReceived"), + ChatPreviewController_OnGlobalMessageReceived_Hook); + IL2CPP_STAGE(hooks, ChatPreviewController::Class::Method("OnRegionalMessageReceived"), + ChatPreviewController_OnRegionalMessageReceived_Hook); + return hooks.Commit(); +} + +bool InstallChatTitles() +{ + const auto update = UserProfileWidget::Class::Method("UpdatePlayerTitle", 0); + il2cpp::HookSet hooks("ChatTitles"); + IL2CPP_STAGE(hooks, update, UserProfileWidget_UpdatePlayerTitle_Hook); + // The feature is the update hook; the bind paths below only add to it. + const bool installed = hooks.Commit(); + // The bind paths catch titles bound without an update, so each is a set of its own and adds to the update hook + // where SPUD can detour it. + const auto set_data = PlayerTitleWidget::Class::Method("SetWidgetData", 0); + const auto bind = PlayerTitleWidget::Class::Method("OnDidBindContext", 0); + if (!set_data || !bind) + return installed; + // Both, or one of them and the update, often branch to one shared body, which SPUD patches in their place. One + // detour on it covers them all; a second would chain on it and run the hook twice, once with a receiver of the + // wrong class. + void* const update_body = il2cpp::DetourTarget(update); + void* const set_body = il2cpp::DetourTarget(set_data); + void* const bind_body = il2cpp::DetourTarget(bind); + if (set_body != update_body) { + il2cpp::HookSet set_hooks("ChatTitles"); + IL2CPP_STAGE(set_hooks, set_data, PlayerTitleWidget_Bind_Hook); + set_hooks.Commit(); + } + if (bind_body != update_body && bind_body != set_body) { + il2cpp::HookSet bind_hooks("ChatTitles"); + IL2CPP_STAGE(bind_hooks, bind, PlayerTitleWidget_Bind_Hook); + bind_hooks.Commit(); + } + return installed; } } // namespace @@ -263,4 +307,5 @@ bool ChatChannelShortcuts() return false; } -const Feature kChat{"Chat", &Config::Patches::chatpatches, InstallChat, UpdateChatOptions}; +const Feature kChat{"Chat", &Config::Patches::chatpatches, InstallChat, ChatFrame}; +const Feature kChatTitles{"ChatTitles", &Config::Patches::chattitledisplayhooks, InstallChatTitles}; diff --git a/src/mod/features/chat_startup.h b/src/mod/features/chat_startup.h index b7e5b36b0..089a73269 100644 --- a/src/mod/features/chat_startup.h +++ b/src/mod/features/chat_startup.h @@ -1,10 +1,11 @@ #pragma once -namespace ChatStartup { +namespace ChatStartup +{ // Pure readiness gate: a false sample restarts the grace period. Completion is // latched so closing chat manually never causes another automatic open. struct Gate { - bool completed = false; + bool completed = false; double ready_since = -1; bool Tick(bool ready, bool already_open, double now) diff --git a/src/mod/features/chat_titles.cc b/src/mod/features/chat_titles.cc deleted file mode 100644 index f8502f78d..000000000 --- a/src/mod/features/chat_titles.cc +++ /dev/null @@ -1,189 +0,0 @@ -#include "features/features.h" -#include "game/chat.h" -#include - -#include "config.h" -#include "game/native_hook_extent.h" - -#include - -#include - -namespace -{ -bool IsChatRow(void* profile_widget) -{ - auto* mb = (Il2CppObject*)profile_widget; - if (!mb) { - return false; - } - - auto* transform = reinterpret_cast(mb)->As()->transform(); - if (!transform) { - return false; - } - - for (int hops = 0; hops < 32 && transform; ++hops) { - auto* gameObject = transform->gameObject(); - if (!gameObject) { - return false; - } - if (reinterpret_cast(gameObject)->GetComponent()) { - return true; - } - transform = transform->parent(); - } - return false; -} -void HideTitleRow(void* title) -{ - auto* game_object = reinterpret_cast( - reinterpret_cast(title)->As()->gameObject()); - if (!game_object) { - return; - } - game_object->SetActive(false); -} - -void ApplyTitleDisplay(void* profile_widget) -{ - auto* widget = static_cast(profile_widget); - if (!widget) { - return; - } - - const auto show = Config::Get().ui.show_player_title; - auto* title = widget->playerTitleWidget(); - if (show || title == nullptr) { - return; - } - - if (!IsChatRow(widget)) { - return; - } - - HideTitleRow(title); -} -} // namespace - -void UserProfileWidget_UpdatePlayerTitle_Hook(auto original, void* _this) -{ - original(_this); - try { - ApplyTitleDisplay(_this); - } catch (...) { - } -} -void PlayerTitleWidget_BindPath_Hook(auto original, void* _this) -{ - original(_this); - try { - if (Config::Get().ui.show_player_title) { - return; - } - UserProfileWidget* owner = nullptr; - auto* transform = reinterpret_cast(_this)->As()->transform(); - for (int hops = 0; hops < 32 && transform && !owner; ++hops) { - auto* gameObject = reinterpret_cast(transform->gameObject()); - if (gameObject) { - owner = gameObject->GetComponent(); - } - transform = transform->parent(); - } - if (owner) { - ApplyTitleDisplay(owner); - } - } catch (...) { - } -} - -namespace -{ -#if __APPLE__ -void* ValidateDetourTarget(void* entry, const char* name) -{ - if (native_hooks::MacHookFits(entry, 16)) { - return entry; - } -#if defined(__aarch64__) - if (const auto* downstream = native_hooks::MacFollowDirectBranch(entry)) { - if (native_hooks::MacHookFits(downstream, 16)) { - return const_cast(downstream); - } - } -#endif - spdlog::warn("[ChatTitleDisplay] {} does not fit the macOS hook window; target skipped", name); - return nullptr; -} -#else -void* ValidateDetourTarget(void* entry, const char* name) -{ - (void)name; - return entry; -} -#endif -} // namespace - -namespace -{ -void InstallChatTitleDisplayHooks() -{ - void* update_player_title = UserProfileWidget::Class::Method("UpdatePlayerTitle", 0).Pointer(); - void* set_widget_data = PlayerTitleWidget::Class::Method("SetWidgetData", 0).Pointer(); - void* on_did_bind_context = PlayerTitleWidget::Class::Method("OnDidBindContext", 0).Pointer(); - if (!set_widget_data || !on_did_bind_context) { - set_widget_data = nullptr; - on_did_bind_context = nullptr; - } - - if (!update_player_title && !set_widget_data && !on_did_bind_context) { - return; - } - - if (update_player_title) { - update_player_title = ValidateDetourTarget(update_player_title, "UpdatePlayerTitle"); - } - if (set_widget_data) { - set_widget_data = ValidateDetourTarget(set_widget_data, "SetWidgetData"); - } - if (on_did_bind_context) { - on_did_bind_context = ValidateDetourTarget(on_did_bind_context, "OnDidBindContext"); - } - - if (!update_player_title && !set_widget_data && !on_did_bind_context) { - spdlog::warn("Skipping ChatTitleDisplayHooks: no detour target fits; no hooks installed."); - return; - } - - // The PlayerTitleWidget callbacks can resolve through thunks to one shared - // native implementation. A second detour on the same address would rebuild - // the first detour's jump into a broken trampoline, so keep one hook per - // target; the shared body funnels both method entries into the same hook. - if (set_widget_data && set_widget_data == update_player_title) { - spdlog::info("[ChatTitleDisplay] SetWidgetData shares its native implementation with UpdatePlayerTitle; " - "keeping one detour"); - set_widget_data = nullptr; - } - if (on_did_bind_context && (on_did_bind_context == update_player_title || on_did_bind_context == set_widget_data)) { - spdlog::info("[ChatTitleDisplay] OnDidBindContext shares its native implementation with another target; " - "keeping one detour"); - on_did_bind_context = nullptr; - } - - if (update_player_title) { - IL2CPP_DETOUR(update_player_title, UserProfileWidget_UpdatePlayerTitle_Hook, - "UserProfileWidget_UpdatePlayerTitle_Hook"); - } - if (set_widget_data) { - IL2CPP_DETOUR(set_widget_data, PlayerTitleWidget_BindPath_Hook, "PlayerTitleWidget_BindPath_Hook"); - } - if (on_did_bind_context) { - IL2CPP_DETOUR(on_did_bind_context, PlayerTitleWidget_BindPath_Hook, "PlayerTitleWidget_BindPath_Hook"); - } - - spdlog::info("Chat title display: chat player title rows {}", - Config::Get().ui.show_player_title ? "shown" : "hidden"); -} -} // namespace - -const Feature kChatTitles{"ChatTitles", &Config::Patches::chattitledisplayhooks, InstallChatTitleDisplayHooks}; diff --git a/src/mod/features/confirmations.cc b/src/mod/features/confirmations.cc new file mode 100644 index 000000000..e9aa57c8c --- /dev/null +++ b/src/mod/features/confirmations.cc @@ -0,0 +1,113 @@ +// Confirmation popups the game can skip: [ui] auto_confirm_ft_upgrade, auto_confirm_alliance_contribution and +// auto_confirm_reveal_ability. Each answers its popup's callback directly instead of showing the popup. + +#include "config.h" +#include "features/features.h" + +#include "game/fleet.h" +#include "game/ui.h" + +#include +#include +#include +#include + +#include +#include + +namespace +{ +// Runs a popup's callback the way its button would. False when the callback threw: the caller then shows the +// popup, so the action is never lost silently. +template bool Answer(const char* feature, Il2CppDelegate* callback, A... args) +{ + try { + il2cpp::InvokeBound(callback, args...); + return true; + } catch (...) { + spdlog::warn("[{}] the confirmation callback threw; showing the popup", feature); + return false; + } +} + +// --- Message box confirmations --- + +// The closure `owner`.RequestAction passes as its confirmation callback, e.g. +// "Digit.Prime.ForbiddenTech.ForbiddenTechManager/<>c__DisplayClass12_0.b__0". +bool IsRequestActionCallback(const Il2CppDelegate* callback, std::string_view owner) +{ + if (!callback || !callback->target || !callback->method || callback->method->parameters_count != 1) + return false; + const auto name = il2cpp::Method(callback->method).Name(); + const auto found = name.find(std::string(owner) + "/"); + return found != std::string::npos && (found == 0 || name[found - 1] == '.') && name.ends_with(".b__0"); +} + +bool ConfirmForbiddenTechUpgrade(Il2CppDelegate* callback) +{ + return Config::Get().ui.auto_confirm_ft_upgrade && IsRequestActionCallback(callback, "ForbiddenTechManager") + && Answer("ForbiddenTechConfirm", callback, MessageBoxResult::Confirm); +} + +bool ConfirmAllianceContribution(Il2CppDelegate* callback) +{ + return Config::Get().ui.auto_confirm_alliance_contribution + && IsRequestActionCallback(callback, "AllianceContributionViewController") + && Answer("ForbiddenTechConfirm", callback, MessageBoxResult::Yes); +} + +void GameMessageBox_Show_Hook(auto original, MessageBoxContext* context) +{ + if (context && ConfirmForbiddenTechUpgrade(context->OnSelection())) + return; + original(context); +} + +// The overload that takes its callback directly. +void GameMessageBox_ShowWithCallback_Hook(auto original, MessageBoxContext* context, Il2CppDelegate* callback) +{ + if (ConfirmForbiddenTechUpgrade(callback) || ConfirmAllianceContribution(callback)) + return; + original(context, callback); +} + +// --- Reveal ability --- + +// The U.S.S. Excelsior's Decloaking Deflector is the only free ability of this type. +bool IsExcelsiorDecloak(int64_t ability_id) +{ + auto* manager = ActivatedAbilityManager::Instance(); + return manager && manager->GetActivatedAbilityType(ability_id) == ActivatedAbilityManager::kRevealHostileVger; +} + +void MessageUtility_ShowFreeAbilityConfirmationPopup_Hook(auto original, int64_t ability_id, unity::TimeSpan cooldown, + Il2CppDelegate* action) +{ + if (Config::Get().ui.auto_confirm_reveal_ability && action && action->method && IsExcelsiorDecloak(ability_id) + && Answer("RevealConfirm", action)) + return; + original(ability_id, cooldown, action); +} + +bool InstallForbiddenTechConfirm() +{ + il2cpp::HookSet hooks("ForbiddenTechConfirm"); + IL2CPP_STAGE(hooks, GameMessageBox::Method("Show", 1), GameMessageBox_Show_Hook); + IL2CPP_STAGE(hooks, GameMessageBox::Method("Show", 2), GameMessageBox_ShowWithCallback_Hook); + return hooks.Commit(); +} + +bool InstallRevealConfirm() +{ + il2cpp::HookSet hooks("RevealConfirm"); + IL2CPP_STAGE( + hooks, + MessageUtility::StaticMethod("ShowFreeAbilityConfirmationPopup"), + MessageUtility_ShowFreeAbilityConfirmationPopup_Hook); + return hooks.Commit(); +} +} // namespace + +const Feature kForbiddenTechConfirm{"ForbiddenTechConfirm", &Config::Patches::forbiddentechconfirmhooks, + InstallForbiddenTechConfirm}; +const Feature kRevealConfirm{"RevealConfirm", &Config::Patches::revealconfirmhooks, InstallRevealConfirm}; diff --git a/src/mod/features/dock.cc b/src/mod/features/dock.cc index 4876b421b..cb79e43ed 100644 --- a/src/mod/features/dock.cc +++ b/src/mod/features/dock.cc @@ -1,14 +1,18 @@ -// The ship management dock: pinned_ships, which moves the named ships to the front of the idle ship list after the -// game sorts it, and double_click_to_assign_ship, where a double click on a ship tile or Enter presses Assign. +// The ship management dock: +// - [ui] pinned_ships moves the named ships to the front of the idle ship list after the game sorts it, and +// Shift+click on a ship tile pins or unpins that ship; +// - [ui] double_click_to_assign_ship presses Assign on a double click on a ship tile, or on Enter; +// - [ui] dock_syncmanagement selects the fleet bar slot of the ship open in ship management. // // Only ShipManagementViewContext.Sort is hooked, not ISortedFleetsContext, so the hailing frequencies inventory keeps // the game's order. #include "config.h" #include "features/features.h" -#include "ship_tile_click.h" +#include "file.h" #include "text.h" +#include "game/app.h" #include "game/fleet.h" #include "game/ui.h" @@ -16,20 +20,37 @@ #include #include #include +#include #include #include +#include #include +#include #include +#include #include #include namespace { +// The ship management screen when it is the one showing. The dock is hosted under more than one game section, so +// this follows the screen rather than SectionID. +ShipManagementViewController* VisibleShipManagement() +{ + for (auto* controller : unity::Instances()) + if (controller->isActiveAndEnabled() && CanvasController::Shows(controller)) + return controller; + return nullptr; +} + +std::string ShipNameText(const std::vector& words) +{ return fmt::format("{}", fmt::join(words, " ")); } + // --- Pinned ships --- // Unmatched entries are warned about once, not on every sort. -std::set warned; +std::set warned_patterns; // Within the pinned group and the rest, the game's active sort order is kept. void PinShips(il2cpp::List* list) @@ -38,18 +59,15 @@ void PinShips(il2cpp::List* list) const auto count = list ? list->Count() : 0; if (pinned.empty() || count <= 1) return; - if (count > 2000) { - spdlog::warn("[PinnedShipSort] SortedIdleShips reported an implausible count ({}); skipping", count); - return; - } struct Ship { FleetPlayerData* data; std::vector words; int64_t level; - bool pinned = false; + bool pinned = false; int8_t tech_applied = -1; + // Read lazily: only ties between equal hulls need it. bool TechApplied() { if (tech_applied < 0) @@ -75,10 +93,8 @@ void PinShips(il2cpp::List* list) best = &ship; if (best) { best->pinned = any = true; - } else if (const auto name = fmt::format("{}", fmt::join(pattern, " ")); warned.insert(name).second) { - spdlog::warn("[PinnedShipSort] pinned_ships entry '{}' matched no idle ship (entries match the end of the " - "game's own ship name)", - name); + } else if (const auto name = ShipNameText(pattern); warned_patterns.insert(name).second) { + spdlog::warn("[PinnedShips] '{}' matches no idle ship; entries match the end of the game's ship name", name); } } if (!any) @@ -102,48 +118,174 @@ void ShipManagementViewContext_Sort_Hook(auto original, ShipManagementViewContex PinShips(_this->SortedIdleShips()); } -// --- Double click to assign --- +// --- Shift+click pinning --- + +// The settings file with only the pinned_ships line of [ui] replaced, or added, so the rest keeps its comments and +// layout. A layout this line edit cannot handle, such as an inline [ui] table or a multi-line value, is caught by +// SavePinnedShips checking the result. +std::string WithPinnedShipsLine(const std::string& settings, const std::string& line) +{ + std::vector lines; + std::istringstream in(settings); + for (std::string text; std::getline(in, text);) + lines.push_back(text); + // Where [ui] ends, and its pinned_ships line, if any. + size_t ui_end = lines.size(), pinned = lines.size(); + bool has_ui = false, in_ui = false; + for (size_t i = 0; i < lines.size(); ++i) { + auto text = std::string_view(lines[i]); + text.remove_prefix(std::min(text.find_first_not_of(" \t"), text.size())); + if (text.starts_with('[')) { + if (in_ui) { + ui_end = i; + break; + } + in_ui = has_ui = text.starts_with("[ui]"); + } else if (in_ui && text.starts_with("pinned_ships")) { + const auto rest = text.substr(std::string_view("pinned_ships").size()); + if (const auto at = rest.find_first_not_of(" \t"); at != std::string_view::npos && rest[at] == '=') + pinned = i; + } + } + if (pinned < lines.size()) + lines[pinned] = line; + else if (has_ui) + lines.insert(lines.begin() + ui_end, line); + else + lines.insert(lines.end(), {"", "[ui]", line}); + std::string result; + for (const auto& text : lines) + result += text + '\n'; + return result; +} + +// Saves the pins to [ui] pinned_ships so they survive a restart. The line edit is used when the result parses back +// to the same value; otherwise the whole file is rewritten from its parsed table, which keeps every setting but +// loses comments. A file that does not parse at all is left alone. +// TODO(tashcan): route this through a settings writer once there is a second setting the mod saves. +void SavePinnedShips(const std::vector>& names) +{ + std::string joined; + for (const auto& words : names) + joined += (joined.empty() ? "" : ", ") + ShipNameText(words); + std::ostringstream value; + value << toml::value(joined); + + const auto path = File::Path(File::Config(), true); + std::string settings; + { + std::ifstream in(path, std::ios::binary); + std::ostringstream text; + text << in.rdbuf(); + settings = text.str(); + } + const auto saves = [&](const std::string& text) { + try { + return toml::parse(text)["ui"]["pinned_ships"].value() == joined; + } catch (const toml::parse_error&) { + return false; + } + }; + auto updated = WithPinnedShipsLine(settings, "pinned_ships = " + value.str()); + if (!saves(updated)) { + try { + auto table = toml::parse(settings); + if (!table["ui"].is_table()) + table.insert_or_assign("ui", toml::table{}); + table["ui"].as_table()->insert_or_assign("pinned_ships", joined); + std::ostringstream text; + text << table; + updated = text.str(); + } catch (const toml::parse_error& error) { + spdlog::warn("[PinnedShipClick] not saving pinned ships: {} does not parse: {}", File::Config(), + error.description()); + return; + } + } + + std::ofstream out(path, std::ios::binary | std::ios::trunc); + out << updated; + if (!out) + spdlog::warn("[PinnedShipClick] could not save pinned ships to {}", File::Config()); +} + +// Re-runs the pinned sort and rebuilds the dock's tile list through the game's own +// SelectableListBase.ClearAndGenerateContents: the controller is the list's IDataContextProvider, and the list binds +// the reordered SortedIdleShips directly. +void RefreshDock(ShipManagementViewController* controller) +{ + auto* context = controller->Context(); + if (!context) + return; + context->Sort(); + for (auto* widget : unity::Instances()) + if (auto* list = widget->isActiveAndEnabled() ? widget->selectableList() : nullptr) + list->Rebuild(controller); +} + +bool pin_click_installed = false; + +void TogglePinOnShiftClick(FleetPlayerData* ship) +{ + const bool shift = unity::Key::Pressed(unity::KeyCode::LeftShift) || unity::Key::Pressed(unity::KeyCode::RightShift); + if (!pin_click_installed || !shift) + return; + auto* controller = VisibleShipManagement(); + auto key = ShipNameWords(ship->DisplayName()); + // Placeholder tiles, such as "build ship", have no name. + if (!controller || key.empty()) + return; + + auto& names = Config::Get().ui.pinned_ships; + if (const auto found = std::ranges::find(names, key); found != names.end()) { + names.erase(found); + spdlog::info("[PinnedShipClick] unpinned '{}'", ShipNameText(key)); + } else { + names.push_back(std::move(key)); + spdlog::info("[PinnedShipClick] pinned '{}'", ShipNameText(names.back())); + } + SavePinnedShips(names); + RefreshDock(controller); +} + +// --- Double click and Enter to assign --- constexpr auto kDoubleClickWindow = std::chrono::milliseconds(400); +bool double_click_installed = false; +// Compared only, never dereferenced. FleetPlayerData* last_clicked_ship = nullptr; std::chrono::steady_clock::time_point last_click_time{}; void PressAssignButton() { for (auto* widget : unity::Instances()) { - if (!widget || !CanvasController::Shows(widget) || !widget->isActiveAndEnabled()) + if (!widget->isActiveAndEnabled() || !CanvasController::Shows(widget)) continue; auto* wrapper = widget->assignButton(); - auto* button = wrapper && wrapper->Widget() ? wrapper->Widget()->TheButton() : nullptr; + auto* button = wrapper && wrapper->Widget() ? wrapper->Widget()->UnityButton() : nullptr; if (button) button->Press(); return; } } -} // namespace - -void DoubleClickOnShipTileClicked(FleetPlayerData* ship) +void AssignOnDoubleClick(FleetPlayerData* ship) { - if (!Config::Get().ui.double_click_to_assign_ship) - return; - if (!ship) + if (!double_click_installed || !Config::Get().ui.double_click_to_assign_ship) return; - const auto now = std::chrono::steady_clock::now(); const bool double_click = ship == last_clicked_ship && now - last_click_time <= kDoubleClickWindow; last_clicked_ship = ship; last_click_time = now; if (!double_click) return; - // Consumed, so a third click does not count as another double click. last_clicked_ship = nullptr; PressAssignButton(); } -void DockUpdate() +void AssignOnEnterFrame() { if (!Config::Get().ui.double_click_to_assign_ship) return; @@ -155,72 +297,101 @@ void DockUpdate() PressAssignButton(); } -namespace +// Shift+click and double click share the tile's click handler: one detour, two features. +void ShipTileWidget_HandleOnClick_Hook(auto original, ShipTileWidget* _this) { -void InstallPinnedShips() -{ IL2CPP_HOOK(ShipManagementViewContext::Class::Method("Sort", 0), ShipManagementViewContext_Sort_Hook); } - -void InstallDoubleClickAssign() -{ ship_tile_click::TryInstallHook(); } + original(_this); + // The click arrives through the UI event system, which can run before the frame tick has polled this frame. + unity::Key::Poll(); + if (auto* ship = _this->Context()) { + TogglePinOnShiftClick(ship); + AssignOnDoubleClick(ship); + } +} -} // namespace +// Shared by Shift+click pinning and double-click assignment, whichever of them install, so it is its own set, +// installed on the first call. +bool InstallShipTileClick() +{ + static const bool installed = [] { + il2cpp::HookSet hooks("ShipTileClick"); + IL2CPP_STAGE(hooks, ShipTileWidget::Class::Method("HandleOnClick", 0), ShipTileWidget_HandleOnClick_Hook); + return hooks.Commit(); + }(); + return installed; +} -const Feature kPinnedShips{"PinnedShips", &Config::Patches::pinnedshiphooks, InstallPinnedShips}; -const Feature kDoubleClickAssign{"DoubleClickAssign", &Config::Patches::doubleclickassignshiphooks, - InstallDoubleClickAssign}; -const Feature kAssignOnEnter{"AssignOnEnter", nullptr, nullptr, DockUpdate}; +// --- Dock selection --- -namespace -{ -void UpdateManageShipDockSelection() +void DockSelectionFrame() { - static uint64_t last_fleet_id = 0; - static auto next_update = std::chrono::steady_clock::time_point{}; - const auto now = std::chrono::steady_clock::now(); - if (now < next_update) + // Ten times a second; selecting a slot is not urgent. + static Throttle throttle(std::chrono::milliseconds(100)); + static int64_t last_fleet_id = 0; + if (!throttle.Due()) return; - next_update = now + std::chrono::milliseconds(100); auto* sections = Hub::Sections(); if (!Config::Get().ui.dock_syncmanagement || !sections || sections->CurrentSection() != SectionID::ShipManagement_Details) { last_fleet_id = 0; return; } - for (auto* controller : unity::Instances()) { - if (!controller || !controller->isActiveAndEnabled()) - continue; - auto* canvas = CanvasController::Of(controller); - if (!canvas || !canvas->Visible()) - continue; - auto* context = controller->Context(); - auto* fleet = context ? context->CurrentFleet() : nullptr; - auto* manager = FleetsManager::Instance(); - if (!fleet || !fleet->HasShip() || !manager || !manager->HasFleetService()) - continue; - const auto index = fleet->Index(); - if (index < 0) - continue; - // Exclude unassigned and synthetic fleets; management list order is not dock order. - auto* dock_fleet = manager->GetFleetPlayerData(index); - if (!dock_fleet || dock_fleet->Id() != fleet->Id()) - continue; - if (last_fleet_id == fleet->Id()) - return; - auto* bar = unity::Newest(); - if (!bar) - return; - if (!bar->IsIndexSelected(index)) { - // Use the bar's selection flow to update both the highlight and fleet panel. - bar->RequestSelect(index); - if (!bar->IsIndexSelected(index)) - return; // Retry next update if native selection was blocked. - spdlog::debug("[ManageShips] selected dock {} for fleet {}", index + 1, fleet->Id()); - } - last_fleet_id = fleet->Id(); + auto* controller = VisibleShipManagement(); + auto* context = controller ? controller->Context() : nullptr; + auto* fleet = context ? context->CurrentFleet() : nullptr; + auto* manager = FleetsManager::Instance(); + if (!fleet || !fleet->HasShip() || !manager || !manager->HasFleetService()) { + last_fleet_id = 0; + return; + } + // Unassigned and synthetic fleets have no dock slot; the management list order is not the dock order. + const auto index = fleet->Index(); + auto* dock_fleet = index >= 0 ? manager->GetFleetPlayerData(index) : nullptr; + if (!dock_fleet || dock_fleet->Id() != fleet->Id()) { + last_fleet_id = 0; + return; + } + if (last_fleet_id == fleet->Id()) + return; + auto* bar = unity::Newest(); + if (!bar) return; + if (!bar->IsIndexSelected(index)) { + // The bar's own selection flow updates both the highlight and the fleet panel. + bar->RequestSelect(index); + // Blocked by the game: try again next time. + if (!bar->IsIndexSelected(index)) + return; + spdlog::debug("[DockSelection] selected dock slot {} for fleet {}", index + 1, fleet->Id()); } - last_fleet_id = 0; + last_fleet_id = fleet->Id(); +} + +bool InstallPinnedShips() +{ + il2cpp::HookSet hooks("PinnedShips"); + IL2CPP_STAGE(hooks, ShipManagementViewContext::Class::Method("Sort", 0), ShipManagementViewContext_Sort_Hook); + return hooks.Commit(); } +bool InstallPinnedShipClick() +{ + pin_click_installed = InstallShipTileClick(); + return pin_click_installed; +} + +bool InstallDoubleClickAssign() +{ + double_click_installed = InstallShipTileClick(); + return double_click_installed; +} } // namespace -const Feature kDockSelection{"DockSelection", &Config::Patches::hotkeyhooks, nullptr, UpdateManageShipDockSelection}; + +const Feature kPinnedShips{"PinnedShips", &Config::Patches::pinnedshiphooks, InstallPinnedShips}; +const Feature kPinnedShipClick{"PinnedShipClick", &Config::Patches::pinnedshipclicktoggle, InstallPinnedShipClick}; +const Feature kDoubleClickAssign{"DoubleClickAssign", &Config::Patches::doubleclickassignshiphooks, + InstallDoubleClickAssign}; +// Frame work only. Enter needs no hook, so it follows the setting alone. +const Feature kAssignOnEnter{"AssignOnEnter", nullptr, nullptr, AssignOnEnterFrame}; +// Frame work only, installed with the hotkeys it was introduced with. +const Feature kDockSelection{"DockSelection", &Config::Patches::hotkeyhooks, nullptr, DockSelectionFrame}; diff --git a/src/mod/features/double_click_assign_ship.h b/src/mod/features/double_click_assign_ship.h deleted file mode 100644 index 162977141..000000000 --- a/src/mod/features/double_click_assign_ship.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -#include "game/fleet.h" - -void AssignShipEnterKeyUpdate(); -void DoubleClickOnShipTileClicked(FleetPlayerData* ship); diff --git a/src/mod/features/features.h b/src/mod/features/features.h index 4c17c9e37..76324970c 100644 --- a/src/mod/features/features.h +++ b/src/mod/features/features.h @@ -1,28 +1,61 @@ #pragma once -// The mod's features: the descriptors mod.cc installs, and the calls the shortcut dispatch in hotkeys.cc makes into -// the features that own the work. Grouped by the file that defines them. +// The mod's features: the descriptors mod.cc installs, and the few calls one feature makes into another, mostly +// from the shortcut dispatch in hotkeys.cc. Grouped by the file that defines them. #include "config.h" +#include #include struct NavigationZoom; enum class NodeDepth : int32_t; -// What mod.cc installs. Only the [patches] hook groups decide what is installed, so users can avoid clashing with -// other mods; feature settings are read inside the hooks, which keeps every feature switchable at runtime. +// What mod.cc installs. Only the [patches] groups decide what is installed, so a clash with another mod or a game +// update can be switched off; feature settings are read inside the hooks, so features switch on and off at runtime. struct Feature { + // Names the feature in the install log, and tags its own log lines: "[Name] ...". const char* name; // The [patches] group that installs it; null for the few that always install. bool Config::Patches::* group; - // Null for frame work that needs no hooks. - void (*install)(); - // Runs every frame from the frame tick once installed, in every hotkey mode, before the shortcuts. Returns at once - // when its feature is off. + // Installs its hooks; false when they did not go in, after the library or the feature has said why. Null for frame + // work that needs no hooks. + bool (*install)(); + // Runs every frame from the frame tick once installed, in every hotkey mode, before the shortcuts. Never runs for a + // feature that failed to install. Returns at once when its feature is off. void (*frame)() = nullptr; }; +// Frame work that only needs to run every so often, such as a scan of the UI. Game thread only. +class Throttle +{ +public: + explicit Throttle(std::chrono::steady_clock::duration interval) + : interval_(interval) + { + } + // True at most once per interval. + bool Due() + { + const auto now = std::chrono::steady_clock::now(); + if (now < next_) + return false; + next_ = now + interval_; + return true; + } + +private: + std::chrono::steady_clock::duration interval_; + std::chrono::steady_clock::time_point next_{}; +}; + +// action_queue.cc +extern const Feature kActionQueueRecovery; +extern const Feature kThinQueueProtection; +extern const Feature kQueueAddressGuard; +// Forgets the pending recovery attempts when the queue is switched off or on. Never touches the game's queues. +void ResetQueueRecoveryTracking(); + // alliance_store.cc extern const Feature kAllianceGamesStore; @@ -39,25 +72,36 @@ extern const Feature kBuffCrashGuard; // camera.cc extern const Feature kZoom; extern const Feature kPan; +extern const Feature kInteriorZoom; -// cargo.cc +// cargo.cc. The instant counter is Windows only. extern const Feature kCargoFormat; +#if _WIN32 extern const Feature kInstantCargo; +#endif // chat.cc extern const Feature kChat; +extern const Feature kChatTitles; // Opens chat, or focuses side chat's input. Never swallows the key. void OpenChatShortcut(); // In chat, the channel shortcuts. True when the key was used. bool ChatChannelShortcuts(); +// confirmations.cc +extern const Feature kForbiddenTechConfirm; +extern const Feature kRevealConfirm; + // dock.cc extern const Feature kPinnedShips; +extern const Feature kPinnedShipClick; extern const Feature kDoubleClickAssign; extern const Feature kAssignOnEnter; +extern const Feature kDockSelection; // fleet_actions.cc extern const Feature kQueueSwitch; +extern const Feature kAbilityButtons; // The pre-scan cargo hooks; installed with the hotkeys. void InstallFleetActions(); // A target or object viewer card is open in space. @@ -71,11 +115,9 @@ bool FleetActionShortcuts(); // fleet_labels.cc, fed by camera.cc's zoom hooks. extern const Feature kFleetLabels; -void FleetLabelsFollowDepth(NavigationZoom* zoom, NodeDepth depth); -void FleetLabelsAfterZoom(NavigationZoom* zoom); -// forbidden_tech.cc -extern const Feature kForbiddenTechConfirm; +void FleetLabelsFollowDepth(NavigationZoom* zoom, NodeDepth depth); +void FleetLabelsAfterZoom(NavigationZoom* zoom); // forbidden_tech_ship_name.cc extern const Feature kForbiddenTechShipName; @@ -95,7 +137,8 @@ bool HandleShortcuts(); // instant_warp.cc extern const Feature kInstantWarp; -void CycleInstantWarpConfirmation(); + +void CycleInstantWarpConfirmation(); // keybind_badges.cc, installed with the hotkeys. void InstallKeybindBadges(); @@ -135,11 +178,12 @@ extern const Feature kObjectTracker; // ui_scale.cc extern const Feature kUiScale; -void AdjustUiScale(bool up); -void AdjustUiShipScale(bool up); -void AdjustUiViewerScale(bool up); -// window.cc +void AdjustUiScale(bool up); +void AdjustUiShipScale(bool up); +void AdjustUiViewerScale(bool up); + +// window.cc. Resizing and the cursor are Windows only. #if _WIN32 extern const Feature kFreeResize; extern const Feature kCursor; @@ -149,13 +193,3 @@ float WindowDpiScale(bool refresh = false); // sync/sync.cc extern const Feature kSync; - -extern const Feature kInteriorZoom; -extern const Feature kChatTitles; -extern const Feature kRevealConfirm; -extern const Feature kActionQueueRecovery; -extern const Feature kQueueAddressGuard; -extern const Feature kThinQueueProtection; -extern const Feature kPinnedShipClick; -extern const Feature kDockSelection; -extern const Feature kAbilityButtons; diff --git a/src/mod/features/fleet_actions.cc b/src/mod/features/fleet_actions.cc index eff2fe8f5..080f674a8 100644 --- a/src/mod/features/fleet_actions.cc +++ b/src/mod/features/fleet_actions.cc @@ -1,16 +1,15 @@ // The selected fleet and the target viewers in space: selecting ships with the number keys, engaging, scanning, // mining, queueing, warping, recalling and repairing from the keyboard, dismissing viewers with Escape, opening a -// target's cargo when it is scanned, and the action queue switch. +// target's cargo when it is scanned, the action queue switch, and re-enabling ability buttons whose cooldown ended. -#include "action_queue.h" #include "config.h" #include "features/features.h" #include "shortcuts.h" -#include #include "game/app.h" #include "game/fleet.h" #include "game/navigation.h" +#include "game/queues.h" #include "game/ui.h" #include "game/viewers.h" @@ -68,7 +67,7 @@ template struct ViewerList { static bool AnyShown() { return (std::ranges::any_of(unity::Instances(), - [](Viewers* widget) { return widget && Shown(widget->visibilityController()); }) + [](Viewers* widget) { return Shown(widget->visibilityController()); }) || ...); } @@ -81,7 +80,7 @@ template struct ViewerList { { bool hid = false; for (auto* widget : unity::Instances()) { - if (widget && Shown(widget->visibilityController())) { + if (Shown(widget->visibilityController())) { widget->HideAllViewers(); hid = true; } @@ -108,7 +107,9 @@ bool ArmadaViewerShown(ArmadaObjectViewerWidget* armada) return false; auto* controller = armada->visibilityController(); if (!controller) { - spdlog::warn("ArmadaWidget has no visibility controller, using default Visible state"); + static bool reported = false; + if (!std::exchange(reported, true)) + spdlog::warn("[Hotkeys] the armada viewer has no visibility controller; treating it as shown"); return true; } const auto state = controller->State(); @@ -153,7 +154,7 @@ bool RequestFleetAction(FleetBarViewController* fleet_bar, ActionType type, std: } if (help_state != FleetState::Unknown && (requested || fleet->CurrentState() == help_state)) requested = panel->RequestAction(fleet, type, 0, ActionBehaviour::AskHelp) || requested; - spdlog::trace("Fleet action {} on fleet {} in state {}: {}", type.flagValue, fleet->Id(), + spdlog::trace("[Hotkeys] action {} on fleet {} in state {}: {}", type.flagValue, fleet->Id(), static_cast(fleet->CurrentState()), requested); return requested; } @@ -182,8 +183,8 @@ bool ActOnPreScan(PreScanTargetWidget* pre_scan, const Pressed& pressed, FleetPl const auto type = TargetHullType(buttons->Context()); if (type != HullType::ArmadaTarget && (type != HullType::Any || pressed.retry)) { if (add_to_queue->isActiveAndEnabled()) { - if (auto* listener = add_to_queue->SemaphoreListener(); listener && !queue->IsQueueFull(fleet)) { - if (auto* button = listener->TheButton()) { + if (auto* listener = add_to_queue->semaphoreButtonListener(); listener && !queue->IsQueueFull(fleet)) { + if (auto* button = listener->button()) { button->Press(); TargetViewers::HideFirst(); } @@ -215,7 +216,7 @@ bool ActOnPreScan(PreScanTargetWidget* pre_scan, const Pressed& pressed, FleetPl if (type != HullType::ArmadaTarget) { buttons->OnEngageButtonClicked(); } else if (auto* attack = pre_scan->armadaAttackButton(); attack && attack->isActiveAndEnabled()) { - if (auto* button = attack->TheButton()) + if (auto* button = attack->UnityButton()) button->Press(); } else { buttons->OnArmadaButtonClicked(); @@ -322,7 +323,7 @@ void ActOnSelectedFleet(FleetBarViewController* fleet_bar, const Pressed& presse void ToggleTargetCargo() { for (auto* pre_scan : unity::Instances()) { - auto* rewards = pre_scan ? pre_scan->rewardsButtonWidget() : nullptr; + auto* rewards = pre_scan->rewardsButtonWidget(); auto* cargo = rewards ? rewards->rewardsController() : nullptr; if (!cargo || !Shown(pre_scan->visibilityController())) continue; @@ -336,7 +337,7 @@ void ToggleTargetCargo() void ShowPendingTargetCargo() { for (auto* pre_scan : unity::Instances()) { - auto* rewards = pre_scan ? pre_scan->rewardsButtonWidget() : nullptr; + auto* rewards = pre_scan->rewardsButtonWidget(); auto* cargo = rewards ? rewards->rewardsController() : nullptr; if (cargo && Shown(pre_scan->visibilityController()) && !cargo->IsShown()) cargo->Show(true); @@ -386,8 +387,7 @@ void RewardsButtonWidget_OnDidBindContext_Hook(auto original, RewardsButtonWidge void PreScanTargetWidget_ShowWithFleet_Hook(auto original, PreScanTargetWidget* _this, void* fleet) { original(_this, fleet); - if (_this) - ShowCargoByDefault(_this->rewardsButtonWidget()); + ShowCargoByDefault(_this->rewardsButtonWidget()); } // queue_enabled switches the game's action queue off by telling it the queue is locked. @@ -401,8 +401,47 @@ void ViewSelectedFleet(FleetPlayerData* fleet) fleets->RequestViewFleet(fleet, true); } -void InstallQueueSwitch() -{ IL2CPP_HOOK(ActionQueueManager::Class::Method("IsQueueUnlocked"), ActionQueueManager_IsQueueUnlocked_Hook); } +bool InstallQueueSwitch() +{ + il2cpp::HookSet hooks("QueueSwitch"); + IL2CPP_STAGE(hooks, ActionQueueManager::Class::Method("IsQueueUnlocked"), ActionQueueManager_IsQueueUnlocked_Hook); + return hooks.Commit(); +} + +// --- Ability buttons --- + +// The game can leave an ability's button disabled after its cooldown ends; this enables it again once the ability is +// usable. Four checks a second are plenty for a button. +void AbilityButtonsFrame() +{ + static Throttle throttle(std::chrono::milliseconds(250)); + if (!throttle.Due()) + return; + auto* bar = unity::Newest(); + auto* controller = bar ? bar->fleetPanelController() : nullptr; + auto* fleet = controller ? controller->fleet() : nullptr; + if (!fleet || (static_cast(fleet->CurrentState()) & static_cast(FleetState::CanActivateAbility)) == 0) + return; + auto* manager = ActivatedAbilityManager::Instance(); + auto* panel = controller->shipActionPanelLocalViewController(); + if (!manager || !panel || !CanvasController::Shows(panel) || panel->HasShipAnActiveAbilityWithActiveEffect()) + return; + auto* ids = panel->abilityIDs(); + auto* buttons = panel->buttons(); + if (!ids || !buttons) + return; + for (size_t index = 0; index < ids->size() && index < buttons->size(); ++index) { + auto* button = (*buttons)[index]; + auto* context = button ? button->Context() : nullptr; + if (!button || !button->isActiveAndEnabled() || !context || context->Interactable()) + continue; + const auto ability_id = (*ids)[index]; + auto* timer = panel->GetRemainingCooldown(ability_id); + // TimeSpan is an eight-byte value in 100 ns ticks. Repair at -1 second or below. + if (timer && timer->RemainingTime().ticks <= -10000000 && manager->CanUseAbility(fleet, ability_id)) + context->SetInteractable(true); + } +} } // namespace bool TargetViewerShown() @@ -510,47 +549,13 @@ bool FleetActionShortcuts() void InstallFleetActions() { - IL2CPP_HOOK(RewardsButtonWidget::Class::Method("OnDidBindContext"), RewardsButtonWidget_OnDidBindContext_Hook); - IL2CPP_HOOK(PreScanTargetWidget::Class::Method("ShowWithFleet"), PreScanTargetWidget_ShowWithFleet_Hook); + il2cpp::HookSet hooks("FleetActions"); + IL2CPP_STAGE(hooks, RewardsButtonWidget::Class::Method("OnDidBindContext"), + RewardsButtonWidget_OnDidBindContext_Hook); + IL2CPP_STAGE(hooks, PreScanTargetWidget::Class::Method("ShowWithFleet"), PreScanTargetWidget_ShowWithFleet_Hook); + hooks.Commit(); } const Feature kQueueSwitch{"QueueSwitch", &Config::Patches::testpatches, InstallQueueSwitch}; - -namespace -{ -void UpdateExpiredAbilityButtons() -{ - static auto next_update = std::chrono::steady_clock::time_point{}; - const auto now = std::chrono::steady_clock::now(); - if (now < next_update) - return; - next_update = now + std::chrono::milliseconds(250); - auto* bar = unity::Newest(); - auto* controller = bar ? bar->fleetPanelController() : nullptr; - auto* fleet = controller ? controller->fleet() : nullptr; - if (!fleet || (static_cast(fleet->CurrentState()) & static_cast(FleetState::CanActivateAbility)) == 0) - return; - auto* manager = ActivatedAbilityManager::Instance(); - auto* panel = controller->shipActionPanelLocalViewController(); - auto* canvas = panel ? CanvasController::Of(panel) : nullptr; - if (!manager || !canvas || !canvas->Visible() || panel->HasShipAnActiveAbilityWithActiveEffect()) - return; - auto* ids = panel->abilityIDs(); - auto* buttons = panel->buttons(); - if (!ids || !buttons) - return; - for (size_t index = 0; index < ids->size() && index < buttons->size(); ++index) { - auto* button = (*buttons)[index]; - auto* context = button ? button->Context() : nullptr; - if (!button || !button->isActiveAndEnabled() || !context || context->Interactable()) - continue; - const auto ability_id = (*ids)[index]; - auto* timer = panel->GetRemainingCooldown(ability_id); - // TimeSpan is an eight-byte value in 100 ns ticks. Repair at -1 second or below. - if (timer && timer->RemainingTime().ticks <= -10000000 && manager->CanUseAbility(fleet, ability_id)) - context->SetInteractable(true); - } -} - -} // namespace -const Feature kAbilityButtons{"AbilityButtons", &Config::Patches::hotkeyhooks, nullptr, UpdateExpiredAbilityButtons}; +// Frame work only, installed with the hotkeys it was introduced with. +const Feature kAbilityButtons{"AbilityButtons", &Config::Patches::hotkeyhooks, nullptr, AbilityButtonsFrame}; diff --git a/src/mod/features/fleet_labels.cc b/src/mod/features/fleet_labels.cc index 7f1a641f4..de0067ddd 100644 --- a/src/mod/features/fleet_labels.cc +++ b/src/mod/features/fleet_labels.cc @@ -1,6 +1,6 @@ -// zoom_label_player_* and zoom_label_non_player_*: how much detail fleet labels in system view show. The game picks -// each label's level of detail from the camera's zoom; these settings override that per fleet kind. The camera's own -// hooks in camera.cc report the zoom here. +// [graphics] zoom_label_player_* and zoom_label_non_player_*: how much detail fleet labels in system view show. The +// game picks each label's level of detail from the camera's zoom; these settings override that per fleet kind. The +// camera's own hooks in camera.cc report the zoom here. #include "config.h" #include "features/features.h" @@ -16,12 +16,14 @@ namespace { -// Fleet label detail. The game picks each fleet label's LOD from the camera's zoom level; the zoom_label_* -// settings override that per fleet kind: always expanded, always compact, or expanded below a zoom threshold. +// The tracked fleet labels and the zoom state they follow. Each fleet kind is always expanded, always compact, or +// expanded below a zoom threshold. class FleetLabels { public: - bool installed = false; + // Set once every hook is in: an untracked OnDisable would leave a freed widget in the map. + void MarkInstalled() + { installed_ = true; } static bool Enabled() { @@ -81,7 +83,7 @@ class FleetLabels // The camera `zoom` is about to show `depth`. Labels only apply in a solar system, and a new camera starts over. void OnDepth(NavigationZoom* zoom, NodeDepth depth) { - if (!installed) + if (!installed_) return; if (depth == NodeDepth::SolarSystem) { Follow(zoom); @@ -93,7 +95,7 @@ class FleetLabels // After the camera's own update: picks up the zoom level and re-applies labels when they change. void OnZoomed(NavigationZoom* zoom) { - if (!installed || zoom->depth() != NodeDepth::SolarSystem) + if (!installed_ || zoom->depth() != NodeDepth::SolarSystem) return; Follow(zoom); const bool was_known = zoom_known_; @@ -173,11 +175,10 @@ class FleetLabels player_expanded_ = player; non_player_expanded_ = non_player; ApplyAll(); -#ifdef _MODDBG - spdlog::info("Fleet label detail threshold state updated at normalized zoom {:.4f}", normalized_zoom_); -#endif + spdlog::debug("[FleetLabels] threshold crossed at normalized zoom {:.4f}", normalized_zoom_); } + bool installed_ = false; std::unordered_map widgets_; // The system camera the zoom state below belongs to. NavigationZoom* camera_ = nullptr; @@ -187,11 +188,11 @@ class FleetLabels bool threshold_known_ = false; bool player_expanded_ = false; bool non_player_expanded_ = false; -} labels; +} fleet_labels; void NavigationLOD_UpdateLOD_Hook(auto original, NavigationLOD* _this, ZoomLevels level) { - const auto effective = labels.LevelFor(_this, level); + const auto effective = fleet_labels.LevelFor(_this, level); if (!effective) return original(_this, level); // The target level is set on both sides of the update so the game's own bookkeeping agrees with ours. @@ -203,55 +204,55 @@ void NavigationLOD_UpdateLOD_Hook(auto original, NavigationLOD* _this, ZoomLevel void NavigationFleetWidget_OnEnable_Hook(auto original, NavigationFleetWidget* _this) { original(_this); - labels.Track(_this); + fleet_labels.Track(_this); } void NavigationFleetWidget_OnDidBindContext_Hook(auto original, NavigationFleetWidget* _this) { original(_this); - labels.Track(_this); + fleet_labels.Track(_this); } void NavigationFleetWidget_OnDisable_Hook(auto original, NavigationFleetWidget* _this) { - labels.Untrack(_this); + fleet_labels.Untrack(_this); original(_this); } void NavigationFleetWidget_OnAboutToReleaseContext_Hook(auto original, NavigationFleetWidget* _this) { - labels.Untrack(_this); + fleet_labels.Untrack(_this); original(_this); } -void InstallFleetLabels() +bool InstallFleetLabels() { // Installed as a set, whether or not zoom_label_* is set, so labels can be switched at runtime. Missing fields // and properties (_lod, Context, FleetType) read as null or zero, which the hooks treat as native labels, so - // only the methods gate installation. The zoom reports come from camera.cc's NavigationZoom.Update hook. - auto update = NavigationZoom::Class::Method("Update"); - auto update_lod = NavigationLOD::Class::Method("UpdateLOD"); - auto on_enable = NavigationFleetWidget::Class::Method("OnEnable"); - auto on_disable = NavigationFleetWidget::Class::Method("OnDisable"); - auto on_bind = NavigationFleetWidget::Class::Method("OnDidBindContext"); - auto on_release = NavigationFleetWidget::Class::Method("OnAboutToReleaseContext"); - if (!update || !update_lod || !on_enable || !on_disable || !on_bind || !on_release) { - spdlog::error("Fleet label detail hooks were not installed; using native fleet labels"); - return; - } - labels.installed = true; - IL2CPP_HOOK(update_lod, NavigationLOD_UpdateLOD_Hook); - IL2CPP_HOOK(on_enable, NavigationFleetWidget_OnEnable_Hook); - IL2CPP_HOOK(on_disable, NavigationFleetWidget_OnDisable_Hook); - IL2CPP_HOOK(on_bind, NavigationFleetWidget_OnDidBindContext_Hook); - IL2CPP_HOOK(on_release, NavigationFleetWidget_OnAboutToReleaseContext_Hook); + // only the methods gate installation. The zoom reports come from camera.cc's NavigationZoom.Update hook, so it + // has to exist too. + if (!NavigationZoom::Class::Method("Update")) + return false; + il2cpp::HookSet hooks("FleetLabels"); + IL2CPP_STAGE(hooks, NavigationLOD::Class::Method("UpdateLOD"), NavigationLOD_UpdateLOD_Hook); + IL2CPP_STAGE(hooks, NavigationFleetWidget::Class::Method("OnEnable"), NavigationFleetWidget_OnEnable_Hook); + IL2CPP_STAGE(hooks, NavigationFleetWidget::Class::Method("OnDisable"), NavigationFleetWidget_OnDisable_Hook); + IL2CPP_STAGE(hooks, NavigationFleetWidget::Class::Method("OnDidBindContext"), + NavigationFleetWidget_OnDidBindContext_Hook); + IL2CPP_STAGE(hooks, NavigationFleetWidget::Class::Method("OnAboutToReleaseContext"), + NavigationFleetWidget_OnAboutToReleaseContext_Hook); + // camera.cc's zoom hooks report here whether or not this installed; they only count once it has. + if (!hooks.Commit()) + return false; + fleet_labels.MarkInstalled(); + return true; } } // namespace void FleetLabelsFollowDepth(NavigationZoom* zoom, NodeDepth depth) -{ labels.OnDepth(zoom, depth); } +{ fleet_labels.OnDepth(zoom, depth); } void FleetLabelsAfterZoom(NavigationZoom* zoom) -{ labels.OnZoomed(zoom); } +{ fleet_labels.OnZoomed(zoom); } const Feature kFleetLabels{"FleetLabels", &Config::Patches::zoomhooks, InstallFleetLabels}; diff --git a/src/mod/features/forbidden_tech.cc b/src/mod/features/forbidden_tech.cc deleted file mode 100644 index 7d6745d2a..000000000 --- a/src/mod/features/forbidden_tech.cc +++ /dev/null @@ -1,91 +0,0 @@ -// auto_confirm_ft_upgrade: answers the forbidden tech upgrade confirmation with Confirm instead of showing it. - -#include "config.h" -#include "features/features.h" - -#include "game/ui.h" - -#include -#include -#include - -#include - -namespace -{ -// The closure ForbiddenTechManager.RequestAction passes as the confirmation callback. -bool IsForbiddenTechConfirmation(const Il2CppDelegate* callback) -{ - if (!callback || !callback->target || !callback->method || callback->method->parameters_count != 1) - return false; - // "Digit.Prime.ForbiddenTech.ForbiddenTechManager/<>c__DisplayClass12_0.b__0", or similar. - const auto name = il2cpp::Method(callback->method).Name(); - const auto owner = name.find("ForbiddenTechManager/"); - return owner != std::string::npos && (owner == 0 || name[owner - 1] == '.') && name.ends_with(".b__0"); -} - -bool IsAllianceContributionMaxLevelWarning(const Il2CppDelegate* callback) -{ - if (!callback || !callback->target || !callback->method || callback->method->parameters_count != 1) - return false; - const auto name = il2cpp::Method(callback->method).Name(); - const auto owner = name.find("AllianceContributionViewController/"); - return owner != std::string::npos && (owner == 0 || name[owner - 1] == '.') && name.ends_with(".b__0"); -} - -bool ConfirmForbiddenTechUpgrade(Il2CppDelegate* callback) -{ - if (!Config::Get().ui.auto_confirm_ft_upgrade || !IsForbiddenTechConfirmation(callback)) - return false; - - // The closure was identified above, so call its method directly. If it throws, show the dialog instead so the - // upgrade is never lost silently. - try { - il2cpp::InvokeBound(callback, MessageBoxResult::Confirm); - } catch (...) { - spdlog::warn("ForbiddenTechConfirmation: confirmation callback threw; showing the dialog"); - return false; - } - return true; -} - -bool ConfirmAllianceContribution(Il2CppDelegate* callback) -{ - if (!Config::Get().ui.auto_confirm_alliance_contribution || !IsAllianceContributionMaxLevelWarning(callback)) { - return false; - } - - try { - il2cpp::InvokeBound(callback, MessageBoxResult::Yes); - } catch (...) { - spdlog::warn("AllianceContributionConfirmation: confirmation callback threw; showing the dialog"); - return false; - } - - spdlog::debug("AllianceContributionConfirmation: auto-confirmed the max-level contribution warning"); - return true; -} - -void MessageBox_Show_Hook(auto original, MessageBoxContext* context) -{ - if (!context || !ConfirmForbiddenTechUpgrade(context->OnSelection())) { - original(context); - } -} - -void MessageBox_ShowWithCallback_Hook(auto original, MessageBoxContext* context, Il2CppDelegate* callback) -{ - if (!ConfirmForbiddenTechUpgrade(callback) && !ConfirmAllianceContribution(callback)) { - original(context, callback); - } -} - -void InstallForbiddenTechConfirm() -{ - IL2CPP_HOOK(GameMessageBox::Method("Show", 1), MessageBox_Show_Hook); - IL2CPP_HOOK(GameMessageBox::Method("Show", 2), MessageBox_ShowWithCallback_Hook); -} -} // namespace - -const Feature kForbiddenTechConfirm{"ForbiddenTechConfirm", &Config::Patches::forbiddentechconfirmhooks, - InstallForbiddenTechConfirm}; diff --git a/src/mod/features/forbidden_tech_ship_name.cc b/src/mod/features/forbidden_tech_ship_name.cc index 617338124..b8e3f3c7d 100644 --- a/src/mod/features/forbidden_tech_ship_name.cc +++ b/src/mod/features/forbidden_tech_ship_name.cc @@ -227,26 +227,25 @@ void ForbiddenTechInventoryItemWidget_ClearWidgetData_Hook(auto original, Forbid original(_this); } -void InstallForbiddenTechShipName() +bool InstallForbiddenTechShipName() { - auto method = ForbiddenTechInventoryItemWidget::Class::Method("SetWidgetData", 0); - auto clear = ForbiddenTechInventoryItemWidget::Class::Method("ClearWidgetData", 0); - // Both methods perform substantive native UI updates, rather than forwarding through tiny wrappers. - // Only the widget's own overrides qualify; the Widget`1 base versions run for every widget. - const auto own = [](il2cpp::Method info) { - return info && info.Info()->klass == ForbiddenTechInventoryItemWidget::Class::Raw(); + auto refresh = ForbiddenTechInventoryItemWidget::Class::Method("SetWidgetData", 0); + auto clear = ForbiddenTechInventoryItemWidget::Class::Method("ClearWidgetData", 0); + // Both methods perform substantive native UI updates, rather than forwarding through tiny wrappers. The lookups + // walk parents; only detour the widget's own overrides, never the Widget`1 base versions that run for every widget. + const auto inherited = [](const il2cpp::Method& method) { + return method && method.Info()->klass != ForbiddenTechInventoryItemWidget::Class::Raw(); }; - if (!own(method) || !own(clear)) { - spdlog::warn("[ForbiddenTechShipName] widget refresh unavailable; native display retained"); - return; - } - // Install cleanup first; failure leaves the native panel entirely untouched. - if (!IL2CPP_HOOK(clear, ForbiddenTechInventoryItemWidget_ClearWidgetData_Hook)) { - spdlog::warn("[ForbiddenTechShipName] widget cleanup hook unavailable; native display retained"); - return; + if (inherited(refresh) || inherited(clear)) { + spdlog::warn("[ForbiddenTechShipName] the widget no longer overrides SetWidgetData and ClearWidgetData; not " + "hooking the shared ones"); + return false; } - const bool installed = IL2CPP_HOOK(method, ForbiddenTechInventoryItemWidget_SetWidgetData_Hook) != nullptr; - spdlog::info("[ForbiddenTechShipName] widget refresh hook {}", installed ? "installed" : "unavailable"); + // One set: the refresh never runs without the cleanup, so a failure leaves the native panel untouched. + il2cpp::HookSet hooks("ForbiddenTechShipName"); + IL2CPP_STAGE(hooks, clear, ForbiddenTechInventoryItemWidget_ClearWidgetData_Hook); + IL2CPP_STAGE(hooks, refresh, ForbiddenTechInventoryItemWidget_SetWidgetData_Hook); + return hooks.Commit(); } } // namespace diff --git a/src/mod/features/game_config.cc b/src/mod/features/game_config.cc index ae8fece20..e67dfcd17 100644 --- a/src/mod/features/game_config.cc +++ b/src/mod/features/game_config.cc @@ -24,8 +24,12 @@ void Model_LoadConfigs_Hook(auto original, Model* _this) config->SetAssetUrlOverride(il2cpp::NewString(game.assets_url_override.c_str())); } -void InstallGameConfigOverrides() -{ IL2CPP_HOOK(Model::Class::Method("LoadConfigs"), Model_LoadConfigs_Hook); } +bool InstallGameConfigOverrides() +{ + il2cpp::HookSet hooks("GameConfigOverrides"); + IL2CPP_STAGE(hooks, Model::Class::Method("LoadConfigs"), Model_LoadConfigs_Hook); + return hooks.Commit(); +} } // namespace const Feature kGameConfigOverrides{"GameConfigOverrides", &Config::Patches::testpatches, InstallGameConfigOverrides}; diff --git a/src/mod/features/haven.cc b/src/mod/features/haven.cc index f1e0545eb..a56c90acd 100644 --- a/src/mod/features/haven.cc +++ b/src/mod/features/haven.cc @@ -1,168 +1,125 @@ -// Haven's extended camera range, road surface depth and help-history ordering. -#include "game/haven.h" +// Haven, the planetary base: [graphics] haven_zoom, which extends the camera's zoom-out and keeps the roads drawn +// above the ground at that distance, and [ui] reverse_haven_history, which lists help history newest first. + +#include "config.h" #include "features/features.h" +#include "game/camera.h" +#include "game/haven.h" + #include +#include #include +#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_{}; -}; +// --- Haven zoom --- +// From further out the roads sink into the ground mesh. A depth bias on the road materials keeps them drawn on top. void ApplyHavenRoadDepthBias() { - static const bool available = unity::Material::Class::Method("HasProperty") - && unity::Material::Class::Method("GetFloat") - && unity::Material::Class::Method("SetFloat"); - if (!available) + // Once a second: the scene search below is too costly for every frame. + static Throttle throttle(std::chrono::seconds(1)); + if (!throttle.Due()) return; - // Leaked: its handles must not be freed by static destructors after the runtime is gone. - static auto* roads = new HavenRoads; - roads->Apply(); + auto* root = unity::GameObject::Find("StarbaseRoot/StarbaseManager/StarbasePlanetaryVisualsHolder"); + auto* renderers = root ? root->GetComponentsInChildren(true) : nullptr; + if (!renderers) + return; + for (auto* renderer : *renderers) { + auto* materials = renderer ? renderer->sharedMaterials() : nullptr; + if (!materials) + continue; + for (auto* material : *materials) { + if (!material) + continue; + 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; + if (!shader || shader->Name() != "Custom/LitMSAE") + continue; + const std::array properties{il2cpp::NewString("_OffsetFactor"), il2cpp::NewString("_OffsetUnits")}; + if (!material->HasProperty(properties[0]) || !material->HasProperty(properties[1])) + continue; + const std::array before{material->GetFloat(properties[0]), material->GetFloat(properties[1])}; + if (!std::isfinite(before[0]) || !std::isfinite(before[1])) + continue; + // Keep an already stronger native bias. Resolve scene objects afresh each time. + for (size_t i = 0; i < properties.size(); ++i) + if (before[i] > -1.0f) + material->SetFloat(properties[i], -1.0f); + } + } } -bool ReadHavenRadius(HavenFrameProvider* provider, float& radius) +bool ReadHavenRadius(FrameProvider* provider, float& radius) { - if (!provider || !provider->Is() || !provider->Is()) + 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()) + 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(); + const auto minimum = data->min(), maximum = data->max(); + 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) +void BlendFrameProvider_UpdateCameraFrame_Hook(auto original, BlendFrameProvider* _this, 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); + // 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(_this, camera); const auto max_zoom = Config::Get().graphics.haven_zoom; - if (provider == nullptr || !std::isfinite(max_zoom) || max_zoom <= 0.0f) + if (!std::isfinite(max_zoom) || max_zoom <= 0.0f) return; - auto* source = provider->Source(); - auto* target = provider->Target(); + auto* source = _this->sourceFrameProvider(); + auto* target = _this->targetFrameProvider(); 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) + auto* source_orbit = source->As(); + auto* target_orbit = target->As(); + auto* pivot = source_orbit->orbitPivot(); + if (!pivot || target_orbit->orbitPivot() != 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) + auto* source_frame = _this->sourceFrame(); + auto* target_frame = _this->targetFrame(); + CameraFrame* result = _this->blendedCameraFrame(); + auto* curve = _this->blendCurve(); + const auto minimum = _this->softLimitMin(), maximum = _this->softLimitMax(), ratio = _this->blendRatio(); + if (!source_frame || !target_frame || !result || !curve || 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(); + 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. + // 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) @@ -192,12 +149,12 @@ void HavenCamera_UpdateCameraFrame_Hook(auto original, HavenBlendProvider* provi 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. + // 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(); + auto clip = result->farPlane(); if (!std::isfinite(clip) || clip <= 0.0f) return; clip += static_cast(extra); @@ -205,7 +162,7 @@ void HavenCamera_UpdateCameraFrame_Hook(auto original, HavenBlendProvider* provi || clip <= 0.0f) return; result->SetPosition(position); - result->SetFarClip(clip); + result->SetFarPlane(clip); static bool reported = false; if (!reported) { spdlog::info("[HavenZoom] expanded native outer distance {} to maximum {}", native_outer, max_zoom); @@ -213,43 +170,38 @@ void HavenCamera_UpdateCameraFrame_Hook(auto original, HavenBlendProvider* provi } } +// --- Help history --- + void PlanetaryHelpHistoryContext_ApplyRecords_Hook(auto original, PlanetaryHelpHistoryContext* _this, il2cpp::List* records) { if (records && Config::Get().ui.reverse_haven_history) { + // Swap through the managed setter so reference writes keep their GC barriers. 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); + for (int32_t i = 0; i < count / 2; ++i) { + auto* first = (*records)[i]; + auto* last = (*records)[count - i - 1]; + records->Set(i, last); + records->Set(count - i - 1, first); } } original(_this, records); } -void InstallHavenZoom() +bool InstallHavenZoom() { - if (!HavenCameraBindingsReady()) - return; - IL2CPP_HOOK(HavenBlendProvider::Class::Method("UpdateCameraFrame"), - HavenCamera_UpdateCameraFrame_Hook); + il2cpp::HookSet hooks("HavenZoom"); + IL2CPP_STAGE(hooks, BlendFrameProvider::Class::Method("UpdateCameraFrame"), + BlendFrameProvider_UpdateCameraFrame_Hook); + return hooks.Commit(); } -void InstallHavenHistory() +bool InstallHavenHistory() { - IL2CPP_HOOK(PlanetaryHelpHistoryContext::Class::Method("ApplyRecords", 1), - PlanetaryHelpHistoryContext_ApplyRecords_Hook); + il2cpp::HookSet hooks("HavenHistory"); + IL2CPP_STAGE(hooks, PlanetaryHelpHistoryContext::Class::Method("ApplyRecords", 1), + PlanetaryHelpHistoryContext_ApplyRecords_Hook); + return hooks.Commit(); } } // namespace diff --git a/src/mod/features/hotkeys.cc b/src/mod/features/hotkeys.cc index 5bec8490f..3209aefc6 100644 --- a/src/mod/features/hotkeys.cc +++ b/src/mod/features/hotkeys.cc @@ -15,7 +15,7 @@ #include -#ifdef _WIN32 +#if _WIN32 #include #endif @@ -117,12 +117,12 @@ bool DispatchShortcuts() if (Shortcut::IsDown(Action::kDisableHotKeys)) { HideKeybindBadges(); config.control.hotkeys_enabled = false; - spdlog::warn("Setting hotkeys to DISABLED"); + spdlog::warn("[Hotkeys] mod hotkeys off"); return true; } if (Shortcut::IsDown(Action::kEnableHotKeys)) { config.control.hotkeys_enabled = true; - spdlog::warn("Setting hotkeys to ENABLED"); + spdlog::warn("[Hotkeys] mod hotkeys on"); return true; } @@ -141,18 +141,19 @@ bool DispatchShortcuts() return true; if (!typing && Shortcut::IsDown(Action::kRestart)) { - spdlog::info("Clearing localisation cache and restarting"); + spdlog::info("[Hotkeys] clearing the localisation cache and restarting"); LanguageManager::ClearCache(); Hub::App()->Reload(); return true; } if (!typing && Shortcut::IsDown(Action::kQuit)) { -#ifdef _WIN32 + // On Windows the process ends here: TerminateProcess does not return. +#if _WIN32 TerminateProcess(GetCurrentProcess(), 1); #else Hub::App()->Quit(); - return true; #endif + return true; } if (SelectShipShortcuts()) @@ -187,17 +188,22 @@ void ShortcutsManager_InitializeActions_Hook(auto original, ShortcutsManager* _t original(_this); } -void Install() +bool InstallHotkeys() { + il2cpp::HookSet hooks("Hotkeys"); + IL2CPP_STAGE(hooks, ShortcutsManager::Class::Method("InitializeActions"), ShortcutsManager_InitializeActions_Hook); // The badges only make sense when the game's own input actions are under the mod's control. - if (IL2CPP_HOOK(ShortcutsManager::Class::Method("InitializeActions"), ShortcutsManager_InitializeActions_Hook)) + if (hooks.Commit()) InstallKeybindBadges(); InstallFleetActions(); + // The dispatch itself needs no hook: the frame tick runs it. The hint badges and pre-scan cargo are extras whose + // own sets report themselves. hotkeys_installed = true; + return true; } } // namespace bool HandleShortcuts() { return hotkeys_installed && DispatchShortcuts(); } -const Feature kHotkeys{"Hotkeys", &Config::Patches::hotkeyhooks, Install}; +const Feature kHotkeys{"Hotkeys", &Config::Patches::hotkeyhooks, InstallHotkeys}; diff --git a/src/mod/features/instant_warp.cc b/src/mod/features/instant_warp.cc index 5e6bc333d..1c4e806be 100644 --- a/src/mod/features/instant_warp.cc +++ b/src/mod/features/instant_warp.cc @@ -1,4 +1,4 @@ -// auto_confirm_instant_warp and the instant_warp_* ship filters: answers the warp-or-jump prompt for the ship, +// [ui] auto_confirm_instant_warp and the instant_warp_* ship filters: answers the warp-or-jump prompt for the ship, // previews that answer on the system card's visit button, and the shortcut that cycles the default. #include "config.h" @@ -17,6 +17,8 @@ #include #include +#include +#include namespace { @@ -41,14 +43,14 @@ InstantWarpConfirmation ResolveConfirmation(FleetPlayerData* fleet) void CoursePromptPopupViewController_AboutToShow_Hook(auto original, CoursePromptPopupViewController* _this) { original(_this); - auto* widget = _this ? _this->PopupWidget() : nullptr; + auto* widget = _this->coursePromptPopupWidget(); auto* context = widget ? widget->Context() : nullptr; if (!context || !context->HasInstantWarp()) return; - auto* course = context->GetCourseData(); + auto* course = context->CourseData(); const auto action = ResolveConfirmation(course ? course->PlayerFleet() : nullptr); - spdlog::debug("InstantWarpConfirmation: resolved action {}", static_cast(action)); + spdlog::debug("[InstantWarp] answering the prompt with {}", static_cast(action)); switch (action) { case InstantWarpConfirmation::kWarp: widget->InitiateRegularWarp(); @@ -61,10 +63,11 @@ void CoursePromptPopupViewController_AboutToShow_Hook(auto original, CoursePromp } } -// The system card's visit button reads WARP or JUMP when auto-confirm would pick one. This runs on the frame -// tick rather than another UI detour, and only overrides the label text: never the button's context, timer, -// resource cost, or click handler. -bool label_installed = false; +// --- System card label --- +// +// The system card's visit button reads WARP or JUMP when auto-confirm would pick one. This runs on the frame tick +// rather than another UI detour, and only overrides the label text: never the button's context, timer, resource +// cost, or click handler. std::string CurrentText(TextLocalizer* label) { return il2cpp::ToUtf8(label->CurrentText()); } @@ -113,15 +116,15 @@ struct OwnedLabel { text = desired; } }; -OwnedLabel action_label; +// Never destroyed: freeing a GC handle in a static destructor can run after the runtime has shut down. +auto& action_label = *new OwnedLabel; -void UpdateLabel() +void UpdateWarpLabel() { - static auto next = std::chrono::steady_clock::time_point{}; - const auto now = std::chrono::steady_clock::now(); - if (now < next) + // Ten times a second is quick enough for a label. + static Throttle throttle(std::chrono::milliseconds(100)); + if (!throttle.Due()) return; - next = now + std::chrono::milliseconds(100); // Without a default or a jump/warp filter every prompt resolves to "ask"; skip the fleet lookups. const auto& ui = Config::Get().ui; @@ -160,25 +163,12 @@ void UpdateLabel() action_label.Apply(unity::Alive(label) ? label : nullptr, desired); } -void InstantWarpUpdate() +bool InstallInstantWarp() { - if (label_installed) - UpdateLabel(); -} - -void InstallInstantWarp() -{ - // The hook is useless without both popup actions, so skip it (and the label) when either has drifted. - const auto about_to_show = CoursePromptPopupViewController::Class::Method("AboutToShow", 0); - if (!about_to_show || !CoursePromptPopupWidget::Class::Method("InitiateRegularWarp", 0) - || !CoursePromptPopupWidget::Class::Method("OnInstantWarpButtonClick", 0)) { - return; - } - - if (!IL2CPP_HOOK(about_to_show, CoursePromptPopupViewController_AboutToShow_Hook)) - return; - label_installed = true; - spdlog::info("[WarpActionLabel] system-card action labels enabled"); + il2cpp::HookSet hooks("InstantWarp"); + IL2CPP_STAGE(hooks, CoursePromptPopupViewController::Class::Method("AboutToShow", 0), + CoursePromptPopupViewController_AboutToShow_Hook); + return hooks.Commit(); } } // namespace @@ -197,7 +187,8 @@ void CycleInstantWarpConfirmation() break; } constexpr const char* kNames[] = {"none", "warp", "jump"}; - spdlog::info("Auto-confirm instant warp set to {}", kNames[static_cast(setting)]); + spdlog::info("[InstantWarp] answering prompts with {}", kNames[static_cast(setting)]); } -const Feature kInstantWarp{"InstantWarp", nullptr, InstallInstantWarp, InstantWarpUpdate}; +// Always installed: its only hook answers a prompt the player asked the mod to answer. +const Feature kInstantWarp{"InstantWarp", nullptr, InstallInstantWarp, UpdateWarpLabel}; diff --git a/src/mod/features/interior_zoom.cc b/src/mod/features/interior_zoom.cc deleted file mode 100644 index 629068b51..000000000 --- a/src/mod/features/interior_zoom.cc +++ /dev/null @@ -1,113 +0,0 @@ -#include "config.h" -#include "features/features.h" - -#include "game/navigation.h" -#include - -#include - -#include - -#include -#include -#include - -// Station-interior zoom-out: the interior view's far stop is an OrbitFrameProvider pinned at a fixed -// radius that is also the exterior station camera, so the hook pushes that radius out while the section -// is Starbase_Interior and restores native otherwise (see fix_pan.cc, which drives the same provider). -// graphics.interior_zoom_scale multiplies that native radius (1.0 = native) rather than setting an -// absolute distance like zoom/haven_zoom, so it stays resolution-independent; field resolution follows -// haven_zoom.cc. -namespace -{ -struct NativeRange { - float min, max; -}; -// Native (pre-widen) radius range, keyed by the Constraint.FloatData radius object, NOT the provider: -// the provider is recreated across transitions while the radius it drives persists and is shared with the -// exterior camera, so provider-keying stranded the exterior at a widened radius it never restored. The -// pointer is only a map key (the stored value is plain floats), so a retired object cannot cause a -// stale-object access. -std::unordered_map g_native; -bool g_reported = false; - -float InteriorScale() -{ - const auto scale = Config::Get().graphics.interior_zoom_scale; - return (std::isfinite(scale) && scale > 1.0f) ? scale : 1.0f; -} - -void OrbitFrameProvider_UpdateConstrains_Hook(auto original, OrbitFrameProvider* provider, void* camera) -{ - if (provider == nullptr) { - original(provider, camera); - return; - } - - auto* constraint = provider->As()->Constraint(); - auto* radius = constraint ? constraint->Radius() : nullptr; - if (radius == nullptr) { - original(provider, camera); - return; - } - - auto* section_manager = Hub::Sections(); - const bool in_interior = - section_manager != nullptr && section_manager->CurrentSection() == SectionID::Starbase_Interior; - const float scale = InteriorScale(); - - if (in_interior && scale > 1.0f) { - // Capture the native range once (before any widening), keyed by this radius object. - auto it = g_native.find(radius); - if (it == g_native.end()) { - NativeRange nr{0.0f, 0.0f}; - nr.min = radius->Minimum(); - nr.max = radius->Maximum(); - it = g_native.emplace(radius, nr).first; - if (!g_reported) { - const bool enabled = radius->Enabled(); - spdlog::info("[InteriorZoom] interior orbit endpoint native radius min={} max={} enabled={} (scale={})", nr.min, - nr.max, enabled, scale); - g_reported = true; - } - } - - // Widen relative to the captured native range. Idempotent: always derived from the stored native, - // so reasserting each frame cannot compound. Both ends scale together so a pinned endpoint - // (min==max) simply relocates farther out; a real range stretches outward. - float new_min = it->second.min * scale; - float new_max = it->second.max * scale; - radius->SetMinimum(new_min); - radius->SetMaximum(new_max); - } else { - // Not the interior (or feature off): the far endpoint is SHARED with the exterior view, so restore - // native on any radius we widened, else the exterior stays zoomed out and its labels drop out of LOD. - auto it = g_native.find(radius); - if (it != g_native.end()) { - float native_min = it->second.min, native_max = it->second.max; - radius->SetMinimum(native_min); - radius->SetMaximum(native_max); - } - } - - original(provider, camera); // native clamp now runs against the (widened or restored) range -} -} // namespace - -namespace -{ -void InstallInteriorZoomHooks() -{ - const bool ready = InteriorOrbitProvider::Class::HasField<"_constraint", InteriorOrbitConstraint*>() - && InteriorOrbitConstraint::Class::HasField<"_radius", InteriorRadiusConstraint*>() - && InteriorRadiusConstraint::Class::HasField<"_min", float>() - && InteriorRadiusConstraint::Class::HasField<"_max", float>(); - if (!ready) { - spdlog::warn("[InteriorZoom] orbit-constraint fields not resolved; native behavior kept"); - return; - } - IL2CPP_HOOK(OrbitFrameProvider::Class::Method("UpdateConstrains"), OrbitFrameProvider_UpdateConstrains_Hook); -} -} // namespace - -const Feature kInteriorZoom{"InteriorZoom", &Config::Patches::interiorzoomhooks, InstallInteriorZoomHooks}; diff --git a/src/mod/features/keybind_badges.cc b/src/mod/features/keybind_badges.cc index 4ecbc099f..79349907d 100644 --- a/src/mod/features/keybind_badges.cc +++ b/src/mod/features/keybind_badges.cc @@ -5,16 +5,15 @@ #include "config.h" #include "features/features.h" #include "features/ship_shortcut_hint.h" -#include "game/fleet.h" #include "shortcuts.h" -#include "text.h" -#include +#include "game/fleet.h" #include "game/ui.h" #include #include #include +#include #include #include @@ -105,19 +104,15 @@ std::string BadgeText(std::string_view action_name) auto hint = hint_action == Action::kCount ? std::string{} : Shortcut::Hint(hint_action); if (hint.empty() && action->fallback != Action::kCount) - hint = Shortcut::Hint(action->fallback); return hint.empty() ? "-" : hint; } -using UpdateTextFn = void(ShortcutKeybindHint*); - -bool installed = false; -UpdateTextFn* original_update_text = nullptr; +bool badges_installed = false; void ShortcutKeybindHint_UpdateText_Hook(auto original, ShortcutKeybindHint* _this) { - auto* label = _this && installed ? _this->keyTextLocalizer() : nullptr; + auto* label = _this->keyTextLocalizer(); if (!label) return original(_this); @@ -140,18 +135,20 @@ void ShortcutKeybindHint_UpdateText_Hook(auto original, ShortcutKeybindHint* _th void ShortcutKeybindHint_UpdateVisibility_Hook(auto original, ShortcutKeybindHint* _this, bool visible) { original(_this, visible); - if (visible && installed && original_update_text) - ShortcutKeybindHint_UpdateText_Hook(original_update_text, _this); + // A badge that becomes visible keeps the text it had while hidden. Update its text again, which runs the text hook, + // so it shows the current binding. + if (visible) + _this->UpdateText(); } } // namespace bool ToggleKeybindBadges() { - if (!installed) + if (!badges_installed) return false; auto* manager = ShortcutsManager::Instance(); if (!manager) - spdlog::warn("[Hotkeys] native shortcut hints are unavailable"); + spdlog::warn("[Hotkeys] the game's shortcut manager is not up; no badges to toggle"); // The same gate the game's own show_keybindings action checks. else if (ShortcutsManager::CanUseShortcuts()) manager->SetShowKeybindings(!manager->ShowKeybindings()); @@ -160,7 +157,7 @@ bool ToggleKeybindBadges() void HideKeybindBadges() { - auto* manager = installed ? ShortcutsManager::Instance() : nullptr; + auto* manager = badges_installed ? ShortcutsManager::Instance() : nullptr; if (manager && manager->ShowKeybindings()) manager->SetShowKeybindings(false); } @@ -173,11 +170,9 @@ void InstallKeybindBadges() // Only the detour targets gate the feature. Members the badge path reads resolve on first use; a missing one is // reported once and reads as null, which leaves the game's own badge text. - auto update_text = ShortcutKeybindHint::Class::Method("UpdateText", 0); - auto update_visibility = ShortcutKeybindHint::Class::Method("UpdateVisibility", 1); - if (!update_text || !update_visibility) - return; - - original_update_text = IL2CPP_HOOK(update_text, ShortcutKeybindHint_UpdateText_Hook); - installed = original_update_text && IL2CPP_HOOK(update_visibility, ShortcutKeybindHint_UpdateVisibility_Hook); + il2cpp::HookSet hooks("KeybindBadges"); + IL2CPP_STAGE(hooks, ShortcutKeybindHint::Class::Method("UpdateText", 0), ShortcutKeybindHint_UpdateText_Hook); + IL2CPP_STAGE(hooks, ShortcutKeybindHint::Class::Method("UpdateVisibility", 1), + ShortcutKeybindHint_UpdateVisibility_Hook); + badges_installed = hooks.Commit(); } diff --git a/src/mod/features/loading_screens.cc b/src/mod/features/loading_screens.cc index 19a917adf..24a88a9b5 100644 --- a/src/mod/features/loading_screens.cc +++ b/src/mod/features/loading_screens.cc @@ -1,5 +1,5 @@ -// The mod's loading screens: the login background and logos, the transition screen between sections, and the -// loading tips. They share the embedded images and reset together when the game reloads. +// The mod's loading screens, from [graphics] loader_*: the login background and logos, the transition screen between +// sections, and the loading tips. They share the embedded images and reset together when the game reloads. #include "config.h" #include "features/features.h" @@ -49,7 +49,7 @@ unity::ImageAsset* Load(unity::ImageAsset& asset, const uint8_t* data, size_t si { if (asset.Load(data, size)) return &asset; - spdlog::warn("[LS] failed to load {} texture", name); + spdlog::warn("[LoadingScreens] could not load the {} image", name); return nullptr; } @@ -79,7 +79,7 @@ struct Assets { unity::ImageAsset* CommunityLogo() { return Load(community_logo, g_embeddedCCLogoImage, g_embeddedCCLogoImage_SIZE, "community logo"); } }; -// Never destroyed: its GC handles must not be freed by static destructors after the runtime is gone. +// Leaked on purpose: its GC handles must not be released by static destructors after the runtime is gone. Assets& assets = *new Assets; unity::GameObject* CreateOverlay(const char* name, unity::ImageAsset& asset, unity::Transform* parent, @@ -333,7 +333,7 @@ void CustomizeTransition(TransitionViewController* controller) CreateOverlay("STFCModBG", *image, parent, {0.0f, 0.0f}, {1.0f, 1.0f}, false, true) != nullptr; // Keep the game's background unless the replacement is fully attached, so a failure is visible but harmless. if (!transition.overlay) { - spdlog::warn("[LS] custom transition background unavailable; preserving game background"); + spdlog::warn("[TransitionScreen] the custom background is unavailable; keeping the game's"); return; } HideImage(background); @@ -391,36 +391,47 @@ void MonoSingleton_PrepareAllForReload_Hook(auto original) original(); } -// Shared by the login, tip and transition installs, whichever of them run. +// Shared by the login, tip and transition installs, whichever of them run, so it is its own set, installed on the +// first call. void InstallReloadReset() { - static bool installed = false; - if (std::exchange(installed, true)) - return; - IL2CPP_HOOK(MonoSingletonBase::Method("PrepareAllForReload"), MonoSingleton_PrepareAllForReload_Hook); + static const bool installed = [] { + il2cpp::HookSet hooks("LoadingScreens"); + IL2CPP_STAGE(hooks, MonoSingletonBase::Method("PrepareAllForReload"), MonoSingleton_PrepareAllForReload_Hook); + return hooks.Commit(); + }(); + (void)installed; } -void InstallLoginScreen() +bool InstallLoginScreen() { InstallReloadReset(); - IL2CPP_HOOK(LoginSequence::Class::Method("Awake"), LoginSequence_Awake_Hook); + il2cpp::HookSet hooks("LoginScreen"); + IL2CPP_STAGE(hooks, LoginSequence::Class::Method("Awake"), LoginSequence_Awake_Hook); + return hooks.Commit(); } -void InstallTransitionScreen() +bool InstallTransitionScreen() { InstallReloadReset(); - IL2CPP_HOOK(TransitionViewController::Class::Method("Awake"), TransitionViewController_Awake_Hook); - IL2CPP_HOOK(TransitionViewController::Class::Method("AboutToShow"), TransitionViewController_AboutToShow_Hook); - IL2CPP_HOOK(TransitionViewController::Class::Method("AboutToHide"), TransitionViewController_AboutToHide_Hook); - IL2CPP_HOOK(SlideShowViewController::Class::Method("ShowCurrentSlide"), - SlideShowViewController_ShowCurrentSlide_Hook); + il2cpp::HookSet hooks("TransitionScreen"); + IL2CPP_STAGE(hooks, TransitionViewController::Class::Method("Awake"), TransitionViewController_Awake_Hook); + IL2CPP_STAGE(hooks, TransitionViewController::Class::Method("AboutToShow"), + TransitionViewController_AboutToShow_Hook); + IL2CPP_STAGE(hooks, TransitionViewController::Class::Method("AboutToHide"), + TransitionViewController_AboutToHide_Hook); + IL2CPP_STAGE(hooks, SlideShowViewController::Class::Method("ShowCurrentSlide"), + SlideShowViewController_ShowCurrentSlide_Hook); + return hooks.Commit(); } -void InstallLoadingTips() +bool InstallLoadingTips() { InstallReloadReset(); - IL2CPP_HOOK(LoadingTipViewController::Class::Method("SetRandomTipLocalisedText"), - LoadingTipViewController_SetRandomTipLocalisedText_Hook); + il2cpp::HookSet hooks("LoadingTips"); + IL2CPP_STAGE(hooks, LoadingTipViewController::Class::Method("SetRandomTipLocalisedText"), + LoadingTipViewController_SetRandomTipLocalisedText_Hook); + return hooks.Commit(); } } // namespace diff --git a/src/mod/features/missions.cc b/src/mod/features/missions.cc index c2769f5d0..12b1fa612 100644 --- a/src/mod/features/missions.cc +++ b/src/mod/features/missions.cc @@ -1,4 +1,4 @@ -// Missions: the hud_* settings, which force mission HUD buttons always shown or never shown, and +// Missions: the [ui] hud_* settings, which force mission HUD buttons always shown or never shown, and // daily_bulk_claim_factions and daily_bulk_claim_toggle_default_on, which limit the missions screen's Claim All to // the chosen factions' dailies, show only their icons next to the toggle, and can switch the toggle on. @@ -12,10 +12,13 @@ #include #include #include +#include #include #include +#include #include +#include namespace { @@ -38,13 +41,10 @@ constexpr ButtonSetting kButtons[] = { {&Config::Ui::hud_missions, HudButton<"_missionsButton">}, }; -// The game decides visibility here; the hud_* settings are applied on top every time it does. -bool hud_ready = false; - +// The game decides visibility in each hooked path; the hud_* settings are applied on top every time it does. A +// missing path would let the game's choice show through, so the paths install as one set: all of them or none. void ApplyHudVisibility(MissionsHudViewController* _this) { - if (!hud_ready) - return; const auto& ui = Config::Get().ui; for (const auto& button : kButtons) { const auto visibility = ui.*button.visibility; @@ -115,7 +115,7 @@ void ShowClaimedFactionIcons(MissionViewController* controller) if (object) object->SetActive(Claimed(faction.name)); else - spdlog::debug("[DailyFactionBulkClaim] icon '{}' not found", faction.icon); + spdlog::debug("[DailyClaimFilter] no {} icon next to Claim All", faction.icon); } } @@ -150,7 +150,7 @@ DailyGoalList* MissionViewController_GetClaimableDailiesList_Hook(auto original, if (main && !Claimed(faction)) list->RemoveAt(i); } - spdlog::debug("[DailyFactionBulkClaim] claiming {} of {} dailies", list->Count(), total); + spdlog::debug("[DailyClaimFilter] claiming {} of {} dailies", list->Count(), total); return list; } @@ -158,34 +158,37 @@ DailyGoalList* MissionViewController_GetClaimableDailiesList_Hook(auto original, // away, so the frame tick keeps trying while the missions screen is open, for at most kMaxToggleAttempts frames. constexpr int kMaxToggleAttempts = 300; -MissionViewController* shown_controller = nullptr; -bool toggle_done = false; -int toggle_attempts = 0; +// Weak, and checked before use: the screen can be destroyed without AboutToHide. Never destroyed: freeing a GC +// handle in a static destructor can run after the runtime has shut down. +auto& shown_missions_screen = *new il2cpp::GcHandle; +bool toggle_done = false; +int toggle_attempts = 0; void MissionViewController_AboutToShow_Hook(auto original, MissionViewController* _this) { original(_this); ShowClaimedFactionIcons(_this); - shown_controller = _this; + shown_missions_screen = il2cpp::GcHandle::Weak(_this); } void MissionViewController_AboutToHide_Hook(auto original, MissionViewController* _this) { - if (shown_controller == _this) - shown_controller = nullptr; + if (shown_missions_screen.Get() == _this) + shown_missions_screen.Reset(); original(_this); } -void MissionsUpdate() +void DailyClaimFilterFrame() { - if (!shown_controller || toggle_done || !Config::Get().ui.daily_bulk_claim_toggle_default_on) + if (toggle_done || !shown_missions_screen || !Config::Get().ui.daily_bulk_claim_toggle_default_on) return; - auto* toggle = shown_controller->claimAllToggle(); + auto* controller = shown_missions_screen.Get(); + auto* toggle = unity::Alive(controller) ? controller->claimAllToggle() : nullptr; if (!toggle) return; if (!toggle->isOn()) { if (++toggle_attempts > kMaxToggleAttempts) { - spdlog::warn("[DailyFactionBulkClaim] Claim All toggle never became interactable; not switching it on"); + spdlog::warn("[DailyClaimFilter] the Claim All toggle never became usable; leaving it off"); toggle_done = true; return; } @@ -194,40 +197,34 @@ void MissionsUpdate() toggle_done = toggle->isOn(); } -void InstallMissionHud() +bool InstallMissionHud() { 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; + il2cpp::HookSet hooks("MissionHud"); + IL2CPP_STAGE(hooks, Controller::Method("OnEnable"), MissionsHudViewController_OnEnable_Hook); + IL2CPP_STAGE(hooks, Controller::Method("SetupAchievementsButton"), + MissionsHudViewController_SetupAchievementsButton_Hook); + IL2CPP_STAGE(hooks, Controller::Method("SetupChallengesButton"), + MissionsHudViewController_SetupChallengesButton_Hook); + IL2CPP_STAGE(hooks, Controller::Method("SetupOutpostsButton"), + MissionsHudViewController_SetupOutpostsButton_Hook); + IL2CPP_STAGE(hooks, Controller::Method("HandleOutpostsAndChallengesHUD"), + MissionsHudViewController_HandleOutpostsAndChallengesHUD_Hook); + return hooks.Commit(); } -void InstallDailyClaimFilter() +bool InstallDailyClaimFilter() { using Controller = MissionViewController::Class; - IL2CPP_HOOK(Controller::Method("GetClaimableDailiesList", 0), MissionViewController_GetClaimableDailiesList_Hook); - IL2CPP_HOOK(Controller::Method("AboutToShow", 0), MissionViewController_AboutToShow_Hook); - IL2CPP_HOOK(Controller::Method("AboutToHide", 0), MissionViewController_AboutToHide_Hook); + il2cpp::HookSet hooks("DailyClaimFilter"); + IL2CPP_STAGE(hooks, Controller::Method("GetClaimableDailiesList", 0), + MissionViewController_GetClaimableDailiesList_Hook); + IL2CPP_STAGE(hooks, Controller::Method("AboutToShow", 0), MissionViewController_AboutToShow_Hook); + IL2CPP_STAGE(hooks, Controller::Method("AboutToHide", 0), MissionViewController_AboutToHide_Hook); + return hooks.Commit(); } } // namespace const Feature kMissionHud{"MissionHud", &Config::Patches::missionhudtweakshooks, InstallMissionHud}; const Feature kDailyClaimFilter{"DailyClaimFilter", &Config::Patches::dailyfactionbulkclaimhooks, - InstallDailyClaimFilter, MissionsUpdate}; + InstallDailyClaimFilter, DailyClaimFilterFrame}; diff --git a/src/mod/features/officers.cc b/src/mod/features/officers.cc index b28219dac..50928a9d5 100644 --- a/src/mod/features/officers.cc +++ b/src/mod/features/officers.cc @@ -1,9 +1,8 @@ -// officer_sort: brings back the "Below Deck Ability" sort option that client m93.1 removed from the officer roster -// and the officer assignment screen, by appending it to the sort menus after the game builds them. +// [ui] officer_sort: brings back the "Below Deck Ability" sort option that client m93.1 removed from the officer +// roster and the officer assignment screen, by appending it to the sort menus after the game builds them. #include "config.h" #include "features/features.h" -#include "text.h" #include "game/officers.h" @@ -99,7 +98,7 @@ void AddBelowDeckOption(il2cpp::List* options, const char* ke il2cpp::List* sorters = nullptr) { if (!options) { - spdlog::warn("[OfficerSort] sort option list is null"); + spdlog::warn("[OfficerSort] the screen has no sort options; not adding Below Deck Ability"); return; } if (!func_class) { @@ -108,7 +107,8 @@ void AddBelowDeckOption(il2cpp::List* options, const char* ke auto* ascending = sort ? sort->ascending() : nullptr; func_class = ascending ? ascending->klass : nullptr; if (!func_class) { - spdlog::warn("[OfficerSort] could not discover Func class; skipping below-deck option"); + spdlog::warn("[OfficerSort] no existing option shows the Func class; not adding Below " + "Deck Ability"); return; } } @@ -117,7 +117,7 @@ void AddBelowDeckOption(il2cpp::List* options, const char* ke auto* sort = NewSortFunction(key, roster); if (!sort) { - spdlog::warn("[OfficerSort] failed to create sort function"); + spdlog::warn("[OfficerSort] could not create the Below Deck Ability sort function"); return; } if (sorters) @@ -143,17 +143,16 @@ void OfficerSortGenerators_InitializeAssignmentSorters_Hook(auto original, Offic AddBelowDeckOption(_this->assignmentOptions(), kAssignmentKey, false); } -void InstallOfficerSort() +bool InstallOfficerSort() { if (!ResolveComparers()) - return; - auto roster = OfficerSortGenerators::Class::Method("InitializeOfficerSorters", 0); - auto assignment = OfficerSortGenerators::Class::Method("InitializeAssignmentSorters", 0); - if (!roster || !assignment) - return; - IL2CPP_HOOK(roster, OfficerSortGenerators_InitializeOfficerSorters_Hook); - IL2CPP_HOOK(assignment, OfficerSortGenerators_InitializeAssignmentSorters_Hook); - spdlog::info("Officer sort: restored Below Deck Ability sort option"); + return false; + il2cpp::HookSet hooks("OfficerSort"); + IL2CPP_STAGE(hooks, OfficerSortGenerators::Class::Method("InitializeOfficerSorters", 0), + OfficerSortGenerators_InitializeOfficerSorters_Hook); + IL2CPP_STAGE(hooks, OfficerSortGenerators::Class::Method("InitializeAssignmentSorters", 0), + OfficerSortGenerators_InitializeAssignmentSorters_Hook); + return hooks.Commit(); } } // namespace diff --git a/src/mod/features/pinned_ship_click.cc b/src/mod/features/pinned_ship_click.cc deleted file mode 100644 index 0f25c2d2e..000000000 --- a/src/mod/features/pinned_ship_click.cc +++ /dev/null @@ -1,169 +0,0 @@ -#include "config.h" -#include "features/features.h" -#include "file.h" - -#include "text.h" - -#include "game/fleet.h" -#include "game/ui.h" -#include "pinned_ship_click.h" -#include "ship_tile_click.h" - -#include -#include -#include - -#include -#include - -#include -#include -#include -#include -#include -#include - -namespace -{ - -bool IsShiftHeld() -{ return unity::Key::Pressed(unity::KeyCode::LeftShift) || unity::Key::Pressed(unity::KeyCode::RightShift); } - -// Presence of a visible ShipManagementViewController instead of a SectionID -// check: the dock is hosted under more than one game section, and this keeps -// the gate in lockstep with the screen the pinned sort hook drives. -bool IsShipManagementScreenVisible() -{ - for (auto* controller : unity::Instances()) { - if (!controller || !controller->isActiveAndEnabled()) - continue; - - auto canvas = CanvasController::Of(controller); - if (canvas && canvas->Visible()) - return true; - } - return false; -} - -std::string JoinNames(const std::vector>& names) -{ - std::string joined; - for (size_t i = 0; i < names.size(); ++i) { - if (i != 0) - joined += ", "; - joined += fmt::format("{}", fmt::join(names[i], " ")); - } - return joined; -} - -// Update ui.pinned_ships in the user's config file so pins survive a restart. -// If/when we merge the runtime config writer we can route this through that instead of writing the file directly. -void PersistPinnedShips(const std::vector>& names) -{ - const auto joined = JoinNames(names); - - toml::table table; - try { - if (std::filesystem::exists(File::Path(File::Config()))) { - table = toml::parse_file(File::Path(File::Config()).string()); - } - } catch (const std::exception& error) { - spdlog::warn("[PinnedShipClick] could not read '{}' to persist pinned ships: {}", File::Config(), error.what()); - return; - } - - if (!table["ui"].is_table()) { - table.emplace("ui", toml::table{}); - } - if (auto ui = table["ui"].as_table()) { - ui->insert_or_assign("pinned_ships", joined); - } - - std::ofstream file(File::Path(File::Config(), true)); - file << table; - file.close(); - if (!file) - spdlog::warn("[PinnedShipClick] could not persist pinned ships to '{}'", File::Config()); -} - -// Re-runs the pinned sort and rebuilds the dock's tile list through the -// game's own SelectableListBase.ClearAndGenerateContents(IDataContextProvider, -// IList): the controller implements IDataContextProvider and the list binds -// the reordered SortedIdleShips directly. -void RefreshPinnedShipSortNow() -{ - for (auto* controller : unity::Instances()) { - if (!controller || !controller->isActiveAndEnabled()) - continue; - - auto canvas = CanvasController::Of(controller); - if (!canvas || !canvas->Visible()) - continue; - - auto* context = controller->Context(); - if (!context) - continue; - context->Sort(); - - for (auto* widget : unity::Instances()) { - if (!widget || !widget->isActiveAndEnabled()) - continue; - - auto* list = widget->selectableList(); - if (!list) - continue; - - list->Rebuild(controller); - } - } -} - -} // namespace - -void PinShipClickOnTileClicked(FleetPlayerData* ship) -{ - auto& cfg = Config::Get(); - if (!cfg.patches.pinnedshipclicktoggle) - return; - if (!IsShiftHeld()) - return; - if (!IsShipManagementScreenVisible()) - return; - if (!ship) - return; - - const auto key = ShipNameWords(ship->DisplayName()); - if (key.empty()) - return; // placeholder tiles (e.g. "build ship") have no name - - auto& names = cfg.ui.pinned_ships; - - const bool was_pinned = std::find(names.begin(), names.end(), key) != names.end(); - if (was_pinned) { - names.erase(std::find(names.begin(), names.end(), key)); - spdlog::info("[PinnedShipClick] unpinned ship '{}'", fmt::join(key, " ")); - } else { - names.push_back(key); - spdlog::info("[PinnedShipClick] pinned ship '{}'", fmt::join(key, " ")); - } - - PersistPinnedShips(names); - - RefreshPinnedShipSortNow(); -} - -namespace -{ -void InstallPinnedShipClickHooks() -{ - // The HandleOnClick detour is owned by the shared ship-tile click - // dispatcher; installing a second detour is not allowed, especially on macOS. - if (!ship_tile_click::TryInstallHook()) { - spdlog::error("[PinnedShipClick] shared ship tile click hook installation failed"); - } else { - spdlog::info("Pinned ship click: hooks installed"); - } -} -} // namespace - -const Feature kPinnedShipClick{"PinnedShipClick", &Config::Patches::pinnedshipclicktoggle, InstallPinnedShipClickHooks}; diff --git a/src/mod/features/pinned_ship_click.h b/src/mod/features/pinned_ship_click.h deleted file mode 100644 index 361bddaa8..000000000 --- a/src/mod/features/pinned_ship_click.h +++ /dev/null @@ -1,5 +0,0 @@ -#pragma once - -#include "game/fleet.h" - -void PinShipClickOnTileClicked(FleetPlayerData* ship); diff --git a/src/mod/features/queue_address_guard.cc b/src/mod/features/queue_address_guard.cc deleted file mode 100644 index 868da242a..000000000 --- a/src/mod/features/queue_address_guard.cc +++ /dev/null @@ -1,129 +0,0 @@ -#include "features/features.h" -#include "features/queue_address_guard_policy.h" -#include "game/queues.h" -#include -#include -#include -#include -#include -#include - -using namespace queue_bindings::address; - -namespace -{ -using Object = Il2CppObject; -using Id = std::int64_t; -std::atomic_bool ready{false}; - -thread_local bool playerStateEvent{}; -struct AddressContext { - Object* player{}; - Id fleet{}; - bool ownResolved{}; - Object* ownDeployment{}; - Object* target{}; -}; -thread_local AddressContext* context{}; - -bool Enabled() -{ return ready.load() && Config::Get().control.kirshara_fixes && Config::Get().control.queue_enabled; } - -void ActionQueueManager_OnPlayerFleetStateChangedEventHandler_Hook(auto original, Object* manager, Object* fleets) -{ - if (!Enabled()) { - original(manager, fleets); - return; - } - struct EventScope { - bool previous{playerStateEvent}; - EventScope() - { playerStateEvent = true; } - ~EventScope() - { playerStateEvent = previous; } - } event; - original(manager, fleets); -} -bool ActionQueueManager_DoesFleetQueueContainMismatchedAddressTargets_Hook(auto original, Object* manager, - Object* player) -{ - if (!Enabled() || !playerStateEvent || !Is(manager, managerClass) || !Is(player, playerClass)) - return original(manager, player); - const Id fleet = Read(player, idField); - if (!OwnsNonemptyQueue(manager, player, fleet)) - return original(manager, player); - AddressContext evidence{player, fleet}; - struct ContextScope { - AddressContext* previous{context}; - ContextScope(AddressContext& evidence) - { context = &evidence; } - ~ContextScope() - { context = previous; } - } scope(evidence); - return original(manager, player); -} -Object* FleetPlayerData_get_Address_Hook(auto original, Object* player) -{ - auto* result = original(player); - auto* e = context; - if (!Enabled() || !e || player != e->player || !Is(e->ownDeployment, deployedClass) || !Is(e->target, deployedClass)) - return result; - auto* own = e->ownDeployment; - auto* correct = Reference(own, "_address"); - const queue_address_guard::Evidence proof{true, - playerStateEvent, - true, - IsLocalDeployment(own, e->fleet), - PlanningRecall(own), - State(player, "_fleetStateContainer"), - State(own, "_stateContainer"), - Enum(own, "k__BackingField"), - Address(result), - Address(correct), - Address(Reference(e->target, "_address"))}; - // A borrowed current address is returned only during this native comparison. - // No fleet fields, queues, or previous-frame address caches are changed. - return queue_address_guard::UseDeployedAddress(proof) ? correct : result; -} -Object* DeploymentService_GetDeployedFleet_Hook(auto original, Object* service, Id target) -{ - auto* result = original(service, target); - if (Enabled() && context) { - auto* e = context; - e->target = result; - if (!e->ownResolved) { - e->ownResolved = true; - e->ownDeployment = original(service, e->fleet); - } - } - return result; -} -} // namespace - -namespace -{ -void InstallQueueAddressGuard() -{ - if (!ResolveBindings()) - return; - const bool a = IL2CPP_DETOUR(il2cpp::contract::Pointer(methods[0]), - ActionQueueManager_OnPlayerFleetStateChangedEventHandler_Hook, - "ActionQueueManager_OnPlayerFleetStateChangedEventHandler_Hook") - != nullptr; - const bool b = IL2CPP_DETOUR(il2cpp::contract::Pointer(methods[1]), - ActionQueueManager_DoesFleetQueueContainMismatchedAddressTargets_Hook, - "ActionQueueManager_DoesFleetQueueContainMismatchedAddressTargets_Hook") - != nullptr; - const bool c = IL2CPP_DETOUR(il2cpp::contract::Pointer(methods[2]), FleetPlayerData_get_Address_Hook, - "FleetPlayerData_get_Address_Hook") - != nullptr; - const bool d = IL2CPP_DETOUR(il2cpp::contract::Pointer(methods[3]), DeploymentService_GetDeployedFleet_Hook, - "DeploymentService_GetDeployedFleet_Hook") - != nullptr; - ready.store(a && b && c && d); - spdlog::info("[QueueAddressGuard] ready={}", ready.load()); -} -} // namespace - -const Feature kQueueAddressGuard{"QueueAddressGuard", &Config::Patches::queueaddressguardhooks, - InstallQueueAddressGuard}; diff --git a/src/mod/features/queue_address_guard_policy.h b/src/mod/features/queue_address_guard_policy.h deleted file mode 100644 index e77f95729..000000000 --- a/src/mod/features/queue_address_guard_policy.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include - -namespace queue_address_guard -{ -struct Address { - bool valid{}; - std::int64_t galaxy{}, system{}, planet{}; - int instance{}; - bool operator==(const Address&) const = default; -}; - -struct Evidence { - bool enabled{}, player_state_event{}, owned_queue{}, owned_deployment{}, planning_recall{}; - int player_state{-1}, deployed_state{-1}, removal_reason{-1}; - Address reported, deployed, target; -}; - -// Restrict correction to the observed base-address substitution. The deployment -// must independently confirm that this ship and this target are still together. -inline bool UseDeployedAddress(const Evidence& e) -{ - const bool player_active = e.player_state == 1 || e.player_state == 64 || e.player_state == 512; - const bool deployed_active = - e.deployed_state == 0 || e.deployed_state == 1 || e.deployed_state == 3 || e.deployed_state == 6; - return e.enabled && e.player_state_event && e.owned_queue && e.owned_deployment && !e.planning_recall - && e.removal_reason == 0 && player_active && deployed_active && e.reported.valid && e.deployed.valid - && e.target.valid && e.reported.planet > 0 && e.deployed.planet == 0 && e.reported.system != e.deployed.system - && e.reported.galaxy == e.deployed.galaxy && e.reported.instance == e.deployed.instance - && e.deployed == e.target; -} -} // namespace queue_address_guard diff --git a/src/mod/features/reveal_confirmation.cc b/src/mod/features/reveal_confirmation.cc deleted file mode 100644 index b21d73cb6..000000000 --- a/src/mod/features/reveal_confirmation.cc +++ /dev/null @@ -1,49 +0,0 @@ -#include "config.h" -#include "features/features.h" - -#include - -#include "game/fleet.h" -#include "game/ui.h" - -#include - -namespace -{ -// Only the U.S.S. Excelsior's Decloaking Deflector is auto-confirmed. It is identified by its -// ActivatedAbilityType, RevealHostileVger, which no other free ability uses. -bool IsExcelsiorDecloakAbility(int64_t abilityId) -{ - auto* manager = ActivatedAbilityManager::Instance(); - if (manager == nullptr) { - return false; - } - - return manager->GetActivatedAbilityType(abilityId) == ActivatedAbilityManager::RevealHostileVgerAbilityType; -} - -// Digit.Client.UI.MessageUtility.ShowFreeAbilityConfirmationPopup(long abilityId, TimeSpan cooldown, -// Action action). TimeSpan is a single 8-byte value type, so it occupies one argument slot. -void MessageUtility_ShowFreeAbilityConfirmationPopup_Hook(auto original, int64_t abilityId, int64_t cooldownTicks, - Il2CppDelegate* action) -{ - if (Config::Get().ui.auto_confirm_reveal_ability && IsExcelsiorDecloakAbility(abilityId) - && MessageUtility::InvokeActivationAction(action)) { - spdlog::debug("RevealConfirmation: auto-confirmed the Excelsior's decloaking ability {}", abilityId); - return; - } - - original(abilityId, cooldownTicks, action); -} -} // namespace - -namespace -{ -void InstallRevealConfirmationHooks() -{ - IL2CPP_DETOUR(MessageUtility::RevealConfirmationTarget(), MessageUtility_ShowFreeAbilityConfirmationPopup_Hook, - "MessageUtility.ShowFreeAbilityConfirmationPopup"); -} -} // namespace - -const Feature kRevealConfirm{"RevealConfirm", &Config::Patches::revealconfirmhooks, InstallRevealConfirmationHooks}; diff --git a/src/mod/features/screens.cc b/src/mod/features/screens.cc index a58408b49..16fc3c239 100644 --- a/src/mod/features/screens.cc +++ b/src/mod/features/screens.cc @@ -14,10 +14,11 @@ #include "game/ui.h" #include -#include #include #include +#include + namespace { // Ship Swap and Officer Swap live on the Manage Ships screen, and Officer Presets on the officer assignment screen @@ -67,10 +68,12 @@ void ShowBookmarks() void ShowCoordinateSearch() { - if (auto* bookmarks = BookmarksManager::Instance()) - return bookmarks->ViewCoordinateSearch(); - spdlog::warn("[ShowLookup] BookmarksManager instance not available, falling back to main bookmarks"); - Hub::GotoSection(SectionID::Bookmarks_Main); + if (auto* bookmarks = BookmarksManager::Instance()) { + bookmarks->ViewCoordinateSearch(); + } else { + spdlog::warn("[Hotkeys] no bookmarks manager for the coordinate search; opening bookmarks instead"); + Hub::GotoSection(SectionID::Bookmarks_Main); + } } void ShowHaven() @@ -80,9 +83,7 @@ void ShowHaven() template bool PressButton(unity::Button* (*pick)(Controller*)) { for (auto* controller : unity::Instances()) { - auto* button = controller && CanvasController::Shows(controller) && controller->isActiveAndEnabled() - ? pick(controller) - : nullptr; + auto* button = controller->isActiveAndEnabled() && CanvasController::Shows(controller) ? pick(controller) : nullptr; if (button) { button->Press(); return true; @@ -108,15 +109,16 @@ bool PressOfficerPresetsButton() return PressButton( [](OfficerAssignmentViewController* controller) -> unity::Button* { auto* widget = controller->officerPresetsButton(); - return widget ? widget->TheButton() : nullptr; + return widget ? widget->UnityButton() : nullptr; }); } void OpenManageShips() { auto* fleet_bar = unity::Newest(); - if (auto* fleet = fleet_bar ? fleet_bar->SelectedFleet() : nullptr) - fleet_bar->fleetPanelController()->RequestAction(fleet, ActionType::Manage, 0, ActionBehaviour::Default); + auto* fleet = fleet_bar ? fleet_bar->SelectedFleet() : nullptr; + if (auto* panel = fleet ? fleet_bar->fleetPanelController() : nullptr) + panel->RequestAction(fleet, ActionType::Manage, 0, ActionBehaviour::Default); } // Opens Manage Ships for the selected fleet, for a button that is only there. False, with a warning, without one. @@ -124,7 +126,7 @@ bool OpenManageShipsFor(const char* what) { auto* fleet_bar = unity::Newest(); if (!fleet_bar || !fleet_bar->SelectedFleet()) { - spdlog::warn("[Hotkeys] unable to open Manage Ships for {}", what); + spdlog::warn("[Hotkeys] no fleet is selected, so Manage Ships cannot open for {}", what); return false; } OpenManageShips(); @@ -209,9 +211,8 @@ bool StepScreen(bool left) if (config.ui.arrow_keys_to_select_ship && Hub::CurrentSection() == SectionID::ShipManagement_Details) { bool moved = false; for (auto* controller : unity::Instances()) { - auto* context = controller && controller->isActiveAndEnabled() && CanvasController::Shows(controller) - ? controller->Context() - : nullptr; + auto* context = + controller->isActiveAndEnabled() && CanvasController::Shows(controller) ? controller->Context() : nullptr; if (!context) continue; const int32_t count = context->ListCount(); @@ -229,8 +230,7 @@ bool StepScreen(bool left) if (config.ui.arrow_keys_to_select_ship) { bool moved = false; for (auto* widget : unity::Instances()) { - auto* list = widget && CanvasController::Shows(widget) && widget->isActiveAndEnabled() ? widget->selectableList() - : nullptr; + auto* list = widget->isActiveAndEnabled() && CanvasController::Shows(widget) ? widget->selectableList() : nullptr; if (!list || list->Count() <= 0) continue; // Skip empty dock slots. @@ -251,7 +251,7 @@ bool StepScreen(bool left) bool moved = false; for (auto* controller : unity::Instances()) { - if (!controller || !controller->isActiveAndEnabled()) + if (!controller->isActiveAndEnabled()) continue; left ? controller->PressLeftArrow() : controller->PressRightArrow(); moved = true; @@ -260,7 +260,7 @@ bool StepScreen(bool left) return true; for (auto* selector : unity::Instances()) { - if (!selector || !selector->isActiveAndEnabled()) + if (!selector->isActiveAndEnabled()) continue; left ? selector->PressDecrement() : selector->PressIncrement(); moved = true; @@ -272,8 +272,8 @@ bool StepScreen(bool left) bool FocusSearchBox() { const auto focus = [](auto* owner) { - auto* field = owner ? owner->inputField() : nullptr; - if (!field || !CanvasController::Shows(owner) || !owner->isActiveAndEnabled() || !field->isActiveAndEnabled()) + auto* field = owner->inputField(); + if (!field || !owner->isActiveAndEnabled() || !CanvasController::Shows(owner) || !field->isActiveAndEnabled()) return false; field->Focus(); return true; diff --git a/src/mod/features/ship_shortcut_hint.h b/src/mod/features/ship_shortcut_hint.h index e67b63a1b..9d157b214 100644 --- a/src/mod/features/ship_shortcut_hint.h +++ b/src/mod/features/ship_shortcut_hint.h @@ -1,5 +1,7 @@ #pragma once +// Which select-ship shortcut a fleet bar slot's keybind badge shows. Free of game types so tests can drive it. + #include "shortcuts.h" #include diff --git a/src/mod/features/ship_tile_click.cc b/src/mod/features/ship_tile_click.cc deleted file mode 100644 index 540af2963..000000000 --- a/src/mod/features/ship_tile_click.cc +++ /dev/null @@ -1,58 +0,0 @@ -#include "ship_tile_click.h" - -#include "config.h" - -#include "double_click_assign_ship.h" -#include "game/fleet.h" -#include "pinned_ship_click.h" - -#include - -#include -#include -#include - -namespace -{ - -void ShipTileWidget_HandleOnClick_Hook(auto original, ShipTileWidget* _this) -{ - original(_this); - unity::Key::Poll(); - - auto& cfg = Config::Get(); - if (!cfg.ui.double_click_to_assign_ship && !cfg.patches.pinnedshipclicktoggle) - return; - - auto* ship = _this ? _this->Context() : nullptr; - if (!ship) - return; - - PinShipClickOnTileClicked(ship); - DoubleClickOnShipTileClicked(ship); -} - -} // namespace - -namespace ship_tile_click -{ - -bool TryInstallHook() -{ - static bool installed = false; - if (installed) { - return true; - } - - auto method = ShipTileWidget::Class::Method("HandleOnClick", 0); - if (!method) { - return false; - } - installed = IL2CPP_HOOK(method, ShipTileWidget_HandleOnClick_Hook); - if (installed) { - spdlog::info("Ship tile click dispatcher: hooks installed"); - } - return installed; -} - -} // namespace ship_tile_click diff --git a/src/mod/features/ship_tile_click.h b/src/mod/features/ship_tile_click.h deleted file mode 100644 index 072f51a49..000000000 --- a/src/mod/features/ship_tile_click.h +++ /dev/null @@ -1,8 +0,0 @@ -#pragma once - -struct ShipTileWidget; - -namespace ship_tile_click -{ -bool TryInstallHook(); -} // namespace ship_tile_click diff --git a/src/mod/features/shop.cc b/src/mod/features/shop.cc index 87d737a47..376b33501 100644 --- a/src/mod/features/shop.cc +++ b/src/mod/features/shop.cc @@ -1,6 +1,6 @@ -// The shop, gift and inventory popups: auto_open_bulk_claim_flyout on the gifts screen, the chest purchase and -// donation limits, the view button on offers that are cooling down, always_skip_reveal_sequence, and -// disable_exchange_all on the artifact shards popup. +// The shop, gift and inventory popups: [ui] auto_open_bulk_claim_flyout on the gifts screen, the chest purchase and +// donation limits, the view button on offers that are cooling down, always_skip_reveal_sequence, +// only_show_first_popup for ads and offers, and disable_exchange_all on the artifact shards popup. #include "config.h" #include "features/features.h" @@ -17,7 +17,8 @@ namespace { // --- Gifts: open the bulk claim drawer --- -// The gifts screen that is showing, and whether its drawer was opened since it appeared. +// The gifts screen that is showing, compared only and never dereferenced, and whether its drawer was opened since it +// appeared. ShopListScrollerViewController* shown_scroller = nullptr; bool drawer_opened = false; @@ -70,7 +71,7 @@ constexpr int64_t kMaximumChestPurchaseMax = 160; // setter, and adjusting the context rather than the slider leaves every other slider alone. void InventoryUseRowWidget_SetWidgetData_Hook(auto original, InventoryUseRowWidget* _this) { - auto* context = _this ? _this->Context() : nullptr; + auto* context = _this->Context(); const auto& ui = Config::Get().ui; if (context && context->IsChestPurchase()) { const auto native_max = context->MaxItemsToUse(); @@ -95,33 +96,41 @@ void InventoryUseRowWidget_SetWidgetData_Hook(auto original, InventoryUseRowWidg // An offer that is cooling down cannot be bought; its button opens the offer's details instead. void BundleDataWidget_OnActionButtonPressedCallback_Hook(auto original, BundleDataWidget* _this) { - if (_this->CurrentState() & BundleDataWidget::kCooldownTimerOn) - _this->AuxViewButtonPressedHandler(); - else - original(_this); + if (_this->currentState() & BundleDataWidget::kCooldownTimerOn) + return _this->AuxViewButtonPressedHandler(); + original(_this); } // --- Reveal sequence --- -bool ShopSceneManager_ShouldShowRevealSequence_Hook(auto original, void* _this, bool ignore) +bool ShopSceneManager_ShouldShowRevealSequence_Hook(auto original, il2cpp::Object* _this, bool ignore) { const auto result = original(_this, ignore); return result && !Config::Get().ui.always_skip_reveal_sequence; } -void InstallGiftsAutoOpen() +// --- First popup only --- + +bool popup_shown = false; + +// True for every ad or offer popup after the first one this session. +bool SuppressPopup() { - using Scroller = ShopListScrollerViewController::Class; - IL2CPP_HOOK(Scroller::Method("AboutToShow"), ShopListScrollerViewController_AboutToShow_Hook); - IL2CPP_HOOK(Scroller::Method("AboutToHide"), ShopListScrollerViewController_AboutToHide_Hook); - IL2CPP_HOOK(Scroller::Method("UpdateDrawer"), ShopListScrollerViewController_UpdateDrawer_Hook); + if (!Config::Get().ui.only_show_first_popup) + return false; + return std::exchange(popup_shown, true); } -void InstallShopLimits() +void InterstitialViewController_AboutToShow_Hook(auto original, InterstitialViewController* _this) { - IL2CPP_HOOK(InventoryUseRowWidget::Class::Method("SetWidgetData", 0), InventoryUseRowWidget_SetWidgetData_Hook); - IL2CPP_HOOK(BundleDataWidget::Class::Method("OnActionButtonPressedCallback"), - BundleDataWidget_OnActionButtonPressedCallback_Hook); + if (!SuppressPopup()) + original(_this); +} + +void ShopSceneManager_ShowPlcOfferPopup_Hook(auto original, il2cpp::Object* _this) +{ + if (!SuppressPopup()) + original(_this); } // --- Artifact Exchange All --- @@ -130,45 +139,51 @@ void InstallShopLimits() void InventoryUsePopupViewController_OnDidBindCanvasContext_Hook(auto original, InventoryUsePopupViewController* _this) { original(_this); - if (!_this || !Config::Get().ui.disable_exchange_all) + if (!Config::Get().ui.disable_exchange_all) return; auto* button = _this->convertAllButton(); if (auto* object = button ? button->gameObject() : nullptr) object->SetActive(false); } -bool popup_shown = false; - -bool SuppressPopup() +bool InstallGiftsAutoOpen() { - if (!Config::Get().ui.only_show_first_popup) - return false; - return std::exchange(popup_shown, true); -} - -void InterstitialViewController_AboutToShow_Hook(auto original, void* _this) -{ - if (!SuppressPopup()) - original(_this); + using Scroller = ShopListScrollerViewController::Class; + il2cpp::HookSet hooks("GiftsAutoOpen"); + IL2CPP_STAGE(hooks, Scroller::Method("AboutToShow"), ShopListScrollerViewController_AboutToShow_Hook); + IL2CPP_STAGE(hooks, Scroller::Method("AboutToHide"), ShopListScrollerViewController_AboutToHide_Hook); + IL2CPP_STAGE(hooks, Scroller::Method("UpdateDrawer"), ShopListScrollerViewController_UpdateDrawer_Hook); + return hooks.Commit(); } -void ShopSceneManager_ShowPlcOfferPopup_Hook(auto original, void* _this) +bool InstallShopLimits() { - if (!SuppressPopup()) - original(_this); + il2cpp::HookSet hooks("ShopLimits"); + IL2CPP_STAGE(hooks, InventoryUseRowWidget::Class::Method("SetWidgetData", 0), + InventoryUseRowWidget_SetWidgetData_Hook); + IL2CPP_STAGE(hooks, BundleDataWidget::Class::Method("OnActionButtonPressedCallback"), + BundleDataWidget_OnActionButtonPressedCallback_Hook); + return hooks.Commit(); } -void InstallSkipRevealSequence() +// The reveal sequence and the first-popup limit share the crash-fix group they were introduced with. +bool InstallSkipRevealSequence() { - 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); + il2cpp::HookSet hooks("SkipRevealSequence"); + IL2CPP_STAGE(hooks, ShopSceneManager::Method("ShouldShowRevealSequence"), + ShopSceneManager_ShouldShowRevealSequence_Hook); + IL2CPP_STAGE(hooks, ShopSceneManager::Method("ShowPlcOfferPopup", 0), ShopSceneManager_ShowPlcOfferPopup_Hook); + IL2CPP_STAGE(hooks, InterstitialViewController::Class::Method("AboutToShow", 0), + InterstitialViewController_AboutToShow_Hook); + return hooks.Commit(); } -void InstallArtifactExchange() +bool InstallArtifactExchange() { - IL2CPP_HOOK(InventoryUsePopupViewController::Class::Method("OnDidBindCanvasContext", 0), - InventoryUsePopupViewController_OnDidBindCanvasContext_Hook); + il2cpp::HookSet hooks("ArtifactExchange"); + IL2CPP_STAGE(hooks, InventoryUsePopupViewController::Class::Method("OnDidBindCanvasContext", 0), + InventoryUsePopupViewController_OnDidBindCanvasContext_Hook); + return hooks.Commit(); } } // namespace diff --git a/src/mod/features/thin_queue_protection.cc b/src/mod/features/thin_queue_protection.cc deleted file mode 100644 index 41766efe1..000000000 --- a/src/mod/features/thin_queue_protection.cc +++ /dev/null @@ -1,141 +0,0 @@ -#include "features/action_queue_guard_policy.h" -#include "features/features.h" -#include - -// Port of the active v2.1.0-guffa.10 guard, not the dormant completion repair. -// Shared 64-bit managed layout; incompatible runtime contracts leave the feature unavailable. -#include "game/queues.h" -#include -#include -#include -#include -#include -#include -#include - -using namespace queue_bindings::thin; - -namespace -{ -using Object = Il2CppObject; -using action_queue_guard::QueueState; -std::atomic_bool ready{false}; - -using Id = std::int64_t; - -bool Enabled() -{ return ready.load() && Config::Get().control.kirshara_fixes && Config::Get().control.queue_enabled; } - -bool Resume(Object* manager, Object* queue, Object* player, bool idle, const QueueState& before, - const QueueState& after, const char* source) -{ - if (!action_queue_guard::IsNativePruneResumeCandidate(Enabled(), idle, before, after)) - return false; - const auto confirmed = Snapshot(queue); - if (!action_queue_guard::IsStableResumePostcondition(after, confirmed)) - return false; - // Call the existing native engage entry, including the Faster Queue Recovery detour. - // Never edit queue contents, engagement flags, retry counts or the native watchdog. - const auto result = tryEngage(manager, player, queue); - spdlog::info("[ThinQueueProtection] resume source={} fleet={} old_head={} head={} count={} result={}", source, - confirmed.player_fleet_id, before.head_target_id, confirmed.head_target_id, confirmed.count, result); - return result == action_queue_guard::kEngageResultSuccess; -} - -bool ActionQueueManager_DoPlanPathAndEngageTarget_Hook(auto original, Object* manager, Object* player) -{ - if (!Enabled() || !Is(player, playerClass)) - return original(manager, player); - const auto id = playerId(player); - auto* queue = FindQueue(manager, id); - const auto before = Snapshot(queue); - const bool result = original(manager, player); - // Session/queue replacement is not evidence that native pruning removed a prefix. - if (FindQueue(manager, id) != queue) - return result; - const auto after = Snapshot(queue); - const bool idle = !result && before.player_fleet_id == id && playerState(player) == 1; - return Resume(manager, queue, player, idle, before, after, "planner") || result; -} - -void ActionQueueManager_HandleStall_Hook(auto original, Object* manager, Object* queue, Object* player, - Object* deployed) -{ - if (!Enabled() || !Is(player, playerClass) || !Is(deployed, deployedClass)) { - original(manager, queue, player, deployed); - return; - } - const auto before = Snapshot(queue); - original(manager, queue, player, deployed); - if (FindQueue(manager, before.player_fleet_id) != queue) - return; - const auto after = Snapshot(queue); - const bool idle = before.player_fleet_id == playerId(player) && before.player_fleet_id == deployedId(deployed) - && playerState(player) == 1 && deployedState(deployed) == 0 && !destroyed(deployed) - && !battling(deployed); - Resume(manager, queue, player, idle, before, after, "watchdog"); -} - -void ActionQueueManager_OnFleetsDisposedEventHandler_Hook(auto original, Object* manager, Object* fleets) -{ - std::array targets{}; - unsigned captured{}; - if (Enabled()) { - int count{}; - if (auto* items = List(fleets, deployedClass, 4096, count)) { - for (int i = 0; i < std::min(count, static_cast(targets.size())); ++i) { - auto* fleet = static_cast(items->vector[i]); - if (Is(fleet, deployedClass) && destroyed(fleet) && removalReason(fleet) == 1) { - const auto id = deployedId(fleet); - if (id && std::find(targets.begin(), targets.begin() + captured, id) == targets.begin() + captured) - targets[captured++] = id; - } - } - } - } - original(manager, fleets); - for (unsigned i = 0; i < captured && Enabled(); ++i) { - const auto target = targets[i]; - auto* queue = FindQueue(manager, 0, target); - const auto before = Snapshot(queue); - if (!action_queue_guard::ShouldProcessDestroyedHead(true, true, target, before)) - continue; - if (FindQueue(manager, 0, target) != queue) - continue; - const auto confirmed = Snapshot(queue); - if (!action_queue_guard::ShouldProcessDestroyedHead(true, true, target, confirmed) - || confirmed.player_fleet_id != before.player_fleet_id || confirmed.count != before.count) - continue; - processTarget(manager, target, false); - spdlog::info("[ThinQueueProtection] process-destroyed-head fleet={} target={}", before.player_fleet_id, target); - } -} - -} // namespace - -namespace -{ -void InstallThinQueueProtection() -{ - if (!ResolveBindings()) - return; - const bool a = IL2CPP_DETOUR(plan, ActionQueueManager_DoPlanPathAndEngageTarget_Hook, - "ActionQueueManager_DoPlanPathAndEngageTarget_Hook") - != nullptr; - const bool b = - IL2CPP_DETOUR(stall, ActionQueueManager_HandleStall_Hook, "ActionQueueManager_HandleStall_Hook") != nullptr; - const bool c = IL2CPP_DETOUR(disposed, ActionQueueManager_OnFleetsDisposedEventHandler_Hook, - "ActionQueueManager_OnFleetsDisposedEventHandler_Hook") - != nullptr; - for (const auto& [installed, name] : std::array{std::pair{a, "ActionQueueManager.DoPlanPathAndEngageTarget"}, - std::pair{b, "ActionQueueManager.HandleStall"}, - std::pair{c, "ActionQueueManager.OnFleetsDisposedEventHandler"}}) - if (!installed) - spdlog::warn("[ThinQueueProtection] hook installation failed: {}; feature unavailable", name); - ready.store(a && b && c); - spdlog::info("[ThinQueueProtection] ready={}", ready.load()); -} -} // namespace - -const Feature kThinQueueProtection{"ThinQueueProtection", &Config::Patches::thinqueueprotectionhooks, - InstallThinQueueProtection}; diff --git a/src/mod/features/toasts.cc b/src/mod/features/toasts.cc index cad2f70a3..1627c49f0 100644 --- a/src/mod/features/toasts.cc +++ b/src/mod/features/toasts.cc @@ -1,5 +1,5 @@ -// Toast banners: hides the types listed in disabled_banner_types and mirrors the ones in notify_banner_types to -// Windows notifications, with a readable body for battle results. +// Toast banners: [ui] disabled_banner_types hides banner types, [ui] notify_banner_types mirrors them to Windows +// notifications with a readable body for battle results, and [audio] alert_* plays a cue for battle results. #include "config.h" #include "features/features.h" @@ -190,7 +190,7 @@ std::string BattleBody(BattleResultHeader* header) enemy = std::move(name); } }); - Guarded("the battle type", [&] { pvp = Contains(kPvpBattles, header->BattleType()); }); + Guarded("the battle type", [&] { pvp = Contains(kPvpBattles, header->Type()); }); Guarded("the player hull", [&] { player_ship = toast_text::TitleCase(HullName(header, header->PlayerShipHullId())); }); Guarded("the enemy hull", [&] { enemy_ship = toast_text::TitleCase(HullName(header, header->EnemyShipHullId())); }); @@ -239,9 +239,8 @@ std::vector ParameterValues(il2cpp::Array* parameter for (il2cpp_array_size_t i = 0; parameters && i < parameters->size(); ++i) { auto* element = (*parameters)[i]; Il2CppString* text = nullptr; - if (element) { + if (element) Guarded("a text parameter", [&] { text = il2cpp::ToString(static_cast(element)); }); - } values.push_back(il2cpp::ToUtf8(text)); } return values; @@ -253,7 +252,7 @@ std::string FormatToastText(const std::string& text, Toast* toast) if (auto* parameters = toast->TextParameters(); parameters && parameters->size() > 0) return FormatPlaceholders(text, ParameterValues(parameters)); if (text.find('{') != std::string::npos) - spdlog::warn("[Notify] TextParameters unavailable; placeholders unresolved in template: \"{}\"", text); + spdlog::warn("[Toasts] TextParameters unavailable; placeholders unresolved in template: \"{}\"", text); return text; } @@ -262,7 +261,7 @@ std::string ToastText(Toast* toast) { auto* context = toast->TextLocaleTextContext(); if (!context) { - spdlog::debug("[Notify] No TextLocaleTextContext for toast state {}", static_cast(toast->State())); + spdlog::debug("[Toasts] No TextLocaleTextContext for toast state {}", static_cast(toast->State())); return {}; } @@ -275,7 +274,7 @@ std::string ToastText(Toast* toast) Il2CppString* text = nullptr; auto* manager = LanguageManager::Instance(); if (!manager || !manager->Localize(text, context) || !text) { - spdlog::debug("[Notify] toast state {} could not be localized", static_cast(toast->State())); + spdlog::debug("[Toasts] toast state {} could not be localized", static_cast(toast->State())); return {}; } return FormatToastText(il2cpp::ToUtf8(text), toast); @@ -319,10 +318,10 @@ void ShowWindowsNotification(const char* title, const std::string& body) try { winrt::init_apartment(winrt::apartment_type::single_threaded); } catch (const winrt::hresult_error& e) { - spdlog::warn("[Notify] Windows notification service failed: {:#x} {}", static_cast(e.code()), + spdlog::warn("[Toasts] Windows notification service failed: {:#x} {}", static_cast(e.code()), winrt::to_string(e.message())); } catch (...) { - spdlog::warn("[Notify] Windows notification service failed (unknown error)"); + spdlog::warn("[Toasts] Windows notification service failed (unknown error)"); } return true; }(); @@ -335,9 +334,9 @@ void ShowWindowsNotification(const char* title, const std::string& body) nodes.Item(1).InnerText(winrt::to_hstring(body)); ToastNotificationManager::CreateToastNotifier(L"Star Trek Fleet Command").Show(ToastNotification(xml)); } catch (const winrt::hresult_error& e) { - spdlog::warn("[Notify] WinRT notification failed: {}", winrt::to_string(e.message())); + spdlog::warn("[Toasts] WinRT notification failed: {}", winrt::to_string(e.message())); } catch (...) { - spdlog::warn("[Notify] WinRT notification failed (unknown error)"); + spdlog::warn("[Toasts] WinRT notification failed (unknown error)"); } } #endif @@ -352,7 +351,7 @@ void Notify(Toast* toast) return; const auto title = std::ranges::find(kTitles, state, &std::pair::first); if (title == std::end(kTitles)) { - spdlog::debug("[Notify] No title mapping for toast state {}, skipping", static_cast(state)); + spdlog::debug("[Toasts] No title mapping for toast state {}, skipping", static_cast(state)); return; } ShowWindowsNotification(title->second, NotificationBody(toast)); @@ -405,11 +404,12 @@ void ToastObserver_EnqueueOrCombineToast_Hook(auto original, ToastObserver* _thi original(_this, toast, compare); } -void InstallToasts() +bool InstallToasts() { - - IL2CPP_HOOK(ToastObserver::Class::Method("EnqueueToast"), ToastObserver_EnqueueToast_Hook); - IL2CPP_HOOK(ToastObserver::Class::Method("EnqueueOrCombineToast"), ToastObserver_EnqueueOrCombineToast_Hook); + il2cpp::HookSet hooks("Toasts"); + IL2CPP_STAGE(hooks, ToastObserver::Class::Method("EnqueueToast"), ToastObserver_EnqueueToast_Hook); + IL2CPP_STAGE(hooks, ToastObserver::Class::Method("EnqueueOrCombineToast"), ToastObserver_EnqueueOrCombineToast_Hook); + return hooks.Commit(); } } // namespace diff --git a/src/mod/features/tracked_objects.cc b/src/mod/features/tracked_objects.cc index c1bab45bd..3c35b84f2 100644 --- a/src/mod/features/tracked_objects.cc +++ b/src/mod/features/tracked_objects.cc @@ -15,18 +15,19 @@ namespace { -void Install() +bool InstallObjectTracker() { - unity::Track(); - unity::Track(); - unity::Track(); unity::Track(); unity::Track(); unity::Track(); + // Pooled: bound and released rather than created and destroyed. unity::Track("OnDidBindCanvasContext", "OnAboutToReleaseCanvasContext"); unity::Track(); unity::Track(); + unity::Track(); unity::Track(); + unity::Track(); + unity::Track(); unity::Track(); unity::Track(); unity::Track(); @@ -34,11 +35,13 @@ void Install() unity::Track(); unity::Track(); unity::Track(); - unity::Track(); - unity::Track(); + unity::Track(); unity::Track(); + unity::Track(); unity::Track(); + // Each class is a set of its own and reports itself; the tracker serves whichever classes went in. + return true; } } // namespace -const Feature kObjectTracker{"ObjectTracker", &Config::Patches::objecttracker, Install}; +const Feature kObjectTracker{"ObjectTracker", &Config::Patches::objecttracker, InstallObjectTracker}; diff --git a/src/mod/features/ui_scale.cc b/src/mod/features/ui_scale.cc index bdf800a9b..fba61b1ad 100644 --- a/src/mod/features/ui_scale.cc +++ b/src/mod/features/ui_scale.cc @@ -1,8 +1,8 @@ -// UI, object viewer, and system ship scaling, and the hotkeys that adjust them at runtime. +// [graphics] ui_scale, ui_scale_viewer and ui_scale_ship: the interface, object viewer and system ship scales, and the +// shortcuts that adjust them during play. #include "config.h" #include "features/features.h" -#include "text.h" #include "game/fleet.h" #include "game/ui.h" @@ -36,7 +36,7 @@ void RescaleLoadedShips(float old_multiplier, float new_multiplier) const auto ratio = new_multiplier / old_multiplier; for (auto* selector : unity::Instances()) { - auto* object = selector ? selector->LoadedObject() : nullptr; + auto* object = selector->LoadedObject(); auto* transform = object ? object->transform() : nullptr; if (!transform) continue; @@ -45,7 +45,7 @@ void RescaleLoadedShips(float old_multiplier, float new_multiplier) } } -bool SystemViewShip_TryGetShipAssetBundleResource_Hook(auto original, void* _this, float* scale, +bool SystemViewShip_TryGetShipAssetBundleResource_Hook(auto original, il2cpp::Object* _this, float* scale, void** asset_bundle_resource) { const auto result = original(_this, scale, asset_bundle_resource); @@ -58,6 +58,7 @@ void ScreenManager_UpdateCanvasRootScaleFactor_Hook(auto original, ScreenManager { original(_this); + // allow_cursor = false, as in window.cc: rescaling canvases makes the game set its own cursor again. #if _WIN32 static auto cursor = LoadCursor(nullptr, IDC_ARROW); if (!Config::Get().graphics.allow_cursor) @@ -74,27 +75,27 @@ void ScreenManager_UpdateCanvasRootScaleFactor_Hook(auto original, ScreenManager scaler->SetScaleFactor(factor); } -void CanvasController_Show_Hook(auto original, CanvasController* _this, int desiredEntryPoint, bool instant) +void CanvasController_Show_Hook(auto original, CanvasController* _this, int32_t entry_point, bool instant) { const auto viewer_scale = Config::Get().graphics.ui_scale_viewer; - if (_this && viewer_scale != 0.0f && il2cpp::Equals(_this->name(), "ObjectViewerTemplate_Canvas")) { + if (viewer_scale != 0.0f && il2cpp::Equals(_this->name(), "ObjectViewerTemplate_Canvas")) if (auto* transform = _this->transform()) transform->SetLocalScale({viewer_scale, viewer_scale, viewer_scale}); - } - return original(_this, desiredEntryPoint, instant); + original(_this, entry_point, instant); } -void InstallUiScale() +bool InstallUiScale() { - IL2CPP_HOOK(SystemViewShip::Method("TryGetShipAssetBundleResource", 2), - SystemViewShip_TryGetShipAssetBundleResource_Hook); - - IL2CPP_HOOK(ScreenManager::Class::Method("UpdateCanvasRootScaleFactor"), - ScreenManager_UpdateCanvasRootScaleFactor_Hook); - - IL2CPP_HOOK(CanvasController::Class::Method("Show", {"System.Int32", "System.Boolean"}), CanvasController_Show_Hook); - + il2cpp::HookSet hooks("UiScale"); + IL2CPP_STAGE(hooks, SystemViewShip::Method("TryGetShipAssetBundleResource", 2), + SystemViewShip_TryGetShipAssetBundleResource_Hook); + IL2CPP_STAGE(hooks, ScreenManager::Class::Method("UpdateCanvasRootScaleFactor"), + ScreenManager_UpdateCanvasRootScaleFactor_Hook); + IL2CPP_STAGE(hooks, CanvasController::Class::Method("Show", {"System.Int32", "System.Boolean"}), + CanvasController_Show_Hook); + const bool installed = hooks.Commit(); WindowDpiScale(true); + return installed; } } // namespace @@ -107,8 +108,8 @@ void AdjustUiScale(bool up) const auto old_scale = config.graphics.ui_scale; const auto new_scale = old_scale + (up ? 1.0f : -1.0f) * config.graphics.ui_scale_step; config.graphics.ui_scale = std::clamp(new_scale, 0.1f, 2.0f); - spdlog::info("UI has been scaled {}, was {}, now {} (unclamped {}) @ {} DPI Scaling", up ? "UP" : "DOWN", old_scale, - config.graphics.ui_scale, new_scale, WindowDpiScale(true)); + spdlog::info("[UiScale] interface scale {} -> {} (asked {}), DPI scale {}", old_scale, config.graphics.ui_scale, + new_scale, WindowDpiScale(true)); } void AdjustUiViewerScale(bool up) @@ -121,8 +122,7 @@ void AdjustUiViewerScale(bool up) const auto old_scale = config.graphics.ui_scale_viewer; const auto new_scale = old_scale + (up ? 1.0f : -1.0f) * config.graphics.ui_scale_step * 0.25f; config.graphics.ui_scale_viewer = std::clamp(new_scale, 0.1f, 2.0f); - spdlog::info("UI Viewer has been scaled {}, was {}, now {} (unclamped {})", up ? "UP" : "DOWN", old_scale, - config.graphics.ui_scale_viewer, new_scale); + spdlog::info("[UiScale] viewer scale {} -> {} (asked {})", old_scale, config.graphics.ui_scale_viewer, new_scale); } void AdjustUiShipScale(bool up) @@ -131,8 +131,7 @@ void AdjustUiShipScale(bool up) const auto old_scale = config.graphics.ui_scale_ship; const auto new_scale = old_scale + (up ? 1.0f : -1.0f) * config.graphics.ui_scale_step; config.graphics.ui_scale_ship = std::clamp(new_scale, 0.1f, 20.0f); - spdlog::info("System ship models have been scaled {}, was {}, now {} (unclamped {})", up ? "UP" : "DOWN", old_scale, - config.graphics.ui_scale_ship, new_scale); + spdlog::info("[UiScale] ship scale {} -> {} (asked {})", old_scale, config.graphics.ui_scale_ship, new_scale); RescaleLoadedShips(old_scale, config.graphics.ui_scale_ship); } diff --git a/src/mod/features/window.cc b/src/mod/features/window.cc index f7017b1d4..5750b44f3 100644 --- a/src/mod/features/window.cc +++ b/src/mod/features/window.cc @@ -1,21 +1,22 @@ -// The game window on Windows: free resizing, the -ccm profile in the title bar, allow_cursor, and the monitor's DPI -// scale. +// The game window on Windows: [graphics] free_resize, the -ccm profile in the title bar, [graphics] allow_cursor, and +// the monitor's DPI scale. #include "features/features.h" #if _WIN32 #include "config.h" #include "file.h" + #include "game/app.h" -#include #include #include - -#include +#include #include +#include + namespace { // The first visible, unowned top-level window of this process. Found once it exists, then cached. @@ -58,12 +59,13 @@ bool ApplyProfileTitle() return SetWindowTextW(window, (L"[" + prefix + L"] " + title).c_str()) != 0; } -// TODO: the old hook also stretched a 1024x768 fullscreen fallback to the monitor, but only once a window -// handle captured by a SetWindowLongW detour was set, and that detour was never installed. Bring it back -// with a tested trigger if the fallback is still seen. // With free_resize the original is skipped: the game's per-frame aspect-ratio enforcement is what would snap the // window back. -void AspectRatioConstraintHandler_Update_Hook(auto original, void* _this) +// +// TODO: the old hook also stretched a 1024x768 fullscreen fallback to the monitor, but only once a window handle +// captured by a SetWindowLongW detour was set, and that detour was never installed. Bring it back with a tested +// trigger if the fallback is still seen. +void AspectRatioConstraintHandler_Update_Hook(auto original, il2cpp::Object* _this) { static bool title_pending = !File::Profile().empty(); if (title_pending && ApplyProfileTitle()) @@ -72,41 +74,47 @@ void AspectRatioConstraintHandler_Update_Hook(auto original, void* _this) original(_this); } -// allow_cursor = false: the plain arrow, never clipped to the window. -void Cursor_SetCursor_Hook(auto original, void* _this, ptrdiff_t texture, unity::Vector2* hotspot, int mode) +// allow_cursor = false: the plain arrow, never clipped to the window. SetCursor_Injected is static; the trailing +// MethodInfo is passed through unchanged. The UI scale hook applies the same cursor when canvases rescale, since the +// game sets its own cursor again then. +void Cursor_SetCursor_Injected_Hook(auto original, intptr_t texture, const unity::Vector2* hotspot, int32_t mode, + const MethodInfo* method) { if (Config::Get().graphics.allow_cursor) - return original(_this, texture, hotspot, mode); - SetCursor(LoadCursor(NULL, IDC_ARROW)); + return original(texture, hotspot, mode, method); + SetCursor(LoadCursor(nullptr, IDC_ARROW)); ClipCursor(nullptr); } -intptr_t AspectRatioConstraintHandler_WndProc_Hook(auto original, HWND hWnd, uint32_t msg, intptr_t wParam, - intptr_t lParam) +intptr_t AspectRatioConstraintHandler_WndProc_Hook(auto original, HWND window, uint32_t message, intptr_t w_param, + intptr_t l_param) { // Skip the game's aspect-ratio clamp and hand the message straight to Unity. The message arrives in the form of // the window's current procedure (the game's, installed through its own P/Invoke), and the A and W calls each // convert from their own form; the wrong one garbles non-ASCII text such as chat input. if (Config::Get().graphics.free_resize) { - const auto forward = IsWindowUnicode(hWnd) ? CallWindowProcW : CallWindowProcA; - return forward(reinterpret_cast(AspectRatioConstraintHandler::unityWndProc()), hWnd, msg, wParam, lParam); + const auto forward = IsWindowUnicode(window) ? CallWindowProcW : CallWindowProcA; + return forward(reinterpret_cast(AspectRatioConstraintHandler::unityWndProc()), window, message, w_param, + l_param); } - return original(hWnd, msg, wParam, lParam); + return original(window, message, w_param, l_param); } -void InstallFreeResize() +bool InstallFreeResize() { - auto update = AspectRatioConstraintHandler::Class::Method("Update"); - auto wndproc = AspectRatioConstraintHandler::Class::Method("WndProc"); - if (!update || !wndproc) - return; - - IL2CPP_HOOK(update, AspectRatioConstraintHandler_Update_Hook); - IL2CPP_HOOK(wndproc, AspectRatioConstraintHandler_WndProc_Hook); + il2cpp::HookSet hooks("FreeResize"); + IL2CPP_STAGE(hooks, AspectRatioConstraintHandler::Class::Method("Update"), AspectRatioConstraintHandler_Update_Hook); + IL2CPP_STAGE(hooks, AspectRatioConstraintHandler::Class::Method("WndProc"), + AspectRatioConstraintHandler_WndProc_Hook); + return hooks.Commit(); } -void InstallCursor() -{ IL2CPP_HOOK(unity::Cursor::Method("SetCursor_Injected"), Cursor_SetCursor_Hook); } +bool InstallCursor() +{ + il2cpp::HookSet hooks("Cursor"); + IL2CPP_STAGE(hooks, unity::Cursor::Method("SetCursor_Injected"), Cursor_SetCursor_Injected_Hook); + return hooks.Commit(); +} } // namespace float WindowDpiScale(bool refresh) diff --git a/src/mod/game/app.h b/src/mod/game/app.h index 1a2b442e6..93ca40f10 100644 --- a/src/mod/game/app.h +++ b/src/mod/game/app.h @@ -172,6 +172,18 @@ struct SectionManager : il2cpp::Object { } }; +// Unity singletons expose a static Instance on their MonoSingleton`1 base. +template struct MonoSingleton : il2cpp::Object { + static T* Instance() + { return T::Class::template Get<"Instance", T*>(); } + // The singleton s_instance already holds, or null. Instance can search the scene or create a replacement. + static T* Existing() + { return T::Class::template Field<"s_instance", T*>(); } +}; + +// The non-generic MonoSingleton base; its static PrepareAllForReload runs before the game reloads. +using MonoSingletonBase = il2cpp::Class<"Assembly-CSharp", "", "MonoSingleton">; + // Digit.Client.Core.CustomReloadArgs and Nullable, laid out as the runtime does. struct CustomReloadArgs { Il2CppObject* SectionArgs; diff --git a/src/mod/game/camera.h b/src/mod/game/camera.h new file mode 100644 index 000000000..44b116ebb --- /dev/null +++ b/src/mod/game/camera.h @@ -0,0 +1,112 @@ +#pragma once + +// The game's camera controller (Digit.Client.CameraController): frame providers, the orbits and blends between them, +// and the constraints that bound them. Used by the system, station interior and Haven cameras alike. + +#include +#include + +// Constraint/FloatData: an optional [min, max] range. +struct FloatConstraint : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "Constraint/FloatData">; + + bool enabled() + { return Field<"_enabled", bool>(); } + float min() + { return Field<"_min", float>(); } + float max() + { return Field<"_max", float>(); } + void SetMin(float value) + { SetField<"_min">(value); } + void SetMax(float value) + { SetField<"_max">(value); } +}; + +struct OrbitConstraint : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "OrbitConstraint">; + + FloatConstraint* radius() + { return Field<"_radius", FloatConstraint*>(); } +}; + +struct TargetController : unity::Behaviour { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "AbstractTargetController">; +}; + +struct CameraFrame : 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 farPlane() + { return Field<"_farPlane", float>(); } + bool orthographic() + { return Field<"_orthographic", bool>(); } + void SetPosition(unity::Vector3 value) + { SetField<"_position">(value); } + void SetFarPlane(float value) + { SetField<"_farPlane">(value); } +}; + +struct EditableCameraFrame : CameraFrame { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "EditableCameraFrame">; +}; + +struct FrameProvider : unity::Behaviour { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "FrameProvider">; +}; + +// An orbit around a pivot, at a radius its constraint bounds. +struct AbstractOrbitFrameProvider : FrameProvider { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "AbstractOrbitFrameProvider">; + + OrbitConstraint* constraint() + { return Field<"_constraint", OrbitConstraint*>(); } + float radius() + { return Field<"_radius", float>(); } + TargetController* orbitPivot() + { return Field<"_orbitPivot", TargetController*>(); } + TargetController* lookTarget() + { return Field<"_lookTarget", TargetController*>(); } + float rotationAngleDelta() + { return Field<"_rotationAngleDelta", float>(); } + void SetRotationAngleDelta(float value) + { SetField<"_rotationAngleDelta">(value); } +}; + +// The system view's camera, and the far end of the station interior's blend. +struct OrbitFrameProvider : AbstractOrbitFrameProvider { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "OrbitFrameProvider">; +}; + +struct PlanetaryOrbitFrameProvider : OrbitFrameProvider { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "PlanetaryOrbitFrameProvider">; +}; + +// Blends two providers' frames by a ratio the zoom drives, within soft limits. +struct BlendFrameProvider : FrameProvider { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "BlendFrameProvider">; + + FrameProvider* sourceFrameProvider() + { return Field<"_sourceFrameProvider", FrameProvider*>(); } + FrameProvider* targetFrameProvider() + { return Field<"_targetFrameProvider", FrameProvider*>(); } + unity::AnimationCurve* blendCurve() + { return Field<"_blendCurve", unity::AnimationCurve*>(); } + float softLimitMin() + { return Field<"_softLimitMin", float>(); } + float softLimitMax() + { return Field<"_softLimitMax", float>(); } + float blendRatio() + { return Field<"_blendRatio", float>(); } + CameraFrame* sourceFrame() + { return Field<"_sourceFrame", CameraFrame*>(); } + CameraFrame* targetFrame() + { return Field<"_targetFrame", CameraFrame*>(); } + EditableCameraFrame* blendedCameraFrame() + { return Field<"_blendedCameraFrame", EditableCameraFrame*>(); } +}; diff --git a/src/mod/game/chat.h b/src/mod/game/chat.h index e28338d9f..badd18ee5 100644 --- a/src/mod/game/chat.h +++ b/src/mod/game/chat.h @@ -86,16 +86,21 @@ struct FullScreenChatViewController : il2cpp::Object { { return Field<"_categoriesTabBarViewController", TabBarViewController*>(); } }; -struct PlayerTitleWidget : il2cpp::Object { +struct PlayerTitleWidget : unity::Component { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.PlayerAvatars", "PlayerTitleWidget">; }; -struct UserProfileWidget : il2cpp::Object { + +// A player's name, avatar and title, on chat rows and other screens. +struct UserProfileWidget : unity::Component { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.PlayerProfile", "UserProfileWidget">; + PlayerTitleWidget* playerTitleWidget() { return Field<"_playerTitleWidget", PlayerTitleWidget*>(); } }; -struct ChatMessageWidget : il2cpp::Object { + +struct ChatMessageWidget : unity::Component { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Chat", "ChatMessageWidget">; + UserProfileWidget* userProfileWidget() { return Field<"_userProfileWidget", UserProfileWidget*>(); } }; diff --git a/src/mod/game/fleet.h b/src/mod/game/fleet.h index fe5084a80..319b2fd34 100644 --- a/src/mod/game/fleet.h +++ b/src/mod/game/fleet.h @@ -23,6 +23,46 @@ enum class DeployedFleetType : int32_t { Challenge, }; +enum class DeployedFleetState : int32_t { + Idle = 0, + Moving = 1, + Warping = 2, + Battling = 3, + Recall = 4, + Docked = 5, + PreBattle = 6, +}; + +enum class FleetRemovalReason : int32_t { + None = 0, + Destroyed = 1, + ExitedActiveSystem = 2, + Recalled = 3, + Hazard = 4, + Summoned = 5, +}; + +// A location in the galaxy, a protobuf message. +struct NodeAddress : il2cpp::Object { + using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "NodeAddress">; + + int64_t Galaxy() + { return Get<"Galaxy", int64_t>(); } + int64_t System() + { return Get<"System", int64_t>(); } + int64_t Planet() + { return Get<"Planet", int64_t>(); } + int32_t Instance() + { return Get<"Instance", int32_t>(); } +}; + +// IDeployedFleet: the server's model behind a deployed fleet. Its class varies (DeployedFleet, DeployedFleetJSON), +// so members resolve on the runtime class. +struct DeployedFleetModel : il2cpp::Object { + int64_t FleetId() + { return Get<"FleetId", int64_t>(); } +}; + struct FleetDeployedData : il2cpp::Object { using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData">; @@ -31,6 +71,25 @@ struct FleetDeployedData : il2cpp::Object { // Nonexistent when the property is missing. DeployedFleetType FleetType() { return Get<"FleetType", DeployedFleetType>(); } + int64_t Id() + { return Get<"ID", int64_t>(); } + DeployedFleetState CurrentState() + { return Get<"CurrentState", DeployedFleetState>(); } + FleetRemovalReason RemovalReason() + { return Get<"RemovalReason", FleetRemovalReason>(); } + bool IsDestroyed() + { return Get<"IsDestroyed", bool>(); } + bool CurrentlyBattling() + { return Get<"CurrentlyBattling", bool>(); } + bool IsPlanningRecallCourse() + { return Get<"IsPlanningRecallCourse", bool>(); } + // True for the player's own fleets. + bool localPlayerFleet() + { return Field<"_localPlayerFleet", bool>(); } + NodeAddress* address() + { return Field<"_address", NodeAddress*>(); } + DeployedFleetModel* deploymentFleet() + { return Field<"_deploymentFleet", DeployedFleetModel*>(); } }; struct BattleTargetData : il2cpp::Object { @@ -158,54 +217,18 @@ struct FleetPlayerData : il2cpp::Object { { return Get<"CurrentState", FleetState>(); } FleetState PreviousState() { return Get<"PreviousState", FleetState>(); } - uint64_t Id() - { return Get<"Id", uint64_t>(); } + int64_t Id() + { return Get<"Id", int64_t>(); } + NodeAddress* Address() + { return Get<"Address", NodeAddress*>(); } int64_t Level() { return Get<"Level", int64_t>(); } bool HasShip() { return Get<"HasShip", bool>(); } int64_t HullId() { return Get<"HullId", int64_t>(); } - bool HasAppliedTech() - { - struct BaseShip : il2cpp::Object { - using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "BaseShip">; - int64_t Id() - { return Get<"Id", int64_t>(); } - }; - struct Slot : il2cpp::Object { - using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "EntitySlot">; - int64_t SpecId() - { return Get<"SlotSpecId", int64_t>(); } - bool HasItem() - { - static const auto* getter = Class::Method("get_SlotItemId", 0).Info(); - if (!getter || (getter->flags & METHOD_ATTRIBUTE_STATIC) - || getter->return_type->type != IL2CPP_TYPE_GENERICINST) - return false; - // Runtime invocation boxes Nullable as null or its value, without assuming its native layout. - return il2cpp::detail::InvokeBoxed(getter, this, nullptr) != nullptr; - } - }; - struct Manager : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ForbiddenTechs", "ForbiddenTechManager">; - bool ActiveSlots(int64_t ship_id, il2cpp::List*& slots) - { return Call<"TryGetActiveForbiddenTechSlot", bool>(ship_id, std::ref(slots)); } - }; - auto* ship = Get<"Ship", BaseShip*>(); - auto* manager = Manager::Class::Get<"Instance", Manager*>(); - il2cpp::List* slots = nullptr; - if (!ship || !manager || !manager->ActiveSlots(ship->Id(), slots) || !slots) - return false; - for (auto* slot : *slots) { - if (!slot) - continue; - const auto spec = slot->SpecId(); - if ((spec == 3502081615 || spec == 953301906) && slot->HasItem()) - return true; - } - return false; - } + // A forbidden tech or chaos tech item is fitted to the ship. + bool HasAppliedTech(); // Live state, read for fleet sync. bool IsHome() @@ -250,7 +273,69 @@ struct FleetPlayerData : il2cpp::Object { { return LocalizedShipName(Hull() ? GetLocaId() : 0); } }; -using IActionData = void*; +// The hull a fleet flies. +struct BaseShip : il2cpp::Object { + using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "BaseShip">; + + int64_t Id() + { return Get<"Id", int64_t>(); } +}; + +// An item slot on a ship, such as its forbidden tech slot. +struct EntitySlot : il2cpp::Object { + using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "EntitySlot">; + + // The slot specs of a ship's forbidden tech and chaos tech slots, as the game's ship tech indicators read them. + static constexpr int64_t kForbiddenTechSlot = 3502081615; + static constexpr int64_t kChaosTechSlot = 953301906; + + int64_t SlotSpecId() + { return Get<"SlotSpecId", int64_t>(); } + // Empty when nothing is fitted. + il2cpp::Nullable SlotItemId() + { return Get<"SlotItemId", il2cpp::Nullable>(); } + ForbiddenTechSlotParams* forbiddenTechSlotParams() + { return Get<"ForbiddenTechSlotParams", ForbiddenTechSlotParams*>(); } +}; + +struct ForbiddenTechManager : MonoSingleton { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ForbiddenTechs", "ForbiddenTechManager">; + + bool TryGetActiveForbiddenTechSlot(int64_t ship_id, il2cpp::List*& slots) + { return Call<"TryGetActiveForbiddenTechSlot", bool>(ship_id, std::ref(slots)); } + bool TryGetEntitySlotFromForbiddenTech(int64_t tech_id, EntitySlot*& slot) + { return Call<"TryGetEntitySlotFromForbiddenTech", bool>(tech_id, std::ref(slot)); } + + // CachedService: a struct around the service, empty until something asks for it. + struct CachedService { + FleetService* cached_service; + }; + + // The fleet service. Until the cache holds it, the cache's Service getter looks it up and keeps it. + FleetService* fleetService() + { + if (auto* service = Field<"_fleetServiceCache", CachedService>().cached_service) + return service; + return CallOnField<"_fleetServiceCache", CachedService, "get_Service", FleetService*>(); + } +}; + +inline bool FleetPlayerData::HasAppliedTech() +{ + auto* ship = Get<"Ship", BaseShip*>(); + auto* manager = ForbiddenTechManager::Instance(); + il2cpp::List* slots = nullptr; + if (!ship || !manager || !manager->TryGetActiveForbiddenTechSlot(ship->Id(), slots) || !slots) + return false; + for (auto* slot : *slots) { + if (!slot) + continue; + const auto spec = slot->SlotSpecId(); + if ((spec == EntitySlot::kForbiddenTechSlot || spec == EntitySlot::kChaosTechSlot) && slot->SlotItemId().has_value) + return true; + } + return false; +} // Digit.PrimeServer.Models.ActionType is a struct around a flag value, not an enum, so it crosses the // managed boundary as a 4-byte struct. @@ -277,14 +362,11 @@ enum class ActionBehaviour : int32_t { RequestConfirmation = 32, }; -struct AbilityTimeSpan { - int64_t ticks; -}; - -struct AbilityTimerDataContext : il2cpp::Object { - using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "TimerDataContext">; - AbilityTimeSpan RemainingTime() - { return Get<"RemainingTime", AbilityTimeSpan>(); } +// Digit.PrimeServer.Models.TimerDataContext, read on its runtime class: the mod only needs the remaining time, and +// the class has moved between client builds. +struct TimerDataContext : il2cpp::Object { + unity::TimeSpan RemainingTime() + { return Get<"RemainingTime", unity::TimeSpan>(); } }; struct ShipActionPanelLocalViewController : il2cpp::Object { @@ -293,8 +375,8 @@ struct ShipActionPanelLocalViewController : il2cpp::Object { { return Field<"_abilityIDs", il2cpp::Array*>(); } il2cpp::Array* buttons() { return Field<"_buttons", il2cpp::Array*>(); } - AbilityTimerDataContext* GetRemainingCooldown(int64_t abilityId) - { return Call<"GetRemainingCooldown", AbilityTimerDataContext*>(abilityId); } + TimerDataContext* GetRemainingCooldown(int64_t abilityId) + { return Call<"GetRemainingCooldown", TimerDataContext*>(abilityId); } bool HasShipAnActiveAbilityWithActiveEffect() { return Call<"HasShipAnActiveAbilityWithActiveEffect", bool>(); } }; @@ -311,7 +393,8 @@ struct FleetLocalViewController : il2cpp::Object { { Call<"CancelButtonClicked">(); } // callback is an Action; null is accepted. - bool RequestAction(IActionData target, ActionType type, int64_t index, ActionBehaviour behaviourMask, + // `target` is the IActionData the action is on, such as a FleetPlayerData. + bool RequestAction(il2cpp::Object* target, ActionType type, int64_t index, ActionBehaviour behaviourMask, void* callback = nullptr) { return Call<"RequestAction", bool>(target, type, index, behaviourMask, callback); } }; @@ -346,32 +429,23 @@ struct FleetBarViewController : il2cpp::Object { }; struct FleetsManager : MonoSingleton { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.FleetManagement", "FleetsManager">; + + // CachedService: a struct around the service, empty until something asks for it. + struct CachedService { + il2cpp::Object* cached_service; + }; + + // The fleet service is up. Until the cache holds it, the cache's HasService looks it up and keeps it. bool HasFleetService() { - static FieldInfo* const field = - Class::Raw() ? il2cpp_class_get_field_from_name(Class::Raw(), "_fleetServiceCache") : nullptr; - static const MethodInfo* const has_service = - field ? il2cpp_class_get_method_from_name(il2cpp_class_from_type(il2cpp_field_get_type(field)), - "get_HasService", 0) - : nullptr; - if (!has_service) { - return false; - } - - auto* cache = reinterpret_cast(this) + il2cpp_field_get_offset(field); - if (*reinterpret_cast(cache)) { + if (Field<"_fleetServiceCache", CachedService>().cached_service) return true; - } - - Il2CppException* exception = nullptr; - auto* result = il2cpp_runtime_invoke(has_service, cache, nullptr, &exception); - return !exception && result && *static_cast(il2cpp_object_unbox(result)); + return CallOnField<"_fleetServiceCache", CachedService, "get_HasService", bool>(); } FleetPlayerData* GetFleetPlayerData(int32_t index) { return Call<"GetFleetPlayerData", FleetPlayerData*>(index); } - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.FleetManagement", "FleetsManager">; - void RequestViewFleet(FleetPlayerData* fleetData, bool showSystemInfo = false) { Call<"RequestViewFleet">(fleetData, showSystemInfo); } @@ -379,35 +453,6 @@ struct FleetsManager : MonoSingleton { { return Call<"GetSelectedFleetData", FleetPlayerData*>(); } }; -struct ForbiddenTechManager : MonoSingleton { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ForbiddenTechs", "ForbiddenTechManager">; - - bool TryGetEntitySlotFromForbiddenTech(int64_t tech_id, EntitySlot*& slot) - { return Call<"TryGetEntitySlotFromForbiddenTech", bool>(tech_id, std::ref(slot)); } - - // _fleetServiceCache is a CachedService struct whose Service getter resolves the service lazily. The - // getter runs on a copy, so the manager's own cache is left as it was; the runtime takes the struct receiver - // unboxed. - FleetService* fleetService() - { - struct CachedService { - FleetService* service; - }; - auto cache = Field<"_fleetServiceCache", CachedService>(); - if (cache.service) - return cache.service; - static FieldInfo* const field = - Class::Raw() ? il2cpp_class_get_field_from_name(Class::Raw(), "_fleetServiceCache") : nullptr; - static const MethodInfo* const getter = - field ? il2cpp_class_get_method_from_name(il2cpp_class_from_type(il2cpp_field_get_type(field)), "get_Service", - 0) - : nullptr; - if (!getter) - return nullptr; - return reinterpret_cast(il2cpp::detail::InvokeBoxed(getter, &cache, nullptr)); - } -}; - struct DeploymentManager : MonoSingleton { using Class = il2cpp::Class<"Assembly-CSharp", "", "DeploymentManager">; @@ -415,17 +460,6 @@ struct DeploymentManager : MonoSingleton { { return Call<"HasInstantWarpAbility", bool>(selectedFleet); } }; -struct ActionQueueManager : MonoSingleton { - using Class = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager">; - - void ClearQueue(FleetPlayerData* playerData) - { Call<"ClearQueue">(playerData); } - bool IsQueueFull(FleetPlayerData* playerData) - { return Call<"IsQueueFull", bool>(playerData); } - bool IsQueueUnlocked() - { return Call<"IsQueueUnlocked", bool>(); } -}; - struct FleetMeshSelector : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "FleetMeshSelector">; @@ -462,34 +496,28 @@ struct ShipManagementViewContext : il2cpp::Object { { return Field<"SortedIdleShips", il2cpp::List*>(); } }; -struct ShipManagementViewController : il2cpp::Object { +struct ShipManagementViewController : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Ships", "ShipManagementViewController">; // m_context lives on the generic ViewController`1 base; field lookup walks the parents. ShipManagementViewContext* Context() { return Field<"m_context", ShipManagementViewContext*>(); } - bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } }; -struct ShipManagementScreenStateViewController : il2cpp::Object { +struct ShipManagementScreenStateViewController : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Ships", "ShipManagementScreenStateViewController">; - bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } unity::Button* officerIconButton() { return Field<"_officerIconButton", unity::Button*>(); } unity::Button* swapShipButton() { return Field<"_swapShipButton", unity::Button*>(); } }; -struct AssignShipsWidget : il2cpp::Object { +struct AssignShipsWidget : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Ships", "AssignShipsWidget">; InputFieldWidget* inputField() { return Field<"_inputField", InputFieldWidget*>(); } - bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } ButtonAndContextWrapper* assignButton() { return Field<"_assignButton", ButtonAndContextWrapper*>(); } SelectableList* selectableList() @@ -522,8 +550,10 @@ struct PlanetaryBaseManager : MonoSingleton { struct ActivatedAbilityManager : MonoSingleton { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ActivatedAbilities", "ActivatedAbilityManager">; - static constexpr int32_t RevealHostileVgerAbilityType = 86002; - int32_t GetActivatedAbilityType(int64_t abilityId) + // ActivatedAbilityType.RevealHostileVger. + static constexpr int32_t kRevealHostileVger = 86002; + + int32_t GetActivatedAbilityType(int64_t abilityId) { return Call<"GetActivatedAbilityType", int32_t>(abilityId); } bool CanUseAbility(FleetPlayerData* fleet, int64_t abilityId) { return Call<"CanUseAbility", bool>(fleet, abilityId); } diff --git a/src/mod/game/haven.h b/src/mod/game/haven.h index 749ca1334..f5fa0d460 100644 --- a/src/mod/game/haven.h +++ b/src/mod/game/haven.h @@ -1,123 +1,11 @@ #pragma once -// Haven's camera providers, blend frames and help history. +// Haven (the planetary base): its help history. Haven's camera uses the bindings in camera.h. + #include "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/src/mod/game/hud.h b/src/mod/game/hud.h index f9df1e4e8..c15661080 100644 --- a/src/mod/game/hud.h +++ b/src/mod/game/hud.h @@ -3,6 +3,7 @@ // The HUD around the space views: the mission buttons, the faction and system panel, the alliance drawer, and the // galactic anomaly the viewed system may have. +#include "fleet.h" #include "ui.h" #include @@ -26,12 +27,8 @@ struct FactionInformation : il2cpp::Object { { return Field<"_systemName", il2cpp::List*>(); } }; -// Timer data contexts, galactic anomalies and navigation view data are read on their runtime classes: the mod only -// needs a few members, and these classes have moved between client builds. -struct TimerDataContext : il2cpp::Object { - unity::TimeSpan RemainingTime() - { return Get<"RemainingTime", unity::TimeSpan>(); } -}; +// Galactic anomalies and navigation view data are read on their runtime classes: the mod only needs a few members, +// and these classes have moved between client builds. // Digit.PrimeServer.Models.GalacticAnomaly struct GalacticAnomaly : il2cpp::Object { @@ -41,17 +38,12 @@ struct GalacticAnomaly : il2cpp::Object { { return Get<"EndTimeTimerDataContext", TimerDataContext*>(); } }; -struct NodeAddress : il2cpp::Object { - int64_t System() - { return Get<"System", int64_t>(); } -}; - struct NavigationViewData : il2cpp::Object { NodeAddress* ViewingAddress() { return Get<"ViewingAddress", NodeAddress*>(); } }; -struct HudNavigationViewController : unity::Component { +struct HudNavigationViewController : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation.UI", "HudNavigationViewController">; // The data context its canvas is registered with, for ScreenManager::View. using Context = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "NavigationViewData">; @@ -60,8 +52,6 @@ struct HudNavigationViewController : unity::Component { { return Field<"_galacticAnomaliesButtonWidget", unity::Component*>(); } FactionInformation* factionInformationHelper() { return Field<"_factionInformationHelper", FactionInformation*>(); } - bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } bool CanShowGalacticAnomaly() { return Call<"CanShowGalacticAnomaly", bool>(); } NavigationViewData* CanvasContext() diff --git a/src/mod/game/inventory.h b/src/mod/game/inventory.h index 0af6fd59e..889025d12 100644 --- a/src/mod/game/inventory.h +++ b/src/mod/game/inventory.h @@ -44,13 +44,11 @@ struct InventoryUsePopupViewController : il2cpp::Object { }; // Also the base of the officer roster and the other inventory-style lists. -struct InventoryListViewController : il2cpp::Object { +struct InventoryListViewController : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Inventories", "InventoryListViewController">; InputFieldWidget* inputField() { return Field<"_inputField", InputFieldWidget*>(); } - bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } }; struct Bundle : il2cpp::Object { @@ -67,7 +65,7 @@ struct BundleDataWidget : Widget { // ItemState flags; only the one the mod reads is named. static constexpr int32_t kCooldownTimerOn = 16384; - int32_t CurrentState() + int32_t currentState() { return Field<"_currentState", int32_t>(); } void AuxViewButtonPressedHandler() { Call<"AuxViewButtonPressedHandler">(); } @@ -289,37 +287,7 @@ ShopListScrollerViewController* BundleDataWidget::scrollerController() InventoryTooltipTriggerWidget* BundleDataWidget::invTooltip() { return Field<"_invTooltip", InventoryTooltipTriggerWidget*>(); } -// Whether the client still has every field the store reads or writes. The store swaps tooltip components through -// these, so it does not install where one would read as zero. -inline bool AllianceGamesStoreFieldsPresent() -{ - using Widget = InventoryTooltipTriggerWidget::Class; - using Trigger = TooltipTrigger::Class; - using Scroller = SmartScrollerBase::Class; - using Card = BundleDataWidget::Class; - return Widget::HasField<"_tooltipTrigger", TooltipTrigger*>() - & Widget::HasField<"_tooltipActionTrigger", TooltipActionTrigger*>() - & Widget::HasField<"_searchActionLocaleSetting", ActionLocaleSetting*>() - & Widget::HasField<"_actionLocaleSetting", ActionLocaleSetting*>() - & Widget::HasField<"_settings", ActionLocaleSettingsList*>() - & Trigger::HasField<"_showHeader", OverridableBool>() - & Trigger::HasField<"_showDescription", OverridableBool>() & Trigger::HasField<"_header", LocaleTextContext*>() - & Trigger::HasField<"_description", LocaleTextContext*>() & Trigger::HasField<"_overrideLocale", bool>() - & Trigger::HasField<"_anchorOverride", int32_t>() & Trigger::HasField<"_sizeOverride", unity::Vector2>() - & Trigger::HasField<"_positionOverrideOffset", unity::Vector2>() - & Trigger::HasField<"_positionOverrideTarget", unity::Transform*>() - & Trigger::HasField<"_isInteractable", bool>() - & Trigger::HasField<"k__BackingField", Il2CppString*>() - & Trigger::HasField<"_loader", TooltipLoadAndShow*>() - & ShopListScrollerViewController::Class::HasField<"_smartScroller", il2cpp::Object*>() - & Scroller::HasField<"_spawnedItems", il2cpp::List*>() - & Card::HasField<"_invTooltip", InventoryTooltipTriggerWidget*>() - & Card::HasField<"_scrollerController", ShopListScrollerViewController*>() - & Scroller::HasField<"_regeneratingContent", bool>() - & Scroller::HasField<"_spawnerCoroutine", il2cpp::Object*>() - & Scroller::HasField<"_dataHeldWhilstInitializing", il2cpp::Object*>(); -} - +// Shows the shop's reveal sequences and offer popups. Only hooked. using ShopSceneManager = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Shop", "ShopSceneManager">; // The forbidden tech inventory tiles and the large selected-item panel. diff --git a/src/mod/game/native_hook_extent.cc b/src/mod/game/native_hook_extent.cc deleted file mode 100644 index aeddfd088..000000000 --- a/src/mod/game/native_hook_extent.cc +++ /dev/null @@ -1,131 +0,0 @@ -#include "game/native_hook_extent.h" - -#if __APPLE__ -#include -#include -#include -#include -#include -#include -#include -#include - -namespace -{ -// LC_FUNCTION_STARTS records ULEB128 deltas from __TEXT. Require an exact -// entry and a following entry: the last function has no proven extent here. -size_t FunctionExtent(const void* method) -{ - Dl_info info{}; - if (!method || !dladdr(method, &info) || !info.dli_fbase) - return 0; - const auto* header = static_cast(info.dli_fbase); - if (header->magic != MH_MAGIC_64) - return 0; - const auto* cursor = reinterpret_cast(header + 1); - const auto* end = cursor + header->sizeofcmds; - const segment_command_64* text = nullptr; - const segment_command_64* linkedit = nullptr; - const linkedit_data_command* starts = nullptr; - for (uint32_t i = 0; i < header->ncmds; ++i) { - if (size_t(end - cursor) < sizeof(load_command)) - return 0; - const auto* command = reinterpret_cast(cursor); - if (command->cmdsize < sizeof(load_command) || command->cmdsize > size_t(end - cursor)) - return 0; - if (command->cmd == LC_SEGMENT_64 && command->cmdsize >= sizeof(segment_command_64)) { - const auto* segment = reinterpret_cast(command); - if (std::strncmp(segment->segname, "__TEXT", 16) == 0) - text = segment; - if (std::strncmp(segment->segname, "__LINKEDIT", 16) == 0) - linkedit = segment; - } - if (command->cmd == LC_FUNCTION_STARTS && command->cmdsize >= sizeof(linkedit_data_command)) - starts = reinterpret_cast(command); - cursor += command->cmdsize; - } - if (!text || !linkedit || !starts || starts->dataoff < linkedit->fileoff) - return 0; - const uint64_t offset = starts->dataoff - linkedit->fileoff; - if (offset > linkedit->filesize || starts->datasize > linkedit->filesize - offset || offset > linkedit->vmsize - || starts->datasize > linkedit->vmsize - offset) - return 0; - const uintptr_t base = reinterpret_cast(header); - const uintptr_t target = reinterpret_cast(method); - if (target < base || target - base >= text->vmsize) - return 0; - const auto slide = base - text->vmaddr; - cursor = reinterpret_cast(slide + linkedit->vmaddr + offset); - end = cursor + starts->datasize; - uint64_t address = 0; - bool found = false; - while (cursor < end) { - uint64_t delta = 0; - unsigned shift = 0; - uint8_t byte; - do { - if (cursor == end || shift >= 64) - return 0; - byte = *cursor++; - if (shift == 63 && (byte & 0x7e)) - return 0; - delta |= uint64_t(byte & 0x7f) << shift; - shift += 7; - } while (byte & 0x80); - if (!delta || delta > text->vmsize - address) - return 0; - address += delta; - if (found) - return address - (target - base); - if (address > target - base) - return 0; - found = address == target - base; - } - return 0; -} - -} // namespace -#endif - -bool native_hooks::MacHookFits(const void* method, std::size_t minimum_extent) -{ -#if __APPLE__ - const auto extent = FunctionExtent(method); - bool fits = false; - if (extent >= std::max(minimum_extent, std::size_t{32})) { - const auto plan = spud::inspect_detour(method, spud::Arch::kHost, extent); - fits = plan.ok() && plan.resolved_target == reinterpret_cast(method) && plan.far_jump_size != 0 - && plan.far_required_patch_bytes >= plan.far_jump_size && plan.far_required_patch_bytes <= extent; - } - spdlog::info("[MacHookExtent] entry={} bytes={} accepted={}", method, extent, fits); - return fits; -#else - (void)method; - (void)minimum_extent; - return false; -#endif -} - -#if defined(__APPLE__) && defined(__aarch64__) -const void* native_hooks::MacFollowDirectBranch(const void* method) -{ - if (!method) { - return nullptr; - } - const auto entry = reinterpret_cast(method); - const uint32_t instruction = *reinterpret_cast(entry); - if ((instruction & 0xFC000000u) != 0x14000000u) { - return nullptr; - } - const uint32_t imm64 = static_cast(instruction & 0x03FFFFFFu); - const int32_t imm26 = static_cast(imm64 | (instruction & 0x02000000u ? 0xFC000000u : 0u)); - const intptr_t offset = static_cast(imm26) * 4; - const auto resolved = entry + offset; - Dl_info info{}; - if (!dladdr(reinterpret_cast(resolved), &info) || !info.dli_fbase - || reinterpret_cast(info.dli_fbase) == resolved) { - return nullptr; - } - return reinterpret_cast(resolved); -} -#endif diff --git a/src/mod/game/native_hook_extent.h b/src/mod/game/native_hook_extent.h deleted file mode 100644 index 89d4e3154..000000000 --- a/src/mod/game/native_hook_extent.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once -#include - -namespace native_hooks -{ -// Validates the entry in the loaded image, before SPUD changes any bytes. -// Unsupported images/architectures fail closed. -// Small, exact-entry callbacks may request a lower floor; the host decoder -// still requires the complete SPUD overwrite window without an early exit. -bool MacHookFits(const void* method, std::size_t minimum_extent = 64); -#if defined(__APPLE__) && defined(__aarch64__) -const void* MacFollowDirectBranch(const void* method); -#endif -} // namespace native_hooks diff --git a/src/mod/game/navigation.h b/src/mod/game/navigation.h index 310662234..2aa310e17 100644 --- a/src/mod/game/navigation.h +++ b/src/mod/game/navigation.h @@ -10,7 +10,7 @@ #include -enum class ZoomLevels { +enum class ZoomLevels : int32_t { None = 0, Near = 1, Middle = 2, @@ -132,7 +132,8 @@ struct NavigationManager : il2cpp::Object { { Call<"HideInteraction">(); } }; -struct NavigationSectionManager : il2cpp::Object { +// A plain MonoBehaviour with its own static Instance, not a MonoSingleton. +struct NavigationSectionManager : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "NavigationSectionManager">; static void ChangeNavigationSection(SectionID sectionId, int64_t systemId = -1) @@ -141,15 +142,15 @@ struct NavigationSectionManager : il2cpp::Object { static NavigationSectionManager* Instance() { return Class::Get<"Instance", NavigationSectionManager*>(); } - // Not NavigationManager(): that would hide the type inside this struct. - NavigationManager* SNavigationManager() - { return Get<"NavigationManager", NavigationManager*>(); } + // Named after the property, so the type needs `::` here. + ::NavigationManager* NavigationManager() + { return Get<"NavigationManager", ::NavigationManager*>(); } // Closes whatever interaction menu is open in space before the mod acts on a fleet. static void HideInteraction() { auto* section = Instance(); - if (auto* navigation = section ? section->SNavigationManager() : nullptr) + if (auto* navigation = section ? section->NavigationManager() : nullptr) navigation->HideInteraction(); } }; @@ -158,12 +159,11 @@ struct NavigationInteractionUIContext : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "NavigationInteractionUIContext">; }; -struct SetCourseWidget : il2cpp::Object { +struct SetCourseWidget : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "SetCourseWidget">; + VisibilityController* visibilityController() { return Field<"_visibilityController", VisibilityController*>(); } - bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } SemaphoreButtonListener* joinWaveDefenseButton() { return Field<"_joinWaveDefenseButton", SemaphoreButtonListener*>(); } }; @@ -190,24 +190,12 @@ struct NavigationInteractionUIViewController : il2cpp::Object { { auto* widget = Field<"_setCourseWidget", SetCourseWidget*>(); auto* visibility = widget ? widget->visibilityController() : nullptr; - return visibility - && (visibility->state() == VisibilityState::Visible || visibility->state() == VisibilityState::Show) - && widget->isActiveAndEnabled(); + return visibility && visibility->IsShown() && widget->isActiveAndEnabled(); } void OnSetCourseButtonClick() { Call<"OnSetCourseButtonClick">(); } }; -struct OrbitFrameProvider : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "OrbitFrameProvider">; - - // Declared on AbstractOrbitFrameProvider. - float RotationAngleDelta() - { return Field<"_rotationAngleDelta", float>(); } - void SetRotationAngleDelta(float value) - { SetField<"_rotationAngleDelta">(value); } -}; - struct PopulatedSystemData : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "PopulatedSystemData">; Il2CppObject* VariationCollection() @@ -217,7 +205,7 @@ struct PopulatedSystemData : il2cpp::Object { struct PlanetViewUtils : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "PlanetViewUtils">; - PopulatedSystemData* Population() + PopulatedSystemData* popData() { return Field<"_popData", PopulatedSystemData*>(); } // The planet's 2D sprite, a Component. Calling this also runs any detour on get_FlatRenderable. @@ -264,8 +252,9 @@ struct CoursePromptPopupDataContext : il2cpp::Object { bool HasInstantWarp() { return Field<"HasInstantWarp", bool>(); } - CourseData* GetCourseData() - { return Field<"CourseData", CourseData*>(); } + // A public field, named after it, so the type needs `::` here. + ::CourseData* CourseData() + { return Field<"CourseData", ::CourseData*>(); } }; struct CoursePromptPopupWidget : Widget { @@ -280,7 +269,7 @@ struct CoursePromptPopupWidget : Widget { struct CoursePromptPopupViewController : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ObjectViewer", "CoursePromptPopupViewController">; - CoursePromptPopupWidget* PopupWidget() + CoursePromptPopupWidget* coursePromptPopupWidget() { return Field<"_coursePromptPopupWidget", CoursePromptPopupWidget*>(); } }; @@ -293,27 +282,3 @@ struct TKTouch : il2cpp::Object { void SetPhase(unity::TouchPhase phase) { SetField<"phase">(phase); } }; - -struct InteriorRadiusConstraint : 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>(); } - void SetMinimum(float value) - { SetField<"_min">(value); } - void SetMaximum(float value) - { SetField<"_max">(value); } -}; -struct InteriorOrbitConstraint : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "OrbitConstraint">; - InteriorRadiusConstraint* Radius() - { return Field<"_radius", InteriorRadiusConstraint*>(); } -}; -struct InteriorOrbitProvider : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.CameraController", "AbstractOrbitFrameProvider">; - InteriorOrbitConstraint* Constraint() - { return Field<"_constraint", InteriorOrbitConstraint*>(); } -}; diff --git a/src/mod/game/officers.h b/src/mod/game/officers.h index 168c233f4..f9d53cf1b 100644 --- a/src/mod/game/officers.h +++ b/src/mod/game/officers.h @@ -6,18 +6,16 @@ #include -struct OfficerAssignmentViewController : il2cpp::Object { +struct OfficerAssignmentViewController : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.OfficerAssignment", "OfficerAssignmentViewController">; InputFieldWidget* inputField() { return Field<"_inputField", InputFieldWidget*>(); } GenericButtonWidget* officerPresetsButton() { return Field<"_officerPresetsButton", GenericButtonWidget*>(); } - bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } }; -// The static comparers the officer lists sort with. +// The static comparers the officer lists sort with. Only looked up, for their methods. using SortingPredicates = il2cpp::Class<"Assembly-CSharp", "Digit.Client.Core", "SortingPredicates">; struct SortFunction : il2cpp::Object { diff --git a/src/mod/game/queues.h b/src/mod/game/queues.h index 69dd26fd6..5ea38667b 100644 --- a/src/mod/game/queues.h +++ b/src/mod/game/queues.h @@ -1,632 +1,78 @@ #pragma once -// Checked queue metadata and storage access. Unknown layouts retain native behavior. -#include "features/action_queue.h" -#include "features/action_queue_guard_policy.h" -#include "features/queue_address_guard_policy.h" -#include -#include -#include -#include -#include -#include -namespace queue_bindings::recovery -{ -using Object = Il2CppObject; -using Id = std::int64_t; -using action_queue::QueueState; -inline Il2CppClass *queueClass{}, *actionClass{}, *int64Class{}; +// The action queue (Kir'Shara): the manager, one queue of target fleets per dock slot, and the course responses +// that drive it. -struct WeakQueues { - using Object = Il2CppObject*; - using Handle = Il2CppGCHandle; - static Handle New(Object value) - { return il2cpp_gchandle_new_weakref(value, false); } - static Object Get(Handle handle) - { return il2cpp_gchandle_get_target(handle); } - static void Free(Handle handle) - { il2cpp_gchandle_free(handle); } -}; +#include "fleet.h" -template T Read(const void* object, std::size_t offset) -{ - T value{}; - if (object) - std::memcpy(&value, static_cast(object) + offset, sizeof(value)); - return value; -} -// Inspect actual List storage without invoking game properties or enumerators. -// Unknown storage/layout is ineligible, never equivalent to an empty queue or absent target. -inline QueueState Inspect(Il2CppObject* queue, std::int64_t target = 0) -{ - QueueState s; - if (!queue || il2cpp_object_get_class(queue) != queueClass) - return s; - s.fleet = Read(queue, 0x30); - s.attempt = Read(queue, 0x14); - s.engaging = Read(queue, 0x10); - auto* list = Read(queue, 0x28); - if (!list) - return s; - auto* cls = il2cpp_object_get_class(list); - auto* size = il2cpp_class_get_field_from_name(cls, "_size"); - auto* storage = il2cpp_class_get_field_from_name(cls, "_items"); - if (!size || !storage || !size->type || !storage->type || size->type->type != IL2CPP_TYPE_I4 - || storage->type->type != IL2CPP_TYPE_SZARRAY || size->type->byref || storage->type->byref - || (il2cpp_field_get_flags(size) & FIELD_ATTRIBUTE_STATIC) - || (il2cpp_field_get_flags(storage) & FIELD_ATTRIBUTE_STATIC)) - return s; - Il2CppArray* items{}; - il2cpp_field_get_value(list, size, &s.count); - il2cpp_field_get_value(list, storage, &items); - if (s.count < 0 || s.count > 128 || !items || il2cpp_array_length(items) < static_cast(s.count) - || il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(items))) != actionClass) - return s; - auto* array = reinterpret_cast(items); - const auto inspectCount = target ? s.count : (s.count > 0 ? 1 : 0); - for (int i = 0; i < inspectCount; ++i) { - auto* action = static_cast(array->vector[i]); - if (!action || il2cpp_object_get_class(action) != actionClass) - return s; - const auto id = Read(action, 0x10); - if (i == 0) - s.front = id; - if (target && id == target) - s.containsTarget = true; - } - s.valid = s.fleet != 0; - return s; -} -inline Il2CppArraySize* Queues(Il2CppObject* manager) -{ - auto* array = Read(manager, 0x48); - if (!array || il2cpp_array_length(array) > 64 - || il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(array))) != queueClass) - return nullptr; - return reinterpret_cast(array); -} -inline Il2CppObject* FindQueue(Il2CppObject* manager, std::int64_t fleet) -{ - auto* array = Queues(manager); - if (!array) - return nullptr; - for (unsigned i = 0; i < array->max_length; ++i) { - auto* queue = static_cast(array->vector[i]); - if (queue && il2cpp_object_get_class(queue) == queueClass && Read(queue, 0x30) == fleet) - return queue; - } - return nullptr; -} -inline bool AbsentFromAllQueues(Il2CppObject* manager, std::int64_t target) -{ - auto* array = Queues(manager); - if (!array) - return false; - for (unsigned i = 0; i < array->max_length; ++i) { - auto* queue = static_cast(array->vector[i]); - if (!queue) - continue; - const auto s = Inspect(queue, target); - if (!s.valid || s.containsTarget) - return false; - } - return true; -} +#include -inline bool Field(Il2CppClass* cls, const char* name, std::ptrdiff_t offset, Il2CppTypeEnum type) -{ - auto* field = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; - const bool valid = field && field->offset == offset && field->type && !field->type->byref - && !(il2cpp_field_get_flags(field) & FIELD_ATTRIBUTE_STATIC) && field->type->type == type; - if (!valid) - spdlog::warn("[FasterQueueRecovery] {}.{}: expected instance field offset=0x{:X} type={}; " - "actual offset={} type={} static={} byref={}", - cls ? cls->name : "", name, offset, static_cast(type), field ? field->offset : -1, - field && field->type ? static_cast(field->type->type) : -1, - field && field->type && bool(il2cpp_field_get_flags(field) & FIELD_ATTRIBUTE_STATIC), - field && field->type && bool(field->type->byref)); - return valid; -} +#include +#include -struct CourseResponse { - std::int64_t fleet; - bool success, recall; - unsigned char padding[6]; - Il2CppObject* target; +enum class EngageResult : int32_t { + Success = 0, + SkipTarget = 1, + Stop = 2, }; -static_assert(sizeof(CourseResponse) == 24 && offsetof(CourseResponse, target) == 16); - -inline void* engage{}; -inline void* retry{}; -inline void* course{}; -inline void* clear{}; -inline bool ResolveBindings() -{ - auto* cls = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager">::Raw(); - queueClass = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance">::Raw(); - actionClass = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "QueueableAction">::Raw(); - int64Class = il2cpp::Class<"mscorlib", "System", "Int64">::Raw(); - if (!cls || !queueClass || !actionClass || !int64Class) { - for (const auto& [type, name] : - std::array{std::pair{cls, "Prime.ActionQueue.ActionQueueManager"}, - std::pair{queueClass, "Prime.ActionQueue.ActionQueueInstance"}, - std::pair{actionClass, "Prime.ActionQueue.QueueableAction"}, std::pair{int64Class, "System.Int64"}}) - if (!type) - spdlog::warn("[FasterQueueRecovery] unavailable: missing class {}", name); - return false; - } - using il2cpp::contract::Pointer; - using il2cpp::contract::Resolve; - const auto* engage_info = - Resolve(cls, "TryPlanPathAndEngageTarget", false, "Digit.Prime.Combat.EngageResult", - {"Digit.PrimeServer.Models.FleetPlayerData", "Prime.ActionQueue.ActionQueueInstance"}); - engage = Pointer(engage_info); - const auto* retry_info = Resolve(cls, "ShouldRetryFailedSetCourse", false, "System.Boolean", - {"System.Int64", "Prime.ActionQueue.ActionQueueInstance"}); - retry = Pointer(retry_info); - const auto* clear_info = Resolve(cls, "StopWatchdogAndClearAllQueues", false, "System.Void", {}); - clear = Pointer(clear_info); - const auto* info = Resolve(cls, "OnSetCourseResponseEventHandler", false, "System.Void", - {"Digit.PrimeServer.Events.SetCourseResponseEventArgs"}); - course = Pointer(info); - auto* event = info ? il2cpp_class_from_type(info->parameters[0]) : nullptr; - auto* result = engage_info ? il2cpp_class_from_type(engage_info->return_type) : nullptr; - const auto* underlying = result && il2cpp_class_is_enum(result) ? il2cpp_class_enum_basetype(result) : nullptr; - std::uint32_t alignment{}; - const bool valid = - underlying && underlying->type == IL2CPP_TYPE_I4 && event && info->parameters[0]->type == IL2CPP_TYPE_VALUETYPE - && il2cpp_class_value_size(event, &alignment) == sizeof(CourseResponse) - && Field(event, "k__BackingField", 0x10, IL2CPP_TYPE_I8) - && Field(event, "k__BackingField", 0x18, IL2CPP_TYPE_BOOLEAN) - && Field(event, "k__BackingField", 0x19, IL2CPP_TYPE_BOOLEAN) - && Field(event, "k__BackingField", 0x20, IL2CPP_TYPE_OBJECT) - && Field(cls, "_battleQueue", 0x48, IL2CPP_TYPE_SZARRAY) - && Field(queueClass, "IsEngaging", 0x10, IL2CPP_TYPE_BOOLEAN) - && Field(queueClass, "LastEngageAttemptTime", 0x14, IL2CPP_TYPE_R4) - && Field(queueClass, "k__BackingField", 0x30, IL2CPP_TYPE_I8) - && Field(queueClass, "_actionQueue", 0x28, IL2CPP_TYPE_GENERICINST) - && Field(actionClass, "k__BackingField", 0x10, IL2CPP_TYPE_I8) && engage && retry && course && clear - && !engage_info->has_full_generic_sharing_signature && !retry_info->has_full_generic_sharing_signature - && !info->has_full_generic_sharing_signature && !clear_info->has_full_generic_sharing_signature && engage != retry - && engage != course && engage != clear && retry != course && retry != clear && course != clear; - if (!valid) { - const std::array methods{ - std::pair{ - engage_info, - "ActionQueueManager.TryPlanPathAndEngageTarget(FleetPlayerData, ActionQueueInstance) -> EngageResult"}, - std::pair{retry_info, "ActionQueueManager.ShouldRetryFailedSetCourse(Int64, ActionQueueInstance) -> Boolean"}, - std::pair{info, "ActionQueueManager.OnSetCourseResponseEventHandler(SetCourseResponseEventArgs) -> Void"}, - std::pair{clear_info, "ActionQueueManager.StopWatchdogAndClearAllQueues() -> Void"}}; - for (const auto& [method, name] : methods) - if (!method || !method->methodPointer || method->has_full_generic_sharing_signature) - spdlog::warn("[FasterQueueRecovery] unavailable: {}: {}", name, - method && method->has_full_generic_sharing_signature ? "unsupported generic sharing" - : "instance method unavailable"); - if (!underlying || underlying->type != IL2CPP_TYPE_I4) - spdlog::warn("[FasterQueueRecovery] unavailable: EngageResult must be an Int32 enum"); - const auto event_size = - event && info->parameters[0]->type == IL2CPP_TYPE_VALUETYPE ? il2cpp_class_value_size(event, &alignment) : -1; - if (event_size != sizeof(CourseResponse)) - spdlog::warn( - "[FasterQueueRecovery] unavailable: SetCourseResponseEventArgs expected value type size={}; actual={}", - sizeof(CourseResponse), event_size); - for (size_t i = 0; i < methods.size(); ++i) - for (size_t j = 0; j < i; ++j) - if (methods[i].first && methods[j].first && methods[i].first->methodPointer - && methods[i].first->methodPointer == methods[j].first->methodPointer) - spdlog::warn("[FasterQueueRecovery] unavailable: {} and {} share a native hook target", methods[i].second, - methods[j].second); - return false; - } - return true; -} - -inline bool ReadCourseTarget(Il2CppObject* value, int64_t& target) -{ - if (!value || il2cpp_object_get_class(value) != int64Class) - return false; - std::memcpy(&target, il2cpp_object_unbox(value), sizeof(target)); - return true; -} - -} // namespace queue_bindings::recovery -namespace queue_bindings::thin -{ -using Object = Il2CppObject; -using Id = std::int64_t; -using action_queue_guard::QueueState; -inline Il2CppClass *queueClass{}, *actionClass{}, *playerClass{}, *deployedClass{}; - -inline Id (*playerId)(Object*); - -inline Id (*deployedId)(Object*); - -inline int (*playerState)(Object*); - -inline int (*deployedState)(Object*); - -inline int (*removalReason)(Object*); - -inline bool (*destroyed)(Object*); - -inline bool (*battling)(Object*); - -inline int (*tryEngage)(Object*, Object*, Object*); - -inline void (*processTarget)(Object*, Id, bool); - -inline bool Is(Object* object, Il2CppClass* cls) -{ return object && il2cpp_object_get_class(object) == cls; } -template T Read(const void* object, std::size_t offset) -{ - T value{}; - if (object) - std::memcpy(&value, static_cast(object) + offset, sizeof(value)); - return value; -} +struct QueueableAction : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "QueueableAction">; -// Inspect storage without invoking List getters/enumerators. Unknown layouts fail closed. -inline Il2CppArraySize* List(Object* list, Il2CppClass* element, int limit, int& count) -{ - count = -1; - if (!list) - return nullptr; - auto* cls = il2cpp_object_get_class(list); - auto* size = il2cpp_class_get_field_from_name(cls, "_size"); - auto* storage = il2cpp_class_get_field_from_name(cls, "_items"); - if (!size || !storage || !size->type || size->type->type != IL2CPP_TYPE_I4 || !storage->type - || storage->type->type != IL2CPP_TYPE_SZARRAY || size->type->byref || storage->type->byref - || (il2cpp_field_get_flags(size) & FIELD_ATTRIBUTE_STATIC) - || (il2cpp_field_get_flags(storage) & FIELD_ATTRIBUTE_STATIC)) - return nullptr; - Il2CppArray* items{}; - il2cpp_field_get_value(list, size, &count); - il2cpp_field_get_value(list, storage, &items); - if (count < 0 || count > limit || !items || il2cpp_array_length(items) < static_cast(count) - || il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(items))) != element) - return nullptr; - return reinterpret_cast(items); -} - -inline QueueState Snapshot(Object* queue) -{ - QueueState s; - if (!Is(queue, queueClass)) - return s; - auto* items = List(Read(queue, 0x28), actionClass, 128, s.count); - if (!items) - return {}; - s.player_fleet_id = Read(queue, 0x30); - s.is_engaging = Read(queue, 0x10); - s.last_engaged_target_id = Read(queue, 0x18); - s.pending_target_id = Read(queue, 0x20); - s.captured_target_count = std::min(s.count, static_cast(s.target_ids.size())); - s.targets_truncated = s.count > s.captured_target_count; - for (int i = 0; i < s.captured_target_count; ++i) { - auto* action = static_cast(items->vector[i]); - if (!Is(action, actionClass)) - return {}; - s.target_ids[i] = Read(action, 0x10); - if (!s.target_ids[i]) - return {}; - } - s.head_target_id = s.count ? s.target_ids[0] : 0; - s.present = s.player_fleet_id != 0; - return s; -} - -inline Object* FindQueue(Object* manager, Id fleet, Id head = 0) -{ - auto* array = Read(manager, 0x48); - if (!array || il2cpp_array_length(array) > 64 - || il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(array))) != queueClass) - return nullptr; - auto* items = reinterpret_cast(array); - for (unsigned i = 0; i < items->max_length; ++i) { - auto* queue = static_cast(items->vector[i]); - const auto s = Snapshot(queue); - if (s.present && (head ? s.head_target_id == head : s.player_fleet_id == fleet)) - return queue; - } - return nullptr; -} - -inline bool Field(Il2CppClass* cls, const char* name, std::ptrdiff_t offset, Il2CppTypeEnum type) -{ - auto* f = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; - const bool valid = f && f->offset == offset && f->type && !f->type->byref - && !(il2cpp_field_get_flags(f) & FIELD_ATTRIBUTE_STATIC) && f->type->type == type; - if (!valid) - spdlog::warn("[ThinQueueProtection] {}.{}: expected instance field offset=0x{:X} type={}; " - "actual offset={} type={} static={} byref={}", - cls ? cls->name : "", name, offset, static_cast(type), f ? f->offset : -1, - f && f->type ? static_cast(f->type->type) : -1, - f && f->type && bool(il2cpp_field_get_flags(f) & FIELD_ATTRIBUTE_STATIC), - f && f->type && bool(f->type->byref)); - return valid; -} - -template bool Getter(T& out, Il2CppClass* cls, const char* name, const char* result) -{ - const auto* method = il2cpp::contract::Resolve(cls, name, false, result, {}); - out = method && !method->has_full_generic_sharing_signature ? reinterpret_cast(il2cpp::contract::Pointer(method)) - : nullptr; - if (method && method->return_type->type == IL2CPP_TYPE_VALUETYPE) { - auto* type = il2cpp_class_from_type(method->return_type); - const auto* underlying = type && il2cpp_class_is_enum(type) ? il2cpp_class_enum_basetype(type) : nullptr; - if (!underlying || underlying->type != IL2CPP_TYPE_I4) - out = nullptr; - } - if (!out) - spdlog::warn("[ThinQueueProtection] {}.{}: expected instance {}(); {}", cls ? cls->name : "", name, - result, - !method ? "method unavailable" - : method->has_full_generic_sharing_signature ? "unsupported generic sharing" - : !method->methodPointer ? "native pointer unavailable" - : "expected Int32 enum result"); - return out != nullptr; -} - -inline void* plan{}; -inline void* stall{}; -inline void* disposed{}; -inline bool ResolveBindings() -{ - auto* manager = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager">::Raw(); - queueClass = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance">::Raw(); - actionClass = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "QueueableAction">::Raw(); - playerClass = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetPlayerData">::Raw(); - deployedClass = - il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData">::Raw(); - using il2cpp::contract::Pointer; - using il2cpp::contract::Resolve; - const auto* plan_info = Resolve(manager, "DoPlanPathAndEngageTarget", false, "System.Boolean", - {"Digit.PrimeServer.Models.FleetPlayerData"}); - plan = Pointer(plan_info); - const auto* stall_info = Resolve(manager, "HandleStall", false, "System.Void", - {"Prime.ActionQueue.ActionQueueInstance", "Digit.PrimeServer.Models.FleetPlayerData", - "Digit.PrimeServer.Models.FleetDeployedData"}); - stall = Pointer(stall_info); - const auto* disposed_info = Resolve(manager, "OnFleetsDisposedEventHandler", false, "System.Void", - {"System.Collections.Generic.List"}); - disposed = Pointer(disposed_info); - const auto* engage = Resolve(manager, "TryPlanPathAndEngageTarget", false, "Digit.Prime.Combat.EngageResult", - {"Digit.PrimeServer.Models.FleetPlayerData", "Prime.ActionQueue.ActionQueueInstance"}); - tryEngage = reinterpret_cast(Pointer(engage)); - const auto* process_info = Resolve(manager, "ProcessQueue", false, "System.Void", {"System.Int64", "System.Boolean"}); - processTarget = reinterpret_cast(Pointer(process_info)); - auto* result = engage ? il2cpp_class_from_type(engage->return_type) : nullptr; - const auto* underlying = result && il2cpp_class_is_enum(result) ? il2cpp_class_enum_basetype(result) : nullptr; - const bool valid = - manager && queueClass && actionClass && playerClass && deployedClass && plan && stall && disposed && tryEngage - && processTarget && underlying && underlying->type == IL2CPP_TYPE_I4 - && !plan_info->has_full_generic_sharing_signature && !stall_info->has_full_generic_sharing_signature - && !disposed_info->has_full_generic_sharing_signature && !engage->has_full_generic_sharing_signature - && !process_info->has_full_generic_sharing_signature && plan != stall && plan != disposed && stall != disposed - && Field(manager, "_battleQueue", 0x48, IL2CPP_TYPE_SZARRAY) - && Field(queueClass, "IsEngaging", 0x10, IL2CPP_TYPE_BOOLEAN) - && Field(queueClass, "LastEngagedTargetId", 0x18, IL2CPP_TYPE_I8) - && Field(queueClass, "PendingEngageTargetId", 0x20, IL2CPP_TYPE_I8) - && Field(queueClass, "_actionQueue", 0x28, IL2CPP_TYPE_GENERICINST) - && Field(queueClass, "k__BackingField", 0x30, IL2CPP_TYPE_I8) - && Field(actionClass, "k__BackingField", 0x10, IL2CPP_TYPE_I8) - && Getter(playerId, playerClass, "get_Id", "System.Int64") - && Getter(playerState, playerClass, "get_CurrentState", "Digit.PrimeServer.Models.FleetState") - && Getter(deployedId, deployedClass, "get_ID", "System.Int64") - && Getter(deployedState, deployedClass, "get_CurrentState", "Digit.PrimeServer.Models.DeployedFleetState") - && Getter(removalReason, deployedClass, "get_RemovalReason", "Digit.PrimeServer.Models.FleetRemovalReason") - && Getter(destroyed, deployedClass, "get_IsDestroyed", "System.Boolean") - && Getter(battling, deployedClass, "get_CurrentlyBattling", "System.Boolean"); - if (!valid) { - for (const auto& [type, name] : std::array{std::pair{manager, "Prime.ActionQueue.ActionQueueManager"}, - std::pair{queueClass, "Prime.ActionQueue.ActionQueueInstance"}, - std::pair{actionClass, "Prime.ActionQueue.QueueableAction"}, - std::pair{playerClass, "Digit.PrimeServer.Models.FleetPlayerData"}, - std::pair{deployedClass, "Digit.PrimeServer.Models.FleetDeployedData"}}) - if (!type) - spdlog::warn("[ThinQueueProtection] unavailable: missing class {}", name); - const std::array methods{ - std::pair{plan_info, "ActionQueueManager.DoPlanPathAndEngageTarget(FleetPlayerData) -> Boolean"}, - std::pair{stall_info, - "ActionQueueManager.HandleStall(ActionQueueInstance, FleetPlayerData, FleetDeployedData) -> Void"}, - std::pair{disposed_info, "ActionQueueManager.OnFleetsDisposedEventHandler(List) -> Void"}, - std::pair{ - engage, - "ActionQueueManager.TryPlanPathAndEngageTarget(FleetPlayerData, ActionQueueInstance) -> EngageResult"}, - std::pair{process_info, "ActionQueueManager.ProcessQueue(Int64, Boolean) -> Void"}}; - for (const auto& [method, name] : methods) - if (!method || !method->methodPointer || method->has_full_generic_sharing_signature) - spdlog::warn("[ThinQueueProtection] unavailable: {}: {}", name, - method && method->has_full_generic_sharing_signature ? "unsupported generic sharing" - : "instance method unavailable"); - if (!underlying || underlying->type != IL2CPP_TYPE_I4) - spdlog::warn("[ThinQueueProtection] unavailable: EngageResult must be an Int32 enum"); - // Only these three methods are detoured; the other two are native calls. - for (size_t i = 0; i < 3; ++i) - for (size_t j = 0; j < i; ++j) - if (methods[i].first && methods[j].first && methods[i].first->methodPointer - && methods[i].first->methodPointer == methods[j].first->methodPointer) - spdlog::warn("[ThinQueueProtection] unavailable: {} and {} share a native hook target", methods[i].second, - methods[j].second); - return false; - } - return true; -} - -} // namespace queue_bindings::thin -namespace queue_bindings::address -{ -using Object = Il2CppObject; -using Id = std::int64_t; -inline Il2CppClass *managerClass{}, *queueClass{}, *actionClass{}, *playerClass{}, *deployedClass{}; - -inline FieldInfo *queuesField{}, *fleetField{}, *actionsField{}, *targetField{}, *idField{}, *indexField{}; - -inline bool Is(Object* object, Il2CppClass* cls) -{ return object && il2cpp_object_get_class(object) == cls; } - -inline FieldInfo* Field(Il2CppClass* cls, const char* name, Il2CppTypeEnum type) -{ - auto* field = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; - - return field && field->type && !(field->type->attrs & FIELD_ATTRIBUTE_STATIC) && field->offset >= sizeof(Object) - && field->type->type == type - ? field - : nullptr; -} -template T Read(Object* object, FieldInfo* field) -{ - T value{}; - if (object && field) - il2cpp_field_get_value(object, field, &value); - return value; -} -inline Object* Reference(Object* object, const char* name) -{ - auto* cls = object ? il2cpp_object_get_class(object) : nullptr; - auto* field = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; - auto* type = field && field->type ? il2cpp_class_from_type(field->type) : nullptr; - return type && !il2cpp_class_is_valuetype(type) && !(field->type->attrs & FIELD_ATTRIBUTE_STATIC) - && field->offset >= sizeof(Object) - ? Read(object, field) - : nullptr; -} -inline int Enum(Object* object, const char* name) -{ - auto* cls = object ? il2cpp_object_get_class(object) : nullptr; - auto* field = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; - auto* type = field && field->type ? il2cpp_class_from_type(field->type) : nullptr; - auto* base = type && il2cpp_class_is_enum(type) ? il2cpp_class_enum_basetype(type) : nullptr; - return base && base->type == IL2CPP_TYPE_I4 && !(field->type->attrs & FIELD_ATTRIBUTE_STATIC) - && field->offset >= sizeof(Object) - ? Read(object, field) - : -1; -} -inline int State(Object* object, const char* name) -{ - auto* cls = object ? il2cpp_object_get_class(object) : nullptr; - auto* field = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; - auto* container = field && field->type ? il2cpp_class_from_type(field->type) : nullptr; - if (!container || !il2cpp_class_is_valuetype(container) || field->offset < sizeof(Object) - || (field->type->attrs & FIELD_ATTRIBUTE_STATIC)) - return -1; - uint32_t alignment{}; - const int size = il2cpp_class_value_size(container, &alignment); - auto* inner = il2cpp_class_get_field_from_name(container, "_currentState"); - auto* type = inner && inner->type ? il2cpp_class_from_type(inner->type) : nullptr; - auto* base = type && il2cpp_class_is_enum(type) ? il2cpp_class_enum_basetype(type) : nullptr; - const int offset = inner ? inner->offset - static_cast(sizeof(Object)) : -1; - if (!base || base->type != IL2CPP_TYPE_I4 || (inner->type->attrs & FIELD_ATTRIBUTE_STATIC) || offset < 0 - || offset + sizeof(int) > size) - return -1; - int value{}; - std::memcpy(&value, reinterpret_cast(object) + field->offset + offset, sizeof(value)); - return value; -} -inline queue_address_guard::Address Address(Object* object) -{ - auto* cls = object ? il2cpp_object_get_class(object) : nullptr; - auto* galaxy = Field(cls, "galaxy_", IL2CPP_TYPE_I8); - auto* system = Field(cls, "system_", IL2CPP_TYPE_I8); - auto* planet = Field(cls, "planet_", IL2CPP_TYPE_I8); - auto* instance = Field(cls, "instance_", IL2CPP_TYPE_I4); - if (!galaxy || !system || !planet || !instance) - return {}; - queue_address_guard::Address value{true, Read(object, galaxy), Read(object, system), Read(object, planet), - Read(object, instance)}; - value.valid = value.galaxy >= 0 && value.system > 0 && value.planet >= 0 && value.instance >= 0; - return value; -} - -// Existing queue snapshots use fixed Windows offsets. This guard also runs on macOS -// and resolves metadata instead; unknown or incomplete storage retains native behavior. -inline bool OwnsNonemptyQueue(Object* manager, Object* player, Id fleet) -{ - auto* array = Read(manager, queuesField); - const int index = Read(player, indexField); - if (!array || !fleet || index < 0 || il2cpp_array_length(array) > 64 - || static_cast(index) >= il2cpp_array_length(array) - || il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(array))) != queueClass) - return false; - auto* queue = static_cast(reinterpret_cast(array)->vector[index]); - if (!Is(queue, queueClass) || Read(queue, fleetField) != fleet) - return false; - auto* list = Read(queue, actionsField); - auto* cls = list ? il2cpp_object_get_class(list) : nullptr; - auto* size = Field(cls, "_size", IL2CPP_TYPE_I4); - auto* storage = Field(cls, "_items", IL2CPP_TYPE_SZARRAY); - if (!size || !storage) - return false; - const int count = Read(list, size); - auto* items = Read(list, storage); - if (count <= 0 || count > 128 || !items || il2cpp_array_length(items) < static_cast(count) - || il2cpp_class_get_element_class(il2cpp_object_get_class(reinterpret_cast(items))) != actionClass) - return false; - for (int i = 0; i < count; ++i) { - auto* action = static_cast(reinterpret_cast(items)->vector[i]); - if (!Is(action, actionClass) || !Read(action, targetField)) - return false; - } - return true; -} + // The queued target's fleet id. + int64_t FleetId() + { return Field<"k__BackingField", int64_t>(); } +}; -inline std::array methods{}; -inline bool ResolveBindings() -{ - managerClass = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager">::Raw(); - queueClass = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance">::Raw(); - actionClass = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "QueueableAction">::Raw(); - playerClass = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetPlayerData">::Raw(); - deployedClass = - il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetDeployedData">::Raw(); - auto* serviceClass = - il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Services", "DeploymentService">::Raw(); - queuesField = Field(managerClass, "_battleQueue", IL2CPP_TYPE_SZARRAY); - fleetField = Field(queueClass, "k__BackingField", IL2CPP_TYPE_I8); - actionsField = Field(queueClass, "_actionQueue", IL2CPP_TYPE_GENERICINST); - targetField = Field(actionClass, "k__BackingField", IL2CPP_TYPE_I8); - idField = Field(playerClass, "k__BackingField", IL2CPP_TYPE_I8); - indexField = Field(playerClass, "k__BackingField", IL2CPP_TYPE_I4); - using il2cpp::contract::Pointer; - using il2cpp::contract::Resolve; - methods = { - Resolve(managerClass, "OnPlayerFleetStateChangedEventHandler", false, "System.Void", - {"System.Collections.Generic.List"}), - Resolve(managerClass, "DoesFleetQueueContainMismatchedAddressTargets", false, "System.Boolean", - {"Digit.PrimeServer.Models.FleetPlayerData"}), - Resolve(playerClass, "get_Address", false, "Digit.PrimeServer.Models.NodeAddress", {}), - Resolve(serviceClass, "GetDeployedFleet", false, "Digit.PrimeServer.Models.FleetDeployedData", {"System.Int64"})}; - bool valid = managerClass && queueClass && actionClass && playerClass && deployedClass && serviceClass && queuesField - && fleetField && actionsField && targetField && idField && indexField; - for (unsigned i = 0; i < methods.size(); ++i) { - valid = valid && methods[i] && !methods[i]->has_full_generic_sharing_signature; - for (unsigned j = 0; j < i; ++j) - valid = valid && Pointer(methods[i]) != Pointer(methods[j]); - } - if (!valid) { - spdlog::warn("[QueueAddressGuard] native method/field contract unavailable; retaining native behavior"); - return false; - } - return true; -} +struct ActionQueueInstance : il2cpp::Object { + using Class = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "ActionQueueInstance">; + + bool IsEngaging() + { return Field<"IsEngaging", bool>(); } + float LastEngageAttemptTime() + { return Field<"LastEngageAttemptTime", float>(); } + int64_t LastEngagedTargetId() + { return Field<"LastEngagedTargetId", int64_t>(); } + int64_t PendingEngageTargetId() + { return Field<"PendingEngageTargetId", int64_t>(); } + int64_t PlayerFleetId() + { return Field<"k__BackingField", int64_t>(); } + // IList, a List`1 in practice. + il2cpp::List* actionQueue() + { return Field<"_actionQueue", il2cpp::List*>(); } +}; -inline bool IsLocalDeployment(Object* own, Id fleet) -{ - auto* cls = own ? il2cpp_object_get_class(own) : nullptr; - auto* local = Field(cls, "_localPlayerFleet", IL2CPP_TYPE_BOOLEAN); - auto* model = Reference(own, "_deploymentFleet"); - auto* model_class = model ? il2cpp_object_get_class(model) : nullptr; - auto* id = Field(model_class, "fleetId_", IL2CPP_TYPE_I8); - if (!id) - id = Field(model_class, "k__BackingField", IL2CPP_TYPE_I8); - return local && Read(own, local) && id && Read(model, id) == fleet; -} -inline bool PlanningRecall(Object* own) -{ - auto* field = Field(own ? il2cpp_object_get_class(own) : nullptr, "k__BackingField", - IL2CPP_TYPE_BOOLEAN); - return !field || Read(own, field); -} +// Digit.PrimeServer.Events.SetCourseResponseEventArgs, passed by value. The method lookup checks its size; the +// field order follows the client's dump. +struct SetCourseResponse { + int64_t fleet_id; + bool success; + bool is_recall; + // A boxed Int64. + il2cpp::Object* target_id; +}; +static_assert(sizeof(SetCourseResponse) == 24 && offsetof(SetCourseResponse, target_id) == 16); + +struct ActionQueueManager : MonoSingleton { + using Class = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager">; + + void ClearQueue(FleetPlayerData* playerData) + { Call<"ClearQueue">(playerData); } + bool IsQueueFull(FleetPlayerData* playerData) + { return Call<"IsQueueFull", bool>(playerData); } + bool IsQueueUnlocked() + { return Call<"IsQueueUnlocked", bool>(); } + + // One queue per dock slot, indexed by FleetPlayerData.Index; empty slots are null. + il2cpp::Array* battleQueue() + { return Field<"_battleQueue", il2cpp::Array*>(); } + EngageResult TryPlanPathAndEngageTarget(FleetPlayerData* fleet, ActionQueueInstance* queue) + { return Call<"TryPlanPathAndEngageTarget", EngageResult(FleetPlayerData*, ActionQueueInstance*)>(fleet, queue); } + // Removes the target fleet from every queue that holds it, as the game does when a target goes away. + void ProcessQueue(int64_t target_fleet_id, bool can_select_new_target) + { Call<"ProcessQueue", void(int64_t, bool)>(target_fleet_id, can_select_new_target); } +}; -} // namespace queue_bindings::address +struct DeploymentService : il2cpp::Object { + using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Services", "DeploymentService">; +}; diff --git a/src/mod/game/server.h b/src/mod/game/server.h index d4d049ce7..80c45e055 100644 --- a/src/mod/game/server.h +++ b/src/mod/game/server.h @@ -3,6 +3,8 @@ // The game server (Digit.PrimeServer): its sessions, the entity groups and realtime messages it sends, and the // server models and services the mod reads, such as battle results, hull specs, buffs and events. +#include "app.h" + #include #include @@ -436,10 +438,9 @@ struct UserProfile : il2cpp::Object { { return Get<"Level", int32_t>(); } }; -struct UserProfileManager : il2cpp::Object { +struct UserProfileManager : MonoSingleton { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.PlayerProfile", "UserProfileManager">; - static UserProfileManager* Instance() - { return Class::Get<"Instance", UserProfileManager*>(); } + UserProfile* GetLocalUserProfile() { return Call<"GetLocalUserProfile", UserProfile*>(); } }; @@ -475,7 +476,8 @@ struct BattleResultHeader : il2cpp::Object { { return static_cast(Get<"EnemyUserProfile", Il2CppObject*>()); } SpecService* specService() { return static_cast(Field<"_specService", Il2CppObject*>()); } - BattleType BattleType() + // The BattleType property; named Type so it does not hide the enum. + ::BattleType Type() { return Get<"BattleType", ::BattleType>(); } int64_t PlayerShipHullId() { return Get<"PlayerShipHullId", int64_t>(); } @@ -550,13 +552,6 @@ struct ForbiddenTechSlotParams : il2cpp::Object { { return Get<"OwnerId", int64_t>(); } }; -struct EntitySlot : il2cpp::Object { - using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "EntitySlot">; - - ForbiddenTechSlotParams* forbiddenTechSlotParams() - { return Get<"ForbiddenTechSlotParams", ForbiddenTechSlotParams*>(); } -}; - struct InventoryItem : il2cpp::Object { using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "InventoryItem">; diff --git a/src/mod/game/ui.h b/src/mod/game/ui.h index f80fa73ab..6ee1e8497 100644 --- a/src/mod/game/ui.h +++ b/src/mod/game/ui.h @@ -3,8 +3,9 @@ // The game's UI framework (Digit.Client.UI): widgets, view controllers, canvases, buttons, lists, localization, // shortcuts, message boxes and toasts. +#include "app.h" + #include -#include #include #include @@ -12,26 +13,15 @@ #include #include -// Unity singletons expose a static Instance on their MonoSingleton`1 base. -template struct MonoSingleton : il2cpp::Object { - static T* Instance() - { return T::Class::template Get<"Instance", T*>(); } - // The singleton s_instance already holds, or null. Instance can search the scene or create a replacement. - static T* Existing() - { return T::Class::template Field<"s_instance", T*>(); } -}; - -// The non-generic MonoSingleton base; its static PrepareAllForReload runs before the game reloads. -using MonoSingletonBase = il2cpp::Class<"Assembly-CSharp", "", "MonoSingleton">; - -// Digit.Client.UI.Widget`1. Members resolve on each widget's runtime class. +// Digit.Client.UI.Widget`1. Members resolve on each widget's runtime class, so this cannot derive from +// unity::Behaviour, which would bind them to Behaviour's class. template struct Widget : il2cpp::Object { T* Context() { return Get<"Context", T*>(); } bool enabled() { return Get<"enabled", bool>(); } bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } + { return unity::ActiveAndEnabled(this); } bool IsActive() { return Call<"IsActive", bool>(); } }; @@ -53,6 +43,8 @@ struct VisibilityController : il2cpp::Object { void Hide(bool instant = false) { Call<"Hide">(instant); } + // The field is the state as last applied; the property is what the controller reports, which can differ while a + // transition runs. The property is called VisibilityState, a name this header gives the enum. VisibilityState state() { return Field<"_state", VisibilityState>(); } VisibilityState State() @@ -66,7 +58,7 @@ struct VisibilityController : il2cpp::Object { } }; -struct CanvasController : il2cpp::Object { +struct CanvasController : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "CanvasController">; bool Visible() @@ -76,8 +68,6 @@ struct CanvasController : il2cpp::Object { { return Get<"Context", il2cpp::Object*>(); } Il2CppString* name() { return Get<"name", Il2CppString*>(); } - unity::Transform* transform() - { return Get<"transform", unity::Transform*>(); } // The canvas a component is drawn on. Passing System.Type selects GetComponentInParent(Type) over the generic // overload. @@ -133,6 +123,8 @@ struct LocaleTextContext : il2cpp::Object { { Call<"ApplyIdentifierParameters">(parameters); } }; +// Two ways into the game's localization, each used where the game itself uses it: LocaleUtilities for texts with +// parameters, such as toasts, and Localizer for plain identifiers, such as ship names. struct LocaleUtilities { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.Localization", "LocaleUtilities">; @@ -181,16 +173,16 @@ struct SemaphoreListenerBase : il2cpp::Object { struct SemaphoreButtonListener : SemaphoreListenerBase { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "SemaphoreButtonListener">; - unity::Button* TheButton() + unity::Button* button() { return Field<"_button", unity::Button*>(); } bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } + { return unity::ActiveAndEnabled(this); } // Presses the listener's button, if it has one. void Press() { - if (auto* button = TheButton()) - button->Press(); + if (auto* target = button()) + target->Press(); } }; @@ -206,7 +198,7 @@ struct GenericButtonContext : il2cpp::Object { struct GenericButtonWidget : Widget { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "GenericButtonWidget">; - SemaphoreButtonListener* SemaphoreListener() + SemaphoreButtonListener* semaphoreButtonListener() { return Field<"_semaphoreButtonListener", SemaphoreButtonListener*>(); } TextLocalizer* mainText() { return Field<"_mainText", TextLocalizer*>(); } @@ -214,10 +206,10 @@ struct GenericButtonWidget : Widget { { return Get<"Interactable", bool>(); } // The Unity button behind the widget, or null. - unity::Button* TheButton() + unity::Button* UnityButton() { - auto* listener = SemaphoreListener(); - return listener ? listener->TheButton() : nullptr; + auto* listener = semaphoreButtonListener(); + return listener ? listener->button() : nullptr; } }; @@ -228,13 +220,11 @@ struct ButtonAndContextWrapper : il2cpp::Object { { return Field<"Widget", GenericButtonWidget*>(); } }; -struct InputFieldWidget : il2cpp::Object { +struct InputFieldWidget : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "InputFieldWidget">; void Focus() { Call<"Focus">(); } - bool isActiveAndEnabled() - { return Get<"isActiveAndEnabled", bool>(); } }; // Digit.Client.UI list hierarchy: BaseListContainer <- SelectableListBase <- SelectableList <- TabBar. @@ -249,6 +239,10 @@ struct BaseListContainer : il2cpp::Object { }; struct SelectableList : BaseListContainer { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "SelectableList">; + + // Regenerates the list's tiles from its current Data, through the game's SelectableListBase + // .ClearAndGenerateContents; `provider` is the IDataContextProvider the tiles bind to. void Rebuild(il2cpp::Object* provider) { auto* data = Get<"Data", il2cpp::Object*>(); @@ -256,8 +250,6 @@ struct SelectableList : BaseListContainer { Call<"ClearAndGenerateContents">(provider, data); } - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "SelectableList">; - int32_t SelectedIndex() { return Get<"SelectedIndex", int32_t>(); } @@ -286,9 +278,9 @@ struct TabBarViewController : il2cpp::Object { struct UIFrameManager : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "UIFrameManager">; - // The game clamps the width to the available space and the side frame's minimum. False when the call failed. - bool ResizeSideFrame(float width) - { return TryCall<"ResizeSideFrame", void(float)>(width); } + // The game clamps the width to the available space and the side frame's minimum. + void ResizeSideFrame(float width) + { Call<"ResizeSideFrame", void(float)>(width); } }; struct ScreenManager : MonoSingleton { @@ -308,15 +300,12 @@ struct ScreenManager : MonoSingleton { auto* manager = Existing(); auto* screens = unity::Alive(manager) ? manager->screens() : nullptr; const int32_t count = screens ? screens->Count() : 0; - if (count < 0 || count > 256) - return nullptr; for (int32_t index = 0; index < count; ++index) { auto* canvas = (*screens)[index]; auto* context = unity::Alive(canvas) ? canvas->Context() : nullptr; if (!context || !context->Is() || !canvas->Visible()) continue; - auto* transform = canvas->transform(); - auto* object = transform ? transform->gameObject() : nullptr; + auto* object = canvas->gameObject(); if (auto* view = object ? object->template GetComponentInChildren() : nullptr; unity::Alive(view)) return view; } @@ -356,8 +345,11 @@ struct ShortcutKeybindHint : il2cpp::Object { { return Field<"_inputAction", unity::InputActionReference*>(); } TextLocalizer* keyTextLocalizer() { return Field<"_keyTextLocalizer", TextLocalizer*>(); } + void UpdateText() + { Call<"UpdateText">(); } }; +// Digit.Client.UI.MessageBox. Only hooked. using GameMessageBox = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "MessageBox">; // MessageBox.ButtonResult @@ -465,13 +457,9 @@ struct AnimatedRewardsScreenViewController : il2cpp::Object { }; // The -/+ stepper on upgrade and quantity screens. -struct ElementSelectorViewController : il2cpp::Object { +struct ElementSelectorViewController : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.UI", "ElementSelectorViewController">; - // Through the internal call, so a drifted managed binding never hides the arrow-key shortcuts. - bool isActiveAndEnabled() - { return unity::ActiveAndEnabled(this); } - void PressDecrement() { if (auto* button = Field<"_decrementButton", unity::Button*>()) @@ -484,13 +472,9 @@ struct ElementSelectorViewController : il2cpp::Object { } }; -struct ArtifactHallDetailsViewController : il2cpp::Object { +struct ArtifactHallDetailsViewController : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ArtifactHall", "ArtifactHallDetailsViewController">; - // Through the internal call, so a drifted managed binding never hides the arrow-key shortcuts. - bool isActiveAndEnabled() - { return unity::ActiveAndEnabled(this); } - void PressLeftArrow() { if (auto* listener = Field<"_leftArrowButton", SemaphoreButtonListener*>()) @@ -519,20 +503,5 @@ struct LegacyComparableProgressBar : il2cpp::Object { { SetField<"_snapToValue">(value); } }; -struct MessageUtility : il2cpp::Object { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "MessageUtility">; - static void* RevealConfirmationTarget() - { - const auto* method = il2cpp::contract::Resolve(Class::Raw(), "ShowFreeAbilityConfirmationPopup", true, - "System.Void", {"System.Int64", "System.TimeSpan", "System.Action"}); - return method && !method->has_full_generic_sharing_signature ? il2cpp::contract::Pointer(method) : nullptr; - } - static bool InvokeActivationAction(Il2CppDelegate* action) - { - if (!action || !action->method) - return false; - Il2CppException* exception = nullptr; - il2cpp_runtime_invoke(action->method, action->target, nullptr, &exception); - return !exception; - } -}; +// Digit.Client.UI.MessageUtility: static helpers that open the game's message popups. Only hooked. +using MessageUtility = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "MessageUtility">; diff --git a/src/mod/game/viewers.h b/src/mod/game/viewers.h index f0b7fdb47..2561e4fc1 100644 --- a/src/mod/game/viewers.h +++ b/src/mod/game/viewers.h @@ -115,7 +115,7 @@ struct WaveDefenseObjectViewerWidget : ObjectViewerBaseWidget { return; } auto* context = widget->Context(); - auto* listener = widget->SemaphoreListener(); + auto* listener = widget->semaphoreButtonListener(); if (context && context->Interactable() && listener) { listener->Press(); } diff --git a/src/mod/mod.cc b/src/mod/mod.cc index bd312ba52..3b652d55f 100644 --- a/src/mod/mod.cc +++ b/src/mod/mod.cc @@ -32,57 +32,61 @@ namespace { -// Installed in this order; the log numbers them. +// Installed in this order, which the log numbers, and their frame work runs in this order too. Grouped by file as +// in features.h. No feature depends on another being installed first; the object tracker leads only so that every +// tracked class is hooked before anything else is. constexpr const Feature* kFeatures[] = { + &kObjectTracker, + &kActionQueueRecovery, + &kThinQueueProtection, + &kQueueAddressGuard, + &kAllianceGamesStore, + &kAnomalyTimer, + &kAudio, + &kOutOfDockPower, + &kBuffCrashGuard, + &kZoom, + &kPan, &kInteriorZoom, + &kCargoFormat, +#if _WIN32 + &kInstantCargo, +#endif + &kChat, &kChatTitles, + &kForbiddenTechConfirm, &kRevealConfirm, - &kActionQueueRecovery, - &kQueueAddressGuard, - &kThinQueueProtection, + &kPinnedShips, &kPinnedShipClick, + &kDoubleClickAssign, + &kAssignOnEnter, &kDockSelection, + &kQueueSwitch, &kAbilityButtons, - &kUiScale, - &kZoom, + &kFleetLabels, + &kForbiddenTechShipName, + &kGameConfigOverrides, &kHavenZoom, &kHavenHistory, - &kAllianceGamesStore, - &kFleetLabels, - &kPan, - &kOutOfDockPower, - &kBuffCrashGuard, - &kToasts, &kHotkeys, - &kGiftsAutoOpen, + &kInstantWarp, + &kLoginScreen, + &kTransitionScreen, + &kLoadingTips, + &kMissionHud, &kDailyClaimFilter, + &kOfficerSort, + &kGiftsAutoOpen, + &kShopLimits, + &kSkipRevealSequence, + &kArtifactExchange, + &kToasts, + &kUiScale, #if _WIN32 &kFreeResize, &kCursor, #endif - &kSkipRevealSequence, - &kGameConfigOverrides, - &kQueueSwitch, - &kShopLimits, - &kMissionHud, - &kArtifactExchange, - &kChat, &kSync, - &kObjectTracker, - &kLoginScreen, - &kTransitionScreen, - &kAnomalyTimer, - &kLoadingTips, - &kInstantCargo, - &kCargoFormat, - &kOfficerSort, - &kPinnedShips, - &kDoubleClickAssign, - &kAssignOnEnter, - &kInstantWarp, - &kForbiddenTechConfirm, - &kForbiddenTechShipName, - &kAudio, }; // The per-frame work of the installed features, in install order. @@ -119,23 +123,29 @@ void InstallFeatures(const Config& config) spdlog::info("Initializing code hooks:"); for (size_t i = 0; i < std::size(kFeatures); ++i) { const auto& feature = *kFeatures[i]; - const bool enabled = !feature.group || config.patches.*feature.group; - spdlog::info(" {} {:>2} of {} ({})", enabled ? "+ Patching" : "x Skipping", i + 1, std::size(kFeatures), - feature.name); - if (!enabled) + if (feature.group && !(config.patches.*feature.group)) { + spdlog::info(" x Skipping {:>2} of {} ({})", i + 1, std::size(kFeatures), feature.name); continue; + } + bool installed = false; try { - if (feature.install) - feature.install(); - if (feature.frame) - frame_work.push_back(feature.frame); + installed = !feature.install || feature.install(); } catch (const std::exception& error) { spdlog::error("{} failed to install: {}", feature.name, error.what()); } + // Logged after installing, so the line says what happened; the reasons for a failure come just before it. + if (installed) + spdlog::info(" + Patching {:>2} of {} ({})", i + 1, std::size(kFeatures), feature.name); + else + spdlog::warn(" ! Not installed {:>2} of {} ({})", i + 1, std::size(kFeatures), feature.name); + if (installed && feature.frame) + frame_work.push_back(feature.frame); } // Last, so a feature never sees a frame before it is installed. Without the tick nothing polls, and the settings // file is only read at startup. - if (!IL2CPP_HOOK(ScreenManager::Class::Method("Update"), ScreenManager_Update_Hook)) + il2cpp::HookSet hooks("FrameTick"); + IL2CPP_STAGE(hooks, ScreenManager::Class::Method("Update"), ScreenManager_Update_Hook); + if (!hooks.Commit()) spdlog::error("The frame tick is missing: shortcuts, settings reload and per-frame features will not run"); } @@ -266,7 +276,9 @@ void StartMod() #else auto il2cpp_init = dlsym(assembly, "il2cpp_init"); #endif - if (!IL2CPP_DETOUR(il2cpp_init, il2cpp_init_Hook, "il2cpp_init")) + il2cpp::HookSet hooks("il2cpp_init"); + IL2CPP_STAGE_AT(hooks, il2cpp_init, il2cpp_init_Hook); + if (!hooks.Commit()) fprintf(stderr, "stfc-community-mod: failed to hook il2cpp_init\n"); } catch (const std::exception& error) { fprintf(stderr, "stfc-community-mod: failed to hook il2cpp_init: %s\n", error.what()); diff --git a/src/mod/sync/fleet_watch.cc b/src/mod/sync/fleet_watch.cc index fd0f83f2e..5ecb42a22 100644 --- a/src/mod/sync/fleet_watch.cc +++ b/src/mod/sync/fleet_watch.cc @@ -24,7 +24,7 @@ constexpr int64_t kSeedStabilizationMs = 5'000; constexpr int64_t kSeedMaxStabilizationMs = 30'000; struct ObservedFleet { - uint64_t fleet_id = 0; + int64_t fleet_id = 0; FleetState state = FleetState::Unknown; bool occupied = false; }; @@ -77,7 +77,7 @@ void reset_observation() s_manager_probe_slot = 0; } -void restart_seed(int slot, uint64_t fleet_id) +void restart_seed(int slot, int64_t fleet_id) { spdlog::debug("[FleetWatch] fleet topology changed; restarting baseline slot={} fleet={}", slot, fleet_id); reset_observation(); diff --git a/src/mod/sync/fleet_watch.h b/src/mod/sync/fleet_watch.h index a137c9a4a..87e980658 100644 --- a/src/mod/sync/fleet_watch.h +++ b/src/mod/sync/fleet_watch.h @@ -6,7 +6,7 @@ namespace fleet_watch { struct Snapshot { int slot = -1; - uint64_t fleet_id = 0; + int64_t fleet_id = 0; FleetState state = FleetState::Unknown; }; diff --git a/src/mod/sync/fleets.cc b/src/mod/sync/fleets.cc index e0d8b32a7..5886ccf3f 100644 --- a/src/mod/sync/fleets.cc +++ b/src/mod/sync/fleets.cc @@ -31,8 +31,8 @@ namespace // Fleets the server just sent new data for. The client fills in some values (mining and travel timers, damage) from // that data, so the game thread re-reads these fleets shortly after. - std::mutex reread_mutex; - std::unordered_map reread_at; + std::mutex reread_mutex; + std::unordered_map reread_at; // 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. @@ -140,7 +140,7 @@ namespace // The values the client fills in late, as last sent per fleet; a re-read is only sent when they changed. Values // that move on their own are left out: cargo while mining, hull while repairing. - std::unordered_map sent_late; + std::unordered_map sent_late; size_t LateFields(const json& record) { @@ -220,7 +220,7 @@ namespace void RereadDue() { - std::vector due; + std::vector due; { std::scoped_lock lock(reread_mutex); if (reread_at.empty()) @@ -270,7 +270,7 @@ void InstallFleetSync() void RequestFleetSnapshot() { snapshot_requested.store(true, std::memory_order_release); } -void RereadFleet(uint64_t fleet_id) +void RereadFleet(int64_t fleet_id) { // Deployed fleets are processed whether or not a target wants them; nothing would drain these. if (!fleet_sync_installed) diff --git a/src/mod/sync/sync.cc b/src/mod/sync/sync.cc index f1e3ae8d1..a6ceb32b3 100644 --- a/src/mod/sync/sync.cc +++ b/src/mod/sync/sync.cc @@ -158,11 +158,12 @@ namespace SetInstanceId(instanceId); } - void Install() + bool Install() { - IL2CPP_HOOK(GameServerModelRegistry::Method("ProcessResultInternal"), - GameServerModelRegistry_ProcessResultInternal_Hook); - IL2CPP_HOOK(PrimeApp::Class::Method("InitPrimeServer"), PrimeApp_InitPrimeServer_Hook); + il2cpp::HookSet hooks("Sync"); + IL2CPP_STAGE(hooks, GameServerModelRegistry::Method("ProcessResultInternal"), + GameServerModelRegistry_ProcessResultInternal_Hook); + IL2CPP_STAGE(hooks, PrimeApp::Class::Method("InitPrimeServer"), PrimeApp_InitPrimeServer_Hook); // This hook only captures the game version for the X-PRIME-VERSION header, which has a fallback. It can be // turned off for users whose other mods detour the same method: a native and a managed detour on one prologue @@ -170,18 +171,22 @@ namespace if (!Config::Get().patches.game_version) spdlog::info("Sync: skipping GameServer::Initialise hook (patches.game_version = false)"); else - IL2CPP_HOOK(GameServer::Method("Initialise"), GameServer_Initialise_Hook); + IL2CPP_STAGE(hooks, GameServer::Method("Initialise"), GameServer_Initialise_Hook); - IL2CPP_HOOK(GameServer::Method("SetInstanceIdHeader"), GameServer_SetInstanceIdHeader_Hook); + IL2CPP_STAGE(hooks, GameServer::Method("SetInstanceIdHeader"), GameServer_SetInstanceIdHeader_Hook); // Each parser overrides ParseFinalPayload, so these are two distinct native methods. - IL2CPP_HOOK(SlotAssignRtcParser::Method("ParseFinalPayload"), RtcParser_ParseFinalPayload_Hook); - IL2CPP_HOOK(SlotClearRtcParser::Method("ParseFinalPayload"), RtcParser_ParseFinalPayload_Hook); + IL2CPP_STAGE(hooks, SlotAssignRtcParser::Method("ParseFinalPayload"), RtcParser_ParseFinalPayload_Hook); + IL2CPP_STAGE(hooks, SlotClearRtcParser::Method("ParseFinalPayload"), RtcParser_ParseFinalPayload_Hook); + // Without its hooks sync receives nothing, so its workers would only idle. + if (!hooks.Commit()) + return false; FollowSettings(); InstallFleetSync(); StartBattleLogs(); std::thread(RunJobs).detach(); + return true; } // Frame work: settings changes reach the targets, then fleet state is polled. diff --git a/src/mod/sync/sync.h b/src/mod/sync/sync.h index 827ba6842..13bfb998e 100644 --- a/src/mod/sync/sync.h +++ b/src/mod/sync/sync.h @@ -258,5 +258,5 @@ 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); +void RereadFleet(int64_t fleet_id); } // namespace data_sync diff --git a/tests/action_queue.cc b/tests/action_queue.cc index 7dcafcf13..9d60704aa 100644 --- a/tests/action_queue.cc +++ b/tests/action_queue.cc @@ -1,43 +1,110 @@ +// The Kir'Shara queue fixes' decisions and request store, against fake weak handles. They do not model the game's +// ABI or network timing. + #include "features/action_queue.h" + #include +#include #include #include +#include + +using namespace action_queue; +using namespace std::chrono_literals; namespace { -struct Handles { - using Object = int; - using Handle = unsigned; - static inline unsigned serial{}; - static inline std::map live; - static Handle New(Object object) +// The shape of il2cpp::GcHandle, over a map that stands in for the GC. Collect clears every handle to an object. +class FakeHandle +{ +public: + static inline unsigned serial{}; + static inline std::map live; + + FakeHandle() = default; + static FakeHandle Weak(int object) { live[++serial] = object; - return serial; + return FakeHandle(serial); + } + ~FakeHandle() + { Reset(); } + FakeHandle(FakeHandle&& other) noexcept + : id_(std::exchange(other.id_, 0)) + { + } + FakeHandle& operator=(FakeHandle&& other) noexcept + { + if (this != &other) { + Reset(); + id_ = std::exchange(other.id_, 0); + } + return *this; } - static Object Get(Handle handle) + explicit operator bool() const + { return id_ != 0; } + int Get() const { - auto it = live.find(handle); + auto it = live.find(id_); return it == live.end() ? 0 : it->second; } - static void Free(Handle handle) - { assert(live.erase(handle) == 1); } - static void Collect(Object object) + static void Collect(int object) { for (auto& [_, value] : live) if (value == object) value = 0; } + +private: + explicit FakeHandle(unsigned id) + : id_(id) + { + } + void Reset() + { + if (id_) + assert(live.erase(std::exchange(id_, 0)) == 1); + } + unsigned id_ = 0; }; -using Requests = action_queue::Requests; -using action_queue::CanAdvance; -using action_queue::QueueState; -using namespace std::chrono_literals; -const auto now = action_queue::Clock::time_point{} + 100s; -QueueState dispatch{true, 10, 100, 2.5f, 2, false, false}; -QueueState remaining{true, 10, 200, 2.5f, 1, false, false}; -} // namespace -int main() + +using Requests = action_queue::Requests; +const auto now = Clock::time_point{} + 100s; +RetryState dispatch{true, 10, 100, 2.5f, 2, false, false}; +RetryState remaining{true, 10, 200, 2.5f, 1, false, false}; + +QueueSnapshot Queue(int count, std::int64_t head) +{ + auto queue = QueueSnapshot{ + .present = true, + .player_fleet_id = 42, + .count = count, + .head_target_id = head, + }; + if (count > 0) { + queue.target_ids[0] = head; + queue.captured_target_count = count; + } + return queue; +} + +QueueSnapshot Queue(std::initializer_list targets) +{ + QueueSnapshot queue{ + .present = true, + .player_fleet_id = 42, + .count = static_cast(targets.size()), + }; + int index = 0; + for (const auto target : targets) { + queue.target_ids[index++] = target; + } + queue.captured_target_count = queue.count; + queue.head_target_id = queue.count > 0 ? queue.target_ids[0] : 0; + return queue; +} + +void TestRecovery() { Requests requests; // Removed target: exactly one immediate handoff. Pending-success state is intentionally not an input. @@ -45,11 +112,11 @@ int main() assert(CanAdvance(remaining, 10, 100, true, true, true)); assert(requests.Consume(1, remaining, 100, now + 1s)); assert(!requests.Consume(1, remaining, 100, now + 2s)); - assert(Handles::live.empty()); + assert(FakeHandle::live.empty()); // Native retry remains responsible while the target is still queued, even after reordering. - auto reordered = remaining; - reordered.containsTarget = true; + auto reordered = remaining; + reordered.contains_target = true; assert(!CanAdvance(reordered, 10, 100, true, true, true)); assert(!CanAdvance(dispatch, 10, 100, true, true, true)); // Cross-fleet target cleanup must retain native false-branch behavior without consuming the request. @@ -83,9 +150,9 @@ int main() auto differentAttempt = remaining; differentAttempt.attempt = 9; assert(!requests.Consume(1, differentAttempt, 100, now)); - Handles::Collect(1); + FakeHandle::Collect(1); assert(!requests.Consume(1, remaining, 100, now)); - assert(Handles::live.empty()); + assert(FakeHandle::live.empty()); // Canceling an older reentrant call cannot cancel the newer dispatch. auto old = requests.Remember(1, dispatch, now); @@ -98,13 +165,13 @@ int main() requests.Remember(1, dispatch, now); assert(!requests.Consume(1, remaining, 100, now + 30s)); - assert(Handles::live.empty()); + assert(FakeHandle::live.empty()); // Bounded capacity, independent fleets and session cleanup release all handles. for (int i = 1; i <= 9; ++i) { auto s = dispatch; s.fleet = i; requests.Remember(i, s, now + std::chrono::seconds(i)); - assert(Handles::live.size() <= 8); + assert(FakeHandle::live.size() <= 8); } auto first = remaining; first.fleet = 1; @@ -114,7 +181,276 @@ int main() assert(requests.Consume(9, last, 100, now + 10s)); requests.Clear(); requests.Clear(); - assert(Handles::live.empty()); + assert(FakeHandle::live.empty()); assert(!requests.Consume(2, remaining, 100, now)); - std::cout << "Faster Queue Recovery policy and request-lifecycle tests passed\n"; +} + +void TestThinProtection() +{ + // native engage results have stable diagnostic names + { + assert(EngageResultName(0).compare("success") == 0); + assert(EngageResultName(1).compare("skip-target") == 0); + assert(EngageResultName(2).compare("stop") == 0); + assert(EngageResultName(-1).compare("not-attempted") == 0); + assert(EngageResultName(99).compare("unknown") == 0); + } + + // destroyed head processing is exact and feature gated + { + const auto queue = Queue(2, 101); + + assert(ShouldProcessDestroyedHead(true, true, 101, queue)); + assert(!ShouldProcessDestroyedHead(false, true, 101, queue)); + assert(!ShouldProcessDestroyedHead(true, false, 101, queue)); + assert(!ShouldProcessDestroyedHead(true, true, 202, queue)); + assert(!ShouldProcessDestroyedHead(true, true, 0, queue)); + } + + // destroyed head processing refuses active queue latches + { + auto queue = Queue(2, 101); + + queue.is_engaging = true; + assert(!ShouldProcessDestroyedHead(true, true, 101, queue)); + + queue.is_engaging = false; + queue.last_engaged_target_id = 101; + assert(!ShouldProcessDestroyedHead(true, true, 101, queue)); + + queue.last_engaged_target_id = 0; + queue.pending_target_id = 101; + assert(!ShouldProcessDestroyedHead(true, true, 101, queue)); + } + + // local kill and repeated disposal are no-ops once the exact target is absent + { + const auto next_head = Queue(1, 202); + const auto empty = Queue(0, 0); + + assert(!ShouldProcessDestroyedHead(true, true, 101, next_head)); + assert(!ShouldProcessDestroyedHead(true, true, 101, empty)); + } + + // native prune identifies a stranded new-head resume candidate + { + const auto before = Queue({101, 202, 303, 404}); + const auto after = Queue({303, 404}); + + assert(IsNativePruneResumeCandidate(true, true, before, after)); + } + + // temporary unavailability without native removal is not a candidate + { + const auto before = Queue({101, 202}); + const auto after = Queue({101, 202}); + + assert(!IsNativePruneResumeCandidate(true, true, before, after)); + } + + // resume requires idle state and rejects latches on the surviving suffix + { + const auto before = Queue({101, 202, 303}); + + auto after = Queue({202, 303}); + assert(!IsNativePruneResumeCandidate(false, true, before, after)); + assert(!IsNativePruneResumeCandidate(true, false, before, after)); + + after.is_engaging = true; + assert(!IsNativePruneResumeCandidate(true, true, before, after)); + + after.is_engaging = false; + after.last_engaged_target_id = 202; + assert(!IsNativePruneResumeCandidate(true, true, before, after)); + + after.last_engaged_target_id = 0; + after.pending_target_id = 202; + assert(!IsNativePruneResumeCandidate(true, true, before, after)); + } + + // resume accepts no-target sentinels and stale latches from the removed prefix + { + const auto before = Queue({101, 202, 303, 404}); + + auto after = Queue({303, 404}); + after.last_engaged_target_id = 101; + after.pending_target_id = 202; + assert(IsNativePruneResumeCandidate(true, true, before, after)); + + after.last_engaged_target_id = -1; + after.pending_target_id = -1; + assert(IsNativePruneResumeCandidate(true, true, before, after)); + } + + // resume rejects unrelated positive native latches + { + const auto before = Queue({101, 202, 303}); + + auto after = Queue({303}); + after.pending_target_id = 909; + assert(!IsNativePruneResumeCandidate(true, true, before, after)); + + after.pending_target_id = 0; + after.last_engaged_target_id = 909; + assert(!IsNativePruneResumeCandidate(true, true, before, after)); + } + + // manual clear and repeated watchdog callbacks are no-ops + { + const auto before = Queue({101, 202}); + const auto empty = Queue(0, 0); + const auto stable = Queue({202}); + + assert(!IsNativePruneResumeCandidate(true, true, before, empty)); + assert(!IsNativePruneResumeCandidate(true, true, stable, stable)); + } + + // resume fails closed on reorder truncation and fleet replacement + { + const auto before = Queue({101, 202, 303, 404}); + + auto after = Queue({404, 303}); + assert(!IsNativePruneResumeCandidate(true, true, before, after)); + + after = Queue({303, 404}); + after.targets_truncated = true; + assert(!IsNativePruneResumeCandidate(true, true, before, after)); + + after = Queue({303, 404}); + after.player_fleet_id = 84; + assert(!IsNativePruneResumeCandidate(true, true, before, after)); + } + + // resume postcondition requires the exact same idle surviving suffix + { + const auto expected = Queue({303, 404}); + + assert(IsStableResumePostcondition(expected, expected)); + + auto confirmed = expected; + confirmed.target_ids[1] = 505; + assert(!IsStableResumePostcondition(expected, confirmed)); + + confirmed = expected; + confirmed.is_engaging = true; + assert(!IsStableResumePostcondition(expected, confirmed)); + + confirmed = expected; + confirmed.pending_target_id = 303; + assert(!IsStableResumePostcondition(expected, confirmed)); + + auto stale_latch = expected; + stale_latch.last_engaged_target_id = 101; + stale_latch.pending_target_id = 202; + assert(IsStableResumePostcondition(stale_latch, stale_latch)); + + confirmed = stale_latch; + confirmed.pending_target_id = 101; + assert(!IsStableResumePostcondition(stale_latch, confirmed)); + + stale_latch.pending_target_id = 303; + assert(!IsStableResumePostcondition(stale_latch, stale_latch)); + + confirmed = expected; + confirmed.player_fleet_id = 84; + assert(!IsStableResumePostcondition(expected, confirmed)); + + confirmed = expected; + confirmed.count = 1; + confirmed.head_target_id = 404; + confirmed.target_ids[0] = 404; + confirmed.target_ids[1] = 0; + confirmed.captured_target_count = 1; + assert(!IsStableResumePostcondition(expected, confirmed)); + } +} + +void TestAddressGuard() +{ + const Address base{true, 0, 71088, 71237, 710}; + const Address system{true, 0, 925162490, 0, 710}; + const AddressEvidence reproduced{true, true, true, true, false, 512, 1, 0, base, system, system}; + assert(UseDeployedAddress(reproduced)); + for (int state : {1, 64, 512}) { + auto e = reproduced; + e.player_state = state; + assert(UseDeployedAddress(e)); + } + // Destruction, dock/repair, and an intentional warp must retain native clearing. + for (int state : {-1, 0, 2, 4, 8, 16, 32, 128, 256}) { + auto e = reproduced; + e.player_state = state; + assert(!UseDeployedAddress(e)); + } + for (int state : {-1, 2, 4, 5, 99}) { + auto e = reproduced; + e.deployed_state = state; + assert(!UseDeployedAddress(e)); + } + for (int reason : {-1, 1, 2, 3, 4, 5}) { + auto e = reproduced; + e.removal_reason = reason; + assert(!UseDeployedAddress(e)); + } + auto reject = [](AddressEvidence e) { assert(!UseDeployedAddress(e)); }; + auto e = reproduced; + e.enabled = false; + reject(e); + e = reproduced; + e.player_state_event = false; + reject(e); // manual clear/UI path + e = reproduced; + e.owned_queue = false; + reject(e); + e = reproduced; + e.owned_deployment = false; + reject(e); // wrong fleet or unknown ownership + e = reproduced; + e.planning_recall = true; + reject(e); + e = reproduced; + e.reported.valid = false; + reject(e); + e = reproduced; + e.deployed.valid = false; + reject(e); + e = reproduced; + e.target.valid = false; + reject(e); + e = reproduced; + e.target.system = 123; + reject(e); // real foreign target + e = reproduced; + e.target.instance = 711; + reject(e); + e = reproduced; + e.target.planet = 123; + reject(e); + e = reproduced; + e.reported.planet = 0; + reject(e); // scope is the observed base substitution + e = reproduced; + e.reported.system = system.system; + reject(e); + e = reproduced; + e.reported.instance = 711; + reject(e); + e = reproduced; + e.reported.galaxy = 1; + reject(e); + e = reproduced; + e.deployed = base; + reject(e); // own deployment really moved to base + e = reproduced; + e.reported = system; + reject(e); // no wrong address to correct +} +} // namespace + +int main() +{ + TestRecovery(); + TestThinProtection(); + TestAddressGuard(); + std::cout << "Action queue tests passed\n"; } diff --git a/tests/il2cpp_bindings.cc b/tests/il2cpp_bindings.cc index 49e4dad12..b0eb088c4 100644 --- a/tests/il2cpp_bindings.cc +++ b/tests/il2cpp_bindings.cc @@ -1,28 +1,39 @@ -// Typed bindings against a controlled managed runtime: lookup, signature checks, native calls, -// fields, dispatch, and failure reporting. +// Typed bindings against a controlled managed runtime: lookup, signature checks, native calls, fields, dispatch, +// failure reporting, hook sets and strings, plus the Unity keyboard and focus snapshots built on them. + #include "il2cpp_test_api.h" +#include #include #include +#include #include #include #include #include +#include #include #include #include +#include #include #include +#include #include +#include #include namespace { -void Require(bool condition, const char* message); +void Require(bool condition, const char* what) +{ + if (!condition) + throw std::runtime_error(what); +} -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{}; +int input_frame = 0, held_reads = 0, down_reads = 0; +bool any_held = false, any_down = false; +std::array held_keys{}, down_keys{}; void KeyboardSnapshot() { @@ -32,9 +43,9 @@ void KeyboardSnapshot() 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; + any_held = any_down = true; + held_keys[std::to_underlying(KeyCode::A)] = true; + down_keys[std::to_underlying(KeyCode::A)] = true; Key::Poll(); for (int i = 0; i < 100; ++i) { Key::Poll(); @@ -43,8 +54,8 @@ void KeyboardSnapshot() } 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; + held_keys[std::to_underlying(KeyCode::A)] = false; + down_keys[std::to_underlying(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"); @@ -75,7 +86,8 @@ std::deque field_types; std::unordered_map registry; std::vector errors; std::vector initialized; -int method_lookups = 0; +// Method table walks started, one per class visited. +int method_lookups = 0; FakeClass& Define(const char* namespaze, const char* name, Il2CppTypeEnum type, FakeClass* parent = nullptr, int32_t value_size = 0) @@ -409,11 +421,10 @@ API(const Il2CppImage*, il2cpp_get_corlib, ()) END_API API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage*, const char* namespaze, const char* name)) { - for (auto& fake : classes) { - if (!std::strcmp(fake.klass.namespaze, namespaze) && !std::strcmp(fake.klass.name, name)) - return &fake.klass; - } - return nullptr; + const auto fake = std::ranges::find_if(classes, [&](const FakeClass& fake) { + return std::string_view(fake.klass.namespaze) == namespaze && std::string_view(fake.klass.name) == name; + }); + return fake == classes.end() ? nullptr : &fake->klass; } END_API API(const char*, il2cpp_class_get_name, (Il2CppClass * klass)) @@ -441,33 +452,19 @@ END_API API(const MethodInfo*, il2cpp_class_get_methods, (Il2CppClass * klass, void** iter)) { auto index = reinterpret_cast(*iter); + method_lookups += index == 0; if (index >= klass->method_count) return nullptr; *iter = reinterpret_cast(index + 1); return klass->methods[index]; } END_API -API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * klass, const char* name, int count)) -{ - ++method_lookups; - for (; klass; klass = klass->parent) { - for (uint16_t i = 0; i < klass->method_count; ++i) { - auto* method = klass->methods[i]; - if (!std::strcmp(method->name, name) && (count < 0 || method->parameters_count == count)) - return method; - } - } - return nullptr; -} -END_API API(const PropertyInfo*, il2cpp_class_get_property_from_name, (Il2CppClass * klass, const char* name)) { - for (; klass; klass = klass->parent) { - for (auto& property : Fake(klass).properties) { - if (!std::strcmp(property.name, name)) + for (; klass; klass = klass->parent) + for (auto& property : Fake(klass).properties) + if (std::string_view(property.name) == name) return &property; - } - } return nullptr; } END_API @@ -479,12 +476,10 @@ API(const MethodInfo*, il2cpp_property_get_set_method, (PropertyInfo * property) END_API API(FieldInfo*, il2cpp_class_get_field_from_name, (Il2CppClass * klass, const char* name)) { - for (; klass; klass = klass->parent) { - for (auto& field : Fake(klass).fields) { - if (!std::strcmp(field.name, name)) + for (; klass; klass = klass->parent) + for (auto& field : Fake(klass).fields) + if (std::string_view(field.name) == name) return &field; - } - } return nullptr; } END_API @@ -502,11 +497,11 @@ API(Il2CppMethodPointer, il2cpp_resolve_icall, (const char* name)) static constexpr auto down = +[]() { return any_down; }; static constexpr auto key = +[](unity::KeyCode key) { ++held_reads; - return held_keys[static_cast(key)]; + return held_keys[std::to_underlying(key)]; }; static constexpr auto key_down = +[](unity::KeyCode key) { ++down_reads; - return down_keys[static_cast(key)]; + return down_keys[std::to_underlying(key)]; }; const std::pair calls[] = { {"Test::Answer()", reinterpret_cast(answer)}, @@ -516,10 +511,8 @@ API(Il2CppMethodPointer, il2cpp_resolve_icall, (const char* name)) {"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; + const auto* call = std::ranges::find(calls, name, &std::pair::first); + return call == std::end(calls) ? nullptr : call->second; } END_API API(Il2CppObject*, il2cpp_type_get_object, (const Il2CppType*)) @@ -547,10 +540,9 @@ API(bool, il2cpp_class_is_assignable_from, (Il2CppClass * klass, Il2CppClass* ot std::fprintf(stderr, "il2cpp_class_is_assignable_from called with a null class\n"); std::abort(); } - for (; other; other = other->parent) { + for (; other; other = other->parent) if (other == klass) return true; - } return false; } END_API @@ -559,12 +551,10 @@ API(void, il2cpp_runtime_class_init, (Il2CppClass * klass)) END_API API(const MethodInfo*, il2cpp_object_get_virtual_method, (Il2CppObject * object, const MethodInfo* method)) { - for (auto* klass = object->klass; klass; klass = klass->parent) { - for (uint16_t i = 0; i < klass->method_count; ++i) { - if (!std::strcmp(klass->methods[i]->name, method->name)) - return klass->methods[i]; - } - } + for (auto* klass = object->klass; klass; klass = klass->parent) + for (const auto* candidate : std::span(klass->methods, klass->method_count)) + if (std::string_view(candidate->name) == method->name) + return candidate; return nullptr; } END_API @@ -647,18 +637,29 @@ struct Gone : il2cpp::Object { }; enum class State : int32_t { kIdle = 0, kBusy = 2 }; -void Require(bool condition, const char* what) +// Detour targets for the hook set test, long enough for SPUD's patch. +[[gnu::noinline]] int Scaled(int value) { - if (!condition) - throw std::runtime_error(what); + volatile int result = value; + for (int i = 0; i < 2; ++i) + result = result * 3 + 1; + return result - value; } +[[gnu::noinline]] int Offset(int value) +{ + volatile int result = value; + for (int i = 0; i < 2; ++i) + result = result + 5; + return result * 2; +} +int Scaled_Hook(auto original, int value) +{ return original(value) + 100; } +int Offset_Hook(auto original, int value) +{ return original(value) + 1000; } size_t ErrorsMentioning(std::string_view text) { - size_t count = 0; - for (auto& error : errors) - count += error.find(text) != std::string::npos; - return count; + return std::ranges::count_if(errors, [text](const std::string& error) { return error.contains(text); }); } } // namespace @@ -670,7 +671,7 @@ int main() // Properties call the generated getter with the receiver and its MethodInfo. Require(base->Count() == 7 && seen_self == &base_object.header, "property getter receiver"); - Require(seen_method && !std::strcmp(seen_method->name, "get_Count"), "trailing MethodInfo"); + Require(seen_method && std::string_view(seen_method->name) == "get_Count", "trailing MethodInfo"); base->SetCount(9); Require(base->Count() == 9, "property setter"); Require(base->Position().y == 2.f, "struct return"); @@ -708,13 +709,13 @@ int main() // Virtual calls dispatch on the receiver; non-virtual calls do not. auto* derived = reinterpret_cast(&derived_object); - Require(!std::strcmp(base->Describe(), "base"), "base dispatch"); - Require(!std::strcmp(static_cast(derived)->Describe(), "derived"), "virtual dispatch"); + Require(std::string_view(base->Describe()) == "base", "base dispatch"); + Require(std::string_view(static_cast(derived)->Describe()) == "derived", "virtual dispatch"); // Static members initialize their class. Require(Base::Class::Call<"Twice", int32_t>(int32_t{21}) == 42, "static call"); Require(Base::Class::Get<"Instance", Base*>() == base, "static property"); - Require(std::find(initialized.begin(), initialized.end(), &base_class.klass) != initialized.end(), "class init"); + Require(std::ranges::contains(initialized, &base_class.klass), "class init"); // Construction runs the matching constructor. auto* made = il2cpp::New(int32_t{12}); @@ -725,9 +726,12 @@ int main() // Lookups happen once per binding, not per call. const int lookups = method_lookups; + base->Call<"Select", void(bool)>(false); + Require(method_lookups > lookups, "first call looks the method up"); + const int resolved = method_lookups; for (int i = 0; i < 3; ++i) - base->Call<"Select">(int32_t{i}); - Require(method_lookups == lookups, "cached method lookup"); + base->Call<"Select", void(bool)>(i == 1); + Require(method_lookups == resolved && seen_overload == "bool", "cached method lookup"); // Missing and drifted members are reported once and return defaults without calling native code. const int calls = native_calls; @@ -751,8 +755,8 @@ int main() Require(ErrorsMentioning("Game.Base.Explode threw: NullReferenceException") == 1, "exception reported once"); // Internal calls run when present; a missing one is reported once and returns the default. - static const auto answer = il2cpp::ResolveICall("Test::Answer()"); - static const auto renamed = il2cpp::ResolveICall("Test::Renamed()"); + static const auto answer = il2cpp::ICall("Test::Answer()"); + static const auto renamed = il2cpp::ICall("Test::Renamed()"); Require(answer && answer() == 42, "internal call"); Require(!renamed && renamed() == 0 && renamed() == 0, "missing internal call returns the default"); Require(ErrorsMentioning("missing internal call Test::Renamed()") == 1, "missing internal call reported once"); @@ -768,9 +772,17 @@ int main() for (auto* item : *second) items += item != nullptr; Require(items == 3 && (*first)[0] == &list_a.header, "list iteration"); - Require(first->Method("get_Count").Pointer() == reinterpret_cast(&ListA_get_Count), - "runtime collection method schema"); - Require(!first->Method("get_Item"), "runtime collection schema rejects a value result"); + Require(std::ranges::count(*second, nullptr) == 0 && std::ranges::distance(il2cpp::Items(second)) == 3, + "lists are ranges"); + Require(il2cpp::At(first, 0) == &list_a.header && il2cpp::At(first, 2) == nullptr && il2cpp::At(first, -1) == nullptr, + "list At checks the index"); + il2cpp::List* no_list = nullptr; + il2cpp::Array* no_array = nullptr; + Require(il2cpp::Items(no_list).empty() && il2cpp::Items(no_array).empty() && il2cpp::At(no_list, 0) == nullptr + && il2cpp::At(no_array, 0) == nullptr, + "a null collection reads as empty"); + Require(first->HasMethod("get_Count"), "runtime collection method schema"); + Require(!first->HasMethod("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, @@ -806,6 +818,43 @@ int main() Require(Base::Class::HasField<"_count", int32_t>(), "instance field schema accepts the declared type"); Require(!Base::Class::HasField<"_count", Other*>(), "instance field schema rejects an incompatible type"); Require(!Base::Class::HasField<"s_flag", int32_t>(), "instance field schema rejects a static field"); + Require(Base::Class::HasProperty<"Count", int32_t>(), "property schema accepts the declared type"); + Require(!Base::Class::HasProperty<"Count", Other*>(), "property schema rejects an incompatible type"); + + // Static hook lookups check the signature without running the class constructor ahead of the game. + initialized.clear(); + Require(Base::Class::StaticMethod("Twice").Pointer() == reinterpret_cast(&Base_Twice), + "typed static hook lookup"); + Require(initialized.empty(), "a static hook lookup leaves the class uninitialized"); + Require(!Base::Class::StaticMethod("Select"), "static hook lookup rejects an instance method"); + + // Boxed primitives unbox only as their own type. + Il2CppObject boxed{}; + boxed.klass = &int_class.klass; + int32_t number = 0; + int64_t wide = 7; + Require(il2cpp::Unbox(&boxed, number) && number == shared_result, "unbox the boxed type"); + Require(!il2cpp::Unbox(&boxed, wide) && wide == 7, "unbox rejects another type and leaves the value"); + Require(!il2cpp::Unbox(static_cast(nullptr), number), "unbox rejects null"); + + // A hook set installs every hook or none: a missing member, or a null address, leaves its targets untouched. + { + const int scaled_before = Scaled(2); + il2cpp::HookSet incomplete("Incomplete"); + IL2CPP_STAGE(incomplete, il2cpp::Method(), Scaled_Hook); + IL2CPP_STAGE_AT(incomplete, &Scaled, Scaled_Hook); + Require(!incomplete.Commit() && Scaled(2) == scaled_before, "a set with a missing method installs nothing"); + Require(ErrorsMentioning("Incomplete not installed: a method it hooks is missing") == 1, + "an incomplete set says what it cost"); +#if defined(__x86_64__) || defined(_M_X64) + const int offset_before = Offset(2); + il2cpp::HookSet complete("Complete"); + IL2CPP_STAGE_AT(complete, &Scaled, Scaled_Hook); + IL2CPP_STAGE_AT(complete, &Offset, Offset_Hook); + Require(complete.Commit() && Scaled(2) == scaled_before + 100 && Offset(2) == offset_before + 1000, + "a complete set installs every hook"); +#endif + } // Managed strings convert to UTF-8, and compare with ASCII without converting. const auto utf16 = [](std::u16string_view text) { diff --git a/tests/il2cpp_gc.cc b/tests/il2cpp_gc.cc index 604b3654a..610cb5811 100644 --- a/tests/il2cpp_gc.cc +++ b/tests/il2cpp_gc.cc @@ -1,3 +1,5 @@ +// GcHandle ownership against a fake handle table: null and failed roots, moves, resets and weak targets. + #include "il2cpp_test_api.h" #include @@ -12,7 +14,14 @@ uintptr_t next_handle = 1; bool fail_allocation = false; int frees = 0; bool weak = false; + +void Require(bool condition) +{ + if (!condition) + throw std::runtime_error("GC ownership regression"); +} } // namespace + API(Il2CppGCHandle, il2cpp_gchandle_new, (Il2CppObject * object, bool pinned)) { if (!object || pinned) @@ -42,11 +51,7 @@ API(void, il2cpp_gchandle_free, (Il2CppGCHandle handle)) ++frees; } END_API -void Require(bool condition) -{ - if (!condition) - throw std::runtime_error("GC ownership regression"); -} + int main() { Require(!il2cpp::GcHandle::Strong(nullptr)); diff --git a/tests/il2cpp_test_api.h b/tests/il2cpp_test_api.h index fc51a2e18..be4a5d448 100644 --- a/tests/il2cpp_test_api.h +++ b/tests/il2cpp_test_api.h @@ -1,5 +1,7 @@ #pragma once +// Lets a test define the IL2CPP exports the library calls, as API(ret, name, params) { body } END_API. + #include #if _WIN32 #undef IL2CPP_IMPORT diff --git a/tests/queue_address_guard.cc b/tests/queue_address_guard.cc deleted file mode 100644 index 1ebaf03fd..000000000 --- a/tests/queue_address_guard.cc +++ /dev/null @@ -1,86 +0,0 @@ -#include "features/queue_address_guard_policy.h" -#include -#include - -int main() -{ - using namespace queue_address_guard; - const Address base{true, 0, 71088, 71237, 710}; - const Address system{true, 0, 925162490, 0, 710}; - const Evidence reproduced{true, true, true, true, false, 512, 1, 0, base, system, system}; - assert(UseDeployedAddress(reproduced)); - for (int state : {1, 64, 512}) { - auto e = reproduced; - e.player_state = state; - assert(UseDeployedAddress(e)); - } - // Destruction, dock/repair, and an intentional warp must retain native clearing. - for (int state : {-1, 0, 2, 4, 8, 16, 32, 128, 256}) { - auto e = reproduced; - e.player_state = state; - assert(!UseDeployedAddress(e)); - } - for (int state : {-1, 2, 4, 5, 99}) { - auto e = reproduced; - e.deployed_state = state; - assert(!UseDeployedAddress(e)); - } - for (int reason : {-1, 1, 2, 3, 4, 5}) { - auto e = reproduced; - e.removal_reason = reason; - assert(!UseDeployedAddress(e)); - } - auto reject = [](Evidence e) { assert(!UseDeployedAddress(e)); }; - auto e = reproduced; - e.enabled = false; - reject(e); - e = reproduced; - e.player_state_event = false; - reject(e); // manual clear/UI path - e = reproduced; - e.owned_queue = false; - reject(e); - e = reproduced; - e.owned_deployment = false; - reject(e); // wrong fleet or unknown ownership - e = reproduced; - e.planning_recall = true; - reject(e); - e = reproduced; - e.reported.valid = false; - reject(e); - e = reproduced; - e.deployed.valid = false; - reject(e); - e = reproduced; - e.target.valid = false; - reject(e); - e = reproduced; - e.target.system = 123; - reject(e); // real foreign target - e = reproduced; - e.target.instance = 711; - reject(e); - e = reproduced; - e.target.planet = 123; - reject(e); - e = reproduced; - e.reported.planet = 0; - reject(e); // scope is the observed base substitution - e = reproduced; - e.reported.system = system.system; - reject(e); - e = reproduced; - e.reported.instance = 711; - reject(e); - e = reproduced; - e.reported.galaxy = 1; - reject(e); - e = reproduced; - e.deployed = base; - reject(e); // own deployment really moved to base - e = reproduced; - e.reported = system; - reject(e); // no wrong address to correct - std::cout << "Kirshara address guard policy tests passed\n"; -} diff --git a/tests/run-action-queue.ps1 b/tests/run-action-queue.ps1 deleted file mode 100644 index 3f10aab96..000000000 --- a/tests/run-action-queue.ps1 +++ /dev/null @@ -1,20 +0,0 @@ -# Standalone policy tests: no game, IL2CPP, or mod DLL required. -[CmdletBinding()] -param() -$ErrorActionPreference = 'Stop' -Push-Location (Split-Path -Parent $PSScriptRoot) -try { - New-Item -ItemType Directory -Path build -Force | Out-Null - & clang++ -std=c++23 -Isrc/mod tests/action_queue.cc -o build/action_queue_tests.exe - if ($LASTEXITCODE -ne 0) { throw 'Action queue test compilation failed.' } - & ./build/action_queue_tests.exe - if ($LASTEXITCODE -ne 0) { throw 'Action queue regression failed.' } - & clang++ -std=c++23 -Isrc/mod tests/thin_queue_protection.cc -o build/thin_queue_protection_tests.exe - if ($LASTEXITCODE -ne 0) { throw 'Thin Queue Protection test compilation failed.' } - & ./build/thin_queue_protection_tests.exe - if ($LASTEXITCODE -ne 0) { throw 'Thin Queue Protection regression failed.' } - & clang++ -std=c++23 -Isrc/mod tests/queue_address_guard.cc -o build/queue_address_guard_tests.exe - if ($LASTEXITCODE -ne 0) { throw "Kirshara address guard test compilation failed." } - & ./build/queue_address_guard_tests.exe - if ($LASTEXITCODE -ne 0) { throw "Kirshara address guard policy regression failed." } -} finally { Pop-Location } diff --git a/tests/spud_relocation.cc b/tests/spud_relocation.cc index 8b8e41f9e..e19e63448 100644 --- a/tests/spud_relocation.cc +++ b/tests/spud_relocation.cc @@ -1,3 +1,7 @@ +// A prologue shaped like the gallery constructor's sub/cmp/jne/lea/call: a RIP-relative cmp and lea ahead of a +// relative call. When both expanded in the trampoline, older SPUD misplaced the branch targets after them. The test +// detours that code and runs it, so both branch paths and the call-through are checked without the game. + #include #include @@ -11,11 +15,6 @@ #include #endif -// Match the gallery constructor's sub/cmp/jne/lea/call prologue: a RIP-relative -// cmp and lea ahead of a relative call. When both expanded in the trampoline, -// older SPUD misplaced the branch targets after them. The helper increments a -// counter, so both branch paths and call-through are checked by executing the -// installed detour, without a game installation. int main() { #if _WIN32 @@ -35,21 +34,24 @@ int main() if (!code) return 1; - const uint8_t body[] = {0x48, 0x83, 0xec, 0x28, // sub rsp, 40 - 0x80, 0x3d, 0x00, 0x00, 0x00, 0x00, 0, // cmp byte [data], 0 - 0x75, 0x18, // jne code+37 - 0x48, 0x8d, 0x0d, 0, 0, 0, 0, // lea rcx, [data+4] - 0xe8, 0x27, 0, 0, 0, // call code+64 - 0x8b, 0x05, 0, 0, 0, 0, // mov eax, [data+4] - 0x48, 0x83, 0xc4, 0x28, // add rsp, 40 - 0xc3, 0x90, // ret; nop - 0x8b, 0x05, 0, 0, 0, 0, // mov eax, [data+4] - 0x48, 0x83, 0xc4, 0x28, 0xc3}; // add rsp, 40; ret - const uint8_t helper[] = {0xff, 0x05, 0, 0, 0, 0, 0xc3}; // inc dword [data+4]; ret + // The function returns a counter, which the helper bumps first unless the flag is set. Both live in `data`: the + // flag at +0 and the counter at +4. + constexpr uint8_t body[] = {0x48, 0x83, 0xec, 0x28, // sub rsp, 40 + 0x80, 0x3d, 0x00, 0x00, 0x00, 0x00, 0, // cmp byte [data], 0 + 0x75, 0x18, // jne code+37 + 0x48, 0x8d, 0x0d, 0, 0, 0, 0, // lea rcx, [data+4] + 0xe8, 0x27, 0, 0, 0, // call code+64 + 0x8b, 0x05, 0, 0, 0, 0, // mov eax, [data+4] + 0x48, 0x83, 0xc4, 0x28, // add rsp, 40 + 0xc3, 0x90, // ret; nop + 0x8b, 0x05, 0, 0, 0, 0, // mov eax, [data+4] + 0x48, 0x83, 0xc4, 0x28, 0xc3}; // add rsp, 40; ret + constexpr uint8_t helper[] = {0xff, 0x05, 0, 0, 0, 0, 0xc3}; // inc dword [data+4]; ret std::memcpy(code, body, sizeof(body)); std::memcpy(code + 64, helper, sizeof(helper)); // Rosetta publishes patched code as RX. Keep the flag and counter on their own writable page. - auto* data = code + size; + auto* data = code + size; + // Writes the rel32 at `offset` so the instruction ending at `next` addresses data + `field`. const auto reference = [&](size_t offset, size_t next, size_t field) { const auto displacement = static_cast(size + field - next); std::memcpy(code + offset, &displacement, sizeof(displacement)); @@ -74,6 +76,8 @@ int main() __builtin___clear_cache(reinterpret_cast(code), reinterpret_cast(code + size)); #endif + // The hook adds 100. A failed check skips the calls after it, since a misrelocated detour may not survive another + // run. using Function = int (*)(void*); auto function = reinterpret_cast(code); bool passed = function(nullptr) == 1; @@ -87,6 +91,7 @@ int main() data[0] = 0; passed = passed && function(nullptr) == 103; } + // Out of scope, the hook is removed and the original runs again. passed = passed && function(nullptr) == 4; #if _WIN32 VirtualFree(code, 0, MEM_RELEASE); diff --git a/tests/sync_logic.cc b/tests/sync_logic.cc index dab8f176e..2037845c1 100644 --- a/tests/sync_logic.cc +++ b/tests/sync_logic.cc @@ -21,11 +21,11 @@ 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() +RecordStore store; +auto settings = std::make_shared(); +std::vector rereads; +Clock::time_point now{}; +SyncTargetConfig Options() { SyncTargetConfig config; config.url = "http://example.invalid"; @@ -563,7 +563,7 @@ void DeployedFleets() == 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"); + 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; @@ -598,7 +598,7 @@ void RepairJobs() == 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"); + Check(rereads == std::vector{5}, "a repair job re-reads its fleet"); } } // namespace std::shared_ptr Config::Sync::Get() @@ -614,7 +614,7 @@ void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode) void QueueNewBattles(const std::vector&, uint64_t) {} void CachePlayerNames(const Input&) {} void CacheAllianceNames(const Input&) {} -void RereadFleet(uint64_t fleet_id) +void RereadFleet(int64_t fleet_id) { rereads.push_back(fleet_id); } } // namespace data_sync int main() diff --git a/tests/thin_queue_protection.cc b/tests/thin_queue_protection.cc deleted file mode 100644 index 91a9e1ccc..000000000 --- a/tests/thin_queue_protection.cc +++ /dev/null @@ -1,222 +0,0 @@ -#include "features/action_queue_guard_policy.h" - -#include -#include -#include - -namespace -{ -using action_queue_guard::QueueState; - -QueueState Queue(int count, std::int64_t head) -{ - auto queue = QueueState{ - .present = true, - .player_fleet_id = 42, - .count = count, - .head_target_id = head, - }; - if (count > 0) { - queue.target_ids[0] = head; - queue.captured_target_count = count; - } - return queue; -} - -QueueState Queue(std::initializer_list targets) -{ - QueueState queue{ - .present = true, - .player_fleet_id = 42, - .count = static_cast(targets.size()), - }; - int index = 0; - for (const auto target : targets) { - queue.target_ids[index++] = target; - } - queue.captured_target_count = queue.count; - queue.head_target_id = queue.count > 0 ? queue.target_ids[0] : 0; - return queue; -} -} // namespace - -int main() -{ - // native engage results have stable diagnostic names - { - assert(action_queue_guard::EngageResultName(0).compare("success") == 0); - assert(action_queue_guard::EngageResultName(1).compare("skip-target") == 0); - assert(action_queue_guard::EngageResultName(2).compare("stop") == 0); - assert(action_queue_guard::EngageResultName(-1).compare("not-attempted") == 0); - assert(action_queue_guard::EngageResultName(99).compare("unknown") == 0); - } - - // destroyed head processing is exact and feature gated - { - const auto queue = Queue(2, 101); - - assert(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, queue)); - assert(!(action_queue_guard::ShouldProcessDestroyedHead(false, true, 101, queue))); - assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, false, 101, queue))); - assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 202, queue))); - assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 0, queue))); - } - - // destroyed head processing refuses active queue latches - { - auto queue = Queue(2, 101); - - queue.is_engaging = true; - assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, queue))); - - queue.is_engaging = false; - queue.last_engaged_target_id = 101; - assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, queue))); - - queue.last_engaged_target_id = 0; - queue.pending_target_id = 101; - assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, queue))); - } - - // local kill and repeated disposal are no-ops once the exact target is absent - { - const auto next_head = Queue(1, 202); - const auto empty = Queue(0, 0); - - assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, next_head))); - assert(!(action_queue_guard::ShouldProcessDestroyedHead(true, true, 101, empty))); - } - - // native prune identifies a stranded new-head resume candidate - { - const auto before = Queue({101, 202, 303, 404}); - const auto after = Queue({303, 404}); - - assert(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after)); - } - - // temporary unavailability without native removal is not a candidate - { - const auto before = Queue({101, 202}); - const auto after = Queue({101, 202}); - - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); - } - - // resume requires idle state and rejects latches on the surviving suffix - { - const auto before = Queue({101, 202, 303}); - - auto after = Queue({202, 303}); - assert(!(action_queue_guard::IsNativePruneResumeCandidate(false, true, before, after))); - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, false, before, after))); - - after.is_engaging = true; - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); - - after.is_engaging = false; - after.last_engaged_target_id = 202; - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); - - after.last_engaged_target_id = 0; - after.pending_target_id = 202; - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); - } - - // resume accepts no-target sentinels and stale latches from the removed prefix - { - const auto before = Queue({101, 202, 303, 404}); - - auto after = Queue({303, 404}); - after.last_engaged_target_id = 101; - after.pending_target_id = 202; - assert(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after)); - - after.last_engaged_target_id = -1; - after.pending_target_id = -1; - assert(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after)); - } - - // resume rejects unrelated positive native latches - { - const auto before = Queue({101, 202, 303}); - - auto after = Queue({303}); - after.pending_target_id = 909; - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); - - after.pending_target_id = 0; - after.last_engaged_target_id = 909; - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); - } - - // manual clear and repeated watchdog callbacks are no-ops - { - const auto before = Queue({101, 202}); - const auto empty = Queue(0, 0); - const auto stable = Queue({202}); - - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, empty))); - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, stable, stable))); - } - - // resume fails closed on reorder truncation and fleet replacement - { - const auto before = Queue({101, 202, 303, 404}); - - auto after = Queue({404, 303}); - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); - - after = Queue({303, 404}); - after.targets_truncated = true; - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); - - after = Queue({303, 404}); - after.player_fleet_id = 84; - assert(!(action_queue_guard::IsNativePruneResumeCandidate(true, true, before, after))); - } - - // resume postcondition requires the exact same idle surviving suffix - { - const auto expected = Queue({303, 404}); - - assert(action_queue_guard::IsStableResumePostcondition(expected, expected)); - - auto confirmed = expected; - confirmed.target_ids[1] = 505; - assert(!(action_queue_guard::IsStableResumePostcondition(expected, confirmed))); - - confirmed = expected; - confirmed.is_engaging = true; - assert(!(action_queue_guard::IsStableResumePostcondition(expected, confirmed))); - - confirmed = expected; - confirmed.pending_target_id = 303; - assert(!(action_queue_guard::IsStableResumePostcondition(expected, confirmed))); - - auto stale_latch = expected; - stale_latch.last_engaged_target_id = 101; - stale_latch.pending_target_id = 202; - assert(action_queue_guard::IsStableResumePostcondition(stale_latch, stale_latch)); - - confirmed = stale_latch; - confirmed.pending_target_id = 101; - assert(!(action_queue_guard::IsStableResumePostcondition(stale_latch, confirmed))); - - stale_latch.pending_target_id = 303; - assert(!(action_queue_guard::IsStableResumePostcondition(stale_latch, stale_latch))); - - confirmed = expected; - confirmed.player_fleet_id = 84; - assert(!(action_queue_guard::IsStableResumePostcondition(expected, confirmed))); - - confirmed = expected; - confirmed.count = 1; - confirmed.head_target_id = 404; - confirmed.target_ids[0] = 404; - confirmed.target_ids[1] = 0; - confirmed.captured_target_count = 1; - assert(!(action_queue_guard::IsStableResumePostcondition(expected, confirmed))); - } - std::cout << "Thin Queue Protection release-policy regressions passed\n"; -} diff --git a/zig/mod.zig b/zig/mod.zig index 0021af088..cc228011f 100644 --- a/zig/mod.zig +++ b/zig/mod.zig @@ -8,7 +8,7 @@ const deps = @import("deps.zig"); /// Runtime bindings: metadata lookup, typed calls, strings, GC handles and detours. Also exposes Unity's /// libil2cpp headers. -pub fn il2cpp(ctx: *const Ctx) Lib { +pub fn il2cpp(ctx: *const Ctx, spud: Lib) Lib { const b = ctx.b; const headers = Lib{ .include_dirs = ctx.dirs(&.{ b.path("src/libs/il2cpp/include"), b.path("third_party/libil2cpp") }), @@ -17,14 +17,21 @@ pub fn il2cpp(ctx: *const Ctx) Lib { }; const module = ctx.createModule(true); headers.includeInto(module); + // hook.h builds on SPUD's detours. + spud.includeInto(module); module.addCSourceFiles(.{ .root = b.path("src/libs/il2cpp"), - .files = &.{ "src/api.cc", "src/il2cpp.cc" }, + .files = &.{ "src/api.cc", "src/hook.cc", "src/il2cpp.cc" }, .flags = ctx.cxxFlags("c++23", &.{}), }); const artifact = ctx.staticLib("il2cpp", module); b.step("il2cpp", "Build the IL2CPP integration static library").dependOn(&b.addInstallArtifact(artifact, .{}).step); - return .{ .artifact = artifact, .include_dirs = headers.include_dirs, .lib_dirs = headers.lib_dirs }; + return .{ + .artifact = artifact, + .include_dirs = headers.include_dirs, + .lib_dirs = headers.lib_dirs, + .dependencies = ctx.b.allocator.dupe(Lib, &.{spud}) catch @panic("OOM"), + }; } /// Unity engine bindings over il2cpp: core types, UI, input and instance tracking. Knows nothing about the game. diff --git a/zig/tests.zig b/zig/tests.zig index a9e116ad1..8a3234617 100644 --- a/zig/tests.zig +++ b/zig/tests.zig @@ -31,12 +31,12 @@ pub fn add(build_ctx: *const Ctx, libs: Libs) void { const flags = ctx.cxxFlags("c++23", &.{}); const mod_flags = ctx.cxxFlags("c++23", &.{"-Wno-deprecated-literal-operator"}); - // Queue logic in src/mod, header-only. - inline for (.{ "action_queue", "thin_queue_protection", "queue_address_guard" }) |name| { + // The action queue decisions in src/mod, header-only. + { const module = ctx.createModule(true); module.addIncludePath(b.path("src/mod")); - module.addCSourceFile(.{ .file = b.path("tests/" ++ name ++ ".cc"), .flags = flags }); - addFixture(ctx, steps.build, steps.mod, name ++ "-tests", module); + module.addCSourceFile(.{ .file = b.path("tests/action_queue.cc"), .flags = flags }); + addFixture(ctx, steps.build, steps.mod, "action-queue-tests", module); } // IL2CPP bindings and GC handles against a fake runtime API. From 393e94c41947d86da0ecae7660bee47a5e0140aa Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 15:13:53 -0700 Subject: [PATCH 04/13] Give the Unity library one shape Unity bindings list accessors, calls and statics in one order, and behaviour active checks go through the managed getter resolved once, instead of an internal call newer Unity builds no longer register. The instance tracker starts once and its add and remove steps live on the registry they guard. Input snapshots cache held and down reads, including negative results. --- src/libs/unity/include/unity/core.h | 93 ++++++++++++++++---------- src/libs/unity/include/unity/input.h | 3 +- src/libs/unity/include/unity/tracker.h | 15 ++--- src/libs/unity/include/unity/ui.h | 12 ++-- src/libs/unity/src/input.cc | 50 +++++++------- src/libs/unity/src/tracker.cc | 80 +++++++++++----------- src/mod/game/fleet.h | 22 ++---- tests/unity_tracker.cc | 6 +- 8 files changed, 147 insertions(+), 134 deletions(-) diff --git a/src/libs/unity/include/unity/core.h b/src/libs/unity/include/unity/core.h index 977c8c128..124b3cabc 100644 --- a/src/libs/unity/include/unity/core.h +++ b/src/libs/unity/include/unity/core.h @@ -5,8 +5,11 @@ #include +#include #include #include +#include +#include #include #include @@ -20,6 +23,12 @@ struct Vector3 { float x, y, z; }; +// Each component times `scale`: the same float multiplies as writing them out. +constexpr Vector2 operator*(Vector2 vector, float scale) +{ return {vector.x * scale, vector.y * scale}; } +constexpr Vector3 operator*(Vector3 vector, float scale) +{ return {vector.x * scale, vector.y * scale, vector.z * scale}; } + struct Quaternion { float x, y, z, w; }; @@ -37,6 +46,20 @@ struct TimeSpan { int64_t ticks; }; +// System.DateTime: 100 ns ticks since 0001-01-01 in the low 62 bits, the Kind in the top two. +struct DateTime { + uint64_t dateData; + + // Unix seconds, or empty for DateTime.MinValue (unset). + std::optional UnixSeconds() const + { + const auto ticks = static_cast(dateData & 0x3FFFFFFFFFFFFFFFull); + if (ticks <= 0) + return std::nullopt; + return (ticks - 621355968000000000LL) / 10000000LL; + } +}; + // UnityEngine.Object's static members. il2cpp::ObjectClass() is System.Object, a different class. struct UnityObject { using Class = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Object">; @@ -90,21 +113,6 @@ struct Transform; struct GameObject : il2cpp::Object { using Class = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "GameObject">; - static GameObject* Find(std::string_view path) - { return Class::Call<"Find", GameObject*>(il2cpp::NewString(path)); } - - template il2cpp::Array* GetComponentsInChildren(bool include_inactive) - { - auto* type = T::Class::TypeObject(); - return type ? Call<"GetComponentsInChildren", il2cpp::Array*>(type, include_inactive) : nullptr; - } - - // A new, active GameObject with only a Transform, at the scene root. - static GameObject* Create(std::string_view name) - { return il2cpp::New(il2cpp::NewString(name)); } - - // The name converted to UTF-8. Accessors named exactly after a member, like `name()` elsewhere, return the managed - // value itself; capitalized helpers such as this one convert it. std::string Name() { return il2cpp::ToUtf8(Get<"name", Il2CppString*>()); } bool NameIs(std::string_view ascii) @@ -136,6 +144,17 @@ struct GameObject : il2cpp::Object { auto* type = T::Class::TypeObject(); return type ? Call<"AddComponent", T*>(type) : nullptr; } + template il2cpp::Array* GetComponentsInChildren(bool include_inactive) + { + auto* type = T::Class::TypeObject(); + return type ? Call<"GetComponentsInChildren", il2cpp::Array*>(type, include_inactive) : nullptr; + } + + // A new, active GameObject with only a Transform, at the scene root. + static GameObject* Create(std::string_view name) + { return il2cpp::New(il2cpp::NewString(name)); } + static GameObject* Find(std::string_view path) + { return Class::Call<"Find", GameObject*>(il2cpp::NewString(path)); } }; struct Transform : il2cpp::Object { @@ -143,13 +162,8 @@ struct Transform : il2cpp::Object { int32_t childCount() { return Get<"childCount", int32_t>(); } - Transform* GetChild(int32_t index) - { return Call<"GetChild", Transform*>(index); } Transform* parent() { return Get<"parent", Transform*>(); } - // The topmost transform of this hierarchy; itself when it has no parent. - Transform* root() - { return Get<"root", Transform*>(); } GameObject* gameObject() { return Get<"gameObject", GameObject*>(); } Vector3 localScale() @@ -158,6 +172,16 @@ struct Transform : il2cpp::Object { { Set<"localScale">(value); } void SetLocalEulerAngles(Vector3 value) { Set<"localEulerAngles">(value); } + + Transform* GetChild(int32_t index) + { return Call<"GetChild", Transform*>(index); } + // The direct children in order, any of which can be null. childCount is read once, so a loop over them must not + // add or remove children. The count is floored at zero because iota needs bound >= 0. + auto Children() + { + return std::views::iota(0, std::max(childCount(), 0)) + | std::views::transform([this](int32_t i) { return GetChild(i); }); + } void SetParent(Transform* parent, bool worldPositionStays) { Call<"SetParent">(parent, worldPositionStays); } void SetAsFirstSibling() @@ -171,8 +195,8 @@ struct Transform : il2cpp::Object { { if (auto* object = gameObject(); object && object->NameIs(name)) return this; - for (int32_t i = 0; i < childCount(); ++i) - if (auto* child = GetChild(i); auto* found = child ? child->FindNamed(name) : nullptr) + for (auto* child : Children()) + if (auto* found = child ? child->FindNamed(name) : nullptr) return found; return nullptr; } @@ -182,19 +206,12 @@ struct Transform : il2cpp::Object { template T* FindInParents(int32_t limit = 32) { auto* transform = this; - for (int32_t depth = 0; transform && depth < limit; ++depth, transform = transform->parent()) + for (int32_t depth = 0; transform && depth < limit; ++depth) { if (auto* object = transform->gameObject()) if (auto* found = object->GetComponent()) return found; - return nullptr; - } - - // The direct child whose GameObject is called `name`. - Transform* ChildNamed(std::string_view name) - { - for (int32_t i = 0; i < childCount(); ++i) - if (auto* child = GetChild(i); child && child->gameObject() && child->gameObject()->NameIs(name)) - return child; + transform = transform->parent(); + } return nullptr; } }; @@ -289,18 +306,21 @@ struct Camera : il2cpp::Object { struct AnimationCurve : il2cpp::Object { using Class = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "AnimationCurve">; + float Evaluate(float value) { return Call<"Evaluate", float>(value); } }; struct Shader : il2cpp::Object { using Class = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Shader">; + std::string Name() { return il2cpp::ToUtf8(Get<"name", Il2CppString*>()); } }; struct Material : il2cpp::Object { using Class = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Material">; + std::string Name() { return il2cpp::ToUtf8(Get<"name", Il2CppString*>()); } Shader* shader() @@ -315,6 +335,7 @@ struct Material : il2cpp::Object { struct Renderer : Component { using Class = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Renderer">; + il2cpp::Array* sharedMaterials() { return Get<"sharedMaterials", il2cpp::Array*>(); } }; @@ -332,7 +353,7 @@ struct Screen { { return Class::Get<"height", int32_t>(); } }; -// Only hooked, so a bare Class. Classes whose static members are called get a struct, like Screen. +// Only hooked. using Cursor = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Cursor">; // UnityEngine.Time through its internal calls, which skip the managed wrappers. @@ -375,10 +396,10 @@ struct Texture2D : il2cpp::Object { struct Sprite : il2cpp::Object { using Class = il2cpp::Class<"UnityEngine.CoreModule", "UnityEngine", "Sprite">; - static Sprite* Create(Texture2D* texture, Rect rect, Vector2 pivot) - { return Class::Call<"Create", Sprite*>(texture, rect, pivot); } - float pixelsPerUnit() { return Get<"pixelsPerUnit", float>(); } + + static Sprite* Create(Texture2D* texture, Rect rect, Vector2 pivot) + { return Class::Call<"Create", Sprite*>(texture, rect, pivot); } }; } // namespace unity diff --git a/src/libs/unity/include/unity/input.h b/src/libs/unity/include/unity/input.h index f304083a6..ce33ccf94 100644 --- a/src/libs/unity/include/unity/input.h +++ b/src/libs/unity/include/unity/input.h @@ -347,7 +347,8 @@ enum class KeyCode : int32_t { // UnityEngine.Input through its internal calls, which skip the managed wrappers. A call that a Unity upgrade // renamed is reported once and then reads as nothing pressed. struct Input { - // True when Unity lacks the call, so a caller that skips key checks on idle frames still checks. + // anyKey and anyKeyDown read true when Unity lacks the call, so a caller that skips key checks on idle frames + // still checks. static bool anyKey() { static const il2cpp::ICall call{"UnityEngine.Input::get_anyKey()"}; diff --git a/src/libs/unity/include/unity/tracker.h b/src/libs/unity/include/unity/tracker.h index 6eaab79d5..4c94b8146 100644 --- a/src/libs/unity/include/unity/tracker.h +++ b/src/libs/unity/include/unity/tracker.h @@ -8,6 +8,7 @@ #include #include +#include #include namespace unity @@ -34,22 +35,20 @@ namespace detail } // namespace detail // Starts tracking T and its subclasses. Call while installing hooks, before T's instances are created: instances -// that already exist are not found. Alternate parameterless lifecycle methods support pooled components. -// Tracking a class again does nothing; the first call's lifecycle methods stay. +// that already exist are not found. Pooled components pass the parameterless methods their pool uses instead of +// the constructor and OnDestroy. Tracking a class again does nothing; the first call's lifecycle methods stay. template void Track(std::string_view created = ".ctor", std::string_view released = "OnDestroy") { - static const bool tracked = [&] { + [[maybe_unused]] static const bool tracked = [&] { detail::StartTracking(); - // Both lifecycle methods are parameterless; Unity's constructor and OnDestroy are the defaults. They go in as - // one set: without its release hook, a pooled component would stay tracked after the pool took it back. - // Classes often share one constructor or OnDestroy body, which then carries a chained hook per tracked class. - // Tracking, untracking and the finalizer registration all tolerate running more than once for one object. + // One set: without its release hook, a pooled component would stay tracked after the pool took it back. + // Classes often share one constructor or OnDestroy body, which then carries a chained hook per tracked class; + // tracking, untracking and the finalizer registration all tolerate running more than once for one object. il2cpp::HookSet hooks("tracking " + il2cpp::ClassName(T::Class::Raw())); IL2CPP_STAGE(hooks, T::Class::Method(created, 0), detail::Ctor_Hook); IL2CPP_STAGE(hooks, T::Class::Method(released, 0), detail::OnDestroy_Hook); return hooks.Commit(); }(); - (void)tracked; } // The newest live instance of T, or null. Only finds classes passed to Track. diff --git a/src/libs/unity/include/unity/ui.h b/src/libs/unity/include/unity/ui.h index b8610f3af..a8527a111 100644 --- a/src/libs/unity/include/unity/ui.h +++ b/src/libs/unity/include/unity/ui.h @@ -64,13 +64,13 @@ struct Button : Behaviour { struct EventSystem : Behaviour { using Class = il2cpp::Class<"UnityEngine.UI", "UnityEngine.EventSystems", "EventSystem">; - static EventSystem* current() - { return Class::Get<"current", EventSystem*>(); } - GameObject* currentSelectedGameObject() { return Get<"currentSelectedGameObject", GameObject*>(); } void SetSelectedGameObject(GameObject* object) { Call<"SetSelectedGameObject">(object); } + + static EventSystem* current() + { return Class::Get<"current", EventSystem*>(); } }; struct PointerEventData : il2cpp::Object { @@ -105,7 +105,7 @@ struct TMP_Text : Behaviour { Ellipsis = 1, }; - // TMP_FontAsset + // A TMP_FontAsset. il2cpp::Object* font() { return Get<"font", il2cpp::Object*>(); } void SetFont(il2cpp::Object* value) @@ -196,8 +196,8 @@ class ImageAsset auto* texture = Texture(); if (!texture) return nullptr; - const Rect rect{0.0f, 0.0f, static_cast(texture->width()), static_cast(texture->height())}; - auto* sprite = unity::Sprite::Create(texture, rect, Vector2{0.5f, 0.5f}); + const Rect whole{.width = static_cast(texture->width()), .height = static_cast(texture->height())}; + auto* sprite = unity::Sprite::Create(texture, whole, Vector2{.x = 0.5f, .y = 0.5f}); sprite_ = il2cpp::GcHandle::Strong(sprite); return sprite; } diff --git a/src/libs/unity/src/input.cc b/src/libs/unity/src/input.cc index c23edde51..895476850 100644 --- a/src/libs/unity/src/input.cc +++ b/src/libs/unity/src/input.cc @@ -5,28 +5,33 @@ #include #include +#include +#include +#include namespace unity { namespace { // On an idle frame anyKey is false and no key is asked about at all; otherwise each key is one internal call. + // The optional members cache a read for the rest of the frame. struct Frame { - int number = -1; - bool any_held = false; - 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{}; + int32_t number = -1; + bool any_held = false; + bool any_down = false; + std::optional focused; + std::optional modified; + // Indexed [down][key]. + std::array, std::to_underlying(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; + auto& value = keys[down][std::to_underlying(key)]; + if (!value) + value = down ? Input::GetKeyDown(key) : Input::GetKey(key); + return *value; } } frame; @@ -43,7 +48,7 @@ namespace Key void Poll() { // Without the frame count nothing is ever read: no key counts as pressed. - const int number = Time::frameCount(); + const int32_t number = Time::frameCount(); if (number < 0 || number == frame.number) return; frame = {.number = number, .any_held = Input::anyKey(), .any_down = Input::anyKeyDown()}; @@ -57,21 +62,20 @@ namespace Key bool IsModified() { - if (frame.modified == 0) { - constexpr std::array kModifiers{KeyCode::LeftAlt, KeyCode::LeftControl, KeyCode::LeftShift, - KeyCode::RightAlt, KeyCode::RightControl, KeyCode::RightShift, - KeyCode::AltGr, KeyCode::LeftCommand, KeyCode::LeftWindows, - KeyCode::RightCommand, KeyCode::RightWindows}; - frame.modified = frame.any_held && std::ranges::any_of(kModifiers, Pressed) ? 1 : -1; - } - return frame.modified == 1; + constexpr std::array kModifiers{KeyCode::LeftAlt, KeyCode::LeftControl, KeyCode::LeftShift, + KeyCode::RightAlt, KeyCode::RightControl, KeyCode::RightShift, + KeyCode::AltGr, KeyCode::LeftCommand, KeyCode::LeftWindows, + KeyCode::RightCommand, KeyCode::RightWindows}; + if (!frame.modified) + frame.modified = frame.any_held && std::ranges::any_of(kModifiers, Pressed); + return *frame.modified; } bool IsInputFocused() { - if (frame.focused == 0) - frame.focused = InputFocused(EventSystem::current()) ? 1 : -1; - return frame.focused == 1; + if (!frame.focused) + frame.focused = InputFocused(EventSystem::current()); + return *frame.focused; } void ClearInputFocus() @@ -80,7 +84,7 @@ namespace Key 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; + frame.focused.reset(); } } // namespace Key } // namespace unity diff --git a/src/libs/unity/src/tracker.cc b/src/libs/unity/src/tracker.cc index 70b6c6de5..e733c1408 100644 --- a/src/libs/unity/src/tracker.cc +++ b/src/libs/unity/src/tracker.cc @@ -6,15 +6,22 @@ #include #include +#include #include #include -#include #include namespace unity::detail { namespace { + // While the GC marks, the low bit of an object's klass pointer is its mark bit. + Il2CppClass* ClassOf(Il2CppObject* object) + { return reinterpret_cast(reinterpret_cast(object->klass) & ~uintptr_t{1}); } + + bool IsMarked(Il2CppObject* object) + { return (reinterpret_cast(object->klass) & 1) != 0; } + // Borrowed pointers, keyed by class and every parent class, oldest first. The GC finalizer thread updates this, // so every access holds the mutex. Never destroyed: the finalizer thread can still run while static destructors // run at exit. @@ -27,33 +34,26 @@ namespace const auto it = objects.find(klass); return it == objects.end() ? nullptr : &it->second; } - }; - Registry& registry = *new Registry; - // While the GC marks, the low bit of an object's klass pointer is its mark bit. - Il2CppClass* ClassOf(Il2CppObject* object) - { return reinterpret_cast(reinterpret_cast(object->klass) & ~uintptr_t{1}); } - - bool IsMarked(Il2CppObject* object) - { return (reinterpret_cast(object->klass) & 1) != 0; } - - // Callers hold the registry lock. A base and a derived constructor can both be detoured and both run for one - // object, so tracking twice is a no-op; a second entry would outlive Untrack and hand out a freed object. - void Track(Il2CppObject* object) - { - auto& own = registry.objects[object->klass]; - if (std::ranges::find(own, object) != own.end()) - return; - for (auto* klass = object->klass; klass; klass = klass->parent) - registry.objects[klass].push_back(object); - } + // Add and Remove need the mutex held. A base and a derived constructor can both be detoured and both run for + // one object, so adding twice is a no-op; a second entry would outlive Remove and hand out a freed object. + void Add(Il2CppObject* object) + { + if (std::ranges::contains(objects[object->klass], object)) + return; + for (auto* klass = object->klass; klass; klass = klass->parent) + objects[klass].push_back(object); + } - void Untrack(Il2CppObject* object) - { - for (auto* klass = ClassOf(object); klass; klass = klass->parent) - if (auto* instances = registry.Find(klass)) - std::erase(*instances, object); - } + // The object may still carry its mark bit when OnDestroy or its finalizer follows a liveness pass. + void Remove(Il2CppObject* object) + { + for (auto* klass = ClassOf(object); klass; klass = klass->parent) + if (auto* instances = Find(klass)) + std::erase(*instances, object); + } + }; + Registry& registry = *new Registry; // Unity's Boehm fork takes a per-entry "finalize mark proc" as a sixth argument. GC_finalize calls it for every // unreachable finalized object, and the game's own registrations pass a no-op. Leaving the argument out puts @@ -79,7 +79,7 @@ namespace { if (object) { std::scoped_lock lock(registry.mutex); - Untrack(static_cast(object)); + registry.Remove(static_cast(object)); } if (auto* chained = static_cast(data)) { const auto next = *chained; @@ -113,7 +113,7 @@ namespace original(state); std::scoped_lock lock(registry.mutex); - // Filter each class list once, including inherited entries; untracking one object at a time rescans them. + // Filter each class list once, including inherited entries; removing one object at a time rescans them. for (auto& [klass, instances] : registry.objects) std::erase_if(instances, IsMarked); } @@ -137,7 +137,6 @@ namespace } return reinterpret_cast(matches.front().address()); } - } // namespace void Constructed(Il2CppObject* object) @@ -146,7 +145,7 @@ void Constructed(Il2CppObject* object) return; WatchFinalizer(object); std::scoped_lock lock(registry.mutex); - Track(object); + registry.Add(object); } void Destroyed(Il2CppObject* object) @@ -154,31 +153,32 @@ void Destroyed(Il2CppObject* object) if (!object) return; std::scoped_lock lock(registry.mutex); - Untrack(object); + registry.Remove(object); } void StartTracking() { - static bool started = false; - if (std::exchange(started, true)) - return; - il2cpp::HookSet hooks("object tracking"); - IL2CPP_STAGE_AT(hooks, il2cpp_unity_liveness_finalize, LivenessFinalize_Hook); - hooks.Commit(); - register_finalizer = FindRegisterFinalizer(); + [[maybe_unused]] static const bool started = [] { + il2cpp::HookSet hooks("object tracking"); + IL2CPP_STAGE_AT(hooks, il2cpp_unity_liveness_finalize, LivenessFinalize_Hook); + hooks.Commit(); + register_finalizer = FindRegisterFinalizer(); + return true; + }(); } +// The registry never holds a null class, so a missing binding finds nothing. Il2CppObject* Newest(Il2CppClass* klass) { std::scoped_lock lock(registry.mutex); - auto* objects = klass ? registry.Find(klass) : nullptr; + auto* objects = registry.Find(klass); return objects && !objects->empty() ? objects->back() : nullptr; } std::vector Instances(Il2CppClass* klass) { std::scoped_lock lock(registry.mutex); - auto* objects = klass ? registry.Find(klass) : nullptr; + auto* objects = registry.Find(klass); return objects ? *objects : std::vector{}; } } // namespace unity::detail diff --git a/src/mod/game/fleet.h b/src/mod/game/fleet.h index 319b2fd34..d70b85806 100644 --- a/src/mod/game/fleet.h +++ b/src/mod/game/fleet.h @@ -131,20 +131,6 @@ enum class FleetState : int32_t { // Cargo, mining slot and timers off FleetPlayerData, read for fleet sync. The interfaces are bound as such: their // getters dispatch to the runtime class, which also covers FleetMiningSlot's explicit IMiningSlot implementation. -// System.DateTime: ticks in the low 62 bits, the Kind in the top two. -struct DateTime { - uint64_t dateData; - - // Unix seconds, or empty for DateTime.MinValue (unset). - std::optional UnixSeconds() const - { - const auto ticks = static_cast(dateData & 0x3FFFFFFFFFFFFFFFull); - if (ticks <= 0) - return std::nullopt; - return (ticks - 621355968000000000LL) / 10000000LL; - } -}; - // Concrete: ProgressData, CargoProgressData. struct IProgressData : il2cpp::Object { using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "IProgressData">; @@ -181,8 +167,8 @@ struct IMiningSlot : il2cpp::Object { { return Get<"ResourceId", int64_t>(); } double AmountMined() { return Get<"AmountMined", double>(); } - DateTime OccupyTimeUtc() - { return Get<"OccupyTimeUtc", DateTime>(); } + unity::DateTime OccupyTimeUtc() + { return Get<"OccupyTimeUtc", unity::DateTime>(); } IMiningNodeParams* PointData() { return Get<"PointData", IMiningNodeParams*>(); } }; @@ -191,8 +177,8 @@ struct IMiningSlot : il2cpp::Object { struct ITimerData : il2cpp::Object { using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "ITimerData">; - DateTime DueTime() - { return Get<"DueTime", DateTime>(); } + unity::DateTime DueTime() + { return Get<"DueTime", unity::DateTime>(); } }; // A ship name as the game shows it, in the player's language, for a hull's localisation id; empty for none. diff --git a/tests/unity_tracker.cc b/tests/unity_tracker.cc index 9968fa984..ee7a9edc8 100644 --- a/tests/unity_tracker.cc +++ b/tests/unity_tracker.cc @@ -41,6 +41,7 @@ void Require(bool condition, const char* message) throw std::runtime_error(message); } +// Sets the GC mark bit, the low bit of klass, which Unity's liveness pass leaves on unreachable objects. void Mark(Il2CppObject* object) { object->klass = reinterpret_cast(reinterpret_cast(object->klass) | 1); } } // namespace @@ -51,8 +52,9 @@ int main() derived_class.parent = &base_class; leaf_class.parent = &derived_class; Base first{}, middle{}, last{}; - first.klass = last.klass = &base_class; - middle.klass = &leaf_class; + first.klass = &base_class; + middle.klass = &leaf_class; + last.klass = &base_class; Require(unity::Instances().empty() && !unity::Newest(), "empty registry"); Constructed(nullptr); From 9c36b691ef58c0d73b403d44f4acbafe6f69197d Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 15:13:53 -0700 Subject: [PATCH 05/13] Give the game bindings one shape Each binding reads class, fields and properties with each setter beside its getter, then calls, composed helpers and statics. Accessors are named after the managed member they read, and classes that are only hooked stay bare aliases. Game lists satisfy forward iterator requirements and work with standard algorithms. Null collections read as empty and a bad index reads as zero, replacing repeated caller checks. Dead accessors are removed. --- src/mod/features/forbidden_tech_ship_name.cc | 8 +- src/mod/game/app.h | 30 ++- src/mod/game/camera.h | 12 +- src/mod/game/chat.h | 34 ++- src/mod/game/fleet.h | 219 +++++++++---------- src/mod/game/haven.h | 2 - src/mod/game/hud.h | 10 +- src/mod/game/inventory.h | 18 +- src/mod/game/loading.h | 3 +- src/mod/game/navigation.h | 64 +++--- src/mod/game/queues.h | 8 +- src/mod/game/server.h | 11 +- src/mod/game/ui.h | 74 +++---- src/mod/game/viewers.h | 42 ++-- src/mod/sync/fleets.cc | 6 +- 15 files changed, 262 insertions(+), 279 deletions(-) diff --git a/src/mod/features/forbidden_tech_ship_name.cc b/src/mod/features/forbidden_tech_ship_name.cc index b8e3f3c7d..fd843c9cd 100644 --- a/src/mod/features/forbidden_tech_ship_name.cc +++ b/src/mod/features/forbidden_tech_ship_name.cc @@ -43,7 +43,7 @@ void SetActive(il2cpp::Object* component, bool active) std::string EquippedShipName(ForbiddenTechInventoryItemWidget* widget) { auto* context = widget->Context(); - auto* tech = context ? context->forbiddenTech() : nullptr; + auto* tech = context ? context->ForbiddenTech() : nullptr; if (!tech) return {}; const auto id = tech->Id(); @@ -56,12 +56,12 @@ std::string EquippedShipName(ForbiddenTechInventoryItemWidget* widget) EntitySlot* slot = nullptr; if (!manager->TryGetEntitySlotFromForbiddenTech(id, slot) || !slot) return {}; - auto* params = slot->forbiddenTechSlotParams(); + auto* params = slot->ForbiddenTechSlotParams(); if (!params) return {}; const auto owner = params->OwnerId(); - auto* service = manager->fleetService(); + auto* service = manager->FleetService(); if (!service) return {}; auto* ship = service->GetShip(owner); @@ -70,7 +70,7 @@ std::string EquippedShipName(ForbiddenTechInventoryItemWidget* widget) return {}; // Hull IDs and localisation IDs differ on many ships. Mirror FleetPlayerData.GetLocaId. auto* refs = hull->idRefs(); - const auto loca_id = refs ? refs->locaId() : hull->Id(); + const auto loca_id = refs ? refs->locaId() : hull->id(); return LocalizedShipName(loca_id); } diff --git a/src/mod/game/app.h b/src/mod/game/app.h index 93ca40f10..71c1d5139 100644 --- a/src/mod/game/app.h +++ b/src/mod/game/app.h @@ -5,6 +5,8 @@ #include +#include +#include #include #include @@ -184,14 +186,10 @@ template struct MonoSingleton : il2cpp::Object { // The non-generic MonoSingleton base; its static PrepareAllForReload runs before the game reloads. using MonoSingletonBase = il2cpp::Class<"Assembly-CSharp", "", "MonoSingleton">; -// Digit.Client.Core.CustomReloadArgs and Nullable, laid out as the runtime does. +// Digit.Client.Core.CustomReloadArgs, passed by value inside a Nullable. struct CustomReloadArgs { - Il2CppObject* SectionArgs; - bool ForceFakeReload; -}; -struct NullableCustomReloadArgs { - bool hasValue; - CustomReloadArgs value; + Il2CppObject* section_args; + bool force_fake_reload; }; struct PrimeApp : il2cpp::Object { @@ -200,7 +198,7 @@ struct PrimeApp : il2cpp::Object { void Quit() { Call<"Quit">(); } void Reload() - { Call<"Reload">(false, NullableCustomReloadArgs{}, false); } + { Call<"Reload">(false, il2cpp::Nullable{}, false); } }; struct AppConfig : il2cpp::Object { @@ -266,21 +264,21 @@ struct Hub { static bool IsInChat() { - const auto section = CurrentSection(); - return section == SectionID::Chat_Private_Message || section == SectionID::Chat_Alliance - || section == SectionID::Chat_Main || section == SectionID::Chat_Private_List; + return std::ranges::contains(std::array{SectionID::Chat_Private_Message, SectionID::Chat_Alliance, + SectionID::Chat_Main, SectionID::Chat_Private_List}, + CurrentSection()); } static bool IsInSystemOrGalaxyOrStarbase() { - const auto section = CurrentSection(); - return section == SectionID::Navigation_Galaxy || section == SectionID::Navigation_System - || section == SectionID::Starbase_Interior || section == SectionID::Starbase_Exterior; + return std::ranges::contains(std::array{SectionID::Navigation_Galaxy, SectionID::Navigation_System, + SectionID::Starbase_Interior, SectionID::Starbase_Exterior}, + CurrentSection()); } }; -// Fabric.EventManager, the game's sound middleware. Its shorter PostEvent overloads funnel into the seven-argument -// one. +// Fabric.EventManager, the game's sound middleware. Its shorter PostEvent overloads funnel into the +// seven-argument one. using AudioEventManager = il2cpp::Class<"Fabric.Core", "Fabric", "EventManager">; // Windows only: keeps the game window at its aspect ratio from Update and its own window procedure. diff --git a/src/mod/game/camera.h b/src/mod/game/camera.h index 44b116ebb..0cb36434f 100644 --- a/src/mod/game/camera.h +++ b/src/mod/game/camera.h @@ -14,10 +14,10 @@ struct FloatConstraint : il2cpp::Object { { return Field<"_enabled", bool>(); } float min() { return Field<"_min", float>(); } - float max() - { return Field<"_max", float>(); } void SetMin(float value) { SetField<"_min">(value); } + float max() + { return Field<"_max", float>(); } void SetMax(float value) { SetField<"_max">(value); } }; @@ -38,18 +38,18 @@ struct CameraFrame : il2cpp::Object { unity::Vector3 position() { return Field<"_position", unity::Vector3>(); } + void SetPosition(unity::Vector3 value) + { SetField<"_position">(value); } unity::Quaternion rotation() { return Field<"_rotation", unity::Quaternion>(); } float fov() { return Field<"_FOV", float>(); } float farPlane() { return Field<"_farPlane", float>(); } - bool orthographic() - { return Field<"_orthographic", bool>(); } - void SetPosition(unity::Vector3 value) - { SetField<"_position">(value); } void SetFarPlane(float value) { SetField<"_farPlane">(value); } + bool orthographic() + { return Field<"_orthographic", bool>(); } }; struct EditableCameraFrame : CameraFrame { diff --git a/src/mod/game/chat.h b/src/mod/game/chat.h index badd18ee5..7d752a204 100644 --- a/src/mod/game/chat.h +++ b/src/mod/game/chat.h @@ -1,11 +1,13 @@ #pragma once -// Chat manager and chat view controllers. +// The chat manager, the full-screen and side chat views, and the chat message rows. #include "ui.h" #include +#include + enum class ChatChannelCategory : int32_t { None = -1, Newbie = 0, @@ -25,6 +27,19 @@ enum class ChatViewMode : int32_t { struct ChatManager : MonoSingleton { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Chat", "ChatManager">; + bool isContextInitialised() + { return Field<"_isContextInitialised", bool>(); } + bool IsSideChatAllowed() + { return Get<"IsSideChatAllowed", bool>(); } + bool IsSideChatOpen() + { return Get<"IsSideChatOpen", bool>(); } + void SetViewMode(ChatViewMode value) + { Set<"ViewMode">(value); } + + bool CanSeeNewbieChat() + { return Call<"CanSeeNewbieChat", bool>(); } + bool CanSeeRegionalChat() + { return Call<"CanSeeRegionalChat", bool>(); } void OpenChannel(ChatChannelCategory category = ChatChannelCategory::Alliance) { Call<"OpenChannel">(category, il2cpp::NewString("")); } @@ -33,20 +48,6 @@ struct ChatManager : MonoSingleton { SetViewMode(viewMode); OpenChannel(category); } - - bool CanSeeNewbieChat() - { return Call<"CanSeeNewbieChat", bool>(); } - bool CanSeeRegionalChat() - { return Call<"CanSeeRegionalChat", bool>(); } - - bool IsSideChatOpen() - { return Get<"IsSideChatOpen", bool>(); } - bool IsSideChatAllowed() - { return Get<"IsSideChatAllowed", bool>(); } - bool isContextInitialised() - { return Field<"_isContextInitialised", bool>(); } - void SetViewMode(ChatViewMode value) - { Set<"ViewMode">(value); } }; struct SwipeScroller : il2cpp::Object { @@ -100,7 +101,4 @@ struct UserProfileWidget : unity::Component { struct ChatMessageWidget : unity::Component { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Chat", "ChatMessageWidget">; - - UserProfileWidget* userProfileWidget() - { return Field<"_userProfileWidget", UserProfileWidget*>(); } }; diff --git a/src/mod/game/fleet.h b/src/mod/game/fleet.h index d70b85806..d2b7c949e 100644 --- a/src/mod/game/fleet.h +++ b/src/mod/game/fleet.h @@ -1,26 +1,30 @@ #pragma once -// Player fleets and ships: fleet data and managers, the fleet bar, the action queue, and the ship management screens -// and dock. +// Player fleets and ships: fleet data and managers, forbidden tech, ship abilities, the fleet bar, the ship +// management screens and dock, and the way into the player's own Haven. #include "app.h" #include "server.h" #include "ui.h" #include +#include +#include +#include #include +#include #include #include enum class DeployedFleetType : int32_t { - Nonexistent, - Player, - Marauder, - NpcInstantiated, - Sentinel, - Alliance, - Challenge, + Nonexistent = 0, + Player = 1, + Marauder = 2, + NpcInstantiated = 3, + Sentinel = 4, + Alliance = 5, + Challenge = 6, }; enum class DeployedFleetState : int32_t { @@ -125,7 +129,7 @@ enum class FleetState : int32_t { Outposting = 4096, CanRecall = 5637, Deployed = 8133, - CanLocate = 8135 + CanLocate = 8135, }; // Cargo, mining slot and timers off FleetPlayerData, read for fleet sync. The interfaces are bound as such: their @@ -188,13 +192,63 @@ inline std::string LocalizedShipName(int64_t loca_id) return {}; auto* context = il2cpp::New(il2cpp::NewString("ship_name_" + std::to_string(loca_id)), il2cpp::NewString("ships")); - auto* name = context ? Localizer::Localise(context) : nullptr; - return name ? il2cpp::ToUtf8(name) : std::string(); + return context ? il2cpp::ToUtf8(Localizer::Localise(context)) : std::string(); } +// The hull a fleet flies. +struct BaseShip : il2cpp::Object { + using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "BaseShip">; + + int64_t Id() + { return Get<"Id", int64_t>(); } +}; + +// An item slot on a ship, such as its forbidden tech slot. +struct EntitySlot : il2cpp::Object { + using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "EntitySlot">; + + // The slot specs of a ship's forbidden tech and chaos tech slots, as the game's ship tech indicators read them. + static constexpr int64_t kForbiddenTechSlot = 3502081615; + static constexpr int64_t kChaosTechSlot = 953301906; + + int64_t SlotSpecId() + { return Get<"SlotSpecId", int64_t>(); } + // Empty when nothing is fitted. + il2cpp::Nullable SlotItemId() + { return Get<"SlotItemId", il2cpp::Nullable>(); } + // Named after the property, so the type needs `::` here. + ::ForbiddenTechSlotParams* ForbiddenTechSlotParams() + { return Get<"ForbiddenTechSlotParams", ::ForbiddenTechSlotParams*>(); } +}; + +struct ForbiddenTechManager : MonoSingleton { + using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ForbiddenTechs", "ForbiddenTechManager">; + + // CachedService: a struct around the service, empty until something asks for it. + struct CachedService { + ::FleetService* cached_service; + }; + + bool TryGetActiveForbiddenTechSlot(int64_t ship_id, il2cpp::List*& slots) + { return Call<"TryGetActiveForbiddenTechSlot", bool>(ship_id, std::ref(slots)); } + bool TryGetEntitySlotFromForbiddenTech(int64_t tech_id, EntitySlot*& slot) + { return Call<"TryGetEntitySlotFromForbiddenTech", bool>(tech_id, std::ref(slot)); } + + // Until the cache holds the service, the cache's Service getter looks it up and keeps it. Named after the service, + // so the type needs `::` here. + ::FleetService* FleetService() + { + auto* service = Field<"_fleetServiceCache", CachedService>().cached_service; + return service ? service : CallOnField<"_fleetServiceCache", CachedService, "get_Service", ::FleetService*>(); + } +}; + struct FleetPlayerData : il2cpp::Object { using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "FleetPlayerData">; + // No property for the mining timer, so read the private field. + ITimerData* miningSlotTimer() + { return Field<"_miningSlotTimer", ITimerData*>(); } int32_t Index() { return Get<"Index", int32_t>(); } HullSpec* Hull() @@ -205,17 +259,12 @@ struct FleetPlayerData : il2cpp::Object { { return Get<"PreviousState", FleetState>(); } int64_t Id() { return Get<"Id", int64_t>(); } - NodeAddress* Address() - { return Get<"Address", NodeAddress*>(); } int64_t Level() { return Get<"Level", int64_t>(); } bool HasShip() { return Get<"HasShip", bool>(); } int64_t HullId() { return Get<"HullId", int64_t>(); } - // A forbidden tech or chaos tech item is fitted to the ship. - bool HasAppliedTech(); - // Live state, read for fleet sync. bool IsHome() { return Get<"IsHome", bool>(); } @@ -233,17 +282,32 @@ struct FleetPlayerData : il2cpp::Object { { return Get<"DrydockID", int32_t>(); } float CargoResourceFillLevel() { return Get<"CargoResourceFillLevel", float>(); } - CargoHoldData* CargoHold() - { return Get<"CargoHoldData", CargoHoldData*>(); } + // Named after the property, so the type needs `::` here. + ::CargoHoldData* CargoHoldData() + { return Get<"CargoHoldData", ::CargoHoldData*>(); } IMiningSlot* MiningData() { return Get<"MiningData", IMiningSlot*>(); } ITimerData* Timer() { return Get<"Timer", ITimerData*>(); } - // No property for the mining timer, so read the private field. - ITimerData* MiningSlotTimer() - { return Field<"_miningSlotTimer", ITimerData*>(); } + int64_t GetLocaId() + { return Call<"GetLocaId", int64_t>(); } float GetMiningRate() { return Call<"GetMiningRate", float>(); } + + // A forbidden tech or chaos tech item is fitted to the ship. + bool HasAppliedTech() + { + auto* ship = Get<"Ship", BaseShip*>(); + auto* manager = ForbiddenTechManager::Instance(); + il2cpp::List* slots = nullptr; + if (!ship || !manager || !manager->TryGetActiveForbiddenTechSlot(ship->Id(), slots)) + return false; + static constexpr std::array kTechSlots{EntitySlot::kForbiddenTechSlot, EntitySlot::kChaosTechSlot}; + return std::ranges::any_of(il2cpp::Items(slots), [](EntitySlot* slot) { + return slot && std::ranges::contains(kTechSlots, slot->SlotSpecId()) && slot->SlotItemId().has_value; + }); + } + // Damage on the fleet's ship, so a docked ship's hull is known without the server. Empty without a ship. std::optional HullDamage() { @@ -251,78 +315,11 @@ struct FleetPlayerData : il2cpp::Object { return ship ? std::optional(ship->HullDamage()) : std::nullopt; } - int64_t GetLocaId() - { return Call<"GetLocaId", int64_t>(); } - // The ship's name as the game shows it, in the player's language; empty without a hull. std::string DisplayName() { return LocalizedShipName(Hull() ? GetLocaId() : 0); } }; -// The hull a fleet flies. -struct BaseShip : il2cpp::Object { - using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "BaseShip">; - - int64_t Id() - { return Get<"Id", int64_t>(); } -}; - -// An item slot on a ship, such as its forbidden tech slot. -struct EntitySlot : il2cpp::Object { - using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "EntitySlot">; - - // The slot specs of a ship's forbidden tech and chaos tech slots, as the game's ship tech indicators read them. - static constexpr int64_t kForbiddenTechSlot = 3502081615; - static constexpr int64_t kChaosTechSlot = 953301906; - - int64_t SlotSpecId() - { return Get<"SlotSpecId", int64_t>(); } - // Empty when nothing is fitted. - il2cpp::Nullable SlotItemId() - { return Get<"SlotItemId", il2cpp::Nullable>(); } - ForbiddenTechSlotParams* forbiddenTechSlotParams() - { return Get<"ForbiddenTechSlotParams", ForbiddenTechSlotParams*>(); } -}; - -struct ForbiddenTechManager : MonoSingleton { - using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ForbiddenTechs", "ForbiddenTechManager">; - - bool TryGetActiveForbiddenTechSlot(int64_t ship_id, il2cpp::List*& slots) - { return Call<"TryGetActiveForbiddenTechSlot", bool>(ship_id, std::ref(slots)); } - bool TryGetEntitySlotFromForbiddenTech(int64_t tech_id, EntitySlot*& slot) - { return Call<"TryGetEntitySlotFromForbiddenTech", bool>(tech_id, std::ref(slot)); } - - // CachedService: a struct around the service, empty until something asks for it. - struct CachedService { - FleetService* cached_service; - }; - - // The fleet service. Until the cache holds it, the cache's Service getter looks it up and keeps it. - FleetService* fleetService() - { - if (auto* service = Field<"_fleetServiceCache", CachedService>().cached_service) - return service; - return CallOnField<"_fleetServiceCache", CachedService, "get_Service", FleetService*>(); - } -}; - -inline bool FleetPlayerData::HasAppliedTech() -{ - auto* ship = Get<"Ship", BaseShip*>(); - auto* manager = ForbiddenTechManager::Instance(); - il2cpp::List* slots = nullptr; - if (!ship || !manager || !manager->TryGetActiveForbiddenTechSlot(ship->Id(), slots) || !slots) - return false; - for (auto* slot : *slots) { - if (!slot) - continue; - const auto spec = slot->SlotSpecId(); - if ((spec == EntitySlot::kForbiddenTechSlot || spec == EntitySlot::kChaosTechSlot) && slot->SlotItemId().has_value) - return true; - } - return false; -} - // Digit.PrimeServer.Models.ActionType is a struct around a flag value, not an enum, so it crosses the // managed boundary as a 4-byte struct. struct ActionType { @@ -357,6 +354,7 @@ struct TimerDataContext : il2cpp::Object { struct ShipActionPanelLocalViewController : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Ships", "ShipActionPanelLocalViewController">; + il2cpp::Array* abilityIDs() { return Field<"_abilityIDs", il2cpp::Array*>(); } il2cpp::Array* buttons() @@ -374,12 +372,11 @@ struct FleetLocalViewController : il2cpp::Object { { return Field<"_shipActionPanelLocalViewController", ShipActionPanelLocalViewController*>(); } FleetPlayerData* fleet() { return Get<"fleet", FleetPlayerData*>(); } - void CancelButtonClicked() { Call<"CancelButtonClicked">(); } - // callback is an Action; null is accepted. - // `target` is the IActionData the action is on, such as a FleetPlayerData. + // `target` is the IActionData the action is on, such as a FleetPlayerData. `callback` is an + // Action, or null. bool RequestAction(il2cpp::Object* target, ActionType type, int64_t index, ActionBehaviour behaviourMask, void* callback = nullptr) { return Call<"RequestAction", bool>(target, type, index, behaviourMask, callback); } @@ -387,6 +384,7 @@ struct FleetLocalViewController : il2cpp::Object { struct FleetBarContext : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.HUD", "FleetBarContext">; + il2cpp::Array* FleetData() { return Get<"FleetData", il2cpp::Array*>(); } }; @@ -394,6 +392,8 @@ struct FleetBarContext : il2cpp::Object { struct FleetBarViewController : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.HUD", "FleetBarViewController">; + FleetLocalViewController* fleetPanelController() + { return Field<"_fleetPanelController", FleetLocalViewController*>(); } FleetBarContext* CanvasContext() { return Get<"CanvasContext", FleetBarContext*>(); } @@ -403,15 +403,19 @@ struct FleetBarViewController : il2cpp::Object { bool IsIndexSelected(int32_t index) { return Call<"IsIndexSelected", bool>(index); } - FleetLocalViewController* fleetPanelController() - { return Field<"_fleetPanelController", FleetLocalViewController*>(); } - // The fleet whose panel is open in the bar, or null. FleetPlayerData* SelectedFleet() { auto* panel = fleetPanelController(); return panel ? panel->fleet() : nullptr; } + + // The fleet panel of the newest fleet bar, or null. Its fleet() is the selected fleet, or null. + static FleetLocalViewController* NewestPanel() + { + auto* bar = unity::Newest(); + return bar ? bar->fleetPanelController() : nullptr; + } }; struct FleetsManager : MonoSingleton { @@ -425,18 +429,15 @@ struct FleetsManager : MonoSingleton { // The fleet service is up. Until the cache holds it, the cache's HasService looks it up and keeps it. bool HasFleetService() { - if (Field<"_fleetServiceCache", CachedService>().cached_service) - return true; - return CallOnField<"_fleetServiceCache", CachedService, "get_HasService", bool>(); + return Field<"_fleetServiceCache", CachedService>().cached_service + || CallOnField<"_fleetServiceCache", CachedService, "get_HasService", bool>(); } FleetPlayerData* GetFleetPlayerData(int32_t index) { return Call<"GetFleetPlayerData", FleetPlayerData*>(index); } - - void RequestViewFleet(FleetPlayerData* fleetData, bool showSystemInfo = false) - { Call<"RequestViewFleet">(fleetData, showSystemInfo); } - FleetPlayerData* GetSelectedFleetData() { return Call<"GetSelectedFleetData", FleetPlayerData*>(); } + void RequestViewFleet(FleetPlayerData* fleetData, bool showSystemInfo = false) + { Call<"RequestViewFleet">(fleetData, showSystemInfo); } }; struct DeploymentManager : MonoSingleton { @@ -466,10 +467,10 @@ using SystemViewShip = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation" struct ShipManagementViewContext : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Ships", "ShipManagementViewContext">; + il2cpp::List* SortedIdleShips() + { return Field<"SortedIdleShips", il2cpp::List*>(); } FleetPlayerData* CurrentFleet() { return Get<"CurrentFleet", FleetPlayerData*>(); } - void Sort() - { Call<"Sort">(); } int32_t CurrentIndex() { return Get<"CurrentIndex", int32_t>(); } void SetCurrentIndex(int32_t index) @@ -478,8 +479,8 @@ struct ShipManagementViewContext : il2cpp::Object { { Set<"LastMovementWasToIncrement">(increment); } int32_t ListCount() { return Get<"ListCount", int32_t>(); } - il2cpp::List* SortedIdleShips() - { return Field<"SortedIdleShips", il2cpp::List*>(); } + void Sort() + { Call<"Sort">(); } }; struct ShipManagementViewController : unity::Behaviour { @@ -522,11 +523,8 @@ struct PlanetaryBaseManager : MonoSingleton { { auto* manager = Instance(); auto* sections = Hub::Sections(); - if (!manager || !sections) - return; - // Preserve the game's Iconian Gateway prerequisite and its locked-building message. - if (!manager->Call<"IsPlanetaryBaseCenterReady", bool>(true)) + if (!manager || !sections || !manager->Call<"IsPlanetaryBaseCenterReady", bool>(true)) return; // PlanetarySectionContext is reserved for visiting another player's Haven. Passing no context makes the // PlanetarySectionDirector initialize the complete local Haven state from the service cache. @@ -536,6 +534,7 @@ struct PlanetaryBaseManager : MonoSingleton { struct ActivatedAbilityManager : MonoSingleton { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ActivatedAbilities", "ActivatedAbilityManager">; + // ActivatedAbilityType.RevealHostileVger. static constexpr int32_t kRevealHostileVger = 86002; diff --git a/src/mod/game/haven.h b/src/mod/game/haven.h index f5fa0d460..e08756aef 100644 --- a/src/mod/game/haven.h +++ b/src/mod/game/haven.h @@ -2,8 +2,6 @@ // Haven (the planetary base): its help history. Haven's camera uses the bindings in camera.h. -#include "ui.h" - #include struct PlanetaryHelpHistoryContext : il2cpp::Object { diff --git a/src/mod/game/hud.h b/src/mod/game/hud.h index c15661080..e6f4fb6a6 100644 --- a/src/mod/game/hud.h +++ b/src/mod/game/hud.h @@ -15,8 +15,8 @@ struct MissionsHudViewController : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.HUD", "MissionsHudViewController">; // The buttons are a mix of Button and GenericButtonWidget fields; only their GameObject matters to the mod. - template unity::Component* ButtonField() - { return this->template Field(); } + template unity::Component* ButtonField() + { return Field(); } }; struct FactionInformation : il2cpp::Object { @@ -70,6 +70,9 @@ struct HudAllianceAndNewsViewController : il2cpp::Object { struct GalacticAnomaliesManager : MonoSingleton { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.GalacticAnomalies", "GalacticAnomaliesManager">; + GalacticAnomaly* GetSystemGalacticAnomalies(int64_t system) + { return Call<"GetSystemGalacticAnomalies", GalacticAnomaly*>(system); } + // The singleton MonoSingleton`1 already holds, or null when there is none or it was destroyed. Instance would // search the scene, or create a replacement, when there is none. static GalacticAnomaliesManager* Current() @@ -77,7 +80,4 @@ struct GalacticAnomaliesManager : MonoSingleton { auto* manager = Existing(); return unity::Alive(manager) ? manager : nullptr; } - - GalacticAnomaly* GetSystemGalacticAnomalies(int64_t system) - { return Call<"GetSystemGalacticAnomalies", GalacticAnomaly*>(system); } }; diff --git a/src/mod/game/inventory.h b/src/mod/game/inventory.h index 889025d12..e19a4dbcb 100644 --- a/src/mod/game/inventory.h +++ b/src/mod/game/inventory.h @@ -67,12 +67,12 @@ struct BundleDataWidget : Widget { int32_t currentState() { return Field<"_currentState", int32_t>(); } - void AuxViewButtonPressedHandler() - { Call<"AuxViewButtonPressedHandler">(); } - + inline InventoryTooltipTriggerWidget* invTooltip(); // Set by OnDidBindContext after the card set up its tooltip. inline ShopListScrollerViewController* scrollerController(); - inline InventoryTooltipTriggerWidget* invTooltip(); + + void AuxViewButtonPressedHandler() + { Call<"AuxViewButtonPressedHandler">(); } }; struct DrawerContext : il2cpp::Object { @@ -262,6 +262,9 @@ struct AllianceGamesStoreContext : il2cpp::Object { { return TryCall<"get_IsFetchingBundlesInProgress", bool()>(); } int32_t BundlesCount() { return Get<"BundlesCount", int32_t>(); } + // Rebuilds the store's currency list from its bundles. + bool UpdateStoreCurrency() + { return TryCall<"UpdateStoreCurrency", void()>(); } // How many soft currencies the store shows. std::optional CurrencyCount() { @@ -269,9 +272,6 @@ struct AllianceGamesStoreContext : il2cpp::Object { auto* soft = currencies ? currencies->Get<"SoftResources", il2cpp::List*>() : nullptr; return soft ? soft->TryCall<"get_Count", int32_t()>() : std::nullopt; } - // Rebuilds the store's currency list from its bundles. - bool UpdateStoreCurrency() - { return TryCall<"UpdateStoreCurrency", void()>(); } }; struct AllianceGamesStoreScrollerViewController : ShopListScrollerViewController { @@ -282,10 +282,10 @@ struct AllianceGamesStoreScrollerViewController : ShopListScrollerViewController { return Get<"AllianceGamesContext", AllianceGamesStoreContext*>(); } }; -ShopListScrollerViewController* BundleDataWidget::scrollerController() -{ return Field<"_scrollerController", ShopListScrollerViewController*>(); } InventoryTooltipTriggerWidget* BundleDataWidget::invTooltip() { return Field<"_invTooltip", InventoryTooltipTriggerWidget*>(); } +ShopListScrollerViewController* BundleDataWidget::scrollerController() +{ return Field<"_scrollerController", ShopListScrollerViewController*>(); } // Shows the shop's reveal sequences and offer popups. Only hooked. using ShopSceneManager = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Shop", "ShopSceneManager">; diff --git a/src/mod/game/loading.h b/src/mod/game/loading.h index a8c213aa1..83b0c39a5 100644 --- a/src/mod/game/loading.h +++ b/src/mod/game/loading.h @@ -2,9 +2,8 @@ // The login screen, the transition screen shown between sections, and their loading tips and slide shows. -#include "ui.h" - #include +#include struct LoginSequence : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Login", "LoginSequence">; diff --git a/src/mod/game/navigation.h b/src/mod/game/navigation.h index 2aa310e17..1d3556669 100644 --- a/src/mod/game/navigation.h +++ b/src/mod/game/navigation.h @@ -20,23 +20,16 @@ enum class ZoomLevels : int32_t { struct NavigationLOD : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "NavigationLOD">; - void UpdateLOD(ZoomLevels level) - { Call<"UpdateLOD">(level); } - // Writes the backing field directly; the managed setter is private. void SetTargetLevel(ZoomLevels level) { SetField<"k__BackingField">(level); } + void UpdateLOD(ZoomLevels level) + { Call<"UpdateLOD">(level); } }; struct NavigationPan : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "NavigationPan">; - void MoveCamera(unity::Vector2 delta, bool isMomentum = false) - { Call<"MoveCamera">(delta, isMomentum); } - - static bool BlockPan() - { return Class::Field<"BlockPan", bool>(); } - unity::Vector2 lastDelta() { return Field<"_lastDelta", unity::Vector2>(); } void SetLastDelta(unity::Vector2 value) @@ -57,6 +50,12 @@ struct NavigationPan : il2cpp::Object { { return Field<"_farMagRadiusRatioSystemNormal", float>(); } void SetFarMagRadiusRatioSystemExtended(float value) { SetField<"_farMagRadiusRatioSystemExtended">(value); } + + void MoveCamera(unity::Vector2 delta, bool isMomentum = false) + { Call<"MoveCamera">(delta, isMomentum); } + + static bool BlockPan() + { return Class::Field<"BlockPan", bool>(); } }; enum class NodeDepth : int32_t { @@ -77,9 +76,6 @@ struct MathUtils { struct NavigationZoom : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "NavigationZoom">; - void ZoomCameraAtWorldPoint() - { Call<"ZoomCameraAtWorldPoint">(); } - float Distance() { return Get<"Distance", float>(); } void SetDistance(float value) @@ -116,6 +112,9 @@ struct NavigationZoom : il2cpp::Object { { SetField<"_farRatioSystemNormal">(value); } void SetFarRatioSystemExtended(float value) { SetField<"_farRatioSystemExtended">(value); } + + void ZoomCameraAtWorldPoint() + { Call<"ZoomCameraAtWorldPoint">(); } }; struct NavigationFleetWidget : Widget { @@ -136,16 +135,15 @@ struct NavigationManager : il2cpp::Object { struct NavigationSectionManager : unity::Behaviour { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "NavigationSectionManager">; - static void ChangeNavigationSection(SectionID sectionId, int64_t systemId = -1) - { Class::Call<"ChangeNavigationSection">(sectionId, systemId); } - - static NavigationSectionManager* Instance() - { return Class::Get<"Instance", NavigationSectionManager*>(); } - // Named after the property, so the type needs `::` here. ::NavigationManager* NavigationManager() { return Get<"NavigationManager", ::NavigationManager*>(); } + static NavigationSectionManager* Instance() + { return Class::Get<"Instance", NavigationSectionManager*>(); } + static void ChangeNavigationSection(SectionID sectionId, int64_t systemId = -1) + { Class::Call<"ChangeNavigationSection">(sectionId, systemId); } + // Closes whatever interaction menu is open in space before the mod acts on a fleet. static void HideInteraction() { @@ -171,21 +169,6 @@ struct SetCourseWidget : unity::Behaviour { struct NavigationInteractionUIViewController : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "NavigationInteractionUIViewController">; - bool TryPressJoinWaveDefense() - { - if (!IsSetCourseVisible()) { - return false; - } - auto* widget = Field<"_setCourseWidget", SetCourseWidget*>(); - auto* listener = widget ? widget->joinWaveDefenseButton() : nullptr; - if (!listener || !listener->isActiveAndEnabled()) { - return false; - } - // Button.Press preserves Unity's active/interactable checks and the native join callback. - listener->Press(); - return true; - } - bool IsSetCourseVisible() { auto* widget = Field<"_setCourseWidget", SetCourseWidget*>(); @@ -194,10 +177,23 @@ struct NavigationInteractionUIViewController : il2cpp::Object { } void OnSetCourseButtonClick() { Call<"OnSetCourseButtonClick">(); } + + // Presses the set-course widget's wave defense join button when it is listening. Button.Press keeps Unity's + // active and interactable checks and the game's own join callback. + bool TryPressJoinWaveDefense() + { + auto* widget = IsSetCourseVisible() ? Field<"_setCourseWidget", SetCourseWidget*>() : nullptr; + auto* listener = widget ? widget->joinWaveDefenseButton() : nullptr; + if (!listener || !listener->isActiveAndEnabled()) + return false; + listener->Press(); + return true; + } }; struct PopulatedSystemData : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Navigation", "PopulatedSystemData">; + Il2CppObject* VariationCollection() { return Field<"k__BackingField", Il2CppObject*>(); } }; @@ -243,7 +239,7 @@ struct BookmarksManager : MonoSingleton { context->SetDefaultXCoordinate(zero); context->SetDefaultYCoordinate(zero); context->SetDefaultSystemInput(il2cpp::NewString("")); - Hub::Sections()->TriggerSectionChange(SectionID::Bookmarks_Search_Coordinates, context, false, false, true); + Hub::GotoSection(SectionID::Bookmarks_Search_Coordinates, context); } }; diff --git a/src/mod/game/queues.h b/src/mod/game/queues.h index 5ea38667b..4b6850345 100644 --- a/src/mod/game/queues.h +++ b/src/mod/game/queues.h @@ -56,16 +56,16 @@ static_assert(sizeof(SetCourseResponse) == 24 && offsetof(SetCourseResponse, tar struct ActionQueueManager : MonoSingleton { using Class = il2cpp::Class<"Assembly-CSharp", "Prime.ActionQueue", "ActionQueueManager">; + // One queue per dock slot, indexed by FleetPlayerData.Index; empty slots are null. + il2cpp::Array* battleQueue() + { return Field<"_battleQueue", il2cpp::Array*>(); } + void ClearQueue(FleetPlayerData* playerData) { Call<"ClearQueue">(playerData); } bool IsQueueFull(FleetPlayerData* playerData) { return Call<"IsQueueFull", bool>(playerData); } bool IsQueueUnlocked() { return Call<"IsQueueUnlocked", bool>(); } - - // One queue per dock slot, indexed by FleetPlayerData.Index; empty slots are null. - il2cpp::Array* battleQueue() - { return Field<"_battleQueue", il2cpp::Array*>(); } EngageResult TryPlanPathAndEngageTarget(FleetPlayerData* fleet, ActionQueueInstance* queue) { return Call<"TryPlanPathAndEngageTarget", EngageResult(FleetPlayerData*, ActionQueueInstance*)>(fleet, queue); } // Removes the target fleet from every queue that holds it, as the game does when a target goes away. diff --git a/src/mod/game/server.h b/src/mod/game/server.h index 80c45e055..cc3dc1d42 100644 --- a/src/mod/game/server.h +++ b/src/mod/game/server.h @@ -352,8 +352,8 @@ using SlotClearRtcParser = // Digit.Networking.RTC.CentrifugoInfo, a value type passed by value: a hook must keep it by value so the arguments // after it keep their ABI positions. struct CentrifugoInfo { - Il2CppString* Channel; - int32_t Offset; + Il2CppString* channel; + int32_t offset; }; static_assert(sizeof(CentrifugoInfo) == 16); @@ -391,7 +391,7 @@ struct IdRefs : il2cpp::Object { struct HullSpec : il2cpp::Object { using Class = il2cpp::Class<"Digit.Client.PrimeLib.Runtime", "Digit.PrimeServer.Models", "HullSpec">; - int64_t Id() + int64_t id() { return Field<"id_", int64_t>(); } // The internal key, such as "Hull_L30_Destroyer_Klingon_LIVE". Il2CppString* name() @@ -557,8 +557,9 @@ struct InventoryItem : il2cpp::Object { ResourceSpec* resourceSpec() { return Field<"_resourceSpec", ResourceSpec*>(); } - ForbiddenTech* forbiddenTech() - { return Get<"ForbiddenTech", ForbiddenTech*>(); } + // Named after the property, so the type needs `::` here. + ::ForbiddenTech* ForbiddenTech() + { return Get<"ForbiddenTech", ::ForbiddenTech*>(); } }; // The context of a cargo bar. diff --git a/src/mod/game/ui.h b/src/mod/game/ui.h index 6ee1e8497..f8a55815c 100644 --- a/src/mod/game/ui.h +++ b/src/mod/game/ui.h @@ -9,6 +9,8 @@ #include #include +#include +#include #include #include #include @@ -52,10 +54,7 @@ struct VisibilityController : il2cpp::Object { // Shown or on its way in. bool IsShown() - { - const auto current = state(); - return current == VisibilityState::Visible || current == VisibilityState::Show; - } + { return std::ranges::contains(std::array{VisibilityState::Visible, VisibilityState::Show}, state()); } }; struct CanvasController : unity::Behaviour { @@ -74,12 +73,15 @@ struct CanvasController : unity::Behaviour { static CanvasController* Of(il2cpp::Object* component) { return component ? component->Call<"GetComponentInParent", CanvasController*>(Class::TypeObject()) : nullptr; } - // Whether `component` is on a canvas that is currently visible. + // `component` is on a canvas that is currently visible. static bool Shows(il2cpp::Object* component) { auto* canvas = Of(component); return canvas && canvas->Visible(); } + // `view` is active and enabled, and its canvas is visible: a hidden screen can keep active views. + static bool OnScreen(unity::Behaviour* view) + { return unity::ActiveAndEnabled(view) && Shows(view); } }; // Digit.Client.UI.TextLocalizer: the label component that renders localized, parameterized text. @@ -89,9 +91,6 @@ struct TextLocalizer : il2cpp::Object { // Declared on GraphicLocalizer<,>. Il2CppString* identifier() { return Field<"m_identifier", Il2CppString*>(); } - // These localizers render on every UI update, so the identifier is compared without converting it. - bool IdentifierIs(std::string_view name) - { return il2cpp::Equals(identifier(), name); } // The backing field, not the SignificantDecimals property. void SetSignificantDecimals(int32_t value) { SetField<"m_significantDecimals">(value); } @@ -107,6 +106,10 @@ struct TextLocalizer : il2cpp::Object { { Call<"ClearTextOverride">(); } void OverrideLocalizedText(Il2CppString* text) { Call<"OverrideLocalizedText">(text); } + + // These localizers render on every UI update, so the identifier is compared without converting it. + bool IdentifierIs(std::string_view name) + { return il2cpp::Equals(identifier(), name); } }; struct ColourTextLocalizer : TextLocalizer { @@ -241,15 +244,6 @@ struct BaseListContainer : il2cpp::Object { struct SelectableList : BaseListContainer { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "SelectableList">; - // Regenerates the list's tiles from its current Data, through the game's SelectableListBase - // .ClearAndGenerateContents; `provider` is the IDataContextProvider the tiles bind to. - void Rebuild(il2cpp::Object* provider) - { - auto* data = Get<"Data", il2cpp::Object*>(); - if (data) - Call<"ClearAndGenerateContents">(provider, data); - } - int32_t SelectedIndex() { return Get<"SelectedIndex", int32_t>(); } @@ -257,10 +251,16 @@ struct SelectableList : BaseListContainer { void RequestSelect(int32_t index, bool simulated = false) { Call<"RequestSelect">(index, simulated); } + // Null when `index` is outside Data. il2cpp::Object* DataItem(int32_t index) + { return il2cpp::At(Get<"Data", il2cpp::List*>(), index); } + + // Regenerates the list's tiles from its current Data, through the game's SelectableListBase + // .ClearAndGenerateContents; `provider` is the IDataContextProvider the tiles bind to. + void Rebuild(il2cpp::Object* provider) { - auto* list = Get<"Data", il2cpp::List*>(); - return list ? (*list)[index] : nullptr; + if (auto* data = Get<"Data", il2cpp::Object*>()) + Call<"ClearAndGenerateContents">(provider, data); } }; @@ -286,9 +286,13 @@ struct UIFrameManager : il2cpp::Object { struct ScreenManager : MonoSingleton { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Client.UI", "ScreenManager">; - // The canvas drawn on top; with visible_only, the top one that is shown. - static CanvasController* GetTopCanvas(bool visible_only) - { return Class::Call<"GetTopCanvas", CanvasController*>(visible_only); } + // Every registered canvas, visible or not. + il2cpp::List* screens() + { return Field<"_screens", il2cpp::List*>(); } + unity::CanvasScaler* canvasRootScaler() + { return Field<"m_canvasRootScaler", unity::CanvasScaler*>(); } + UIFrameManager* frameManager() + { return Field<"_frameManager", UIFrameManager*>(); } // The first live T on a visible registered canvas whose data context is a T::Context. Only the canvas that owns // that context is searched: the top canvas and the scene root can belong to unrelated overlays. @@ -297,11 +301,8 @@ struct ScreenManager : MonoSingleton { struct Context { using Class = typename T::Context; }; - auto* manager = Existing(); - auto* screens = unity::Alive(manager) ? manager->screens() : nullptr; - const int32_t count = screens ? screens->Count() : 0; - for (int32_t index = 0; index < count; ++index) { - auto* canvas = (*screens)[index]; + auto* manager = Existing(); + for (auto* canvas : il2cpp::Items(unity::Alive(manager) ? manager->screens() : nullptr)) { auto* context = unity::Alive(canvas) ? canvas->Context() : nullptr; if (!context || !context->Is() || !canvas->Visible()) continue; @@ -311,14 +312,6 @@ struct ScreenManager : MonoSingleton { } return nullptr; } - - // Every registered canvas, visible or not. - il2cpp::List* screens() - { return Field<"_screens", il2cpp::List*>(); } - unity::CanvasScaler* canvasRootScaler() - { return Field<"m_canvasRootScaler", unity::CanvasScaler*>(); } - UIFrameManager* frameManager() - { return Field<"_frameManager", UIFrameManager*>(); } }; struct ShortcutsManager : MonoSingleton { @@ -328,13 +321,14 @@ struct ShortcutsManager : MonoSingleton { { return Get<"ShowKeybindings", bool>(); } void SetShowKeybindings(bool value) { Set<"ShowKeybindings">(value); } - // Private static gate the native shortcut actions check (tutorial, modals, text input). - static bool CanUseShortcuts() - { return Class::Get<"CanUseShortcuts", bool>(); } // Runs one of the On*Action(InputAction.CallbackContext) handlers, as the game's own shortcut for it would. template void Run() { Call(unity::InputActionCallbackContext{}); } + + // The private static gate the game's own shortcut actions check (tutorial, modals, text input). + static bool CanUseShortcuts() + { return Class::Get<"CanUseShortcuts", bool>(); } }; // The badge next to a button that shows the key of one of the game's input actions. @@ -450,10 +444,10 @@ struct ToastObserver : il2cpp::Object { struct AnimatedRewardsScreenViewController : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.Missions.UI", "AnimatedRewardsScreenViewController">; - void GoBackToLastSection() - { Call<"GoBackToLastSection">(); } bool IsActive() { return Call<"IsActive", bool>(); } + void GoBackToLastSection() + { Call<"GoBackToLastSection">(); } }; // The -/+ stepper on upgrade and quantity screens. diff --git a/src/mod/game/viewers.h b/src/mod/game/viewers.h index 2561e4fc1..7ddbd868c 100644 --- a/src/mod/game/viewers.h +++ b/src/mod/game/viewers.h @@ -1,6 +1,6 @@ #pragma once -// Object viewer cards shown for targets in space, and their scan/engage buttons. +// The object viewer cards shown for targets in space, and their scan, engage and armada buttons. #include "fleet.h" #include "navigation.h" @@ -12,6 +12,7 @@ struct ParentObjectViewerViewController : il2cpp::Object { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ObjectViewer", "ParentObjectViewerViewController">; + bool IsShowing() { return Get<"IsShowing", bool>(); } }; @@ -19,13 +20,12 @@ struct ParentObjectViewerViewController : il2cpp::Object { struct ObjectViewerBaseWidget : Widget { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ObjectViewer", "ObjectViewerBaseWidget">; + VisibilityController* visibilityController() + { return Field<"_visibilityController", VisibilityController*>(); } ParentObjectViewerViewController* Parent() { return Get<"Parent", ParentObjectViewerViewController*>(); } void HideAllViewers() { Call<"HideAllViewers">(); } - - VisibilityController* visibilityController() - { return Field<"_visibilityController", VisibilityController*>(); } }; struct RewardsButtonWidget : Widget { @@ -66,11 +66,10 @@ struct AllianceStarbaseObjectViewerWidget : ObjectViewerBaseWidget { struct ArmadaObjectViewerWidget : ObjectViewerBaseWidget { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ObjectViewer", "ArmadaObjectViewerWidget">; - void ValidateThenJoinArmada() - { Call<"ValidateThenJoinArmada">(); } - GenericButtonContext* joinContext() { return Field<"_joinContext", GenericButtonContext*>(); } + void ValidateThenJoinArmada() + { Call<"ValidateThenJoinArmada">(); } }; struct CelestialObjectViewerWidget : ObjectViewerBaseWidget { @@ -86,16 +85,19 @@ struct HousingObjectViewerWidget : ObjectViewerBaseWidget { }; // MiningObjectViewerWidget.OccupiedState, a private nested enum. -enum class OccupiedState : int32_t { NotOccupied = 0, LocalPlayerOccupied = 1, OtherPlayerOccupied = 2 }; +enum class OccupiedState : int32_t { + NotOccupied = 0, + LocalPlayerOccupied = 1, + OtherPlayerOccupied = 2, +}; struct MiningObjectViewerWidget : ObjectViewerBaseWidget { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ObjectViewer", "MiningObjectViewerWidget">; - void MineClicked() - { Call<"MineClicked">(); } - ScanEngageButtonsWidget* scanEngageButtonsWidget() { return Field<"_scanEngageButtonsWidget", ScanEngageButtonsWidget*>(); } + void MineClicked() + { Call<"MineClicked">(); } }; struct MissionsObjectViewerWidget : ObjectViewerBaseWidget { @@ -108,27 +110,25 @@ struct WaveDefenseObjectViewerWidget : ObjectViewerBaseWidget { GenericButtonWidget* primaryButton() { return Field<"_primaryButton", GenericButtonWidget*>(); } + // Presses the primary button the viewer shows, through the button's own Press, when the widget is active and its + // context interactable. void PressPrimaryButton() { - auto* widget = primaryButton(); - if (!widget || !widget->isActiveAndEnabled()) { - return; - } - auto* context = widget->Context(); - auto* listener = widget->semaphoreButtonListener(); - if (context && context->Interactable() && listener) { + auto* widget = primaryButton(); + auto* listener = widget && widget->isActiveAndEnabled() ? widget->semaphoreButtonListener() : nullptr; + auto* context = listener ? widget->Context() : nullptr; + if (context && context->Interactable()) listener->Press(); - } } }; struct StarNodeObjectViewerWidget : ObjectViewerBaseWidget { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.ObjectViewer", "StarNodeObjectViewerWidget">; + GenericButtonWidget* visitButton() + { return Field<"_visitButton", GenericButtonWidget*>(); } void OnViewButtonActivation() { Call<"OnViewButtonActivation">(); } void InitiateWarp() { Call<"InitiateWarp">(); } - GenericButtonWidget* visitButton() - { return Field<"_visitButton", GenericButtonWidget*>(); } }; diff --git a/src/mod/sync/fleets.cc b/src/mod/sync/fleets.cc index 5886ccf3f..f1da2292e 100644 --- a/src/mod/sync/fleets.cc +++ b/src/mod/sync/fleets.cc @@ -106,7 +106,7 @@ namespace out["hull_damage"] = std::llround(*damage); out["cargo_fill"] = fleet->CargoResourceFillLevel(); - if (auto* hold = fleet->CargoHold()) { + if (auto* hold = fleet->CargoHoldData()) { if (auto* current = hold->CurrentCargo()) { out["cargo"] = current->CurrentValue(); out["cargo_max"] = current->MaxValue(); @@ -125,7 +125,7 @@ namespace out["node_amount"] = node->Amount(); } out["mining_rate"] = fleet->GetMiningRate(); - if (auto* timer = fleet->MiningSlotTimer()) + if (auto* timer = fleet->miningSlotTimer()) Put(out, "mining_due", timer->DueTime().UnixSeconds()); } @@ -160,7 +160,7 @@ namespace auto* hull = fleet->Hull(); // Hull ids are 32-bit and run past 2^31 (e.g. Kumari 3005922948); take the value as unsigned before widening so // those never come out negative in the record. - const int64_t hull_id = hull ? static_cast(static_cast(hull->Id())) : 0; + const int64_t hull_id = hull ? static_cast(static_cast(hull->id())) : 0; json out = {{"fid", fleet->Id()}, {"slot", slot}, From 917259125d21fb530c2facbc5f1ce9182fe55fa9 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 15:00:38 -0700 Subject: [PATCH 06/13] Bound sync retries A battle journal the server will not return retried every second forever and stalled the journals behind it. It now retries with a doubling wait, capped, for about four minutes, then drops. Every category is stored and filtered per target at delivery, so a target switched on by a reload receives data without waiting for the next login. Sync bodies move to plainer statements and shared helpers; wire output and threading are unchanged. --- src/mod/game/server.h | 6 +- src/mod/sync/battlelogs.cc | 261 +++++++++++++++--------------------- src/mod/sync/fleets.cc | 31 +++-- src/mod/sync/records.cc | 206 ++++++++++++++-------------- src/mod/sync/state.cc | 230 +++++++++++++++++++------------- src/mod/sync/sync.cc | 256 +++++++++++++++++++++++------------ src/mod/sync/sync.h | 235 +++++++++++++++++++++++++------- src/mod/sync/upload.cc | 235 +++++++++++--------------------- tests/sync_logic.cc | 266 ++++++++++++++++++++++++++++++------- 9 files changed, 1020 insertions(+), 706 deletions(-) diff --git a/src/mod/game/server.h b/src/mod/game/server.h index cc3dc1d42..aea4a504f 100644 --- a/src/mod/game/server.h +++ b/src/mod/game/server.h @@ -8,14 +8,16 @@ #include #include +#include struct ByteString : il2cpp::Object { using Class = il2cpp::Class<"Google.Protobuf", "Google.Protobuf", "ByteString">; il2cpp::Array* bytes() { return Field<"bytes", il2cpp::Array*>(); } - int32_t Length() - { return Get<"Length", int32_t>(); } + // The payload. This client's ByteString owns its whole array (Length is bytes.Length), so that is all of it. + std::span Span() + { return il2cpp::Items(bytes()); } }; struct EntityGroup : il2cpp::Object { diff --git a/src/mod/sync/battlelogs.cc b/src/mod/sync/battlelogs.cc index cc3cf35c3..1c2c29781 100644 --- a/src/mod/sync/battlelogs.cc +++ b/src/mod/sync/battlelogs.cc @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -28,66 +29,6 @@ namespace data_sync { namespace { - using json = nlohmann::json; - using Clock = std::chrono::steady_clock; - - // 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; - - // Filled by the processing thread from responses the game requested and by the battle-log thread from its own - // requests, so it locks. - template class NameCache - { - public: - std::optional Find(const Key& key, uint64_t generation) - { - std::scoped_lock lock(mutex_); - 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()) { - entries_.erase(it); - return std::nullopt; - } - return it->second.value; - } - - void Store(const Key& key, Value value, uint64_t generation) - { - 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. - if (entries_.size() >= kNameCacheSize) - entries_.clear(); - } - entries_.insert_or_assign(key, Entry{std::move(value), expires_at}); - } - - private: - struct Entry { - Value value; - Clock::time_point expires_at; - }; - uint64_t generation_ = 0; - std::mutex mutex_; - std::unordered_map entries_; - }; - struct Player { std::string name; int64_t alliance = -1; @@ -98,19 +39,34 @@ namespace std::string tag; }; - // Shared with the processing thread and never destroyed, see sync.h. + // Shared with the processing thread. Leaked so the detached battle-log thread never sees them destroyed at exit. auto& players = *new NameCache; auto& alliances = *new NameCache; + + // A journal the game server would not hand over is tried this many times, waiting 1 s and doubling up to a minute: + // about four minutes in all, enough to ride out a server hiccup without retrying a lost journal all session. + constexpr int kJournalAttempts = 10; + constexpr int kJournalMaxBackoff = 60; + + // A battle to fetch, with the session that reported it: its journal is fetched with that session's credentials + // or not at all. struct Battle { - uint64_t id; - GameSession session; + uint64_t id; + GameSession session; + int attempts = 0; + Clock::time_point retry_at{}; }; - auto& battles = *new BoundedQueue{"battle log", kRecentBattles}; + // Only the recent journals are kept for targets, so a longer backlog would be fetched for nothing. + auto& battles = *new BoundedQueue{"battle log", kRecentBattles}; + // Guards `pending` and keeps it in step with `battles`. Taken before the state mutex (HasBattle), never after. auto& battle_mutex = *new std::mutex; - // Keep one fetch per journal in flight; target delivery is tracked by the canonical record owner. + // {generation, id} of battles queued or being fetched, so each journal is fetched once. Entries leave when the + // run loop finishes with a battle or the queue drops it. Delivery to targets is tracked by the record store. auto& pending = *new std::set>; + // The game client's UnityWebRequest identity, which the game server expects. Update with each client Unity bump. const std::string kUnityVersion = "6000.0.59f2-deadlock-fix-test"; + const std::string kClientCurl = "libcurl/8.10.1-DEV"; std::string NewUuid() { @@ -148,37 +104,38 @@ namespace return result; } - // The battle-log thread's session with the game server, set up as the game client sets up its own. It is + // The battle-log thread's HTTP session with the game server, set up as the game client sets up its own. It is // recreated when a settings reload changes the [sync] proxy or TLS check. Battle-log thread only. - cpr::Session& GameServer() + cpr::Session& GameServerHttp() { - struct Session { + struct Cached { std::unique_ptr http; std::string proxy; bool verify_ssl = true; }; - static auto& session = *new Session; // never destroyed, see sync.h + static auto& cached = *new Cached; // Leaked, like the thread that uses it. 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; + if (cached.http && options.proxy == cached.proxy && options.verify_ssl == cached.verify_ssl) + return *cached.http; - session.proxy = options.proxy; - session.verify_ssl = options.verify_ssl; - session.http = std::make_unique(); - auto& http = *session.http; + cached.proxy = options.proxy; + cached.verify_ssl = options.verify_ssl; + cached.http = std::make_unique(); + auto& http = *cached.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); + logging::Debug("download", "game server", "new session, proxy '{}', verify_ssl {}", options.proxy, + options.verify_ssl); if (!options.proxy.empty()) http.SetProxies({{"https", options.proxy}}); if (!options.verify_ssl) http.SetSslOptions(cpr::Ssl(cpr::ssl::VerifyHost{false}, cpr::ssl::VerifyPeer{false}, cpr::ssl::NoRevoke{true})); - http.SetUserAgent("UnityPlayer/" + kUnityVersion + " (UnityWebRequest/1.0, libcurl/8.10.1-DEV)"); + http.SetUserAgent("UnityPlayer/" + kUnityVersion + " (UnityWebRequest/1.0, " + kClientCurl + ")"); http.SetHeader({ {"Accept", "application/json"}, {"Content-Type", "application/json"}, @@ -190,20 +147,20 @@ 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. + // error, or when `session` has ended before or during the request. Battle-log thread only. std::string RequestGameData(const GameSession& session, const std::string& path, const std::string& body) { + // The URL and credentials are the captured session's; the version and instance headers are the latest, since + // the game reports them after login. const auto current = Session(); if (session.generation != current.generation) return {}; - auto& http = GameServer(); + auto& http = GameServerHttp(); auto& headers = http.GetHeader(); - { - 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", current.version); - headers.insert_or_assign("X-Instance-ID", fmt::format("{:03}", current.instance_id)); - } + 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", 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); @@ -211,15 +168,15 @@ namespace if (session.generation != SessionGeneration()) return {}; if (response.status_code == 0) { - logging::error("DOWNLOAD", path, "Failed to send request: {}", response.error.message); + logging::Error("download", path, "the request was not sent: {}", response.error.message); return {}; } if (response.status_code < 200 || response.status_code >= 300) { - logging::error("DOWNLOAD", path, "Failed to communicate with server: {}", response.status_line); + logging::Error("download", path, "the server answered {}", response.status_line); return {}; } const auto type = response.header.find("Content-Type"); - logging::debug("DOWNLOAD", path, "Response: {} ({}), {:.0f} ms elapsed", response.status_line, + logging::Debug("download", path, "{} ({}) in {:.0f} ms", response.status_line, type == response.header.end() ? "unknown" : type->second, response.elapsed * 1'000); return response.text; } @@ -242,13 +199,11 @@ namespace if (const auto uid = deployed.find("uid"); uid != deployed.end() && uid->is_string()) ids.insert(uid->get()); } - for (const char* key : {"initiator_id", "target_id"}) { - // NPC ids contain '_' (mar_61, npc__1); there is nothing to resolve for them. - if (const auto& id = journal.value(key, json()); - id.is_string() && !id.get_ref().empty() - && id.get_ref().find('_') == std::string::npos) - ids.insert(id.get()); - } + // NPC ids contain '_' (mar_61, npc__1); there is nothing to resolve for them. + for (const char* key : {"initiator_id", "target_id"}) + if (const auto id = journal.value(key, json()); id.is_string()) + if (const auto& text = id.get_ref(); !text.empty() && !text.contains('_')) + ids.insert(text); return ids; } @@ -259,17 +214,18 @@ namespace std::unordered_map fetched; if (ids.empty()) return fetched; - logging::trace("PROCESS", "combat log", "Fetching {} player profiles", ids.size()); + logging::Trace("download", "battle log", "fetching {} player profiles", ids.size()); const auto reply = 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()}; + Player player{.name = profile.at("name").get(), + .alliance = profile.at("alliance_id").get()}; 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()); + logging::Error("process", "player profile", "skipping {}: {}", id, e.what()); } } return fetched; @@ -280,7 +236,7 @@ namespace std::unordered_map fetched; if (ids.empty()) return fetched; - logging::trace("PROCESS", "combat log", "Fetching {} alliance profiles", ids.size()); + logging::Trace("download", "battle 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(session, "/alliance/get_alliances_public_info", request.dump()), nullptr, false); @@ -288,11 +244,11 @@ namespace 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()}; + Alliance alliance{.name = info.at("name").get(), .tag = info.at("tag").get()}; 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()); + logging::Error("process", "alliance profile", "skipping one: {}", e.what()); } } return fetched; @@ -327,9 +283,8 @@ namespace auto names = json::object(); for (const auto& [id, player] : known) { - json entry{{"name", player.name}, {"alliance_name", nullptr}, {"alliance_tag", nullptr}}; - const auto alliance = known_alliances.find(player.alliance); - if (alliance != known_alliances.end()) { + json entry{{"name", player.name}, {"alliance_name", nullptr}, {"alliance_tag", nullptr}}; + if (const auto alliance = known_alliances.find(player.alliance); alliance != known_alliances.end()) { entry["alliance_name"] = alliance->second.name; entry["alliance_tag"] = alliance->second.tag; } else { @@ -340,57 +295,62 @@ namespace return names; } - bool UploadBattle(const Battle& work) + // Fetches one journal and publishes it with its names. False when the game server did not hand it over. + bool FetchBattle(const Battle& battle) { - 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(session, "/journals/get", json{{"journal_id", journal_id}}.dump()); - if (reply.empty()) + const auto& session = battle.session; + logging::Trace("download", "battle log", "fetching the journal of battle {}", battle.id); + const auto text = RequestGameData(session, "/journals/get", json{{"journal_id", battle.id}}.dump()); + if (text.empty()) 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); + auto reply = json::parse(text, nullptr, false); + if (!reply.is_object() || !reply.contains("journal")) { + logging::Error("process", "battle log", "the journal of battle {} does not parse", battle.id); return false; } - - auto& journal = battle["journal"]; + auto& journal = reply["journal"]; auto names = ResolveNames(session, journal); - Publish(Stream::kBattles, {{journal_id, {{"names", std::move(names)}, {"journal", std::move(journal)}}}}, + Publish(Stream::kBattles, {{battle.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(); + return true; } +} // namespace - void RunBattleLogs() - { - for (;;) { - 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)); +void RunBattleLogs() +{ + for (;;) { + auto battle = battles.Pop(); + // A battle from an older session, or one queued before battle logs were switched off, is only forgotten. + if (battle.session.generation == SessionGeneration() && Config::Sync::Get()->options.battlelogs) { + // A retried journal goes to the back of the queue, so everything queued before it has been fetched; only + // what arrived later waits for it. + if (const auto now = Clock::now(); battle.retry_at > now) + std::this_thread::sleep_for(battle.retry_at - now); + bool fetched = false; + try { + fetched = FetchBattle(battle); + } catch (const std::exception& e) { + logging::Error("process", "battle log", "battle {} failed: {}", battle.id, e.what()); + } + if (!fetched) { + if (++battle.attempts >= kJournalAttempts) { + logging::Error("download", "battle log", "giving up on battle {} after {} attempts", battle.id, + kJournalAttempts); + } else if (battle.session.generation == SessionGeneration()) { + battle.retry_at = + Clock::now() + std::chrono::seconds(std::min(1 << (battle.attempts - 1), kJournalMaxBackoff)); + // Under the lock, like QueueNewBattles, so a battle the full queue drops leaves `pending` in the same step. std::scoped_lock lock(battle_mutex); - if (auto dropped = battles.Push(work)) + if (auto dropped = battles.Push(battle)) pending.erase({dropped->session.generation, dropped->id}); continue; } } - if (!fetched) { - std::scoped_lock lock(battle_mutex); - pending.erase({work.session.generation, work.id}); - } } + std::scoped_lock lock(battle_mutex); + pending.erase({battle.session.generation, battle.id}); } - -} // namespace +} void QueueNewBattles(const std::vector& ids, uint64_t generation) { @@ -398,6 +358,7 @@ void QueueNewBattles(const std::vector& ids, uint64_t generation) if (session.generation != generation) return; std::scoped_lock lock(battle_mutex); + // Older sessions' entries can never match again; the run loop drops their battles without fetching. 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) @@ -409,28 +370,20 @@ void QueueNewBattles(const std::vector& ids, uint64_t generation) void CachePlayerNames(const Input& input) { - Digit::PrimeServer::Models::UserProfilesResponse response; - if (!response.ParseFromString(input.bytes)) { - spdlog::error("Failed to parse user profile"); + const auto response = Parse(input, "player profiles"); + if (!response) return; - } - for (const auto& profile : response.userprofiles()) + for (const auto& profile : response->userprofiles()) players.Store(profile.userid(), {profile.name(), profile.allianceid()}, input.generation); } void CacheAllianceNames(const Input& input) { - Digit::PrimeServer::Models::GetAllianceProfilesResponse response; - if (!response.ParseFromString(input.bytes)) { - spdlog::error("Failed to parse alliance profile"); + const auto response = Parse(input, "alliance profiles"); + if (!response) return; - } - for (const auto& alliance : response.allianceprofiles()) + for (const auto& alliance : response->allianceprofiles()) if (alliance.id() > 0) alliances.Store(alliance.id(), {alliance.name(), alliance.tag()}, input.generation); } - -void StartBattleLogs() -{ std::thread(RunBattleLogs).detach(); } - } // namespace data_sync diff --git a/src/mod/sync/fleets.cc b/src/mod/sync/fleets.cc index f1da2292e..5ab882267 100644 --- a/src/mod/sync/fleets.cc +++ b/src/mod/sync/fleets.cc @@ -198,7 +198,7 @@ namespace records.push_back(Record(slot, fleet, fleet->CurrentState(), fleet->PreviousState())); if (!records.empty()) { - logging::debug("PROCESS", "fleets", "Fleet snapshot ({}): {} fleets", reason, records.size()); + logging::Debug("process", "fleets", "{} snapshot of {} fleets", reason, records.size()); Append(std::move(records), true); } } @@ -253,19 +253,24 @@ namespace if (!records.empty()) Append(std::move(records), false); } -} // namespace -void InstallFleetSync() -{ - 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"); - return; + // Subscribes to fleet_watch the first frame a target wants fleets, so a reload that turns them on starts the + // watcher. It then keeps running, and delivery filters per target as for every other category. Game thread. + bool FleetSyncInstalled() + { + static bool tried = false; + if (fleet_sync_installed || tried || !Config::Sync::Get()->options.fleets) + return fleet_sync_installed; + tried = true; + if (!fleet_watch::Subscribe({.on_transition = OnTransition, .on_baseline = OnBaseline})) { + spdlog::warn("[Sync] fleet state subscriber could not be installed; fleet sync disabled"); + return false; + } + fleet_sync_installed = true; + spdlog::info("[Sync] fleet state sync enabled"); + return true; } - fleet_sync_installed = true; - spdlog::info("Sync: fleet state sync enabled"); -} +} // namespace void RequestFleetSnapshot() { snapshot_requested.store(true, std::memory_order_release); } @@ -281,7 +286,7 @@ void RereadFleet(int64_t fleet_id) void UpdateFleetSync() { - if (!fleet_sync_installed) + if (!FleetSyncInstalled()) return; fleet_watch::Tick(); RereadDue(); diff --git a/src/mod/sync/records.cc b/src/mod/sync/records.cc index ab41856a1..a1f36bae3 100644 --- a/src/mod/sync/records.cc +++ b/src/mod/sync/records.cc @@ -1,16 +1,21 @@ -// Game responses become canonical wire records. Delivery owns comparison, removal and initial-sync state. +// Game responses become canonical wire records, on the processing thread. Each processor parses one response kind +// and publishes its records; Delivery (state.cc) owns comparison, removal and initial-sync state. + #include "sync/sync.h" #include "text.h" + #include #include +#include +#include + #include #include #include #include -#include +#include #include #include -#include #include NLOHMANN_JSON_NAMESPACE_BEGIN @@ -28,7 +33,6 @@ namespace data_sync { namespace { - using json = nlohmann::json; namespace pb = Digit::PrimeServer::Models; namespace content = Digit::PrimePlatform::Content; @@ -36,34 +40,31 @@ namespace template void Read(const Input& input, Stream stream, Build build, Update mode = Update::kMerge) { - Message message; - if (!message.ParseFromString(input.bytes)) { - spdlog::error("Failed to parse {}", message.GetTypeName()); + const auto message = Parse(input, Message::default_instance().GetTypeName()); + if (!message) return; - } RecordSet records; - build(message, 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); } json AwayAssignment(const pb::AwayAssignmentInstance& instance) { - 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) + // Protobuf maps are unordered; both lists go out in key order. + const auto& slots = instance.officerids(); + auto officers = json::array(); + for (const auto id : std::map(slots.begin(), slots.end()) | std::views::values) if (id) officers.push_back(id); auto traits = json::array(); for (const auto& trait : instance.officertraits()) 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) + const auto& scores = instance.parameters().criticaltraitscores(); + auto critical = json::array(); + for (const auto& [id, score] : std::map(scores.begin(), scores.end())) critical.push_back({{"tid", id}, {"score", score}}); return {{"aid", instance.id()}, {"template_id", instance.awayassignmenttemplateid()}, @@ -101,13 +102,11 @@ namespace } void ProcessBattleHeaders(const Input& input) { - pb::BattleResultHeadersResponse response; - if (!response.ParseFromString(input.bytes)) { - spdlog::error("Failed to parse battle headers"); + const auto response = Parse(input, "battle headers"); + if (!response) return; - } std::vector ids; - for (const auto& header : response.headers()) + for (const auto& header : response->headers()) ids.push_back(static_cast(header.id())); QueueNewBattles(ids, input.generation); } @@ -117,11 +116,10 @@ namespace 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)}}; + const auto& active = buff.activebuff(); + records[buff.buffid()] = {{"bid", buff.buffid()}, + {"level", buff.level()}, + {"expiry_time", Seconds(active.has_expirytime(), active.expirytime().seconds())}}; } }, Update::kReplace); @@ -129,17 +127,19 @@ namespace 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; - } + const auto claimed = std::ranges::find_if(response.properties(), [](const pb::AllianceGameProperty& property) { + return property.propertyname() == "claimed_loyalty_tiers"; + }); + if (claimed == response.properties().end()) + return; + // The highest claimed tier. from_chars reads a tier's leading digits, so "5x" counts as 5 and "x" as none. + int32_t level = -1; + for (const auto& tier : claimed->valuelist()) { + int32_t value = -1; + std::from_chars(tier.data(), tier.data() + tier.size(), value); + level = std::max(level, value); + } + records[nullptr] = {{"level", level}}; }); } void ProcessStarbaseModules(const Input& input) @@ -157,13 +157,11 @@ namespace } void ProcessHavenBase(const Input& input) { - pb::PlanetaryBase response; - if (!response.ParseFromString(input.bytes)) { - spdlog::error("Failed to parse {}", response.GetTypeName()); + const auto response = Parse(input, pb::PlanetaryBase::default_instance().GetTypeName()); + if (!response) return; - } RecordSet records; - for (const auto& [piece, count] : response.pieces()) + for (const auto& [piece, count] : response->pieces()) records[piece] = {{"sid", piece}, {"count", count}}; PublishHaven(Stream::kHavenPieces, std::move(records), input.generation); } @@ -178,26 +176,20 @@ namespace } void ProcessHavenMap(const Input& input) { - pb::PlanetaryMapData response; - if (!response.ParseFromString(input.bytes)) { - spdlog::error("Failed to parse {}", response.GetTypeName()); + const auto response = Parse(input, pb::PlanetaryMapData::default_instance().GetTypeName()); + if (!response) return; - } RecordSet records; - HavenBuildings(response, records); + HavenBuildings(*response, records); PublishHaven(Stream::kHaven, std::move(records), input.generation); } void ProcessHavenResponse(const Input& input) { - pb::PlanetaryMapResponse response; - if (!response.ParseFromString(input.bytes)) { - spdlog::error("Failed to parse planetary map response"); - return; - } - if (!response.owneruserid().empty() && response.owneruserid() != input.user_id) + const auto response = Parse(input, "Haven map"); + if (!response || (!response->owneruserid().empty() && response->owneruserid() != input.user_id)) return; RecordSet records; - HavenBuildings(response.mapdata(), records); + HavenBuildings(response->mapdata(), records); PublishHaven(Stream::kHaven, std::move(records), input.generation); } void ProcessHavenProsperity(const Input& input) @@ -273,6 +265,8 @@ namespace } void ProcessActiveMissions(const Input& input) { + // The whole list is one record, so each response replaces the last; an empty list still sends one entry with a + // null mid so targets can clear theirs. Read(input, Stream::kActiveMissions, [](const auto& response, auto& records) { std::set ids; for (const auto& mission : response.activemissions()) @@ -462,17 +456,20 @@ namespace Update::kFull); } + // The game's JSON response mixes categories, one section each. void ProcessJson(const Input& input) { - const auto config = Config::Sync::Get(); - const auto& options = config->options; - const auto result = json::parse(input.bytes, nullptr, false); + const bool battle_logs = Config::Sync::Get()->options.battlelogs; + const auto result = json::parse(input.bytes, nullptr, false); if (!result.is_object()) { - spdlog::error("Failed to parse sync JSON"); + logging::Error("process", "JSON response", "the response does not parse"); return; } + // The sections this response can carry, besides battle headers. + constexpr std::pair kSections[] = { + {"resources", Stream::kResources}, {"starbase_modules", Stream::kBuildings}, {"ships", Stream::kShips}}; for (const auto& [name, section] : result.items()) { - if (name == "battle_result_headers" && options.battlelogs) { + if (name == "battle_result_headers" && battle_logs) { std::vector ids; for (const auto& header : section) if (header.is_object()) { @@ -483,16 +480,11 @@ namespace 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 + const auto known = std::ranges::find(kSections, name, &std::pair::first); + if (known == std::end(kSections)) continue; - RecordSet records; + const auto stream = known->second; + RecordSet records; for (const auto& [key, record] : section.items()) { try { if (stream == Stream::kResources) { @@ -508,12 +500,14 @@ namespace record.at("components").get>()); } } catch (const std::exception& e) { - spdlog::error("Skipping malformed {} record {}: {}", name, key, e.what()); + logging::Error("process", name, "skipping malformed record {}: {}", key, e.what()); } } + // Skipped records are logged above; the rest still count as the full list. Publish(stream, std::move(records), input.generation, Update::kFull); } } + const char* DeployedStateName(pb::DeployedFleetState state) { switch (state) { @@ -595,7 +589,8 @@ namespace void ProcessDeployedFleets(const Input& input) { // 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. + // last record per fleet, for this session only, tells which ones those are. Leaked, like the processing thread + // that uses it. static uint64_t seen_generation = 0; static auto& seen = *new std::unordered_map; if (seen_generation != input.generation) { @@ -645,12 +640,14 @@ namespace Update::kReplace); } + // The processor for each response kind. struct Route { EntityGroup::Kind type; Processor process; - bool battle = false; + // Battle log routes only run while a target wants battle logs. + bool battle_logs = false; }; - const Route kRoutes[] = { + constexpr Route kRoutes[] = { {EntityGroup::Kind::AwayAssignmentsList, ProcessAwayAssignments}, {EntityGroup::Kind::AwayAssignmentsInstance, ProcessAwayAssignment}, {EntityGroup::Kind::BattleResultHeaders, ProcessBattleHeaders, true}, @@ -706,10 +703,12 @@ namespace } } // namespace +// A realtime slot update: the same record as the protobuf EntitySlot, from the realtime channel's JSON. The cases +// follow Slot above; this channel abbreviates fleet setups and sends times as UTC timestamps. void ProcessRealtimeSlot(const Input& input) { try { - auto data = json::parse(input.bytes); + const 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"}, @@ -757,18 +756,19 @@ void ProcessRealtimeSlot(const Input& input) 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()); + logging::Error("process", "realtime slot", "the payload does not parse: {}", e.what()); } } + // 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) { - content::Bundles response; - if (!response.ParseFromString(input.bytes)) { - spdlog::error("Failed to parse {}", response.GetTypeName()); + const auto parsed = Parse(input, "store offers"); + if (!parsed) return; - } + const auto& response = *parsed; + constexpr std::string_view kCategory = "factions"; struct Offer { const content::Bundle* bundle; @@ -815,36 +815,36 @@ void ProcessStoreOffers(const Input& input) Publish(Stream::kStoreOffers, std::move(records), input.generation); } -// 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. +// UserProfiles responses also list other players; only the local player's record 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"); + const auto response = Parse(input, "own profile"); + if (!response) return; - } - 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); + const auto& profiles = response->userprofiles(); + const auto own = std::ranges::find_if( + profiles, [&input](const pb::UserProfile& profile) { return profile.userid() == input.user_id; }); + if (own == profiles.end()) return; - } + RecordSet records; + records[input.user_id] = {{"uid", input.user_id}, + {"level", input.level}, + {"xp", own->xp()}, + {"military_might", own->militarymight()}, + {"planetary_base_unlocked", own->planetarybaseunlocked()}}; + Publish(Stream::kProfile, std::move(records), input.generation, Update::kFull); } +// Every category is parsed and stored, and each target filters what it receives at delivery: a target or category +// switched on by a settings reload then gets the current state at once, not after the next login. Battle logs are +// the exception, because processing their headers downloads every journal. Processor ProcessorFor(EntityGroup::Kind kind) { - 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; + const auto route = std::ranges::find(kRoutes, kind, &Route::type); + if (route == std::end(kRoutes)) + return nullptr; + return !route->battle_logs || Config::Sync::Get()->options.battlelogs ? route->process : nullptr; } } // namespace data_sync diff --git a/src/mod/sync/state.cc b/src/mod/sync/state.cc index 712002e41..c7da15b82 100644 --- a/src/mod/sync/state.cc +++ b/src/mod/sync/state.cc @@ -1,130 +1,158 @@ -// Canonical record state and per-target acknowledgement, without threads or HTTP. +// Canonical record state and per-target acknowledgement, without threads or HTTP. Callers hold the state mutex. + #include "sync/sync.h" + +#include #include +#include namespace data_sync { -bool RecordKeyLess::operator()(const nlohmann::json& a, const nlohmann::json& b) const +bool RecordKeyLess::operator()(const json& a, const json& b) const { return a < b; } + namespace { - using json = nlohmann::json; + // When a stream's uploads carry the full-sync header: never, on the first complete list a target receives, or + // on every upload. + enum class FullSync { kNever, kInitial, kAlways }; + + // How a stream goes on the wire. `removed` and `id` name the record a target receives when an entity it + // acknowledged disappears. A journal keeps its most recent `journal` entries; each is delivered on its own and + // never merged with a later one. struct Spec { + Stream stream; 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; + FullSync full_sync = FullSync::kNever; + const char* removed = nullptr; + const char* id = nullptr; + 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"}, + Spec{Stream::kAway, &SyncConfig::away_assignments, "away_assignment", FullSync::kInitial, + "collected_away_assignment", "aid"}, + Spec{Stream::kBattles, &SyncConfig::battlelogs, "battlelog", FullSync::kNever, nullptr, nullptr, kRecentBattles}, + Spec{Stream::kBuffs, &SyncConfig::buffs, "buff", FullSync::kInitial, "expired_buff", "bid"}, + Spec{Stream::kBuildings, &SyncConfig::buildings, "module"}, + // The emerald chain is the alliance loyalty tier, which sync sites list with the buffs it grants. + Spec{Stream::kEmerald, &SyncConfig::buffs, "emerald_chain"}, + Spec{Stream::kFleets, &SyncConfig::fleets, "fleet", FullSync::kNever, nullptr, nullptr, kRecentFleetChanges}, + Spec{Stream::kDeployedFleets, &SyncConfig::fleets, "fleet"}, + Spec{Stream::kHaven, &SyncConfig::haven, "haven", FullSync::kInitial}, + Spec{Stream::kHavenPieces, &SyncConfig::haven, "haven_pieces", FullSync::kInitial}, + Spec{Stream::kHavenProsperity, &SyncConfig::haven, "haven_prosperity"}, + Spec{Stream::kInventory, &SyncConfig::inventory, "inventory", FullSync::kInitial}, + Spec{Stream::kJobs, &SyncConfig::jobs, "job", FullSync::kInitial, "completed_job", "uuid"}, + Spec{Stream::kActiveMissions, &SyncConfig::missions, "active_mission"}, + Spec{Stream::kCompletedMissions, &SyncConfig::missions, "mission"}, + Spec{Stream::kOfficers, &SyncConfig::officers, "officer"}, + Spec{Stream::kProfile, &SyncConfig::profile, "profile", FullSync::kInitial}, + Spec{Stream::kResearch, &SyncConfig::research, "research"}, + Spec{Stream::kResources, &SyncConfig::resources, "resource", FullSync::kInitial}, + Spec{Stream::kAllianceResources, &SyncConfig::resources, "alliance_resource", FullSync::kInitial}, + Spec{Stream::kShips, &SyncConfig::ships, "ship", FullSync::kInitial}, + Spec{Stream::kSlots, &SyncConfig::slots, "slot"}, + Spec{Stream::kStoreOffers, &SyncConfig::store_offers, "store_offer"}, + Spec{Stream::kTech, &SyncConfig::tech, "ft"}, + Spec{Stream::kTraits, &SyncConfig::traits, "trait"}, }; + // Indexed by Stream. static_assert(kStreams.size() == static_cast(Stream::kCount)); + static_assert([] { + for (size_t i = 0; i < kStreams.size(); ++i) + if (kStreams[i].stream != static_cast(i)) + return false; + return true; + }()); + + // New entities a target may have in flight, unacknowledged, before more are introduced. Each could be a server + // write that needs a matching removal later, so this bounds the removals a target can owe. + constexpr size_t kMaxUnacknowledgedEntities = 1024; + + // Ship level progress drifts between responses; a change under 0.01 is not worth an upload. Profile XP rides along + // but is not part of the key: an XP-only change waits for the next level, Power or Haven change. So does the read + // time of a server fleet record: an unchanged fleet is not sent again. + bool Unchanged(Stream stream, const json& record, const json& old) + { + if (record == old) + return true; + if (stream == Stream::kProfile || stream == Stream::kDeployedFleets) { + const char* along = stream == Stream::kProfile ? "xp" : "t"; + auto comparable = record; + comparable[along] = old.at(along); + return comparable == old; + } + if (stream != Stream::kShips) + return false; + const auto& progress = old.at("level_percentage"); + const bool drift = std::fabs(record.at("level_percentage").get() - progress.get()) < 0.01; + if (!drift) + return false; + auto comparable = record; + comparable["level_percentage"] = progress; + return comparable == old; + } } // namespace -nlohmann::json Upload::Records() const + +json Upload::Records() const { - auto result = nlohmann::json::array(); - for (const auto& record : records) { + auto result = json::array(); + for (const auto& record : records) if (record->is_array()) - for (const auto& item : *record) - result.push_back(item); + result.insert(result.end(), record->begin(), record->end()); else result.push_back(*record); - } return result; } + const char* Upload::Type() const { return kStreams[static_cast(stream)].type; } + void RecordStore::Reset() { - ++generation; + ++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) + if (changed) 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; + auto& current = slot->records; + const auto& spec = kStreams[static_cast(stream)]; for (auto& [key, record] : records) { - const auto type = kStreams[static_cast(stream)].type; if (record.is_array()) for (auto& item : record) - item["type"] = type; + item["type"] = spec.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; - } - } + record["type"] = spec.type; + const auto old = current.find(key); + const bool added = old == current.end(); + if (!added && Unchanged(stream, record, *old->second)) + continue; changed = true; - if (journal && old == current.end()) + if (spec.journal && added) slot->order.push_back(key); current.insert_or_assign(key, std::make_shared(std::move(record))); } + // `records` was moved from per entry above, but its keys are intact. 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) { + while (slot->order.size() > spec.journal) { changed |= current.erase(slot->order.front()) != 0; slot->order.pop_front(); } @@ -132,6 +160,7 @@ bool RecordStore::Apply(Stream stream, RecordSet records, Update mode) ++slot->version; return changed; } + void Delivery::Configure(const SyncTargetConfig& config) { if (config == config_) @@ -140,10 +169,18 @@ void Delivery::Configure(const SyncTargetConfig& config) ++revision_; cursors_ = {}; } + +void Delivery::Retire() +{ + config_ = {}; + ++revision_; +} + std::optional Delivery::Next(const RecordStore& store, Clock::time_point now) { - if (generation_ != store.generation) { - generation_ = store.generation; + // A new session starts every cursor over. + if (generation_ != store.Generation()) { + generation_ = store.Generation(); cursors_ = {}; } for (size_t count = 0; count < kStreams.size(); ++count) { @@ -153,21 +190,31 @@ std::optional Delivery::Next(const RecordStore& store, Clock::time_point 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{}; + const auto& current = collection->records; + const bool journal = spec.journal != 0; + // The last upload failed, perhaps after the server wrote it, so every record but journals is sent again. + const bool uncertain = cursor.retry_at != Clock::time_point{}; + // Journals are acknowledged one at a time, so their cursor is compared by content rather than version. Pruned + // journals leave the cursor too, or it would never match again. 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; + Upload upload{ + .stream = spec.stream, + .generation = store.Generation(), + .revision = revision_, + .version = collection->version, + .first_sync = spec.full_sync == FullSync::kAlways + || (spec.full_sync == FullSync::kInitial && collection->full && !cursor.full), + }; + bool blocked = false; for (const auto& [key, record] : current) { - const bool accepted = cursor.accepted.contains(key); + const auto acknowledged = cursor.accepted.find(key); + const bool accepted = acknowledged != cursor.accepted.end(); if (spec.removed && !accepted && !cursor.attempted.contains(key)) { // Bound uncertain writes by waiting for acknowledgement before introducing more entities. - if (cursor.attempted.size() >= 1024) { + if (cursor.attempted.size() >= kMaxUnacknowledgedEntities) { blocked = true; continue; } @@ -175,8 +222,10 @@ std::optional Delivery::Next(const RecordStore& store, Clock::time_point } if (!journal) upload.snapshot.emplace(key, record); - if (upload.first_sync || (uncertain && !journal) || !accepted || cursor.accepted.at(key) != record) { + // Records are shared and immutable, so an unchanged one is the same pointer. + if (upload.first_sync || (uncertain && !journal) || !accepted || acknowledged->second != record) { upload.records.push_back(record); + // Journals are large; one per request keeps a slow target making progress. if (journal) { upload.snapshot.emplace(key, record); // Fleet journal keys are [sequence, snapshot]. A snapshot lists every fleet, so it is a first sync. @@ -185,20 +234,20 @@ std::optional Delivery::Next(const RecordStore& store, Clock::time_point } } } - // 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. + // A timeout may hide a successful server write. Remove records from attempted uploads too; attempted contains + // only new, unacknowledged keys, so the two maps are disjoint. + if (spec.removed) 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; } + // Nothing differs from what the target holds (say, a change and its revert): catch up without a request. if (upload.records.empty() && !upload.first_sync) { cursor.accepted = current; cursor.version = collection->version; @@ -209,16 +258,19 @@ std::optional Delivery::Next(const RecordStore& store, Clock::time_point } 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)]; + // Backoff runs 1 s doubling to a minute; a success resets it. if (!success) { cursor.retry_at = now + cursor.backoff; cursor.backoff = std::min(cursor.backoff * 2, std::chrono::seconds(60)); return; } + // A journal upload carries only the journal it sent; every other upload carries the whole stream. if (kStreams[static_cast(upload.stream)].journal) for (auto& [key, record] : upload.snapshot) cursor.accepted.insert_or_assign(key, std::move(record)); diff --git a/src/mod/sync/sync.cc b/src/mod/sync/sync.cc index a6ceb32b3..4b3690dd5 100644 --- a/src/mod/sync/sync.cc +++ b/src/mod/sync/sync.cc @@ -1,11 +1,13 @@ -// Data sync: watches the game's server responses and realtime updates and hands them to the processing thread, -// which turns them into records for the sync targets (records.cc), uploaded by upload.cc. It also captures the game's -// session for the battle-log requests. +// [sync]: watches the game's server responses and realtime updates, turns them into records on one processing +// thread (records.cc), and keeps the shared state: records, the game session and the targets. Each target uploads +// on its own thread (upload.cc); battle journals are fetched on another (battlelogs.cc). +// +// TODO: sync has no shutdown. Its threads are detached and run until the process exits, which can interrupt an +// HTTP request mid-flight. The shared state is leaked so they never see it destroyed. #include "sync/sync.h" #include "features/features.h" -#include "text.h" #include "game/app.h" #include "game/server.h" @@ -14,11 +16,14 @@ #include #include +#include namespace data_sync { namespace { + // --- Processing thread --- + // Responses waiting for the processing thread. A login delivers a few dozen at once; 256 leaves room for a // slow backlog without holding unbounded copies of large inventory payloads. constexpr size_t kJobQueueCapacity = 256; @@ -28,150 +33,158 @@ namespace Input input; }; - auto& pending = *new BoundedQueue{"processing", kJobQueueCapacity}; + // Leaked, like the shared state: the processing thread may still pop from it during exit. + auto& pending_jobs = *new BoundedQueue{"processing", kJobQueueCapacity}; // One thread processes every response in arrival order, so a delta is never applied before the full state it - // updates. + // updates. A processor that throws loses its one response, not the thread. void RunJobs() { for (;;) { - auto job = pending.Pop(); + auto job = pending_jobs.Pop(); try { + // Queued before a new login: its records would land in the wrong session. if (job.input.generation == SessionGeneration()) job.process(job.input); } catch (const std::exception& e) { - spdlog::error("Exception while processing sync data: {}", e.what()); + spdlog::error("[Sync] processing a response failed: {}", e.what()); } catch (...) { - spdlog::error("Unknown exception while processing sync data"); + spdlog::error("[Sync] processing a response failed with an unknown exception"); } } } - // 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) 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(); - 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; + const auto process = ProcessorFor(type); + // UserProfiles also carries the local player's own record, stored like every category for the targets that + // sync the profile. + const bool own_profile = type == EntityGroup::Kind::UserProfiles; if (!process && !own_profile) return; - Input input{SessionGeneration(), - length ? std::string(reinterpret_cast(bytes->data()), static_cast(length)) - : std::string{}}; + const auto bytes = group->Span(); + Input input{.generation = SessionGeneration(), .bytes = {bytes.begin(), bytes.end()}}; + // A map response can be another player's Haven, and a profiles response lists other players; the processors keep + // only the local player's. if (type == EntityGroup::Kind::PlanetaryMapResponse || own_profile) { - if (auto* profile = LocalUserProfile()) { + auto* manager = UserProfileManager::Instance(); + if (auto* profile = manager ? manager->GetLocalUserProfile() : nullptr) { input.user_id = il2cpp::ToUtf8(profile->UserId()); input.level = profile->Level(); } } if (process) - pending.Push({process, input}); + pending_jobs.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)}); + pending_jobs.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, so a target that turns them on later - // waits for the next store refresh. + // store offers are read from them, and only while a target wants them: unlike the categories above, a target that + // turns them on waits for the next store refresh. void HandlePlatformResponse(PlatformServiceResponse* response) { - 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))}}); + for (auto* entity_group : il2cpp::Items(response->EntityGroups())) { + auto* group = entity_group && entity_group->Type() == PlatformEntityGroup::Kind::StoreOffers + ? entity_group->Group() + : nullptr; + if (!group) + continue; + const auto bytes = group->Span(); + pending_jobs.Push( + {ProcessStoreOffers, {.generation = SessionGeneration(), .bytes = {bytes.begin(), bytes.end()}}}); } } - void* RtcParser_ParseFinalPayload_Hook(auto original, void* _this, CentrifugoInfo centrifugoInfo, - RealtimeDataPayload* realtimeDataPayload, void* finalPayload) + // --- Hooks --- + + // The parser and game server classes below are only bound as hook targets, so their receivers stay untyped. + + void* RtcParser_ParseFinalPayload_Hook(auto original, void* _this, CentrifugoInfo centrifugo_info, + RealtimeDataPayload* payload, void* final_payload) { - 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, {SessionGeneration(), il2cpp::ToUtf8(realtimeDataPayload->Data())}}); - } - return original(_this, centrifugoInfo, realtimeDataPayload, finalPayload); + // DataType arrives as a number in a string; JSON is "0". This runs inside the game's parser, so it only copies + // the payload out. + if (payload && payload->Target() && il2cpp::Equals(payload->DataType(), "0")) + if (auto* data = payload->Data()) + pending_jobs.Push({ProcessRealtimeSlot, {SessionGeneration(), il2cpp::ToUtf8(data)}}); + return original(_this, centrifugo_info, payload, final_payload); } void GameServerModelRegistry_ProcessResultInternal_Hook(auto original, void* _this, void* parsing_context, ServiceResponse* service_response, MethodInfo* method) { // The hooked method is shared generic code; PlatformModelRegistry passes its own response type. - if (auto* groups = - service_response && service_response->Is() ? service_response->EntityGroups() : nullptr) - for (auto* group : *groups) + if (service_response && service_response->Is()) { + for (auto* group : il2cpp::Items(service_response->EntityGroups())) HandleEntityGroup(group); - else if (auto* platform = service_response && Config::Sync::Get()->options.store_offers - ? service_response->As() - : nullptr) - HandlePlatformResponse(platform); + } else if (service_response && Config::Sync::Get()->options.store_offers) { + if (auto* platform = service_response->As()) + HandlePlatformResponse(platform); + } return original(_this, parsing_context, service_response, method); } - void PrimeApp_InitPrimeServer_Hook(auto original, void* _this, Il2CppString* gameServerUrl, - Il2CppString* gatewayServerUrl, Il2CppString* sessionId, - Il2CppString* serverRegion) + void PrimeApp_InitPrimeServer_Hook(auto original, PrimeApp* _this, Il2CppString* game_server_url, + Il2CppString* gateway_server_url, Il2CppString* session_id, + Il2CppString* server_region) { - // Establish the generation before original can deliver responses from the new session. - SetGameServer(il2cpp::ToUtf8(gameServerUrl), il2cpp::ToUtf8(sessionId)); - original(_this, gameServerUrl, gatewayServerUrl, sessionId, serverRegion); + // A new session drops every record; targets start over from the next responses. The generation changes + // before original can deliver responses from the new session. + auto url = il2cpp::ToUtf8(game_server_url); + auto id = il2cpp::ToUtf8(session_id); + auto& state = State(); + { + std::scoped_lock lock(state.mutex); + state.records.Reset(); + state.session.generation = state.records.Generation(); + state.session.server_url = std::move(url); + state.session.session_id = std::move(id); + state.generation = state.records.Generation(); + } + state.ready.notify_all(); + original(_this, game_server_url, gateway_server_url, session_id, server_region); } - void GameServer_Initialise_Hook(auto original, void* _this, Il2CppString* sessionId, Il2CppString* gameVersion, - bool encryptRequests, Il2CppString* serverRegion) + void GameServer_Initialise_Hook(auto original, void* _this, Il2CppString* session_id, Il2CppString* game_version, + bool encrypt_requests, Il2CppString* server_region) { - original(_this, sessionId, gameVersion, encryptRequests, serverRegion); - SetGameVersion(il2cpp::ToUtf8(gameVersion)); + original(_this, session_id, game_version, encrypt_requests, server_region); + auto version = il2cpp::ToUtf8(game_version); + auto& state = State(); + std::scoped_lock lock(state.mutex); + state.session.version = std::move(version); } - void GameServer_SetInstanceIdHeader_Hook(auto original, void* _this, int32_t instanceId) + void GameServer_SetInstanceIdHeader_Hook(auto original, void* _this, int32_t instance_id) { - original(_this, instanceId); - SetInstanceId(instanceId); + original(_this, instance_id); + auto& state = State(); + std::scoped_lock lock(state.mutex); + state.session.instance_id = instance_id; } - bool Install() + bool InstallSync() { il2cpp::HookSet hooks("Sync"); IL2CPP_STAGE(hooks, GameServerModelRegistry::Method("ProcessResultInternal"), GameServerModelRegistry_ProcessResultInternal_Hook); IL2CPP_STAGE(hooks, PrimeApp::Class::Method("InitPrimeServer"), PrimeApp_InitPrimeServer_Hook); - // This hook only captures the game version for the X-PRIME-VERSION header, which has a fallback. It can be - // turned off for users whose other mods detour the same method: a native and a managed detour on one prologue - // corrupt each other and crash at login. - if (!Config::Get().patches.game_version) - spdlog::info("Sync: skipping GameServer::Initialise hook (patches.game_version = false)"); - else + // Only captures the game version for the X-PRIME-VERSION header, which has a fallback (GameSession). Users + // whose other mods detour the same method turn it off: a native and a managed detour on one prologue corrupt + // each other and crash at login. Unlike every other [patches] key, game_version is a switch inside this + // feature's installer, so it means nothing without syncpatches. + if (Config::Get().patches.game_version) IL2CPP_STAGE(hooks, GameServer::Method("Initialise"), GameServer_Initialise_Hook); + else + spdlog::debug("[Sync] not capturing the game version ([patches] game_version = false)"); IL2CPP_STAGE(hooks, GameServer::Method("SetInstanceIdHeader"), GameServer_SetInstanceIdHeader_Hook); @@ -182,20 +195,89 @@ namespace if (!hooks.Commit()) return false; - FollowSettings(); - InstallFleetSync(); - StartBattleLogs(); std::thread(RunJobs).detach(); + std::thread(RunBattleLogs).detach(); + FollowSettings(); return true; } // Frame work: settings changes reach the targets, then fleet state is polled. - void Frame() + void SyncFrame() { FollowSettings(); UpdateFleetSync(); } } // namespace + +// --- Shared state --- + +SharedState& State() +{ + static auto& state = *new SharedState; + return state; +} + +void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode) +{ + auto& state = State(); + { + std::scoped_lock lock(state.mutex); + if (generation != state.records.Generation() || !state.records.Apply(stream, std::move(records), mode)) + return; + } + // Wakes the upload threads only when a stream actually changed. + state.ready.notify_all(); +} + +bool HasBattle(uint64_t id, uint64_t generation) +{ + auto& state = State(); + std::scoped_lock lock(state.mutex); + return generation == state.records.Generation() && state.records.Contains(Stream::kBattles, id); +} + +// Starts, reconfigures and retires target threads to match [sync] after a settings reload. Runs every frame, so +// it returns at once while the settings object is unchanged. Game thread only. +void FollowSettings() +{ + static std::shared_ptr applied; + const auto config = Config::Sync::Get(); + if (applied == config) + return; + auto& state = State(); + { + std::scoped_lock lock(state.mutex); + // Dropping the registry's reference ends the target's thread after its current request (see UploadTarget). + std::erase_if(state.targets, [&](const auto& entry) { + if (config->targets.contains(entry.first)) + return false; + entry.second->Retire(); + return true; + }); + for (const auto& [name, options] : config->targets) { + if (const auto found = state.targets.find(name); found != state.targets.end()) { + found->second->Configure(options); + continue; + } + auto target = std::make_shared(); + target->Configure(options); + std::thread(UploadTarget, std::weak_ptr(target), name).detach(); + state.targets.emplace(name, std::move(target)); + } + } + applied = config; + state.ready.notify_all(); +} + +uint64_t SessionGeneration() +{ return State().generation.load(); } + +GameSession Session() +{ + auto& state = State(); + std::scoped_lock lock(state.mutex); + return state.session; +} } // namespace data_sync -const Feature kSync{"Sync", &Config::Patches::syncpatches, data_sync::Install, data_sync::Frame}; +const Feature kSync{"Sync", &Config::Patches::syncpatches, data_sync::InstallSync, data_sync::SyncFrame}; diff --git a/src/mod/sync/sync.h b/src/mod/sync/sync.h index 13bfb998e..f0953cef0 100644 --- a/src/mod/sync/sync.h +++ b/src/mod/sync/sync.h @@ -1,7 +1,8 @@ #pragma once -// 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. +// Sync internals, shared by the files in sync/ and its tests. Features never include this; kSync is the only entry +// point. Records, delivery cursors and the game session live in one process-lifetime state; each upload captures +// its session and target settings before leaving that state's lock. #include "config.h" @@ -11,6 +12,7 @@ #include #include +#include #include #include #include @@ -20,13 +22,22 @@ #include #include #include +#include +#include #include #include +// Not `sync`: POSIX already declares ::sync(). 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. +using json = nlohmann::json; +using Clock = std::chrono::steady_clock; + +// --- Logging --- + +// Sync messages show up only with [sync] logging on; debug and trace also need [sync] debug. Server drift and +// unreachable targets are routine for sync, so even its errors stay behind the switch. The message is formatted +// only when it will be logged. `what` is the activity (upload, download, process), `about` its subject. namespace logging { inline bool Enabled(spdlog::level::level_enum level) @@ -38,27 +49,29 @@ namespace logging } template - void error(std::string_view type, std::string_view target, fmt::format_string format, Args&&... args) + void Error(std::string_view what, std::string_view about, fmt::format_string format, Args&&... args) { if (Enabled(spdlog::level::err)) - spdlog::error("SYNC-{} - {}: {}", type, target, fmt::format(format, std::forward(args)...)); + spdlog::error("[Sync] {} {}: {}", what, about, fmt::format(format, std::forward(args)...)); } template - void debug(std::string_view type, std::string_view target, fmt::format_string format, Args&&... args) + void Debug(std::string_view what, std::string_view about, fmt::format_string format, Args&&... args) { if (Enabled(spdlog::level::debug)) - spdlog::debug("SYNC-{} - {}: {}", type, target, fmt::format(format, std::forward(args)...)); + spdlog::debug("[Sync] {} {}: {}", what, about, fmt::format(format, std::forward(args)...)); } template - void trace(std::string_view type, std::string_view target, fmt::format_string format, Args&&... args) + void Trace(std::string_view what, std::string_view about, fmt::format_string format, Args&&... args) { if (Enabled(spdlog::level::trace)) - spdlog::trace("SYNC-{} - {}: {}", type, target, fmt::format(format, std::forward(args)...)); + spdlog::trace("[Sync] {} {}: {}", what, about, fmt::format(format, std::forward(args)...)); } } // namespace logging +// --- Thread-safe containers --- + // A FIFO shared between threads that drops its oldest entry when full. A slow or unreachable target must not // grow memory without bound while the game keeps producing updates. template class BoundedQueue @@ -70,6 +83,7 @@ template class BoundedQueue { } + // Returns the entry dropped to make room, if any, so its owner can forget it. std::optional Push(T item) { std::optional dropped; @@ -78,7 +92,8 @@ template class BoundedQueue if (items_.size() >= capacity_) { dropped = std::move(items_.front()); items_.pop_front(); - // Every 100th drop, so a dead target does not flood the log. + // A dropped entry is a real fault, so this is not behind [sync] logging; every 100th, so a dead target + // does not flood the log. if (dropped_++ % 100 == 0) spdlog::warn("[Sync] {} queue is full ({} entries); dropped {} so far", name_, capacity_, dropped_); } @@ -107,7 +122,78 @@ template class BoundedQueue std::deque items_; }; -// Separate streams preserve the wire protocol's player/alliance resources and active/completed missions. +// Names seen in battles and profile responses, kept for [sync] resolver_cache_ttl seconds. Filled by the +// processing thread from responses the game requested and by the battle-log thread from its own requests, so it +// locks. A new session's generation forgets the old session's names. +template class NameCache +{ +public: + // A busy server shows a few thousand players a day; past this size, expired entries are dropped first. + static constexpr size_t kCapacity = 10'000; + + std::optional Find(const Key& key, uint64_t generation) + { + std::scoped_lock lock(mutex_); + if (!Follow(generation)) + return std::nullopt; + const auto it = entries_.find(key); + if (it == entries_.end()) + return std::nullopt; + if (it->second.expires_at <= Clock::now()) { + entries_.erase(it); + return std::nullopt; + } + return it->second.value; + } + + void Store(const Key& key, Value value, uint64_t generation) + { + const auto expires_at = Clock::now() + std::chrono::seconds(Config::Sync::Get()->resolver_cache_ttl); + std::scoped_lock lock(mutex_); + if (!Follow(generation)) + return; + if (entries_.size() >= kCapacity) { + 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. + if (entries_.size() >= kCapacity) + entries_.clear(); + } + entries_.insert_or_assign(key, Entry{.value = std::move(value), .expires_at = expires_at}); + } + + size_t Size() + { + std::scoped_lock lock(mutex_); + return entries_.size(); + } + +private: + struct Entry { + Value value; + Clock::time_point expires_at; + }; + + // False for an older session's late request. A newer one clears the cache. Callers hold the lock. + bool Follow(uint64_t generation) + { + if (generation < generation_) + return false; + if (generation != generation_) { + entries_.clear(); + generation_ = generation; + } + return true; + } + + uint64_t generation_ = 0; + std::mutex mutex_; + std::unordered_map entries_; +}; + +// --- Records --- + +// One stream per wire record type. Player and alliance resources, and active and completed missions, are separate +// streams because the wire protocol keeps them apart. enum class Stream { kAway, kBattles, @@ -137,14 +223,16 @@ enum class Stream { kTraits, kCount }; -// Keep recursive JSON ordering out of each record producer's translation unit. + +// Keeps recursive JSON ordering out of each record producer's translation unit. struct RecordKeyLess { - bool operator()(const nlohmann::json& a, const nlohmann::json& b) const; + bool operator()(const json& a, const json& b) const; }; -using RecordSet = std::map; +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, RecordKeyLess>; -using Clock = std::chrono::steady_clock; +using Snapshot = std::map, RecordKeyLess>; + +// Journals are large and only the recent ones matter to a target; older ones are forgotten. inline constexpr size_t kRecentBattles = 300; // Fleet changes are a journal too, bounded like battles. inline constexpr size_t kRecentFleetChanges = 300; @@ -152,45 +240,52 @@ inline constexpr size_t kRecentFleetChanges = 300; // Full lists establish the initial-sync marker; Replace also prunes entities absent from the list. enum class Update { kMerge, kFull, kReplace }; +// The current session's records, per stream. Its generation changes with every session, which invalidates every +// target's cursors and any work queued for the old session. 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; + bool Contains(Stream stream, const json& key) const; + uint64_t Generation() const + { return generation_; } private: friend class Delivery; + // `version` moves on with every change, so a target can tell at a glance that it has nothing new. `full` says a + // complete list has arrived this session; until then no target can be sent a full baseline. A journal's `order` + // holds its keys, oldest first, for pruning to its bound. struct Collection { - Snapshot records; - uint64_t version = 0; - bool full = false; - // Journal keys in arrival order, oldest first. - std::deque order; + Snapshot records; + uint64_t version = 0; + bool full = false; + std::deque order; }; + uint64_t generation_ = 0; std::array, static_cast(Stream::kCount)> streams_; }; +// One request to one target. `generation` and `revision` are the session and target configuration it was built +// for; `version` is the stream version it acknowledges, or 0 when it is partial. `snapshot` is what the target +// holds once it succeeds, and `records` what is sent. 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; + Stream stream; + uint64_t generation, revision, version; + Snapshot snapshot; + std::vector> records; + bool first_sync = false; + json Records() const; + const char* Type() const; }; +// One target's view of the records: what it has acknowledged, what is in flight, and when to retry. class Delivery { public: - void Reset(uint64_t generation) - { - generation_ = generation; - cursors_ = {}; - } - void Configure(const SyncTargetConfig& config); + void Configure(const SyncTargetConfig& config); + // For a target removed from the settings: an upload still in flight can no longer acknowledge anything. + void Retire(); 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); @@ -207,18 +302,42 @@ class Delivery Clock::time_point retry_at{}; std::chrono::seconds backoff{1}; }; + // revision_ changes with the configuration, so an upload built for an older one cannot acknowledge anything. + // Streams are visited round-robin from next_, so one stream that always has news cannot starve the rest. SyncTargetConfig config_; uint64_t revision_ = 0, generation_ = 0; size_t next_ = 0; std::array(Stream::kCount)> cursors_; }; +// --- Session and shared state --- + +// What the battle-log thread needs to ask the game server for data as the game client would. struct GameSession { uint64_t generation = 0; std::string server_url, session_id; + // The X-PRIME-VERSION header until GameServer.Initialise reports the real one, which it does not when [patches] + // game_version is off. Bump it when the server starts rejecting it. std::string version = "1.000.51336"; int32_t instance_id = 0; }; + +// Shared by the game thread, the processing thread and the network workers. Leaked on purpose: the detached +// workers may still run during process exit, after static destructors. +struct SharedState { + std::mutex mutex; + std::condition_variable ready; + RecordStore records; + GameSession session; + std::map> targets; + // records.Generation(), readable without the lock: the game thread reads it for every response, while workers + // can hold the lock to copy whole snapshots. + std::atomic generation = 0; +}; +SharedState& State(); + +// --- Entry points between the files --- + struct Input { uint64_t generation; std::string bytes; @@ -228,32 +347,46 @@ struct Input { int32_t level = 0; }; using Processor = void (*)(const Input&); + +// The protobuf `Message` a response holds, or nullopt after logging that `about` does not parse. +template std::optional Parse(const Input& input, std::string_view about) +{ + Message message; + if (message.ParseFromString(input.bytes)) + return message; + logging::Error("process", about, "the response does not parse"); + return std::nullopt; +} + +// records.cc. Null for responses no target wants. Processor ProcessorFor(EntityGroup::Kind kind); void ProcessRealtimeSlot(const Input& input); void ProcessStoreOffers(const Input& input); -void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode = Update::kMerge); -void FollowSettings(); // The local player's own record from a UserProfiles response; needs user_id and level. void ProcessOwnProfile(const Input& input); -// Jobs only need the generation; Session() also copies the request headers. -uint64_t SessionGeneration(); + +// sync.cc. Publish drops records from an older session. +void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode = Update::kMerge); +bool HasBattle(uint64_t id, uint64_t generation); +void FollowSettings(); +uint64_t SessionGeneration(); +// Also copies the request headers, unlike 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(); + +// upload.cc: one thread per target, until the target leaves the settings. +void UploadTarget(std::weak_ptr target, std::string name); + +// battlelogs.cc. Journals are cached per session, so every target can receive them without a global sent-ID filter. +// RunBattleLogs is the battle-log thread and never returns. +void RunBattleLogs(); 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 for fleets: 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: subscribes to fleet_watch once a target wants fleets, then polls it and re-reads and +// snapshots what was asked for. Returns at once until then. void UpdateFleetSync(); // Asks the game thread for a full fleet snapshot, sent as a first sync. Any thread. void RequestFleetSnapshot(); diff --git a/src/mod/sync/upload.cc b/src/mod/sync/upload.cc index f6a1ea08a..355c71bf3 100644 --- a/src/mod/sync/upload.cc +++ b/src/mod/sync/upload.cc @@ -1,179 +1,98 @@ -// One owner for canonical records, target cursors and session identity. HTTP runs outside its lock. +// Each sync target's upload thread: takes the next upload from its Delivery and POSTs it, outside the state lock. + #include "sync/sync.h" #include "version.h" + #include #include -#include -#include + +#include namespace data_sync { namespace { - struct Owner { - std::mutex mutex; - std::condition_variable ready; - RecordStore records; - GameSession session; - std::map> targets; - }; - - // 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) + // Set up once per target configuration; a reload that changes the target makes a new one. Owned by the target's + // thread alone. + std::unique_ptr NewTargetSession(const SyncTargetConfig& config) { - 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}); + auto http = std::make_unique(); + http->SetUrl(config.url); + http->SetUserAgent("stfc community mod " VER_FILE_VERSION_STR " (libcurl/" LIBCURL_VERSION ")"); + http->SetAcceptEncoding(cpr::AcceptEncoding{}); + http->SetHttpVersion(cpr::HttpVersion{cpr::HttpVersionCode::VERSION_1_1}); + http->SetRedirect(cpr::Redirect{3, true, false, cpr::PostRedirectFlags::POST_ALL}); #ifndef _MODDBG - http->SetConnectTimeout(cpr::ConnectTimeout{3'000}); - http->SetTimeout(cpr::Timeout{10'000}); + http->SetConnectTimeout(cpr::ConnectTimeout{3'000}); + http->SetTimeout(cpr::Timeout{10'000}); #endif - if (!config.proxy.empty()) - http->SetProxies({{"http", config.proxy}, {"https", config.proxy}}); - if (!config.verify_ssl) - http->SetSslOptions( - cpr::Ssl(cpr::ssl::VerifyHost{false}, cpr::ssl::VerifyPeer{false}, cpr::ssl::NoRevoke{true})); - http->SetHeader({{"Content-Type", "application/json"}, - {"X-Powered-By", "stfc community mod/" VER_FILE_VERSION_STR}, - {"stfc-sync-token", config.token}}); - 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()); - } - } - Owner& Sync() - { - static auto& owner = *new Owner; - return owner; + 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}}); + return http; } } // namespace -void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode) + +// The registry owns the target; this thread only borrows it per upload, so a removed target finishes its request +// and then the thread ends, without the game thread joining it. +void UploadTarget(std::weak_ptr target, std::string name) { - auto& owner = Sync(); - { - std::scoped_lock lock(owner.mutex); - if (generation != owner.records.generation) - return; - if (!owner.records.Apply(stream, std::move(records), mode)) + auto& state = State(); + std::unique_ptr http; + uint64_t configured = 0; + for (;;) { + std::unique_lock lock(state.mutex); + const auto delivery = target.lock(); + if (!delivery) 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 { - 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)); + auto upload = delivery->Next(state.records, Clock::now()); + if (!upload) { + // New records notify; a retry coming due does not, so this also wakes every second to look again. + state.ready.wait_for(lock, std::chrono::seconds(1)); + continue; + } + // Copied, because a reload can reconfigure the target while the request runs. Complete then sees the newer + // revision and drops this upload's result. + 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 = NewTargetSession(config); + 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(); + // Game strings can hold invalid UTF-8 (a lone surrogate from a player name). Throwing on it would fail this + // upload on every retry and block the stream for good, so it is replaced instead. + http->SetBody(cpr::Body{records.dump(-1, ' ', false, json::error_handler_t::replace)}); + const auto response = http->Post(); + success = response.status_code >= 200 && response.status_code < 300; + if (success) + logging::Debug("upload", label, "sent {} records in {:.0f} ms", records.size(), response.elapsed * 1'000); + else + logging::Error("upload", label, "failed ({}): {}; kept for retry", response.status_code, + response.error.message); + } catch (const std::exception& e) { + // Not behind [sync] logging: an exception here is a fault in the mod, not a target being unreachable. + spdlog::error("[Sync] upload {} failed: {}; kept for retry", 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, state.records.Generation(), Clock::now()); } - 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) -{ - 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); - } - 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/tests/sync_logic.cc b/tests/sync_logic.cc index 2037845c1..9ab716dd4 100644 --- a/tests/sync_logic.cc +++ b/tests/sync_logic.cc @@ -1,9 +1,14 @@ -// Production encoders against independent wire fixtures, and delivery against controlled HTTP outcomes/time. +// Production encoders against independent wire fixtures, and delivery against controlled HTTP outcomes and time. +// Check throws instead of asserting, so one run reports every failing case. + #include "sync/sync.h" + #include +#include + #include #include -#include +#include struct CountedLogValue { int* formats; @@ -54,14 +59,14 @@ json Send(Delivery& target) { auto upload = Take(target); auto records = upload.Records(); - target.Complete(std::move(upload), true, store.generation, now); + 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()}); + process({store.Generation(), message.SerializeAsString()}); } void Slots() { @@ -84,14 +89,14 @@ void Slots() 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, + {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, + {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"}); + ProcessRealtimeSlot({store.Generation(), "not JSON"}); Check(!target.Next(store, now), "malformed JSON cannot change state"); } void Presets() @@ -117,10 +122,102 @@ void Presets() 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, + {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 SlotTransports() +{ + auto target = Target(); + game::EntitySlots slots; + auto* preset = slots.add_entityslots_(); + preset->set_id(1); + preset->set_slotspecid(10); + preset->set_slottype(game::SLOTTYPE_OFFICERPRESET); + preset->mutable_officerpresetslotparams()->set_name("Mining"); + preset->mutable_officerpresetslotparams()->set_order(1); + preset->mutable_officerpresetslotparams()->add_officerids(4); + preset->mutable_officerpresetslotparams()->add_officerids(5); + auto* commander = slots.add_entityslots_(); + commander->set_id(2); + commander->set_slotspecid(11); + commander->set_slottype(game::SLOTTYPE_FLEETCOMMANDER); + commander->mutable_fleetcommanderslotparams()->set_order(3); + auto* skill = slots.add_entityslots_(); + skill->set_id(3); + skill->set_slotspecid(12); + skill->set_slottype(game::SLOTTYPE_SELECTABLESKILL); + skill->mutable_selectableskillslotparams()->mutable_cooldownexpiration()->set_seconds(300); + auto* tech = slots.add_entityslots_(); + tech->set_id(4); + tech->set_slotspecid(13); + tech->set_slottype(game::SLOTTYPE_FORBIDDENTECH); + tech->mutable_forbiddentechslotparams()->set_ownerid(6); + tech->mutable_forbiddentechslotparams()->set_tier(2); + tech->mutable_forbiddentechslotparams()->set_level(7); + auto* worker = slots.add_entityslots_(); + worker->set_id(5); + worker->set_slottype(game::SLOTTYPE_WORKER); + Process(EntityGroup::Kind::EntitySlots, slots); + Check( + Send(target) + == json::parse( + R"([{"type":"slot","sid":1,"slot_type":2,"spec_id":10,"item_id":null,"params":{"name":"Mining","order":1,"officer_ids":[4,5]}}, + {"type":"slot","sid":2,"slot_type":3,"spec_id":11,"item_id":null,"params":{"order":3}}, + {"type":"slot","sid":3,"slot_type":4,"spec_id":12,"item_id":null,"params":{"cooldown_expiration":300}}, + {"type":"slot","sid":4,"slot_type":6,"spec_id":13,"item_id":null,"params":{"owner_id":6,"tier":2,"level":7}}])"), + "slot parameter wire fixtures, without unsupported slot types"); + const char* realtime[] = { + R"({"slot_id":1,"slot_type":2,"slot_spec_id":10,"slot_item_id":null,"officer_preset_slot_params":{"name":"Mining","order":1,"officer_ids":[4,5]}})", + R"({"slot_id":2,"slot_type":3,"slot_spec_id":11,"fleet_commander_slot_params":{"order":3}})", + R"({"slot_id":3,"slot_type":4,"slot_spec_id":12,"selectable_skill_slot_params":{"cooldown_expiration":"1970-01-01T00:05:00Z"}})", + R"({"slot_id":4,"slot_type":6,"slot_spec_id":13,"forbidden_tech_slot_params":{"owner_id":6,"tier":2,"level":7}})", + R"({"slot_id":5,"slot_type":1,"slot_spec_id":0})", + }; + for (const auto* payload : realtime) + ProcessRealtimeSlot({store.Generation(), payload}); + Check(!target.Next(store, now), "every realtime slot type encodes the protobuf record"); + ProcessRealtimeSlot({store.Generation(), R"({"slot_id":2,"slot_type":3,"slot_spec_id":11,"slot_item_id":8})"}); + Check(Send(target) + == json::parse(R"([{"type":"slot","sid":2,"slot_type":3,"spec_id":11,"item_id":8,"params":null}])"), + "a realtime slot without parameters has null params"); + ProcessRealtimeSlot( + {store.Generation(), + R"({"slot_id":3,"slot_type":4,"slot_spec_id":12,"selectable_skill_slot_params":{"cooldown_expiration":"soon"}})"}); + Check(Send(target)[0]["params"] == json::parse(R"({"cooldown_expiration":null})"), + "an unreadable realtime timestamp is null"); +} +void AwayAndEmerald() +{ + auto target = Target(); + game::AwayAssignmentInstance away; + away.set_id(4); + (*away.mutable_officerids())[3] = 30; + (*away.mutable_officerids())[1] = 10; + (*away.mutable_officerids())[2] = 0; + (*away.mutable_parameters()->mutable_criticaltraitscores())[9] = 5; + (*away.mutable_parameters()->mutable_criticaltraitscores())[7] = 6; + Process(EntityGroup::Kind::AwayAssignmentsInstance, away); + const auto records = Send(target); + Check(records.size() == 1 && records[0]["aid"] == 4 && records[0]["officer_ids"] == json::parse("[10,30]") + && records[0]["critical_trait_scores"] == json::parse(R"([{"tid":7,"score":6},{"tid":9,"score":5}])"), + "away assignment lists go out in key order without empty slots"); + game::AllianceGamePropertiesResponse properties; + auto* other = properties.add_properties(); + other->set_propertyname("other"); + other->add_valuelist("99"); + auto* tiers = properties.add_properties(); + tiers->set_propertyname("claimed_loyalty_tiers"); + tiers->add_valuelist("2"); + tiers->add_valuelist("x"); + tiers->add_valuelist("5"); + Process(EntityGroup::Kind::AllianceGetGameProperties, properties); + Check(Send(target) == json::parse(R"([{"type":"emerald_chain","level":5}])"), "emerald chain wire fixture"); + game::AllianceGamePropertiesResponse unclaimed; + unclaimed.add_properties()->set_propertyname("claimed_loyalty_tiers"); + Process(EntityGroup::Kind::AllianceGetGameProperties, unclaimed); + Check(Send(target) == json::parse(R"([{"type":"emerald_chain","level":-1}])"), "no claimed tier is level -1"); +} void Snapshots() { auto target = Target(); @@ -141,9 +238,10 @@ void Snapshots() 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); + 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}}})"}); + {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"); @@ -207,7 +305,7 @@ void HavenRecovery() { auto target = Target(); const auto process = ProcessorFor(EntityGroup::Kind::PlanetaryBaseData); - process({store.generation, "\xff"}); + process({store.Generation(), "\xff"}); Check(!target.Next(store, now), "invalid protobuf publishes nothing"); game::PlanetaryBase haven; Process(EntityGroup::Kind::PlanetaryBaseData, haven); @@ -241,12 +339,12 @@ void HavenRecovery() 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"}); + 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()}); + 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"}); + process_map({store.Generation(), response.SerializeAsString(), "local"}); Check(Send(target)[0]["level"] == 3, "local player's Haven response is delivered"); } void ShipTolerance() @@ -276,19 +374,19 @@ void RetryAndCoalescing() 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); + 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); + 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); + 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"); @@ -300,7 +398,7 @@ void AmbiguousRemoval() Send(target); store.Apply(Stream::kJobs, {{"j", {{"uuid", "j"}}}}, Update::kReplace); auto upload = Take(target); - target.Complete(std::move(upload), false, store.generation, now); + 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"}])"), @@ -315,10 +413,11 @@ void EmptyBaselines() 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); + 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"); + store.Reset(); + store.Apply(stream, {}, Update::kReplace); + Check(Take(target).first_sync, "a new session resends an empty baseline"); } } void AcknowledgedAndUncertainRemovals() @@ -328,7 +427,7 @@ void AcknowledgedAndUncertainRemovals() 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); + 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); @@ -345,11 +444,11 @@ void InFlightUpdates() 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); + 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); + 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"); @@ -363,7 +462,7 @@ void RemovalBacklog() 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); + target.Complete(std::move(upload), false, store.Generation(), now); now += std::chrono::seconds(60); } store.Apply(Stream::kJobs, {{1025, {{"uuid", 1025}}}}, Update::kReplace); @@ -373,7 +472,7 @@ void RemovalBacklog() 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); + 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"); } @@ -386,14 +485,14 @@ void LargeBaseline() 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); + 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); + 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); + target.Complete(std::move(upload), true, store.Generation(), now); Check(!target.Next(store, now), "the complete baseline is acknowledged only after delivery"); } void DeltaBeforeBaseline() @@ -406,7 +505,7 @@ void DeltaBeforeBaseline() 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); + target.Complete(std::move(upload), true, store.Generation(), now); game::ResourcesResponse baseline; auto* full = baseline.add_resources(); full->set_id(7); @@ -427,10 +526,10 @@ void ConfigurationAndSession() auto options = Options(); options.token = "new-account"; target.Configure(options); - target.Complete(std::move(upload), true, store.generation, now); + 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); + 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"); @@ -438,12 +537,12 @@ void ConfigurationAndSession() target.Configure(options); upload = Take(target); Check(upload.first_sync, "re-enabled category receives a baseline"); - const auto old_generation = store.generation; + const auto old_generation = store.Generation(); store.Reset(); - target.Complete(std::move(upload), true, store.generation, now); + 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); + 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"); } @@ -454,24 +553,24 @@ void SyncLogging() 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}); + 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}); + logging::Error("TEST", "target", "{}", CountedLogValue{&formats}); settings->logging = true; settings->debug = false; - logging::debug("TEST", "target", "{}", CountedLogValue{&formats}); - logging::trace("TEST", "target", "{}", CountedLogValue{&formats}); + 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}); + logging::Error("TEST", "target", "{}", CountedLogValue{&formats}); settings->debug = true; - logging::debug("TEST", "target", "{}", CountedLogValue{&formats}); - logging::trace("TEST", "target", "{}", CountedLogValue{&formats}); + 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; @@ -489,10 +588,10 @@ void BattlesAndFairness() 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); + 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); + 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"); @@ -518,7 +617,7 @@ void FleetTransitions() && 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); + 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"); @@ -528,7 +627,7 @@ void FleetTransitions() 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); + 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) @@ -600,7 +699,69 @@ void RepairJobs() "repair job wire fixture"); Check(rereads == std::vector{5}, "a repair job re-reads its fleet"); } +void RetiredTarget() +{ + auto target = Target(); + store.Apply(Stream::kResources, {{3, {{"rid", 3}, {"amount", 100}}}}, Update::kFull); + auto upload = Take(target); + target.Retire(); + target.Complete(std::move(upload), true, store.Generation(), now); + target.Configure(Options()); + Check(Take(target).first_sync, "an upload in flight when its target was retired acknowledges nothing"); +} +void OnlyBattlesArePruned() +{ + for (int i = 1; i <= 305; ++i) + store.Apply(Stream::kBattles, {{i, {{"journal", {{"id", i}}}}}}); + for (int i = 1; i <= 400; ++i) + store.Apply(Stream::kResources, {{i, {{"rid", i}, {"amount", i}}}}); + Check(store.Contains(Stream::kResources, 1) && store.Contains(Stream::kResources, 400), + "the journal bound does not prune other streams"); + Check(!store.Contains(Stream::kBattles, 5) && store.Contains(Stream::kBattles, 6), + "the journal bound keeps the newest journals"); +} +void UnwantedCategories() +{ + settings->options.officers = false; + settings->options.battlelogs = false; + Check(ProcessorFor(EntityGroup::Kind::Officers) != nullptr, + "a category no target wants is still stored, for a target that enables it later"); + Check(!ProcessorFor(EntityGroup::Kind::BattleResultHeaders), "battle logs are skipped while no target wants them"); + settings->options.officers = true; + settings->options.battlelogs = true; + Check(ProcessorFor(EntityGroup::Kind::BattleResultHeaders) != nullptr, "and run once one does"); +} +void Queues() +{ + BoundedQueue queue{"test", 2}; + Check(!queue.Push(1) && !queue.Push(2), "a queue with room drops nothing"); + const auto dropped = queue.Push(3); + Check(dropped == 1, "a full queue drops and returns its oldest entry"); + Check(queue.Pop() == 2 && queue.Pop() == 3, "the rest keep their order"); +} +void NameCaches() +{ + const auto previous_ttl = settings->resolver_cache_ttl; + settings->resolver_cache_ttl = 300; + NameCache names; + names.Store("a", 1, 2); + Check(names.Find("a", 2) == 1, "a stored name is found in its session"); + Check(!names.Find("a", 1), "an older session's lookup finds nothing"); + names.Store("b", 2, 1); + Check(!names.Find("b", 2), "an older session's late result is not stored"); + Check(!names.Find("a", 3) && names.Size() == 0, "a new session forgets the old names"); + for (size_t i = 0; i <= NameCache::kCapacity; ++i) + names.Store(std::to_string(i), static_cast(i), 3); + Check(names.Size() <= NameCache::kCapacity, "the cache stays within its bound"); + settings->resolver_cache_ttl = 0; + names.Store("c", 3, 3); + Check(!names.Find("c", 3), "a zero TTL expires names at once"); + settings->resolver_cache_ttl = previous_ttl; +} } // namespace + +// Stand-ins for config.cc, sync.cc, battlelogs.cc and fleets.cc, which this test does not link: no threads, no game, +// one store. std::shared_ptr Config::Sync::Get() { return settings; } @@ -608,7 +769,7 @@ namespace data_sync { void Publish(Stream stream, RecordSet records, uint64_t generation, Update mode) { - if (generation == store.generation) + if (generation == store.Generation()) store.Apply(stream, std::move(records), mode); } void QueueNewBattles(const std::vector&, uint64_t) {} @@ -634,6 +795,8 @@ int main() }; run("slots", Slots); run("presets", Presets); + run("slot transports", SlotTransports); + run("away and emerald", AwayAndEmerald); run("snapshots", Snapshots); run("removals", EmptyAndRemoved); run("jobs", Jobs); @@ -652,6 +815,11 @@ int main() run("fleet transitions", FleetTransitions); run("deployed fleets", DeployedFleets); run("repair jobs", RepairJobs); + run("retired target", RetiredTarget); + run("battle bound", OnlyBattlesArePruned); + run("category gating", UnwantedCategories); + run("queues", Queues); + run("name caches", NameCaches); run("logging", SyncLogging); if (!failures) std::cout << "Sync wire and delivery tests passed\n"; From 896e4e4c5dc1d865945a9a854d9776bca99690a5 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 15:03:35 -0700 Subject: [PATCH 07/13] Report early startup failures visibly Both games discard stderr, so early startup failures went nowhere. Startup now reports through debugger output on Windows and the system log on macOS before the log exists. The log and custom loading image open by their UTF-8 names on Windows instead of through the ANSI code page, and the file sink takes the wide name directly. A negative child count reads as no children. --- src/mod/file.cc | 23 +-- src/mod/file.h | 7 +- src/mod/mod.cc | 233 ++++++++++++++++++------------- src/mod/mod.h | 4 + src/mod/text.h | 63 +++------ src/mod/version.h | 15 +- src/platform/macos/dylib/main.cc | 6 +- src/platform/windows/main.cc | 6 + zig/deps.zig | 18 ++- 9 files changed, 213 insertions(+), 162 deletions(-) diff --git a/src/mod/file.cc b/src/mod/file.cc index f24a200e4..875ad5c68 100644 --- a/src/mod/file.cc +++ b/src/mod/file.cc @@ -1,3 +1,6 @@ +// Owns the mod's file names and their directory, the command-line flags that change them, and moving files left by +// older releases. + #include "file.h" #include @@ -22,7 +25,7 @@ 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. +// Names used by older releases; "path" is their typo. constexpr std::string_view kLegacyVars = "community_path_runtime.vars"; constexpr std::string_view kLegacyParsed = "community_patch_settings_parsed.toml"; @@ -36,16 +39,13 @@ bool debug = false; #endif bool trace = false; -// std::filesystem::path(std::string) uses the ANSI code page on Windows; the mod's strings are UTF-8. -std::filesystem::path FromUtf8(std::string_view text) -{ return std::u8string(text.begin(), text.end()); } - std::string ToUtf8(const std::filesystem::path& path) { const auto text = path.u8string(); return {text.begin(), text.end()}; } +// The process's arguments as UTF-8, program name first. std::vector Arguments() { std::vector args; @@ -69,14 +69,14 @@ std::vector Arguments() return args; } -std::filesystem::path Directory(bool legacy = false) +// Where the mod's files live: beside the -ccm profile, the macOS preferences folder, or the working directory. +std::filesystem::path Directory([[maybe_unused]] bool legacy = false) { if (!profile_path.empty()) return profile_path.parent_path(); #if __APPLE__ return MacLibraryDirectory() / "Preferences" / (legacy ? "com.tashcan.startrekpatch" : "com.stfcmod.startrekpatch"); #else - (void)legacy; return {}; #endif } @@ -97,7 +97,7 @@ void MigrateMacPreferences() if (!fs::is_regular_file(old_path)) return; - spdlog::info("mac config migration: moving {} to {}", ToUtf8(old_dir), ToUtf8(new_dir)); + spdlog::info("moving settings from {} to {}", ToUtf8(old_dir), ToUtf8(new_dir)); try { fs::rename(old_dir, new_dir); fs::create_directories(old_dir); @@ -109,7 +109,7 @@ void MigrateMacPreferences() info << "A symlink has been placed for your convenience, but it is generally recommended, that you use the new " "path and delete this directory going forward."; } catch (const std::exception& error) { - spdlog::warn("mac config migration failed: {}", error.what()); + spdlog::warn("could not move settings from {}: {}", ToUtf8(old_dir), error.what()); } #endif } @@ -177,6 +177,9 @@ std::filesystem::path Path(std::string_view name, bool create_dir) return dir / FromUtf8(name).filename(); } +std::filesystem::path FromUtf8(std::string_view text) +{ return std::u8string(text.begin(), text.end()); } + void MigrateLegacyFiles() { MigrateMacPreferences(); @@ -184,6 +187,6 @@ void MigrateLegacyFiles() std::error_code ignored; std::filesystem::remove(Path(kLegacyVars), ignored); if (std::filesystem::remove(Path(kLegacyParsed), ignored)) - spdlog::info("Removed {} (old parsed file)", kLegacyParsed); + spdlog::info("removed {}, which older releases wrote", kLegacyParsed); } } // namespace File diff --git a/src/mod/file.h b/src/mod/file.h index a3b83082d..51af0e354 100644 --- a/src/mod/file.h +++ b/src/mod/file.h @@ -21,12 +21,15 @@ const std::string& DefaultConfig(); // The -ccm profile name ("alt" for -ccm alt.toml), or empty without -ccm. const std::string& Profile(); -bool Debug(); -bool Trace(); +// -debug and -trace set the starting log level; _MODDBG builds start at debug. +bool Debug(); +bool Trace(); // Full path of a mod file. On macOS this is ~/Library/Preferences/com.stfcmod.startrekpatch unless -ccm points // elsewhere; on Windows it is the game's working directory. std::filesystem::path Path(std::string_view name, bool create_dir = false); +// A path from UTF-8 text. std::filesystem::path(std::string) reads the ANSI code page on Windows instead. +std::filesystem::path FromUtf8(std::string_view text); // Moves settings left behind by older releases. Logs, so call after the logger is up. void MigrateLegacyFiles(); diff --git a/src/mod/mod.cc b/src/mod/mod.cc index 3b652d55f..bbfb59128 100644 --- a/src/mod/mod.cc +++ b/src/mod/mod.cc @@ -1,3 +1,6 @@ +// The mod's startup: StartMod hooks il2cpp_init, which sets up logging and settings, installs the features, and +// installs the frame tick that runs them. + #include "mod.h" #include "config.h" @@ -15,192 +18,237 @@ #include #include +#include #include +#include #include #if _WIN32 #include #else #include -#include #include #include #endif -// The mod's startup: StartMod hooks il2cpp_init, which sets up logging and settings, installs the features, and -// installs the frame tick that runs them. - namespace { +// --- Logging --- + +// Opens the session log, keeping the previous five as .1 (newest) to .5. +void StartLogging() +{ + // File::Log() is UTF-8, and Windows reads a narrow file name in the ANSI code page, so every name here goes + // through File::FromUtf8. + const auto log = File::FromUtf8(File::Log()); + std::string rotation_error; + try { + const auto backup = [](int index) { return File::FromUtf8(std::format("{}.{}", File::Log(), index)); }; + std::filesystem::remove(backup(5)); + for (int index = 4; index >= 1; --index) + if (std::filesystem::exists(backup(index))) + std::filesystem::rename(backup(index), backup(index + 1)); + if (std::filesystem::exists(log)) + std::filesystem::rename(log, backup(1)); + } catch (const std::filesystem::filesystem_error& error) { + rotation_error = error.what(); + } + // Append if rotation failed so an existing session log is not truncated. spdlog takes the path's native name, + // which is wide on Windows (the build sets SPDLOG_WCHAR_FILENAMES there). + auto logger = spdlog::basic_logger_mt("default", log.native(), false); + logger->sinks().push_back(std::make_shared()); + spdlog::set_default_logger(logger); + + const auto level = File::Trace() ? spdlog::level::trace : File::Debug() ? spdlog::level::debug : spdlog::level::info; + spdlog::set_level(level); + spdlog::flush_on(level); + if (!rotation_error.empty()) + spdlog::warn("could not rotate the log history; appending to the existing log: {}", rotation_error); + il2cpp::SetErrorHandler([](std::string_view message) { spdlog::error("{}", message); }); +} + +// Between the banner, the settings and the install log. +void LogSeparator() +{ + spdlog::info(""); + spdlog::info("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-="); + spdlog::info(""); +} + +void LogBanner() +{ + if constexpr (VERSION_PATCH && sizeof(VERSION_COMMIT_HASH) > 1) + spdlog::info("Initializing STFC Community Mod ({} [{}])", VER_PRODUCT_VERSION_STR, VERSION_COMMIT_HASH); + else + spdlog::info("Initializing STFC Community Mod ({})", VER_PRODUCT_VERSION_STR); + spdlog::info(""); + spdlog::info("{}", File::Profile().empty() ? "Using standard names" : "Using custom names"); + spdlog::info(" Log: {}", File::Log()); + spdlog::info(" Cfg: {}", File::Config()); + spdlog::info(" Var: {}", File::Vars()); + spdlog::info(""); + if constexpr (VERSION_PATCH) { + spdlog::warn("*** NOTE: Beta versions may have unexpected bugs and issues"); + spdlog::info(""); + } + spdlog::info("Please see https://github.com/STFC-Mod/mod for latest configuration help,"); + spdlog::info("examples and future releases, or visit the STFC Community Mod discord server"); + spdlog::info("at https://discord.gg/PrpHgs7Vjs"); + LogSeparator(); +} + +// --- Features and the frame tick --- + // Installed in this order, which the log numbers, and their frame work runs in this order too. Grouped by file as // in features.h. No feature depends on another being installed first; the object tracker leads only so that every // tracked class is hooked before anything else is. constexpr const Feature* kFeatures[] = { + // tracked_objects.cc &kObjectTracker, + // action_queue.cc &kActionQueueRecovery, &kThinQueueProtection, &kQueueAddressGuard, + // alliance_store.cc &kAllianceGamesStore, + // anomaly_timer.cc &kAnomalyTimer, + // audio.cc &kAudio, + // buffs.cc &kOutOfDockPower, &kBuffCrashGuard, + // camera.cc &kZoom, &kPan, &kInteriorZoom, + // cargo.cc &kCargoFormat, #if _WIN32 &kInstantCargo, #endif + // chat.cc &kChat, &kChatTitles, + // confirmations.cc &kForbiddenTechConfirm, &kRevealConfirm, + // dock.cc &kPinnedShips, &kPinnedShipClick, &kDoubleClickAssign, &kAssignOnEnter, &kDockSelection, + // fleet_actions.cc &kQueueSwitch, &kAbilityButtons, + // fleet_labels.cc &kFleetLabels, + // forbidden_tech_ship_name.cc &kForbiddenTechShipName, + // game_config.cc &kGameConfigOverrides, + // haven.cc &kHavenZoom, &kHavenHistory, + // hotkeys.cc &kHotkeys, + // instant_warp.cc &kInstantWarp, + // loading_screens.cc &kLoginScreen, &kTransitionScreen, &kLoadingTips, + // missions.cc &kMissionHud, &kDailyClaimFilter, + // officers.cc &kOfficerSort, + // shop.cc &kGiftsAutoOpen, &kShopLimits, &kSkipRevealSequence, &kArtifactExchange, + // toasts.cc &kToasts, + // ui_scale.cc &kUiScale, +// window.cc #if _WIN32 &kFreeResize, &kCursor, #endif + // sync/sync.cc &kSync, }; -// The per-frame work of the installed features, in install order. -std::vector frame_work; +// The installed features with frame work, in install order. +std::vector frame_features; -// Runs one piece of frame work. An exception must not unwind into the game's native frame. -void RunGuarded(void (*work)()) +// Runs one piece of frame work for `name`. An exception must not unwind into the game's native frame. +template void RunGuarded(const char* name, Work&& work) { try { work(); } catch (const std::exception& error) { - spdlog::warn("[Frame] update failed: {}", error.what()); + spdlog::warn("[{}] frame update failed: {}", name, error.what()); } catch (...) { - spdlog::warn("[Frame] update failed with an unknown exception"); + spdlog::warn("[{}] frame update failed with an unknown exception", name); } } -// ScreenManager.Update is the mod's frame tick. It runs whatever hotkey mode is set: the settings file watch and -// each feature's frame work first, then the shortcut dispatch. Returning without calling the game's Update swallows -// the key, so the game's own handling of it does not run as well. +// ScreenManager.Update is the mod's frame tick and runs in every hotkey mode: input, the settings file watch and +// each installed feature's frame work, then the shortcut dispatch. Skipping the game's Update swallows a key a +// shortcut used, so the game does not handle it as well. void ScreenManager_Update_Hook(auto original, ScreenManager* _this) { unity::Key::Poll(); - RunGuarded(Config::ReloadIfChanged); - for (auto work : frame_work) - RunGuarded(work); - if (HandleShortcuts()) - return; - original(_this); + RunGuarded("Settings", Config::ReloadIfChanged); + for (const auto* feature : frame_features) + RunGuarded(feature->name, feature->frame); + bool handled = false; + RunGuarded("Shortcuts", [&] { handled = HandleShortcuts(); }); + if (!handled) + original(_this); } +// Installs every feature whose [patches] group is on, then the frame tick that runs them. void InstallFeatures(const Config& config) { + constexpr auto total = std::size(kFeatures); spdlog::info("Initializing code hooks:"); - for (size_t i = 0; i < std::size(kFeatures); ++i) { + for (size_t i = 0; i < total; ++i) { const auto& feature = *kFeatures[i]; if (feature.group && !(config.patches.*feature.group)) { - spdlog::info(" x Skipping {:>2} of {} ({})", i + 1, std::size(kFeatures), feature.name); + spdlog::info(" x Skipping {:>2} of {} ({})", i + 1, total, feature.name); continue; } bool installed = false; try { installed = !feature.install || feature.install(); } catch (const std::exception& error) { - spdlog::error("{} failed to install: {}", feature.name, error.what()); + spdlog::error("[{}] failed to install: {}", feature.name, error.what()); } // Logged after installing, so the line says what happened; the reasons for a failure come just before it. - if (installed) - spdlog::info(" + Patching {:>2} of {} ({})", i + 1, std::size(kFeatures), feature.name); - else - spdlog::warn(" ! Not installed {:>2} of {} ({})", i + 1, std::size(kFeatures), feature.name); - if (installed && feature.frame) - frame_work.push_back(feature.frame); + if (!installed) { + spdlog::warn(" ! Not installed {:>2} of {} ({})", i + 1, total, feature.name); + continue; + } + spdlog::info(" + Patching {:>2} of {} ({})", i + 1, total, feature.name); + if (feature.frame) + frame_features.push_back(&feature); } // Last, so a feature never sees a frame before it is installed. Without the tick nothing polls, and the settings // file is only read at startup. il2cpp::HookSet hooks("FrameTick"); IL2CPP_STAGE(hooks, ScreenManager::Class::Method("Update"), ScreenManager_Update_Hook); if (!hooks.Commit()) - spdlog::error("The frame tick is missing: shortcuts, settings reload and per-frame features will not run"); + spdlog::error("the frame tick is not installed: shortcuts, settings reload and frame updates will not run"); } -void StartLogging() -{ - std::string rotation_error; - try { - const auto backup = [](int index) { return std::filesystem::path(File::Log() + "." + std::to_string(index)); }; - std::filesystem::remove(backup(5)); - for (int index = 4; index >= 1; --index) - if (std::filesystem::exists(backup(index))) - std::filesystem::rename(backup(index), backup(index + 1)); - if (std::filesystem::exists(File::Log())) - std::filesystem::rename(File::Log(), backup(1)); - } catch (const std::filesystem::filesystem_error& error) { - rotation_error = error.what(); - } - // Append if rotation failed so an existing session log is not truncated. - auto logger = spdlog::basic_logger_mt("default", File::Log(), false); - logger->sinks().push_back(std::make_shared()); - spdlog::set_default_logger(logger); - - const auto level = File::Trace() ? spdlog::level::trace : File::Debug() ? spdlog::level::debug : spdlog::level::info; - spdlog::set_level(level); - spdlog::flush_on(level); - if (!rotation_error.empty()) - spdlog::warn("Failed to rotate log history; preserving existing log: {}", rotation_error); - il2cpp::SetErrorHandler([](std::string_view message) { spdlog::error("{}", message); }); -} - -void LogBanner() -{ - if constexpr (VERSION_PATCH && sizeof(VERSION_COMMIT_HASH) > 1) - spdlog::info("Initializing STFC Community Mod ({} [{}])", VER_PRODUCT_VERSION_STR, VERSION_COMMIT_HASH); - else - spdlog::info("Initializing STFC Community Mod ({})", VER_PRODUCT_VERSION_STR); - spdlog::info(""); - spdlog::info("{}", File::Profile().empty() ? "Using standard names" : "Using custom names"); - spdlog::info(" Log: {}", File::Log()); - spdlog::info(" Cfg: {}", File::Config()); - spdlog::info(" Var: {}", File::Vars()); - spdlog::info(""); - if constexpr (VERSION_PATCH) { - spdlog::warn("*** NOTE: Beta versions may have unexpected bugs and issues"); - spdlog::info(""); - } - spdlog::info("Please see https://github.com/STFC-Mod/mod for latest configuration help,"); - spdlog::info("examples and future releases, or visit the STFC Community Mod discord server"); - spdlog::info("at https://discord.gg/PrpHgs7Vjs"); - spdlog::info(""); - spdlog::info("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-="); - spdlog::info(""); -} +// --- Startup --- -int64_t il2cpp_init_Hook(auto original, const char* domain_name) +int il2cpp_init_Hook(auto original, const char* domain_name) { #if _WIN32 && !defined(NDEBUG) AllocConsole(); @@ -209,6 +257,7 @@ int64_t il2cpp_init_Hook(auto original, const char* domain_name) #endif // Nothing here may throw into the game: a mod that cannot set itself up leaves the game running without it. + // Keeping the settings pointer is safe here because only the frame tick reloads, and it is not installed yet. const Config* config = nullptr; try { File::Init(); @@ -218,12 +267,10 @@ int64_t il2cpp_init_Hook(auto original, const char* domain_name) spdlog::info("Loading Configuration..."); spdlog::info(""); config = &Config::Get(); - spdlog::info(""); - spdlog::info("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-="); - spdlog::info(""); + LogSeparator(); } catch (const std::exception& error) { // The file logger may not exist; spdlog falls back to its console logger. - spdlog::error("Community mod setup failed, not installing any patches: {}", error.what()); + spdlog::error("setup failed, so no features are installed: {}", error.what()); } // Bindings resolve against the runtime's metadata, so nothing is installed before the domain exists. @@ -239,9 +286,7 @@ int64_t il2cpp_init_Hook(auto original, const char* domain_name) VERSION_PATCH); else spdlog::info("Installed release version {}.{}.{}", VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION); - spdlog::info(""); - spdlog::info("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-="); - spdlog::info(""); + LogSeparator(); return result; } @@ -255,17 +300,15 @@ void StartMod() char executable[PATH_MAX]; uint32_t size = sizeof(executable); if (_NSGetExecutablePath(executable, &size) != 0) { - fprintf(stderr, "stfc-community-mod: executable path does not fit\n"); + ReportStartupFailure("the executable path does not fit"); return; } - char assembly_path[PATH_MAX]; - snprintf(assembly_path, sizeof(assembly_path), "%s/../Frameworks/GameAssembly.dylib", dirname(executable)); - auto assembly = dlopen(assembly_path, RTLD_LAZY | RTLD_GLOBAL); + const auto assembly_path = std::filesystem::path(executable).parent_path() / "../Frameworks/GameAssembly.dylib"; + auto assembly = dlopen(assembly_path.c_str(), RTLD_LAZY | RTLD_GLOBAL); #endif - // The logger does not exist yet; it is set up inside il2cpp_init once the settings path is known. if (!assembly) { - fprintf(stderr, "stfc-community-mod: failed to load GameAssembly\n"); + ReportStartupFailure("failed to load GameAssembly"); return; } @@ -279,8 +322,8 @@ void StartMod() il2cpp::HookSet hooks("il2cpp_init"); IL2CPP_STAGE_AT(hooks, il2cpp_init, il2cpp_init_Hook); if (!hooks.Commit()) - fprintf(stderr, "stfc-community-mod: failed to hook il2cpp_init\n"); + ReportStartupFailure("failed to hook il2cpp_init"); } catch (const std::exception& error) { - fprintf(stderr, "stfc-community-mod: failed to hook il2cpp_init: %s\n", error.what()); + ReportStartupFailure(std::format("failed to hook il2cpp_init: {}", error.what()).c_str()); } } diff --git a/src/mod/mod.h b/src/mod/mod.h index 30481cf0e..b90eecaf5 100644 --- a/src/mod/mod.h +++ b/src/mod/mod.h @@ -4,3 +4,7 @@ // Loads GameAssembly and hooks il2cpp_init, which installs everything else once the runtime is up. void StartMod(); + +// Reports a failure from before the mod's log exists. Each platform entry point defines it where a user can find +// the message: debugger output on Windows, the system log on macOS. +void ReportStartupFailure(const char* message); diff --git a/src/mod/text.h b/src/mod/text.h index 615f1924b..289de059c 100644 --- a/src/mod/text.h +++ b/src/mod/text.h @@ -9,34 +9,23 @@ #include #include #include +#include #include #include #include +#include #include -constexpr bool IsAsciiSpace(char c) -{ return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; } - -constexpr std::string_view StripTrailingAsciiWhitespace(std::string_view str) -{ - while (!str.empty() && IsAsciiSpace(str.back())) - str.remove_suffix(1); - return str; -} - -constexpr std::string_view StripLeadingAsciiWhitespace(std::string_view str) +constexpr std::string_view StripAsciiWhitespace(std::string_view str) { - while (!str.empty() && IsAsciiSpace(str.front())) + constexpr std::string_view kSpace = " \t\n\r\f\v"; + while (!str.empty() && kSpace.contains(str.front())) str.remove_prefix(1); + while (!str.empty() && kSpace.contains(str.back())) + str.remove_suffix(1); return str; } -constexpr std::string_view StripAsciiWhitespace(std::string_view str) -{ return StripTrailingAsciiWhitespace(StripLeadingAsciiWhitespace(str)); } - -constexpr std::string_view StripPrefix(std::string_view str, std::string_view prefix) -{ return str.starts_with(prefix) ? str.substr(prefix.size()) : str; } - constexpr std::string AsciiStrToUpper(std::string_view s) { std::string str(s); @@ -59,15 +48,10 @@ constexpr std::string AsciiStrToLower(std::string_view s) constexpr std::vector StrSplit(std::string_view input, char delimiter) { std::vector result; - while (true) { - const auto end = input.find(delimiter); - const auto token = StripAsciiWhitespace(input.substr(0, end)); - if (!token.empty()) + for (const auto piece : std::views::split(input, delimiter)) + if (const auto token = StripAsciiWhitespace(std::string_view(piece)); !token.empty()) result.emplace_back(token); - if (end == std::string_view::npos) - return result; - input.remove_prefix(end + 1); - } + return result; } // Seconds since the Unix epoch for a "YYYY-MM-DDTHH:MM:SS" server timestamp, which is UTC. A fraction or @@ -95,6 +79,7 @@ inline std::optional ParseUtcTimestamp(std::string_view timestamp) const auto [year_, month_, day_, hour, minute, second] = values; const year_month_day date{std::chrono::year{year_}, std::chrono::month(static_cast(month_)), std::chrono::day(static_cast(day_))}; + // 60 allows a leap second. if (!date.ok() || hour > 23 || minute > 59 || second > 60) return std::nullopt; const auto time = sys_days{date} + hours{hour} + minutes{minute} + seconds{second}; @@ -102,10 +87,8 @@ inline std::optional ParseUtcTimestamp(std::string_view timestamp) } // Ship names for pinned_ships and the instant_warp_* filters are compared as words, so a setting can name a ship -// by its last words: "Athena" matches "Academy Athena". - -// Upper case, '_' and '-' as spaces, '.' and '\'' dropped, single spaces, no leading "USS": -// "U.S.S. Enterprise-D" -> "ENTERPRISE D". +// by its last words: "Athena" matches "Academy Athena". Names are normalized first: upper case, '_' and '-' as +// spaces, '.' and '\'' dropped, single spaces, no leading "USS": "U.S.S. Enterprise-D" -> "ENTERPRISE D". inline std::string NormalizeShipName(std::string_view name) { std::string normalized; @@ -119,26 +102,22 @@ inline std::string NormalizeShipName(std::string_view name) continue; normalized.push_back(c); } - if (!normalized.empty() && normalized.back() == ' ') + if (normalized.ends_with(' ')) normalized.pop_back(); - return std::string(StripPrefix(normalized, "USS ")); + if (normalized.starts_with("USS ")) + normalized.erase(0, 4); + return normalized; } inline std::vector ShipNameWords(std::string_view name) { + // Normalized names have single spaces and none at either end, so no word is empty. std::vector words; - const auto normalized = NormalizeShipName(name); - for (size_t start = 0; start < normalized.size();) { - const auto end = std::min(normalized.find(' ', start), normalized.size()); - words.emplace_back(normalized.substr(start, end - start)); - start = end + 1; - } + for (const auto word : std::views::split(NormalizeShipName(name), ' ')) + words.emplace_back(std::string_view(word)); return words; } // Whether `pattern` is the last words of `name`; both from ShipNameWords. inline bool ShipNameMatches(std::span name, std::span pattern) -{ - return !pattern.empty() && name.size() >= pattern.size() - && std::ranges::equal(name.subspan(name.size() - pattern.size()), pattern); -} +{ return !pattern.empty() && std::ranges::ends_with(name, pattern); } diff --git a/src/mod/version.h b/src/mod/version.h index a2d79e18e..40672e537 100644 --- a/src/mod/version.h +++ b/src/mod/version.h @@ -1,5 +1,8 @@ #pragma once +// The mod's version and build identity, for the logs, the loading screen and the Windows version resource. CI +// stamps VERSION_COMMIT_HASH, so keep that line's shape. + // clang-format off #define VERSION_MAJOR 1 #define VERSION_MINOR 1 @@ -7,11 +10,12 @@ #define VERSION_PATCH 1 #define VERSION_COMMIT_HASH "" -#define STRINGIFY_(s) #s -#define STRINGIFY(s) STRINGIFY_(s) +#define VERSION_STRINGIFY_(s) #s +#define VERSION_STRINGIFY(s) VERSION_STRINGIFY_(s) +// A nonzero patch is a beta build. #if VERSION_PATCH - #define VERSION_PATCH_STR " (Dev)" + #define VERSION_PATCH_STR " (Beta)" #else #define VERSION_PATCH_STR "" #endif @@ -19,7 +23,7 @@ #define VER_FILE_DESCRIPTION_STR "Star Trek Fleet Command: Community Mod" VERSION_PATCH_STR #define VER_FILE_VERSION VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION, VERSION_PATCH -#define VER_FILE_VERSION_STR STRINGIFY(VERSION_MAJOR) "." STRINGIFY(VERSION_MINOR) "." STRINGIFY(VERSION_REVISION) "." STRINGIFY(VERSION_PATCH) +#define VER_FILE_VERSION_STR VERSION_STRINGIFY(VERSION_MAJOR) "." VERSION_STRINGIFY(VERSION_MINOR) "." VERSION_STRINGIFY(VERSION_REVISION) "." VERSION_STRINGIFY(VERSION_PATCH) #define STFC_DISTRIBUTION_ID "netniv.stfc-community-mod" @@ -53,7 +57,8 @@ #define VER_PRODUCTNAME_STR "STFC: Community Mod" #define VER_PRODUCT_VERSION VERSION_MAJOR, VERSION_MINOR, VERSION_REVISION #define VER_PRODUCT_VERSION_STR VER_FILE_VERSION_STR -#define VER_ORIGINAL_FILENAME_STR "stfc-community-mod.dll" +// Windows loads the mod as a proxy version.dll. +#define VER_ORIGINAL_FILENAME_STR "version.dll" #define VER_INTERNAL_NAME_STR VER_ORIGINAL_FILENAME_STR #define VER_COPYRIGHT_STR "Copyright (C) 2026" diff --git a/src/platform/macos/dylib/main.cc b/src/platform/macos/dylib/main.cc index 136d69e13..b380e919a 100644 --- a/src/platform/macos/dylib/main.cc +++ b/src/platform/macos/dylib/main.cc @@ -4,8 +4,12 @@ #include "mod.h" +// Before the mod's log exists, failures go to the system log: the game's stderr goes nowhere a user can see. +void ReportStartupFailure(const char* message) +{ syslog(LOG_ERR, "stfc-community-mod: %s", message); } + __attribute__((constructor)) void InjectedConstructor(int, const char** argv) { - syslog(LOG_ERR, "[+] dylib injected in %s\n", argv[0]); + syslog(LOG_INFO, "stfc-community-mod: loaded into %s", argv[0]); StartMod(); } diff --git a/src/platform/windows/main.cc b/src/platform/windows/main.cc index 88ca1daf4..abe2a8593 100644 --- a/src/platform/windows/main.cc +++ b/src/platform/windows/main.cc @@ -2,10 +2,16 @@ #include +#include #include #include "mod.h" +// Before the mod's log exists, failures go to the debugger output (DebugView shows it without a debugger): the +// game has no console, so its stderr goes nowhere. +void ReportStartupFailure(const char* message) +{ OutputDebugStringA((std::string("stfc-community-mod: ") + message + "\n").c_str()); } + BOOL WINAPI DllMain(HINSTANCE instance, DWORD reason, LPVOID) { if (reason != DLL_PROCESS_ATTACH) diff --git a/zig/deps.zig b/zig/deps.zig index 057738375..6dd6d53c5 100644 --- a/zig/deps.zig +++ b/zig/deps.zig @@ -11,9 +11,16 @@ const globFiles = @import("ctx.zig").globFiles; pub fn spdlog(ctx: *const Ctx) Lib { const b = ctx.b; const src = b.dependency("spdlog", .{}).path(""); + var lib: Lib = .{ + .include_dirs = ctx.dirs(&.{src.path(b, "include")}), + // Wide file names on Windows, so the log's UTF-8 path is not read in the ANSI code page. + .defines = if (ctx.isWindows()) + &.{ .{ "SPDLOG_COMPILED_LIB", "1" }, .{ "SPDLOG_WCHAR_FILENAMES", "1" } } + else + &.{.{ "SPDLOG_COMPILED_LIB", "1" }}, + }; const module = ctx.createModule(true); - module.addIncludePath(src.path(b, "include")); - module.addCMacro("SPDLOG_COMPILED_LIB", "1"); + lib.includeInto(module); ctx.addCxxBatch(module, "spdlog", src, &.{ "src/spdlog.cpp", "src/stdout_sinks.cpp", @@ -23,11 +30,8 @@ pub fn spdlog(ctx: *const Ctx) Lib { "src/cfg.cpp", "src/bundled_fmtlib_format.cpp", }, ctx.cxxFlags("c++20", &.{})); - return .{ - .artifact = ctx.staticLib("spdlog", module), - .include_dirs = ctx.dirs(&.{src.path(b, "include")}), - .defines = &.{.{ "SPDLOG_COMPILED_LIB", "1" }}, - }; + lib.artifact = ctx.staticLib("spdlog", module); + return lib; } /// toml++ compiled once from zig/deps/tomlplusplus.cc. Consumers see only declarations. From 3abf1f0ed063a185246303ad9a73be369f74a174 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 14:55:14 -0700 Subject: [PATCH 08/13] Rewrite the visual features plainly Camera, Haven, UI scale, loading, window, anomaly and mission features walk game collections through shared helpers instead of hand-written null and bounds checks. Zoom units, Haven blend weights and the DPI factor now say what they compute and why. File-scope names carry their feature name, since macOS release builds compile four feature files per translation unit. Behaviour is unchanged, including float NaN handling. --- src/mod/features/anomaly_timer.cc | 69 ++++---- src/mod/features/audio.cc | 10 +- src/mod/features/camera.cc | 151 ++++++++-------- src/mod/features/haven.cc | 190 +++++++++++++-------- src/mod/features/loading_screens.cc | 126 +++++++------- src/mod/features/missions.cc | 10 +- src/mod/features/notification_audio.cc | 55 +++--- src/mod/features/notification_audio_mac.mm | 6 +- src/mod/features/ui_scale.cc | 71 ++++---- src/mod/features/window.cc | 12 +- 10 files changed, 386 insertions(+), 314 deletions(-) diff --git a/src/mod/features/anomaly_timer.cc b/src/mod/features/anomaly_timer.cc index 54cdd772f..5aec8f458 100644 --- a/src/mod/features/anomaly_timer.cc +++ b/src/mod/features/anomaly_timer.cc @@ -18,7 +18,7 @@ namespace { -using Clock = std::chrono::steady_clock; +using AnomalyClock = std::chrono::steady_clock; // The rounded key-hint background next to the native anomaly button, reused for the panel. unity::Sprite* NativeBackgroundSprite(HudNavigationViewController* nav) @@ -36,7 +36,7 @@ unity::Sprite* NativeBackgroundSprite(HudNavigationViewController* nav) return sprite ? sprite : image->sprite(); } -unity::Image* AddImage(unity::GameObject* object, unity::Color tint, unity::Sprite* sprite) +unity::Image* AddPanelImage(unity::GameObject* object, unity::Color tint, unity::Sprite* sprite) { auto* image = object->AddComponent(); if (!image) @@ -46,6 +46,8 @@ unity::Image* AddImage(unity::GameObject* object, unity::Color tint, unity::Spri if (sprite && sprite->pixelsPerUnit() > 0) { image->SetSprite(sprite); image->SetType(unity::Image::Type::Sliced); + // Sliced borders are drawn at the sprite's pixel density; normalizing it to 100 keeps the corners the same size + // whatever the localized artwork's density is. image->SetPixelsPerUnitMultiplier(100 / sprite->pixelsPerUnit()); } return image; @@ -58,7 +60,7 @@ class AnomalyTimer public: void Update() { - const auto now = Clock::now(); + const auto now = AnomalyClock::now(); if (now < next_update_) return; next_update_ = now + std::chrono::milliseconds(250); @@ -71,12 +73,15 @@ class AnomalyTimer auto* nav = Navigation(); // Remaining ticks of the active anomaly in the viewed system. const int64_t ticks = nav && nav->isActiveAndEnabled() && nav->CanShowGalacticAnomaly() ? RemainingTicks(nav) : 0; + auto* label = Label(); if (ticks <= 0) { - if (auto* panel = Label() ? Label()->ParentObject() : nullptr) + if (auto* panel = label ? label->ParentObject() : nullptr) panel->SetActive(false); return; } - if (!Label() && !Create(nav)) { + if (!label) + label = Create(nav); + if (!label) { static bool warned = false; if (!std::exchange(warned, true)) spdlog::warn("[AnomalyTimer] could not build the countdown panel; leaving the HUD unchanged"); @@ -85,15 +90,16 @@ class AnomalyTimer return; } + // TimeSpan ticks are 100 ns. Round up, so the last second shows as 1s rather than 0s. const auto seconds = ticks / 10'000'000 + (ticks % 10'000'000 != 0); if (seconds != shown_seconds_) { shown_seconds_ = seconds; const auto text = seconds >= 3600 ? fmt::format("{}h {:02}m {:02}s", seconds / 3600, seconds / 60 % 60, seconds % 60) : fmt::format("{:02}m {:02}s", seconds / 60, seconds % 60); - Label()->SetText(text); + label->SetText(text); } - if (auto* panel = Label()->ParentObject()) + if (auto* panel = label->ParentObject()) panel->SetActive(true); } @@ -112,7 +118,7 @@ class AnomalyTimer auto* held = navigation_.Get(); if (unity::Alive(held) && held->isActiveAndEnabled()) return held; - if (const auto now = Clock::now(); now >= next_discovery_) { + if (const auto now = AnomalyClock::now(); now >= next_discovery_) { next_discovery_ = now + std::chrono::seconds(1); auto* replacement = ScreenManager::View(); if ((replacement && replacement != held) || !unity::Alive(held)) { @@ -154,42 +160,48 @@ class AnomalyTimer } // Builds the panel: the native rounded sprite twice, with a narrow inset making the border, and a countdown in - // the system-name font. A missing sprite still gives a visible frame. - bool Create(HudNavigationViewController* nav) + // the system-name font. A missing sprite still gives a visible frame. Returns the countdown label, or null. + unity::TMP_Text* Create(HudNavigationViewController* nav) { auto* helper = nav->factionInformationHelper(); - auto* names = helper ? helper->systemName() : nullptr; - auto* name = names && names->Count() > 0 ? (*names)[0] : nullptr; + auto* name = il2cpp::At(helper ? helper->systemName() : nullptr, 0); if (!unity::Alive(name)) - return false; + return nullptr; auto* drawer = ScreenManager::View(); auto* promotion = drawer ? drawer->chestPromotion() : nullptr; auto* chest = promotion ? promotion->transform() : nullptr; auto* parent = chest ? chest->parent() : nullptr; - auto* source = name->gameObject() ? name->gameObject()->GetComponent() : nullptr; + auto* object = name->gameObject(); + auto* source = object ? object->GetComponent() : nullptr; auto* font = source ? source->font() : nullptr; if (!parent || !font) - return false; + return nullptr; il2cpp::GcHandle panel, fill, text; auto* panel_rect = unity::CreateUiObject("CommunityMod_AnomalyTimer", parent, panel); if (!panel_rect) - return false; + return nullptr; panel_rect->Place({0.5f, 0}, {0.5f, 0}, {0.5f, 1}, {198, 38}, {0, -40}); auto* rounded = NativeBackgroundSprite(nav); - if (!AddImage(panel.Get(), {0.64f, 0.34f, 0.46f, 0.85f}, rounded)) - return Abandon(panel); + if (!AddPanelImage(panel.Get(), {0.64f, 0.34f, 0.46f, 0.85f}, rounded)) { + Abandon(panel); + return nullptr; + } auto* fill_rect = unity::CreateUiObject("AnomalyFill", panel_rect, fill); - if (!fill_rect || !AddImage(fill.Get(), {0.06f, 0.10f, 0.14f, 0.96f}, rounded)) - return Abandon(panel); + if (!fill_rect || !AddPanelImage(fill.Get(), {0.06f, 0.10f, 0.14f, 0.96f}, rounded)) { + Abandon(panel); + return nullptr; + } fill_rect->Place({0.5f, 0.5f}, {0.5f, 0.5f}, {0.5f, 0.5f}, {194, 34}, {0, 0}); fill.Get()->SetActive(true); auto* text_rect = unity::CreateUiObject("Countdown", panel_rect, text); auto* label = text_rect ? text.Get()->AddComponent() : nullptr; - if (!label) - return Abandon(panel); + if (!label) { + Abandon(panel); + return nullptr; + } text_rect->Place({0.5f, 0.5f}, {0.5f, 0.5f}, {0.5f, 0.5f}, {190, 28}, {0, 0}); label->SetFont(font); label->SetFontSize(22); @@ -200,19 +212,19 @@ class AnomalyTimer text.Get()->SetActive(true); // The children are owned by the hierarchy once parented; only the label is kept. label_ = il2cpp::GcHandle::Strong(label); - return true; + return label; } - static bool Abandon(il2cpp::GcHandle& panel) + // Destroys a half-built panel. + static void Abandon(il2cpp::GcHandle& panel) { if (auto* object = panel.Get(); unity::Alive(object)) unity::Destroy(object); - return false; } - il2cpp::GcHandle navigation_, label_; - Clock::time_point next_update_{}, next_discovery_{}; - int64_t shown_seconds_ = -1; + il2cpp::GcHandle navigation_, label_; + AnomalyClock::time_point next_update_{}, next_discovery_{}; + int64_t shown_seconds_ = -1; }; // Leaked on purpose: its GC handles must not be released by static destructors after the runtime is gone. @@ -221,7 +233,6 @@ AnomalyTimer* anomaly_timer = nullptr; void AnomalyTimerFrame() { anomaly_timer->Update(); } -// No hooks: the countdown is all frame work. bool InstallAnomalyTimer() { anomaly_timer = new AnomalyTimer; diff --git a/src/mod/features/audio.cc b/src/mod/features/audio.cc index f216afede..1170901fb 100644 --- a/src/mod/features/audio.cc +++ b/src/mod/features/audio.cc @@ -11,21 +11,22 @@ #include #include +#include #include 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; } +{ return all ? std::ranges::contains(std::array{0, 3, 16, 33}, action) : 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, +bool AudioEventManager_PostEvent_Hook(auto original, Il2CppObject* _this, Il2CppString* event_name, + int32_t event_action, Il2CppObject* parameter, Il2CppObject* parent_game_object, Il2CppObject* initialise_parameters, bool add_to_queue, Il2CppObject* on_event_notify) { @@ -34,8 +35,7 @@ bool AudioEventManager_PostEvent_Hook(auto original, Il2CppObject* _this, Il2Cpp 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)) { + if (SuppressEvent(audio.disable_all_events, std::ranges::contains(audio.disabled_events, event), event_action)) { spdlog::debug("[Audio] muted {}", event); return false; } diff --git a/src/mod/features/camera.cc b/src/mod/features/camera.cc index bdd9f1582..6c044eb98 100644 --- a/src/mod/features/camera.cc +++ b/src/mod/features/camera.cc @@ -18,9 +18,9 @@ #include #include +#include +#include #include -#include -#include #include #include @@ -31,7 +31,8 @@ namespace // Entering a system jumps to default_system_zoom on the next camera update. bool zoom_to_default = false; -// Camera distance as a share of the zoom range, scaled to the configured maximum (the unit the presets use). +// Presets and default_system_zoom are in zoom units: the camera distance's place in [minimum, maximum] mapped onto +// [0, graphics.zoom], so they keep their meaning when the game moves the range. ZoomAtMouse(absolute) is the inverse. float CurrentZoom(NavigationZoom* zoom) { return (zoom->Distance() - zoom->minimum()) / (zoom->maximum() - zoom->minimum()) * Config::Get().graphics.zoom; } @@ -42,14 +43,14 @@ void StorePreset(const char* label, float& preset, NavigationZoom* zoom) spdlog::info("[Zoom] {} set from {} to {}", label, old, preset); } -struct Preset { +struct ZoomPreset { Action store; Action go; float Config::Graphics::* value; const char* label; }; -constexpr Preset kPresets[] = { +constexpr ZoomPreset kZoomPresets[] = { {Action::kSetZoomPreset1, Action::kZoomPreset1, &Config::Graphics::system_zoom_preset_1, "System Preset 1"}, {Action::kSetZoomPreset2, Action::kZoomPreset2, &Config::Graphics::system_zoom_preset_2, "System Preset 2"}, {Action::kSetZoomPreset3, Action::kZoomPreset3, &Config::Graphics::system_zoom_preset_3, "System Preset 3"}, @@ -57,14 +58,19 @@ constexpr Preset kPresets[] = { {Action::kSetZoomPreset5, Action::kZoomPreset5, &Config::Graphics::system_zoom_preset_5, "System Preset 5"}, }; -// Widens the system view's zoom range to the configured maximum and pushes the far clip plane out with it. +// Widens the system view's zoom range to the configured maximum and pushes the far clip plane out with it. A radius of +// 0 leaves the far ratios alone and only reapplies the camera settings. void ApplyZoomRange(NavigationZoom* zoom, float radius) { const auto max_zoom = Config::Get().graphics.zoom; + // The far ratios are multiples of the system's view radius, so max_zoom / radius puts the extended stop at max_zoom. + // The normal stop at 0.55 of that is carried over from the original mod. if (radius > 0.0f) { zoom->SetFarRatioSystemNormal(0.55f * max_zoom / radius); zoom->SetFarRatioSystemExtended(max_zoom / radius); } + // At max_zoom the camera still has to reach the far side of the system and its backdrop, hence the 3.75x clip. + // Clearing to black instead of the skybox hides the void past the backdrop's edge. if (auto* camera = zoom->sceneCamera()) { camera->SetFarClipPlane(max_zoom * 3.75f); camera->SetClearFlags(unity::Camera::ClearFlags::SolidColor); @@ -72,7 +78,8 @@ void ApplyZoomRange(NavigationZoom* zoom, float radius) } } -// The game resets the range now and then; reapply it around every update. +// The game resets the range now and then; reapply it around every update. _zoomtotal caches maximum - minimum and +// must follow the widened maximum. void KeepZoomRange(NavigationZoom* zoom) { const auto max_zoom = Config::Get().graphics.zoom; @@ -85,7 +92,7 @@ void KeepZoomRange(NavigationZoom* zoom) zoom->SetZoomtotal(total); } -// Zooms around the mouse pointer, either by `delta` or (with `absolute`) to a preset value. +// Zooms around the mouse pointer, either by a step of `value` or, with `absolute`, to `value` in zoom units. void ZoomAtMouse(NavigationZoom* zoom, float value, bool absolute) { const auto mouse = unity::Input::mousePosition(); @@ -102,51 +109,45 @@ void ZoomAtMouse(NavigationZoom* zoom, float value, bool absolute) } // Keyboard zoom: Q/E zoom by keyboard_zoom_speed per second, presets jump, SHIFT-F1.. store the current zoom. -// Returns false when a preset was stored, which skips the game's update for that frame. +// ZoomMin goes all the way out (graphics.zoom) and ZoomMax close in. Returns false when a preset was stored, which +// skips the game's update for that frame. bool KeyboardZoom(NavigationZoom* zoom) { - auto& config = Config::Get(); - const bool shortcuts = config.control.hotkeys_enabled && !config.control.use_scopely_hotkeys; - - float target = 0.0f; - bool from_preset = false; - if (shortcuts) { - for (const auto& preset : kPresets) { - if (Shortcut::IsDown(preset.store)) { - StorePreset(preset.label, config.graphics.*preset.value, zoom); - return false; - } - } - if (Shortcut::IsDown(Action::kSetZoomDefault)) { - StorePreset("System Default", config.graphics.default_system_zoom, zoom); + auto& config = Config::Get(); + if (!config.control.hotkeys_enabled || config.control.use_scopely_hotkeys) + return true; + + for (const auto& preset : kZoomPresets) + if (Shortcut::IsDown(preset.store)) { + StorePreset(preset.label, config.graphics.*preset.value, zoom); return false; } - for (const auto& preset : kPresets) { - if (Shortcut::IsDown(preset.go)) { - target = config.graphics.*preset.value; - from_preset = true; - break; - } - } - if (config.control.hotkeys_extended) { - if (Shortcut::IsDown(Action::kZoomReset)) - target = config.graphics.default_system_zoom; - else if (Shortcut::IsDown(Action::kZoomMin)) - target = config.graphics.zoom; - else if (Shortcut::IsDown(Action::kZoomMax)) - target = 100.0f; - } + if (Shortcut::IsDown(Action::kSetZoomDefault)) { + StorePreset("System Default", config.graphics.default_system_zoom, zoom); + return false; } - if (target != 0.0f) { - ZoomAtMouse(zoom, target, true); - } else { - const float step = config.graphics.keyboard_zoom_speed * unity::Time::deltaTime(); - if (shortcuts && Shortcut::IsPressed(Action::kZoomIn)) - ZoomAtMouse(zoom, step, false); - else if (shortcuts && Shortcut::IsPressed(Action::kZoomOut)) - ZoomAtMouse(zoom, -step, false); + + // An extended shortcut held with a preset wins the jump, but the preset still counts for use_presets_as_default. + const auto* preset = std::ranges::find_if(kZoomPresets, [](const ZoomPreset& p) { return Shortcut::IsDown(p.go); }); + const bool from_preset = preset != std::ranges::end(kZoomPresets); + float target = from_preset ? config.graphics.*preset->value : 0.0f; + if (config.control.hotkeys_extended) { + if (Shortcut::IsDown(Action::kZoomReset)) + target = config.graphics.default_system_zoom; + else if (Shortcut::IsDown(Action::kZoomMin)) + target = config.graphics.zoom; + else if (Shortcut::IsDown(Action::kZoomMax)) + target = 100.0f; } + const float step = config.graphics.keyboard_zoom_speed * unity::Time::deltaTime(); + if (target != 0.0f) + ZoomAtMouse(zoom, target, true); + else if (Shortcut::IsPressed(Action::kZoomIn)) + ZoomAtMouse(zoom, step, false); + else if (Shortcut::IsPressed(Action::kZoomOut)) + ZoomAtMouse(zoom, -step, false); + // With use_presets_as_default, jumping to a preset also makes it the zoom used on entering a system. if (from_preset && target > 0.0f && config.graphics.use_presets_as_default) StorePreset("System Preset Default from Preset", config.graphics.default_system_zoom, zoom); @@ -195,6 +196,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. +// +// A backdrop that was just created or reloaded has its authored scale again and is always scaled. The getter is +// hooked, so ScaleAuthoredBackdrop asks for the renderable with authored_backdrop set; backdrop_revision counts +// CreateBackdrop calls so Populate can tell whether one ran. bool authored_backdrop = false; uint64_t backdrop_revision = 0; @@ -204,7 +209,10 @@ void ScaleFlatRenderable(unity::Component* renderable, bool authored) constexpr size_t kRemembered = 256; static std::unordered_map scaled; const float factor = Config::Get().graphics.fr_scale; - auto* transform = renderable && factor > 0.0f && factor != 1.0f ? renderable->transform() : nullptr; + // Written so a NaN fr_scale, which the reader accepts, skips scaling too. + if (!renderable || !(factor > 0.0f) || factor == 1.0f) + return; + auto* transform = renderable->transform(); if (!transform) return; const auto scale = transform->localScale(); @@ -212,8 +220,9 @@ void ScaleFlatRenderable(unity::Component* renderable, bool authored) return; if (scaled.size() >= kRemembered) scaled.clear(); - transform->SetLocalScale({scale.x * factor, scale.y * factor, scale.z * factor}); - scaled.insert_or_assign(transform, scale.x * factor); + const auto new_scale = scale * factor; + transform->SetLocalScale(new_scale); + scaled.insert_or_assign(transform, new_scale.x); } unity::Component* PlanetViewUtils_get_FlatRenderable_Hook(auto original, PlanetViewUtils* _this) @@ -255,7 +264,7 @@ void PlanetViewUtils_Populate_Hook(auto original, PlanetViewUtils* _this, Il2Cpp const auto revision = backdrop_revision; original(_this, system, node_id, depth); auto* population = _this->popData(); - // Cached variation backdrops reload authored scale without calling CreateBackdrop. + // A cached variation backdrop reloads its authored scale without calling CreateBackdrop. if (depth == NodeDepth::SolarSystem && population && population != previous && population->VariationCollection() && revision == backdrop_revision) ScaleAuthoredBackdrop(_this); @@ -293,9 +302,10 @@ bool NavigationPan_LateUpdate_Hook(auto original, NavigationPan* _this) if (disable_target_camera_lock && _this->trackingPOI()) _this->SetTrackingPOI(nullptr); // An arrow held for a CTRL-arrow style shortcut must not pan as well, natively or through the mod. - const bool arrow_owned = Shortcut::Owns(unity::KeyCode::LeftArrow) || Shortcut::Owns(unity::KeyCode::RightArrow) - || Shortcut::Owns(unity::KeyCode::UpArrow) || Shortcut::Owns(unity::KeyCode::DownArrow); - const bool keyboard = config.control.hotkeys_enabled && !config.ui.disable_move_keys && !arrow_owned; + constexpr std::array kArrows{unity::KeyCode::LeftArrow, unity::KeyCode::RightArrow, unity::KeyCode::UpArrow, + unity::KeyCode::DownArrow}; + const bool arrow_owned = std::ranges::any_of(kArrows, Shortcut::Owns); + const bool keyboard = config.control.hotkeys_enabled && !config.ui.disable_move_keys && !arrow_owned; // With the game's own hotkeys, its LateUpdate pans and glides by itself. The mod still applies its momentum falloff // to that glide, without moving the camera a second time. @@ -310,25 +320,27 @@ bool NavigationPan_LateUpdate_Hook(auto original, NavigationPan* _this) const bool pointer = PointerHeld() || _this->panningStarted(); bool by_keys = false; if (keyboard && !native && !blocked && !pointer && KeyboardPanAllowed()) { - // Opposite bindings cancel; normalize diagonals like the native 2D composite. + // Opposite bindings cancel; normalize diagonals like the native 2D composite. MoveCamera takes a drag, and + // dragging left moves the view right, hence the negation. const float x = float(Shortcut::IsPressed(Action::kMoveRight)) - float(Shortcut::IsPressed(Action::kMoveLeft)); const float y = float(Shortcut::IsPressed(Action::kMoveUp)) - float(Shortcut::IsPressed(Action::kMoveDown)); if (const float length = std::sqrt(x * x + y * y); length > 0.0f) { - d.x = -x / length * _this->keyboardDeltaScaler(); - d.y = -y / length * _this->keyboardDeltaScaler(); - by_keys = true; - _this->MoveCamera(unity::Vector2{d.x * _this->deltaScaler(), d.y * _this->deltaScaler()}, true); + const float speed = _this->keyboardDeltaScaler(); + d = {-x / length * speed, -y / length * speed}; + by_keys = true; + const float scale = _this->deltaScaler(); + _this->MoveCamera(d * scale, true); } } if (blocked) { d = {}; } else if (!by_keys && !pointer) { + // Glide: the last drag or key delta shrinks by the falloff every frame. const auto falloff = config.graphics.system_pan_momentum_falloff; - d.x *= falloff; - d.y *= falloff; + d = d * falloff; if (!native) - _this->MoveCamera(unity::Vector2{d.x, d.y}, true); + _this->MoveCamera(d, true); } _this->SetLastDelta(d); _this->SetFarMagRadiusRatioSystemExtended(_this->farMagRadiusRatioSystemNormal()); @@ -381,17 +393,16 @@ void OrbitFrameProvider_UpdateConstrains_Hook(auto original, OrbitFrameProvider* auto* sections = Hub::Sections(); const bool interior = sections && sections->CurrentSection() == SectionID::Starbase_Interior; const float scale = Config::Get().graphics.interior_zoom_scale; - auto native = native_interior_radius.find(radius); if (interior && std::isfinite(scale) && scale > 1.0f) { - if (native == native_interior_radius.end()) - native = native_interior_radius.emplace(radius, std::pair{radius->min(), radius->max()}).first; - // Always scaled from the native range, so doing this every frame cannot compound. - radius->SetMin(native->second.first * scale); - radius->SetMax(native->second.second * scale); - } else if (native != native_interior_radius.end()) { + // Recorded on first sight and always scaled from there, so doing this every frame cannot compound. + const auto [low, high] = native_interior_radius.try_emplace(radius, radius->min(), radius->max()).first->second; + radius->SetMin(low * scale); + radius->SetMax(high * scale); + } else if (const auto native = native_interior_radius.find(radius); native != native_interior_radius.end()) { // Left wider, the exterior view stays zoomed out and its station labels drop out of range. - radius->SetMin(native->second.first); - radius->SetMax(native->second.second); + const auto [low, high] = native->second; + radius->SetMin(low); + radius->SetMax(high); } } // The game clamps the camera against the widened or restored range. diff --git a/src/mod/features/haven.cc b/src/mod/features/haven.cc index a56c90acd..fa7670b41 100644 --- a/src/mod/features/haven.cc +++ b/src/mod/features/haven.cc @@ -16,67 +16,115 @@ #include #include #include +#include +#include +#include +#include +#include +#include namespace { // --- Haven zoom --- // From further out the roads sink into the ground mesh. A depth bias on the road materials keeps them drawn on top. -void ApplyHavenRoadDepthBias() +// Finding the materials (GetComponentsInChildren and every material's name) is the costly part, so it runs every 5 s +// while the bias is checked every second. The handles are weak, so a cached material never keeps an unloaded scene or +// asset alive. +class HavenRoads { - // Once a second: the scene search below is too costly for every frame. - static Throttle throttle(std::chrono::seconds(1)); - if (!throttle.Due()) - return; - auto* root = unity::GameObject::Find("StarbaseRoot/StarbaseManager/StarbasePlanetaryVisualsHolder"); - auto* renderers = root ? root->GetComponentsInChildren(true) : nullptr; - if (!renderers) - return; - for (auto* renderer : *renderers) { - auto* materials = renderer ? renderer->sharedMaterials() : nullptr; - if (!materials) - continue; - for (auto* material : *materials) { - if (!material) - continue; - 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; - if (!shader || shader->Name() != "Custom/LitMSAE") - continue; - const std::array properties{il2cpp::NewString("_OffsetFactor"), il2cpp::NewString("_OffsetUnits")}; - if (!material->HasProperty(properties[0]) || !material->HasProperty(properties[1])) - continue; - const std::array before{material->GetFloat(properties[0]), material->GetFloat(properties[1])}; - if (!std::isfinite(before[0]) || !std::isfinite(before[1])) - continue; - // Keep an already stronger native bias. Resolve scene objects afresh each time. - for (size_t i = 0; i < properties.size(); ++i) - if (before[i] > -1.0f) - material->SetFloat(properties[i], -1.0f); +public: + void Apply() + { + if (!apply_.Due()) + return; + 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 (search_.Due()) + 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); + std::unordered_set seen; + for (auto* renderer : il2cpp::Items(root ? root->GetComponentsInChildren(true) : nullptr)) + for (auto* material : il2cpp::Items(renderer ? renderer->sharedMaterials() : nullptr)) + if (material && IsRoad(material) && seen.insert(material).second) + materials_.push_back(il2cpp::GcHandle::Weak(material)); + } + + static bool IsRoad(unity::Material* material) + { + static constexpr std::string_view kRoadMaterials[] = { + "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_"}; + const auto name = material->Name(); + if (!std::ranges::any_of(kRoadMaterials, [&](std::string_view prefix) { return name.starts_with(prefix); })) + return false; + auto* shader = material->shader(); + 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; + // Negative offsets pull the roads toward the camera. 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); } + + Throttle apply_{std::chrono::seconds(1)}, search_{std::chrono::seconds(5)}; + il2cpp::GcHandle owner_; + std::vector materials_; +}; + +void ApplyHavenRoadDepthBias() +{ + // Leaked: its handles must not be freed by static destructors after the runtime is gone. + static auto* roads = new HavenRoads; + roads->Apply(); } -bool ReadHavenRadius(FrameProvider* provider, float& radius) +// Haven's zoom blends between two planetary orbits, each pinned to one radius (min == max) and sitting on it. Reads +// that radius, or nothing for any other kind of endpoint. +std::optional HavenOrbitRadius(FrameProvider* provider) { if (!provider || !provider->Is() || !provider->Is()) - return false; + return std::nullopt; auto* orbit = provider->As(); auto* constraint = orbit->constraint(); auto* data = constraint ? constraint->radius() : nullptr; if (!data || !data->enabled()) - return false; + return std::nullopt; const auto minimum = data->min(), maximum = data->max(); - radius = orbit->radius(); - return std::isfinite(radius) && std::isfinite(maximum) && maximum > 0.0f && minimum == maximum - && std::abs(radius - maximum) < 0.01f; + const auto radius = orbit->radius(); + if (std::isfinite(radius) && std::isfinite(maximum) && maximum > 0.0f && minimum == maximum + && std::abs(radius - maximum) < 0.01f) + return radius; + return std::nullopt; } +// Every value is finite: NaN and infinity both fail. +bool HavenFinite(std::floating_point auto... values) +{ return (std::isfinite(values) && ...); } + void BlendFrameProvider_UpdateCameraFrame_Hook(auto original, BlendFrameProvider* _this, unity::Camera* camera) { // Native input, constraints and endpoint updates run first. Only this blend's newly generated output is extended; @@ -86,11 +134,14 @@ void BlendFrameProvider_UpdateCameraFrame_Hook(auto original, BlendFrameProvider if (!std::isfinite(max_zoom) || max_zoom <= 0.0f) return; - auto* source = _this->sourceFrameProvider(); - auto* target = _this->targetFrameProvider(); - 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)) + auto* source = _this->sourceFrameProvider(); + auto* target = _this->targetFrameProvider(); + const auto source_pinned = HavenOrbitRadius(source); + const auto target_pinned = source_pinned ? HavenOrbitRadius(target) : std::nullopt; + if (!target_pinned) + return; + const float source_radius = *source_pinned, target_radius = *target_pinned; + if (source_radius == target_radius || max_zoom <= std::max(source_radius, target_radius)) return; auto* source_orbit = source->As(); @@ -105,31 +156,33 @@ void BlendFrameProvider_UpdateCameraFrame_Hook(auto original, BlendFrameProvider auto* curve = _this->blendCurve(); const auto minimum = _this->softLimitMin(), maximum = _this->softLimitMax(), ratio = _this->blendRatio(); if (!source_frame || !target_frame || !result || !curve || 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) + || result == target_frame || !HavenFinite(minimum, maximum, 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) + 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: unit rotations that agree up to sign + // (|dot| = 1) and one field of view. Only then is moving along the camera's back axis the same as a wider orbit. + // Preserve native output when a client changes that geometry or camera mode. + if (!HavenFinite(rotation_length, rotation_dot, af, bf) || std::abs(rotation_length - 1.0f) > 0.0001f + || std::abs(std::abs(rotation_dot) - 1.0f) > 0.0001f || af <= 0.0f || std::abs(af - bf) > 0.001f + || source_frame->orthographic() || target_frame->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)) + if (!HavenFinite(at_ratio, at_minimum, at_maximum)) return; + // Weights run from 0 at the inner orbit to 1 at the outer one, whichever way the blend goes. outer_weight and + // inner_weight are the curve at the soft limits, where the native zoom stops; progress is how far the current + // ratio is between them. The extra distance grows with progress up to max_zoom - native_outer at the outer stop, + // and never takes the camera past max_zoom. 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); @@ -150,7 +203,8 @@ void BlendFrameProvider_UpdateCameraFrame_Hook(auto original, BlendFrameProvider return; // Sequential endpoint updates can cache different positions of their shared pivot during pan. Use the camera back - // axis so expansion remains steady. + // axis so expansion remains steady: the rotation's forward vector is (2(xz + wy), 2(yz - wx), 1 - 2(x^2 + y^2)), and + // the camera moves `extra` against it. The far plane moves out by the same amount. 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); @@ -158,16 +212,13 @@ void BlendFrameProvider_UpdateCameraFrame_Hook(auto original, BlendFrameProvider 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) + if (!HavenFinite(position.x, position.y, position.z, clip) || clip <= 0.0f) return; result->SetPosition(position); result->SetFarPlane(clip); static bool reported = false; - if (!reported) { + if (!std::exchange(reported, true)) spdlog::info("[HavenZoom] expanded native outer distance {} to maximum {}", native_outer, max_zoom); - reported = true; - } } // --- Help history --- @@ -177,12 +228,11 @@ void PlanetaryHelpHistoryContext_ApplyRecords_Hook(auto original, PlanetaryHelpH { if (records && Config::Get().ui.reverse_haven_history) { // Swap through the managed setter so reference writes keep their GC barriers. - const auto count = records->Count(); - for (int32_t i = 0; i < count / 2; ++i) { + for (int32_t i = 0, j = records->Count() - 1; i < j; ++i, --j) { auto* first = (*records)[i]; - auto* last = (*records)[count - i - 1]; + auto* last = (*records)[j]; records->Set(i, last); - records->Set(count - i - 1, first); + records->Set(j, first); } } original(_this, records); diff --git a/src/mod/features/loading_screens.cc b/src/mod/features/loading_screens.cc index 24a88a9b5..d53fad95c 100644 --- a/src/mod/features/loading_screens.cc +++ b/src/mod/features/loading_screens.cc @@ -3,6 +3,7 @@ #include "config.h" #include "features/features.h" +#include "file.h" #include "version.h" #include "game/loading.h" @@ -13,8 +14,8 @@ #include #include +#include #include -#include #include #include #include @@ -44,8 +45,8 @@ void HideImage(unity::Image* image) image->SetColor({0.0f, 0.0f, 0.0f, 0.0f}); } -// Decodes `data` into `asset` unless it is still loaded; warns once per failed decode. -unity::ImageAsset* Load(unity::ImageAsset& asset, const uint8_t* data, size_t size, const char* name) +// Decodes `data` into `asset` unless it is still loaded; warns on every failed decode. +unity::ImageAsset* LoadScreenImage(unity::ImageAsset& asset, const uint8_t* data, size_t size, const char* name) { if (asset.Load(data, size)) return &asset; @@ -53,7 +54,7 @@ unity::ImageAsset* Load(unity::ImageAsset& asset, const uint8_t* data, size_t si return nullptr; } -struct Assets { +struct ScreenImages { unity::ImageAsset background, logo, community_logo; // The configured loader_image, or the embedded one when it is unset or unreadable. Called from hooks, so it @@ -62,25 +63,27 @@ struct Assets { { if (background.Texture()) return &background; - const auto& path = Config::Get().graphics.loader_image; + // loader_image is UTF-8; File::FromUtf8 keeps Windows from reading it in the ANSI code page. + const auto path = File::FromUtf8(Config::Get().graphics.loader_image); std::error_code error; if (!path.empty() && std::filesystem::exists(path, error)) { std::ifstream file(path, std::ios::binary); std::vector data(std::istreambuf_iterator(file), {}); - if (!data.empty() && Load(background, data.data(), data.size(), "custom loading background")) + if (!data.empty() && LoadScreenImage(background, data.data(), data.size(), "custom loading background")) return &background; } - return Load(background, g_embeddedLoadingImage, g_embeddedLoadingImage_SIZE, "embedded loading background"); + return LoadScreenImage(background, g_embeddedLoadingImage, g_embeddedLoadingImage_SIZE, + "embedded loading background"); } unity::ImageAsset* Logo() - { return Load(logo, g_embeddedLogoImage, g_embeddedLogoImage_SIZE, "mod logo"); } + { return LoadScreenImage(logo, g_embeddedLogoImage, g_embeddedLogoImage_SIZE, "mod logo"); } unity::ImageAsset* CommunityLogo() - { return Load(community_logo, g_embeddedCCLogoImage, g_embeddedCCLogoImage_SIZE, "community logo"); } + { return LoadScreenImage(community_logo, g_embeddedCCLogoImage, g_embeddedCCLogoImage_SIZE, "community logo"); } }; // Leaked on purpose: its GC handles must not be released by static destructors after the runtime is gone. -Assets& assets = *new Assets; +ScreenImages& screen_images = *new ScreenImages; unity::GameObject* CreateOverlay(const char* name, unity::ImageAsset& asset, unity::Transform* parent, unity::Vector2 anchor_min, unity::Vector2 anchor_max, bool preserve_aspect, @@ -106,42 +109,42 @@ unity::GameObject* CreateOverlay(const char* name, unity::ImageAsset& asset, uni return object; } -enum class Corner { kBottomLeft, kBottomRight }; +enum class LogoCorner { kBottomLeft, kBottomRight }; -// A logo in a bottom corner, sized relative to a 1920-wide screen and scaled by loader_logo_scale. -bool CreateLogo(const char* name, unity::ImageAsset* asset, unity::Transform* parent, Corner corner) +// A logo in a bottom corner, laid out in pixels for a 1920-wide screen, then scaled to the real width and by +// loader_logo_scale. The overlay is placed purely by anchors, so the pixel box becomes fractions of the screen. +bool CreateLogo(const char* name, unity::ImageAsset* asset, unity::Transform* parent, LogoCorner corner) { if (!asset || !parent) return false; - float width = static_cast(unity::Screen::width()); - float height = static_cast(unity::Screen::height()); - if (width <= 0.0f) - width = 1334.0f; - if (height <= 0.0f) - height = 750.0f; + // Screen can report 0 early in startup; fall back to a phone-sized screen. + const auto screen_width = unity::Screen::width(), screen_height = unity::Screen::height(); + const float width = screen_width > 0 ? static_cast(screen_width) : 1334.0f; + const float height = screen_height > 0 ? static_cast(screen_height) : 750.0f; const float scale = width / 1920.0f * Config::Get().graphics.loader_logo_scale; const float logo_width = 150.0f * scale; const float logo_height = logo_width * asset->AspectRatio(); const float pad_x = 30.0f * scale; const float pad_y = 185.0f * scale; - const float left = corner == Corner::kBottomLeft ? pad_x : width - pad_x - logo_width; + const float left = corner == LogoCorner::kBottomLeft ? pad_x : width - pad_x - logo_width; return CreateOverlay(name, *asset, parent, {left / width, pad_y / height}, {(left + logo_width) / width, (pad_y + logo_height) / height}, true, false); } // The mod logo on the right, the community logo on the left. -struct Logos { +struct ScreenLogos { bool logo = false; bool community_logo = false; // Creates whichever is still missing. void Create(unity::Transform* parent) { - logo = logo || CreateLogo("STFCModLogo", assets.Logo(), parent, Corner::kBottomRight); - community_logo = community_logo || CreateLogo("STFCCCLogo", assets.CommunityLogo(), parent, Corner::kBottomLeft); + logo = logo || CreateLogo("STFCModLogo", screen_images.Logo(), parent, LogoCorner::kBottomRight); + community_logo = + community_logo || CreateLogo("STFCCCLogo", screen_images.CommunityLogo(), parent, LogoCorner::kBottomLeft); } }; @@ -155,15 +158,14 @@ unity::Image* FindLoginBackground(unity::Transform* transform, int depth, unity: "LoginBG", "SplashBG", "Image"}; if (!transform || depth > 4) return nullptr; - for (int32_t i = 0; i < transform->childCount(); ++i) { - auto* child = transform->GetChild(i); + for (auto* child : transform->Children()) { auto* object = child ? child->gameObject() : nullptr; if (!object) continue; if (auto* image = object->GetComponent()) { if (!first) first = image; - if (std::ranges::find(kNames, object->Name()) != std::end(kNames)) + if (std::ranges::contains(kNames, object->Name())) return image; } if (auto* found = FindLoginBackground(child, depth + 1, first)) @@ -178,16 +180,15 @@ void LoginSequence_Awake_Hook(auto original, LoginSequence* _this) if (!Config::Get().graphics.loader_enabled) return; - auto* background = assets.Background(); + auto* background = screen_images.Background(); auto* canvas = background ? _this->mainCanvas() : nullptr; auto* root = canvas ? canvas->transform() : nullptr; if (!root) return; unity::Image* first = nullptr; - auto* image = FindLoginBackground(root, 0, first); - if (!image) - image = first; + auto* found = FindLoginBackground(root, 0, first); + auto* image = found ? found : first; if (!image) return; @@ -198,17 +199,17 @@ void LoginSequence_Awake_Hook(auto original, LoginSequence* _this) image->SetVerticesDirty(); } #endif - Logos{}.Create(image->transform()); + ScreenLogos{}.Create(image->transform()); } // --- Loading tips --- -struct Tip { +struct LoadingTip { const char* text; int weight; }; -constexpr Tip kTips[] = { +constexpr LoadingTip kTipTexts[] = { {"Did you know? You can customize zoom presets, hotkeys, and UI scale in the community mod settings file to suit " "your playstyle.", 15}, @@ -228,9 +229,9 @@ constexpr Tip kTips[] = { // The first loading screen of a session gets the welcome line. After that, half the transition screens keep the // game's own tip and the rest get one of ours by weight, never the same one twice in a row. -struct Tips { +struct TipPicker { bool first_screen = true; - size_t last = std::size(kTips); + size_t last = std::size(kTipTexts); std::mt19937 random{std::random_device{}()}; const char* Next() @@ -241,19 +242,19 @@ struct Tips { if (std::bernoulli_distribution(0.5)(random)) return nullptr; - std::array weights{}; + std::array weights{}; for (size_t i = 0; i < weights.size(); ++i) - weights[i] = i == last ? 0 : kTips[i].weight; + weights[i] = i == last ? 0 : kTipTexts[i].weight; last = std::discrete_distribution(weights.begin(), weights.end())(random); - return kTips[last].text; + return kTipTexts[last].text; } void Reset() { first_screen = true; - last = std::size(kTips); + last = std::size(kTipTexts); } -} tips; +} tip_picker; void LoadingTipViewController_SetRandomTipLocalisedText_Hook(auto original, LoadingTipViewController* _this) { @@ -261,7 +262,7 @@ void LoadingTipViewController_SetRandomTipLocalisedText_Hook(auto original, Load if (!Config::Get().graphics.loader_tip_enabled) return; auto* label = _this->textMeshPro(); - if (const char* tip = tips.Next(); tip && label) + if (const char* tip = tip_picker.Next(); tip && label) label->SetText(tip); } @@ -269,11 +270,11 @@ void LoadingTipViewController_SetRandomTipLocalisedText_Hook(auto original, Load // What has been done to the current TransitionViewController. Its Unity objects are looked up again on each // attempt rather than kept, since nothing roots them between hooks. A new controller or a reload starts over. -struct Transition { - bool customized = false; - bool overlay = false; - Logos logos; -} transition; +struct TransitionState { + bool customized = false; + bool overlay = false; + ScreenLogos logos; +} transition_screen; // The transition background: _staticOverride, or the Image on BGContainer's first child (whose transform is // returned in `box` so it can be stretched). @@ -282,8 +283,9 @@ unity::Image* FindTransitionBackground(TransitionViewController* controller, uni if (auto* image = controller->staticOverride()) return image; auto* root = controller->transform(); - for (int32_t i = 0; root && i < root->childCount(); ++i) { - auto* child = root->GetChild(i); + if (!root) + return nullptr; + for (auto* child : root->Children()) { auto* object = child ? child->gameObject() : nullptr; if (!object || object->Name() != "BGContainer") continue; @@ -298,8 +300,9 @@ unity::Image* FindTransitionBackground(TransitionViewController* controller, uni void ArrangeTransitionLayout(TransitionViewController* controller) { auto* root = controller->transform(); - for (int32_t i = 0; root && i < root->childCount(); ++i) { - auto* child = root->GetChild(i); + if (!root) + return; + for (auto* child : root->Children()) { auto* object = child ? child->gameObject() : nullptr; if (!object) continue; @@ -327,12 +330,12 @@ void CustomizeTransition(TransitionViewController* controller) if (!Config::Get().graphics.loader_transition_black) { #ifndef _USE_ORIGINAL_BG - if (parent && !transition.overlay) - if (auto* image = assets.Background()) - transition.overlay = + if (parent && !transition_screen.overlay) + if (auto* image = screen_images.Background()) + transition_screen.overlay = CreateOverlay("STFCModBG", *image, parent, {0.0f, 0.0f}, {1.0f, 1.0f}, false, true) != nullptr; // Keep the game's background unless the replacement is fully attached, so a failure is visible but harmless. - if (!transition.overlay) { + if (!transition_screen.overlay) { spdlog::warn("[TransitionScreen] the custom background is unavailable; keeping the game's"); return; } @@ -346,10 +349,10 @@ void CustomizeTransition(TransitionViewController* controller) } if (parent) - transition.logos.Create(parent); + transition_screen.logos.Create(parent); if (!Config::Get().graphics.loader_transition_black) ArrangeTransitionLayout(controller); - transition.customized = true; + transition_screen.customized = true; } void TransitionViewController_Awake_Hook(auto original, TransitionViewController* _this) @@ -357,14 +360,14 @@ void TransitionViewController_Awake_Hook(auto original, TransitionViewController original(_this); if (!Config::Get().graphics.loader_transition) return; - transition = {}; + transition_screen = {}; CustomizeTransition(_this); } void TransitionViewController_AboutToShow_Hook(auto original, TransitionViewController* _this) { original(_this); - if (Config::Get().graphics.loader_transition && !transition.customized) + if (Config::Get().graphics.loader_transition && !transition_screen.customized) CustomizeTransition(_this); } @@ -386,8 +389,8 @@ void SlideShowViewController_ShowCurrentSlide_Hook(auto original, SlideShowViewC // The hierarchy holding our overlays is destroyed during a reload; forget it so Awake does not touch dead objects. void MonoSingleton_PrepareAllForReload_Hook(auto original) { - transition = {}; - tips.Reset(); + transition_screen = {}; + tip_picker.Reset(); original(); } @@ -395,12 +398,11 @@ void MonoSingleton_PrepareAllForReload_Hook(auto original) // first call. void InstallReloadReset() { - static const bool installed = [] { + [[maybe_unused]] static const bool installed = [] { il2cpp::HookSet hooks("LoadingScreens"); IL2CPP_STAGE(hooks, MonoSingletonBase::Method("PrepareAllForReload"), MonoSingleton_PrepareAllForReload_Hook); return hooks.Commit(); }(); - (void)installed; } bool InstallLoginScreen() diff --git a/src/mod/features/missions.cc b/src/mod/features/missions.cc index 12b1fa612..1e316805e 100644 --- a/src/mod/features/missions.cc +++ b/src/mod/features/missions.cc @@ -105,8 +105,9 @@ bool Claimed(std::string_view faction) void ShowClaimedFactionIcons(MissionViewController* controller) { - auto* toggle = controller->claimAllToggle(); - auto* parent = toggle && toggle->transform() ? toggle->transform()->parent() : nullptr; + auto* toggle = controller->claimAllToggle(); + auto* transform = toggle ? toggle->transform() : nullptr; + auto* parent = transform ? transform->parent() : nullptr; if (!parent) return; for (const auto& faction : kFactions) { @@ -123,10 +124,7 @@ void ShowClaimedFactionIcons(MissionViewController* controller) std::string FactionOf(DailyGoal* daily) { constexpr std::string_view kPrefix = "Resource_FactionPoint_"; - auto* rewards = daily ? daily->factionPointRewards() : nullptr; - if (!rewards) - return {}; - for (auto* item : *rewards) { + for (auto* item : il2cpp::Items(daily ? daily->factionPointRewards() : nullptr)) { auto* spec = item ? item->resourceSpec() : nullptr; auto* id = spec ? spec->resourceId() : nullptr; if (auto resource = id ? il2cpp::ToUtf8(id) : std::string(); resource.starts_with(kPrefix)) diff --git a/src/mod/features/notification_audio.cc b/src/mod/features/notification_audio.cc index fa51a975f..cdb7aa480 100644 --- a/src/mod/features/notification_audio.cc +++ b/src/mod/features/notification_audio.cc @@ -4,9 +4,14 @@ #include +#include #include #include #include +#include +#include +#include +#include #include #if _WIN32 @@ -33,34 +38,35 @@ constexpr std::array kPatterns[] = { {{{1046, 95}}}, {{{440, 70}, {0, 18}, {554, 70}, {0, 18}, {740, 140}}}, }; -static_assert(std::size(kPatterns) == static_cast(NotificationSound::kCount)); +static_assert(std::size(kPatterns) == std::to_underlying(NotificationSound::kCount)); constexpr int kSampleRate = 44100; -constexpr double kTwoPi = 6.28318530717958647692; -constexpr double kAmplitude = 0.26; +constexpr double kTwoPi = 2 * std::numbers::pi; +// Peak level, about -12 dBFS. +constexpr double kAmplitude = 0.26; +int SegmentSamples(const ToneSegment& segment) +{ return static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); } + +// RIFF stores integers little-endian. void AppendU16(std::vector& buffer, uint16_t value) -{ - buffer.push_back(static_cast(value & 0xff)); - buffer.push_back(static_cast((value >> 8) & 0xff)); -} +{ buffer.insert(buffer.end(), {static_cast(value), static_cast(value >> 8)}); } void AppendU32(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)); + AppendU16(buffer, static_cast(value)); + AppendU16(buffer, static_cast(value >> 16)); } void AppendAscii(std::vector& buffer, std::string_view value) { buffer.insert(buffer.end(), value.begin(), value.end()); } +// A canonical 44-byte RIFF header (one PCM fmt chunk, then data) followed by the samples. std::vector BuildWav(std::span pattern) { uint32_t sample_count = 0; for (const auto& segment : pattern) - sample_count += static_cast((static_cast(segment.duration_ms) * kSampleRate) / 1000); + sample_count += static_cast(SegmentSamples(segment)); constexpr uint16_t channels = 1; constexpr uint16_t bits_per_sample = 16; @@ -69,27 +75,30 @@ std::vector BuildWav(std::span pattern) std::vector buffer; buffer.reserve(44 + data_bytes); AppendAscii(buffer, "RIFF"); - AppendU32(buffer, 36 + data_bytes); + AppendU32(buffer, 36 + data_bytes); // everything after this field AppendAscii(buffer, "WAVE"); AppendAscii(buffer, "fmt "); - AppendU32(buffer, 16); - AppendU16(buffer, 1); + AppendU32(buffer, 16); // fmt chunk size + AppendU16(buffer, 1); // PCM AppendU16(buffer, channels); AppendU32(buffer, kSampleRate); - AppendU32(buffer, kSampleRate * channels * (bits_per_sample / 8)); - AppendU16(buffer, channels * (bits_per_sample / 8)); + AppendU32(buffer, kSampleRate * channels * (bits_per_sample / 8)); // bytes per second + AppendU16(buffer, channels * (bits_per_sample / 8)); // bytes per frame AppendU16(buffer, bits_per_sample); AppendAscii(buffer, "data"); AppendU32(buffer, data_bytes); + // A frequency of 0 is a rest. Each tone fades in over 80 samples and out over 120 (2-3 ms), which removes the + // click a hard edge would make. 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; + const auto segment_samples = SegmentSamples(segment); + const bool rest = segment.frequency_hz <= 0.0; + const auto step = rest ? 0.0 : kTwoPi * segment.frequency_hz / kSampleRate; 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; + const auto fade_in = std::min(1.0, index / 80.0); + const auto fade_out = std::min(1.0, (segment_samples - index) / 120.0); + const auto sample = rest ? 0.0 : std::sin(phase) * kAmplitude * fade_in * fade_out; AppendU16(buffer, static_cast(static_cast(sample * 32767.0))); phase += step; if (phase > kTwoPi) @@ -102,7 +111,7 @@ std::vector BuildWav(std::span pattern) std::span NotificationSoundBytes(NotificationSound sound) { - const auto index = static_cast(sound); + const size_t index = std::to_underlying(sound); if (index == 0 || index >= std::size(kPatterns)) return {}; static std::array ready; diff --git a/src/mod/features/notification_audio_mac.mm b/src/mod/features/notification_audio_mac.mm index 163c7dc6b..271df4264 100644 --- a/src/mod/features/notification_audio_mac.mm +++ b/src/mod/features/notification_audio_mac.mm @@ -16,12 +16,10 @@ bool PlayNotificationBuffer(const uint8_t* data, size_t size) return false; [current_sound stop]; -#if __has_feature(objc_arc) - current_sound = sound; -#else +#if !__has_feature(objc_arc) [current_sound release]; - current_sound = sound; #endif + current_sound = sound; return [current_sound play]; } } diff --git a/src/mod/features/ui_scale.cc b/src/mod/features/ui_scale.cc index fba61b1ad..031dde46b 100644 --- a/src/mod/features/ui_scale.cc +++ b/src/mod/features/ui_scale.cc @@ -1,5 +1,6 @@ // [graphics] ui_scale, ui_scale_viewer and ui_scale_ship: the interface, object viewer and system ship scales, and the -// shortcuts that adjust them during play. +// shortcuts that adjust them during play. A ui_scale or ui_scale_viewer of 0 leaves the game's own scale alone, and +// the shortcuts do not switch it on. #include "config.h" #include "features/features.h" @@ -22,12 +23,6 @@ namespace { -float ShipScaleMultiplier() -{ - const auto multiplier = Config::Get().graphics.ui_scale_ship; - return multiplier > 0.0f ? multiplier : 1.0f; -} - // Ships already in the system keep the scale they were loaded with, so rescale them by the change. void RescaleLoadedShips(float old_multiplier, float new_multiplier) { @@ -40,17 +35,18 @@ void RescaleLoadedShips(float old_multiplier, float new_multiplier) auto* transform = object ? object->transform() : nullptr; if (!transform) continue; - const auto scale = transform->localScale(); - transform->SetLocalScale({scale.x * ratio, scale.y * ratio, scale.z * ratio}); + transform->SetLocalScale(transform->localScale() * ratio); } } +// The scale a ship model is loaded with; ships loaded earlier are handled by RescaleLoadedShips. bool SystemViewShip_TryGetShipAssetBundleResource_Hook(auto original, il2cpp::Object* _this, float* scale, void** asset_bundle_resource) { - const auto result = original(_this, scale, asset_bundle_resource); - if (result && scale) - *scale *= ShipScaleMultiplier(); + const auto result = original(_this, scale, asset_bundle_resource); + const auto multiplier = Config::Get().graphics.ui_scale_ship; + if (result && scale && multiplier > 0.0f) + *scale *= multiplier; return result; } @@ -69,10 +65,11 @@ void ScreenManager_UpdateCanvasRootScaleFactor_Hook(auto original, ScreenManager if (ui_scale == 0.0f) return; - auto factor = ui_scale * WindowDpiScale(); - factor = std::isnan(factor) ? 1.0f : std::clamp(factor, 0.1f, 5.0f); + // The canvas scale factor counts physical pixels per UI unit. Multiplying by the monitor's display scale keeps + // ui_scale meaning the same size on a 100% and a 150% monitor. + const auto factor = ui_scale * WindowDpiScale(); if (auto* scaler = _this->canvasRootScaler()) - scaler->SetScaleFactor(factor); + scaler->SetScaleFactor(std::isnan(factor) ? 1.0f : std::clamp(factor, 0.1f, 5.0f)); } void CanvasController_Show_Hook(auto original, CanvasController* _this, int32_t entry_point, bool instant) @@ -93,46 +90,42 @@ bool InstallUiScale() ScreenManager_UpdateCanvasRootScaleFactor_Hook); IL2CPP_STAGE(hooks, CanvasController::Class::Method("Show", {"System.Int32", "System.Boolean"}), CanvasController_Show_Hook); - const bool installed = hooks.Commit(); - WindowDpiScale(true); - return installed; + return hooks.Commit(); } } // namespace void AdjustUiScale(bool up) { - auto& config = Config::Get(); - if (config.graphics.ui_scale == 0.0f) + auto& graphics = Config::Get().graphics; + if (graphics.ui_scale == 0.0f) return; - - const auto old_scale = config.graphics.ui_scale; - const auto new_scale = old_scale + (up ? 1.0f : -1.0f) * config.graphics.ui_scale_step; - config.graphics.ui_scale = std::clamp(new_scale, 0.1f, 2.0f); - spdlog::info("[UiScale] interface scale {} -> {} (asked {}), DPI scale {}", old_scale, config.graphics.ui_scale, - new_scale, WindowDpiScale(true)); + const auto old_scale = graphics.ui_scale; + const auto new_scale = old_scale + (up ? 1.0f : -1.0f) * graphics.ui_scale_step; + graphics.ui_scale = std::clamp(new_scale, 0.1f, 2.0f); + spdlog::info("[UiScale] interface scale {} -> {} (asked {}), DPI scale {}", old_scale, graphics.ui_scale, new_scale, + WindowDpiScale(true)); } void AdjustUiViewerScale(bool up) { - auto& config = Config::Get(); - if (config.graphics.ui_scale_viewer == 0.0f) + auto& graphics = Config::Get().graphics; + if (graphics.ui_scale_viewer == 0.0f) return; - // Viewers change in quarter steps; a full step is too coarse at their size. - const auto old_scale = config.graphics.ui_scale_viewer; - const auto new_scale = old_scale + (up ? 1.0f : -1.0f) * config.graphics.ui_scale_step * 0.25f; - config.graphics.ui_scale_viewer = std::clamp(new_scale, 0.1f, 2.0f); - spdlog::info("[UiScale] viewer scale {} -> {} (asked {})", old_scale, config.graphics.ui_scale_viewer, new_scale); + const auto old_scale = graphics.ui_scale_viewer; + const auto new_scale = old_scale + (up ? 1.0f : -1.0f) * graphics.ui_scale_step * 0.25f; + graphics.ui_scale_viewer = std::clamp(new_scale, 0.1f, 2.0f); + spdlog::info("[UiScale] viewer scale {} -> {} (asked {})", old_scale, graphics.ui_scale_viewer, new_scale); } void AdjustUiShipScale(bool up) { - auto& config = Config::Get(); - const auto old_scale = config.graphics.ui_scale_ship; - const auto new_scale = old_scale + (up ? 1.0f : -1.0f) * config.graphics.ui_scale_step; - config.graphics.ui_scale_ship = std::clamp(new_scale, 0.1f, 20.0f); - spdlog::info("[UiScale] ship scale {} -> {} (asked {})", old_scale, config.graphics.ui_scale_ship, new_scale); - RescaleLoadedShips(old_scale, config.graphics.ui_scale_ship); + auto& graphics = Config::Get().graphics; + const auto old_scale = graphics.ui_scale_ship; + const auto new_scale = old_scale + (up ? 1.0f : -1.0f) * graphics.ui_scale_step; + graphics.ui_scale_ship = std::clamp(new_scale, 0.1f, 20.0f); + spdlog::info("[UiScale] ship scale {} -> {} (asked {})", old_scale, graphics.ui_scale_ship, new_scale); + RescaleLoadedShips(old_scale, graphics.ui_scale_ship); } const Feature kUiScale{"UiScale", &Config::Patches::uiscalehooks, InstallUiScale}; diff --git a/src/mod/features/window.cc b/src/mod/features/window.cc index 5750b44f3..e4ca86c36 100644 --- a/src/mod/features/window.cc +++ b/src/mod/features/window.cc @@ -52,10 +52,9 @@ bool ApplyProfileTitle() title.resize(GetWindowTextW(window, title.data(), length + 1)); const auto& profile = File::Profile(); - std::wstring prefix(MultiByteToWideChar(CP_UTF8, 0, profile.data(), static_cast(profile.size()), nullptr, 0), - L'\0'); - MultiByteToWideChar(CP_UTF8, 0, profile.data(), static_cast(profile.size()), prefix.data(), - static_cast(prefix.size())); + const auto bytes = static_cast(profile.size()); + std::wstring prefix(MultiByteToWideChar(CP_UTF8, 0, profile.data(), bytes, nullptr, 0), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, profile.data(), bytes, prefix.data(), static_cast(prefix.size())); return SetWindowTextW(window, (L"[" + prefix + L"] " + title).c_str()) != 0; } @@ -117,6 +116,8 @@ bool InstallCursor() } } // namespace +// The display scale of the monitor holding the game window, cached until `refresh` or a move to another monitor: the +// display mode's physical width over the width Windows reports to this process, 1.5 when it scales the game to 150%. float WindowDpiScale(bool refresh) { static HMONITOR last_monitor = nullptr; @@ -130,8 +131,7 @@ float WindowDpiScale(bool refresh) MONITORINFOEX info{}; info.cbSize = sizeof(info); - DEVMODE mode{}; - mode.dmSize = sizeof(mode); + DEVMODE mode{.dmSize = sizeof(DEVMODE)}; if (GetMonitorInfo(monitor, &info) && EnumDisplaySettings(info.szDevice, ENUM_CURRENT_SETTINGS, &mode)) { const auto logical_width = info.rcMonitor.right - info.rcMonitor.left; if (logical_width > 0) From 262adda4c1eae42beca127a79198e3e7a2db8dc4 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 15:13:53 -0700 Subject: [PATCH 09/13] Rewrite the remaining features plainly Screen, dock, chat, shop, toast and fleet features move to plainer statements and shared helpers for transform children, vector scaling, the fleet bar panel and on-screen views. Index loops and repeated null checks give way to the shared collection helpers. Shared queue fixes read queues through typed bindings instead of fixed offsets, and check every member whose default would change a decision before installing. Behaviour, settings and log lines are unchanged. --- CONTRIBUTING.md | 3 +- src/mod/features/action_queue.cc | 195 +++++------ src/mod/features/action_queue.h | 53 ++- src/mod/features/alliance_store.cc | 214 +++++++------ src/mod/features/buffs.cc | 10 +- src/mod/features/cargo.cc | 22 +- src/mod/features/chat.cc | 64 ++-- src/mod/features/confirmations.cc | 11 +- src/mod/features/dock.cc | 184 ++++++----- src/mod/features/features.h | 14 +- src/mod/features/fleet_actions.cc | 241 +++++++------- src/mod/features/fleet_labels.cc | 109 +++---- src/mod/features/forbidden_tech_ship_name.cc | 267 ++++++++-------- src/mod/features/game_config.cc | 4 +- src/mod/features/hotkeys.cc | 118 ++++--- src/mod/features/instant_warp.cc | 41 +-- src/mod/features/keybind_badges.cc | 48 ++- src/mod/features/officers.cc | 65 ++-- src/mod/features/screens.cc | 216 +++++++------ src/mod/features/ship_shortcut_hint.h | 8 +- src/mod/features/shop.cc | 20 +- src/mod/features/toast_text.h | 61 ++-- src/mod/features/toasts.cc | 91 +++--- src/mod/game/inventory.h | 7 + src/mod/sync/fleet_watch.cc | 320 ------------------- src/mod/sync/fleet_watch.h | 38 --- src/mod/sync/fleets.cc | 258 ++++++++++++--- src/mod/sync/sync.h | 6 +- tests/action_queue.cc | 237 ++++++-------- 29 files changed, 1327 insertions(+), 1598 deletions(-) delete mode 100644 src/mod/sync/fleet_watch.cc delete mode 100644 src/mod/sync/fleet_watch.h diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f56bcc6d..5c5161b76 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -213,8 +213,7 @@ It uses text matching. Runtime type checks and in-game validation remain necessa | `sync/state.cc` | Comparisons, removals, and per-target acknowledgements | | `sync/upload.cc` | Sessions, the record store, target workers, and HTTP delivery | | `sync/battlelogs.cc` | Battle-journal fetching | -| `sync/fleets.cc` | Client fleet records: state changes, snapshots, and late re-reads, on the game thread | -| `sync/fleet_watch.cc` | Fleet state polling, baselines, and transitions for fleet sync | +| `sync/fleets.cc` | Client fleet records: slot polling, baselines, state changes, snapshots, and late re-reads, on the game thread | Payload construction and HTTP run outside the store lock. Session generations prevent old responses and acknowledgements from changing a new login. diff --git a/src/mod/features/action_queue.cc b/src/mod/features/action_queue.cc index 9155238d5..4f74bc813 100644 --- a/src/mod/features/action_queue.cc +++ b/src/mod/features/action_queue.cc @@ -18,15 +18,16 @@ #include #include -#include #include +#include #include +#include namespace { -using action_queue::Clock; +using Clock = action_queue::Clock; -// The only check a hook needs: each fix installs all of its hooks or none, so a hook that runs has its partners. +// The only check a hook needs: each fix installs whole or not at all, so a hook that runs has its partners. bool QueueFixesEnabled() { const auto& control = Config::Get().control; @@ -34,71 +35,63 @@ bool QueueFixesEnabled() } // Sets a thread-local for the length of one native call, so hooks the game reaches from inside it can see it. -template class Scoped +template class QueueCallScope { public: - Scoped(T& slot, T value) + QueueCallScope(T& slot, T value) : slot_(slot) , previous_(std::exchange(slot, value)) { } - ~Scoped() + ~QueueCallScope() { slot_ = previous_; } - Scoped(const Scoped&) = delete; - Scoped& operator=(const Scoped&) = delete; + QueueCallScope(const QueueCallScope&) = delete; + QueueCallScope& operator=(const QueueCallScope&) = delete; private: T& slot_; T previous_; }; -// A queue's actions and their count, or null when the queue has no list. -il2cpp::List* Actions(ActionQueueInstance* queue, int32_t& count) -{ - auto* list = queue ? queue->actionQueue() : nullptr; - count = list ? list->Count() : 0; - return list; -} - ActionQueueInstance* FindQueue(ActionQueueManager* manager, int64_t fleet_id) { - if (auto* queues = manager->battleQueue()) - for (auto* queue : *queues) - if (queue && queue->PlayerFleetId() == fleet_id) - return queue; + for (auto* queue : il2cpp::Items(manager->battleQueue())) + if (queue && queue->PlayerFleetId() == fleet_id) + return queue; return nullptr; } +// Empty, which reads as no queue, when the queue or any captured action cannot be read. action_queue::QueueSnapshot SnapshotOf(ActionQueueInstance* queue) { - action_queue::QueueSnapshot snapshot; - int32_t count = 0; - auto* actions = Actions(queue, count); + auto* actions = queue ? queue->actionQueue() : nullptr; if (!actions) return {}; - snapshot.count = count; - snapshot.player_fleet_id = queue->PlayerFleetId(); - snapshot.is_engaging = queue->IsEngaging(); - snapshot.last_engaged_target_id = queue->LastEngagedTargetId(); - snapshot.pending_target_id = queue->PendingEngageTargetId(); - snapshot.captured_target_count = std::min(count, static_cast(snapshot.target_ids.size())); - snapshot.targets_truncated = count > snapshot.captured_target_count; - for (int32_t i = 0; i < snapshot.captured_target_count; ++i) { - auto* action = (*actions)[i]; - if (!action || !(snapshot.target_ids[i] = action->FleetId())) + action_queue::QueueSnapshot snapshot{ + .player_fleet_id = queue->PlayerFleetId(), + .count = actions->Count(), + .is_engaging = queue->IsEngaging(), + .last_engaged_target_id = queue->LastEngagedTargetId(), + .pending_target_id = queue->PendingEngageTargetId(), + }; + for (auto* action : *actions | std::views::take(snapshot.target_ids.size())) { + const auto target = action ? action->FleetId() : 0; + if (!target) return {}; + snapshot.target_ids[snapshot.captured_target_count++] = target; } - snapshot.head_target_id = count ? snapshot.target_ids[0] : 0; - snapshot.present = snapshot.player_fleet_id != 0; + snapshot.targets_truncated = snapshot.count > snapshot.captured_target_count; + snapshot.head_target_id = snapshot.count ? snapshot.target_ids[0] : 0; + snapshot.present = snapshot.player_fleet_id != 0; return snapshot; } // --- Faster queue recovery --- // Shared by four hooks; locked rather than assuming they all run on the game thread. -std::mutex requests_mutex; +std::mutex recovery_mutex; // Never destroyed: freeing a GC handle in a static destructor can run after the runtime has shut down. -auto& requests = *new action_queue::Requests; +auto& recovery_requests = *new action_queue::Requests; // What the course response handler knew on entry, for the retry decision it makes inside. struct CourseContext { @@ -110,32 +103,36 @@ thread_local CourseContext* current_course = nullptr; void ClearRequests() { - std::lock_guard lock(requests_mutex); - requests.Clear(); + std::lock_guard lock(recovery_mutex); + recovery_requests.Clear(); } // `target`, when given, is checked against every queued action; otherwise only the head is read. action_queue::RetryState RetryStateOf(ActionQueueInstance* queue, int64_t target = 0) { - action_queue::RetryState state; if (!queue) - return state; - state.fleet = queue->PlayerFleetId(); - state.attempt = queue->LastEngageAttemptTime(); - state.engaging = queue->IsEngaging(); - int32_t count = 0; - auto* actions = Actions(queue, count); + return {}; + action_queue::RetryState state{ + .fleet = queue->PlayerFleetId(), + .attempt = queue->LastEngageAttemptTime(), + .engaging = queue->IsEngaging(), + }; + auto* actions = queue->actionQueue(); if (!actions) return state; - state.count = count; - for (int32_t i = 0; i < (target ? count : std::min(count, 1)); ++i) { - auto* action = (*actions)[i]; - if (!action) + state.count = actions->Count(); + if (state.count > 0) { + auto* head = (*actions)[0]; + if (!head) return state; - const auto id = action->FleetId(); - if (i == 0) - state.front = id; - state.contains_target |= target && id == target; + state.front = head->FleetId(); + } + if (target) { + for (auto* action : *actions) { + if (!action) + return state; + state.contains_target |= action->FleetId() == target; + } } state.valid = state.fleet != 0; return state; @@ -145,16 +142,12 @@ action_queue::RetryState RetryStateOf(ActionQueueInstance* queue, int64_t target bool AbsentFromAllQueues(ActionQueueManager* manager, int64_t target) { auto* queues = manager->battleQueue(); - if (!queues) - return false; - for (auto* queue : *queues) { - if (!queue) - continue; - const auto state = RetryStateOf(queue, target); - if (!state.valid || state.contains_target) - return false; - } - return true; + return queues && std::ranges::all_of(*queues, [target](ActionQueueInstance* queue) { + if (!queue) + return true; + const auto state = RetryStateOf(queue, target); + return state.valid && !state.contains_target; + }); } EngageResult ActionQueueManager_TryPlanPathAndEngageTarget_Hook(auto original, ActionQueueManager* _this, @@ -163,16 +156,16 @@ EngageResult ActionQueueManager_TryPlanPathAndEngageTarget_Hook(auto original, A uint64_t serial = 0; if (QueueFixesEnabled()) { const auto state = RetryStateOf(queue); - std::lock_guard lock(requests_mutex); - serial = requests.Remember(queue, state, Clock::now()); + std::lock_guard lock(recovery_mutex); + serial = recovery_requests.Remember(queue, state, Clock::now()); } else { ClearRequests(); } const auto result = original(_this, fleet, queue); // Only a dispatched attempt can be answered by a course response. if (serial && result != EngageResult::Success) { - std::lock_guard lock(requests_mutex); - requests.Cancel(serial); + std::lock_guard lock(recovery_mutex); + recovery_requests.Cancel(serial); } return result; } @@ -189,6 +182,7 @@ bool ActionQueueManager_ShouldRetryFailedSetCourse_Hook(auto original, ActionQue return retry; const auto& course = *current_course; const auto state = RetryStateOf(queue, target); + // Absence from every queue is checked below, last, because it reads all of them. if (course.target_id != target || !action_queue::CanAdvance(state, course.fleet_id, target, course.engaging_at_entry, course.queue == queue, true)) @@ -197,8 +191,8 @@ bool ActionQueueManager_ShouldRetryFailedSetCourse_Hook(auto original, ActionQue // still hold the target. if (!AbsentFromAllQueues(_this, target)) return retry; - std::lock_guard lock(requests_mutex); - if (!requests.Consume(queue, state, target, Clock::now())) + std::lock_guard lock(recovery_mutex); + if (!recovery_requests.Consume(queue, state, target, Clock::now())) return retry; // True takes the handler's retry branch, which runs the native planner for this fleet with all of its checks. return true; @@ -215,7 +209,7 @@ void ActionQueueManager_OnSetCourseResponseEventHandler_Hook(auto original, Acti course.queue = FindQueue(_this, response.fleet_id); course.engaging_at_entry = course.queue && course.queue->IsEngaging(); } - Scoped scope(current_course, &course); + QueueCallScope scope(current_course, &course); original(_this, response); } @@ -230,10 +224,9 @@ void ActionQueueManager_StopWatchdogAndClearAllQueues_Hook(auto original, Action ActionQueueInstance* FindQueueWithHead(ActionQueueManager* manager, int64_t target) { - if (auto* queues = manager->battleQueue()) - for (auto* queue : *queues) - if (const auto snapshot = SnapshotOf(queue); snapshot.present && snapshot.head_target_id == target) - return queue; + for (auto* queue : il2cpp::Items(manager->battleQueue())) + if (const auto snapshot = SnapshotOf(queue); snapshot.present && snapshot.head_target_id == target) + return queue; return nullptr; } @@ -294,22 +287,17 @@ void ActionQueueManager_HandleStall_Hook(auto original, ActionQueueManager* _thi void ActionQueueManager_OnFleetsDisposedEventHandler_Hook(auto original, ActionQueueManager* _this, il2cpp::List* fleets) { - // Read before the game disposes of them. A wave destroys a handful at a time; 64 is plenty. - std::array destroyed{}; - size_t count = 0; - const auto total = QueueFixesEnabled() && fleets ? fleets->Count() : 0; - for (int32_t i = 0; i < std::min(total, destroyed.size()); ++i) { - auto* fleet = (*fleets)[i]; - if (!fleet || !fleet->IsDestroyed() || fleet->RemovalReason() != FleetRemovalReason::Destroyed) - continue; - const auto id = fleet->Id(); - if (id && std::find(destroyed.begin(), destroyed.begin() + count, id) == destroyed.begin() + count) - destroyed[count++] = id; - } + if (!QueueFixesEnabled()) + return original(_this, fleets); + // Read before the game disposes of them. + std::vector destroyed; + for (auto* fleet : il2cpp::Items(fleets)) + if (fleet && fleet->IsDestroyed() && fleet->RemovalReason() == FleetRemovalReason::Destroyed) + if (const auto id = fleet->Id(); id && !std::ranges::contains(destroyed, id)) + destroyed.push_back(id); original(_this, fleets); - for (size_t i = 0; i < count; ++i) { - const auto target = destroyed[i]; - const auto queue = SnapshotOf(FindQueueWithHead(_this, target)); + for (const auto target : destroyed) { + const auto queue = SnapshotOf(FindQueueWithHead(_this, target)); if (!action_queue::ShouldProcessDestroyedHead(true, true, target, queue)) continue; _this->ProcessQueue(target, false); @@ -346,21 +334,10 @@ action_queue::Address AddressOf(NodeAddress* address) // The fleet's own slot holds its queue, and every queued action names a target. bool OwnsNonemptyQueue(ActionQueueManager* manager, FleetPlayerData* fleet, int64_t fleet_id) { - auto* queues = manager->battleQueue(); - const auto index = fleet->Index(); - if (!queues || !fleet_id || index < 0 || static_cast(index) >= queues->size()) - return false; - auto* queue = (*queues)[index]; - if (!queue || queue->PlayerFleetId() != fleet_id) - return false; - int32_t count = 0; - auto* actions = Actions(queue, count); - if (!actions || count == 0) - return false; - for (auto* action : *actions) - if (!action || !action->FleetId()) - return false; - return true; + auto* queue = il2cpp::At(manager->battleQueue(), fleet->Index()); + auto* actions = fleet_id && queue && queue->PlayerFleetId() == fleet_id ? queue->actionQueue() : nullptr; + return actions && actions->Count() > 0 + && std::ranges::all_of(*actions, [](QueueableAction* action) { return action && action->FleetId(); }); } bool IsOwnDeployment(FleetDeployedData* deployment, int64_t fleet_id) @@ -374,7 +351,7 @@ void ActionQueueManager_OnPlayerFleetStateChangedEventHandler_Hook(auto original { if (!QueueFixesEnabled()) return original(_this, fleets); - Scoped scope(in_player_state_event, true); + QueueCallScope scope(in_player_state_event, true); original(_this, fleets); } @@ -386,8 +363,8 @@ bool ActionQueueManager_DoesFleetQueueContainMismatchedAddressTargets_Hook(auto const auto fleet_id = fleet->Id(); if (!OwnsNonemptyQueue(_this, fleet, fleet_id)) return original(_this, fleet); - AddressCheck check{fleet, fleet_id}; - Scoped scope(current_address_check, &check); + AddressCheck check{fleet, fleet_id}; + QueueCallScope scope(current_address_check, &check); return original(_this, fleet); } @@ -434,8 +411,6 @@ FleetDeployedData* DeploymentService_GetDeployedFleet_Hook(auto original, Deploy } // --- Installation --- -// -// Each fix needs all of its hooks, so each installs as one HookSet: every hook or none. bool InstallActionQueueRecovery() { diff --git a/src/mod/features/action_queue.h b/src/mod/features/action_queue.h index 0851fd49a..be38c9e07 100644 --- a/src/mod/features/action_queue.h +++ b/src/mod/features/action_queue.h @@ -3,9 +3,11 @@ // The decisions behind the Kir'Shara queue fixes in action_queue.cc, kept free of game types so tests can drive // them. Each fix acts only on narrow, observed evidence and otherwise leaves the game's own handling alone. +#include #include #include #include +#include #include #include @@ -61,6 +63,7 @@ template class Requests if (record.started < slot->started) slot = &record; } + // An attempt without a readable head still clears the slot, so an older attempt cannot answer for it. *slot = {}; const auto serial = ++serial_; if (state.valid && state.count > 0 && state.front != 0) @@ -150,6 +153,9 @@ struct QueueSnapshot { bool is_engaging = false; std::int64_t last_engaged_target_id = 0; std::int64_t pending_target_id = 0; + + constexpr std::span Targets() const + { return std::span(target_ids).first(static_cast(captured_target_count)); } }; // A destroyed target the native disposal left at the head of an idle queue. @@ -161,26 +167,6 @@ constexpr bool ShouldProcessDestroyedHead(bool enabled, bool target_destroyed, s && after_native.last_engaged_target_id != target_id && after_native.pending_target_id != target_id; } -constexpr bool IsNoTargetOrRemovedPrefix(std::int64_t target_id, const QueueSnapshot& before_native, int removed_prefix) -{ - if (target_id <= 0) - return true; - for (int index = 0; index < removed_prefix; ++index) - if (before_native.target_ids[index] == target_id) - return true; - return false; -} - -constexpr bool LatchNamesSurvivingTarget(std::int64_t target_id, const QueueSnapshot& queue) -{ - if (target_id <= 0) - return false; - for (int index = 0; index < queue.count; ++index) - if (queue.target_ids[index] == target_id) - return true; - return false; -} - // The native call removed an exact prefix and left the same fleet idle with an unchanged remainder, and neither // latch names a target that is still queued. constexpr bool IsNativePruneResumeCandidate(bool enabled, bool player_fleet_idle, const QueueSnapshot& before_native, @@ -196,13 +182,12 @@ constexpr bool IsNativePruneResumeCandidate(bool enabled, bool player_fleet_idle || after_native.captured_target_count != after_native.count) return false; - const auto removed_prefix = before_native.count - after_native.count; - for (int index = 0; index < after_native.count; ++index) - if (after_native.target_ids[index] != before_native.target_ids[index + removed_prefix]) - return false; - - return IsNoTargetOrRemovedPrefix(after_native.last_engaged_target_id, before_native, removed_prefix) - && IsNoTargetOrRemovedPrefix(after_native.pending_target_id, before_native, removed_prefix); + // A latch that names nothing, or only a target the native call removed, is stale rather than live. + const auto removed = + before_native.Targets().first(static_cast(before_native.count - after_native.count)); + const auto stale = [&](std::int64_t target) { return target <= 0 || std::ranges::contains(removed, target); }; + return std::ranges::equal(after_native.Targets(), before_native.Targets().subspan(removed.size())) + && stale(after_native.last_engaged_target_id) && stale(after_native.pending_target_id); } // The queue read again just before resuming still matches what the decision was made on. @@ -215,15 +200,15 @@ constexpr bool IsStableResumePostcondition(const QueueSnapshot& expected, const || confirmed.targets_truncated || expected.captured_target_count != expected.count || confirmed.captured_target_count != confirmed.count || confirmed.is_engaging || expected.is_engaging || confirmed.last_engaged_target_id != expected.last_engaged_target_id - || confirmed.pending_target_id != expected.pending_target_id - || LatchNamesSurvivingTarget(confirmed.last_engaged_target_id, confirmed) - || LatchNamesSurvivingTarget(confirmed.pending_target_id, confirmed)) + || confirmed.pending_target_id != expected.pending_target_id) return false; - for (int index = 0; index < expected.count; ++index) - if (confirmed.target_ids[index] != expected.target_ids[index]) - return false; - return true; + // Both queues are fully captured by now, so Targets() is the whole queue. + const auto queued = [&](std::int64_t target) { + return target > 0 && std::ranges::contains(confirmed.Targets(), target); + }; + return std::ranges::equal(confirmed.Targets(), expected.Targets()) && !queued(confirmed.last_engaged_target_id) + && !queued(confirmed.pending_target_id); } // --- Queue address guard --- diff --git a/src/mod/features/alliance_store.cc b/src/mod/features/alliance_store.cc index 931056206..8bdc1b8d9 100644 --- a/src/mod/features/alliance_store.cc +++ b/src/mod/features/alliance_store.cc @@ -1,5 +1,5 @@ -// alliance_store: native material research tooltips in the Alliance Games store, and its currency refresh once its -// bundles are ready. One feature owner, no claim-trace or file probe. [patches] alliancegamesstorehooks. +// [patches] alliancegamesstorehooks: the Alliance Games store. Material offers get the native Research actions in +// their tooltips, and the store's currencies and visible offers refresh once its bundles are ready. #include "features/features.h" @@ -22,30 +22,36 @@ namespace { -using Clock = std::chrono::steady_clock; +using StoreClock = std::chrono::steady_clock; + +// --- Pending work --- // The thread that installed the hooks, the game's main thread. The hooks leave calls from any other thread alone. std::thread::id alliance_store_thread; -bool StoreThread() +bool OnStoreThread() { return std::this_thread::get_id() == alliance_store_thread; } +// The store waiting for its debounced pass. The source is weak, so a closed store is not kept alive, and `epoch` +// changes with it, so a pass that began for one store stops when another takes its place. struct AllianceStoreWork { - il2cpp::GcHandle source; - uint64_t epoch = 0; - bool pending = false, currency = false, refreshing_currency = false, layout = false; - Clock::time_point due{}; + il2cpp::GcHandle source; + uint64_t epoch = 0; + bool pending = false, currency = false, refreshing_currency = false, layout = false; + StoreClock::time_point due{}; } alliance_store_work; -enum class ResearchUpdate { Unchanged, Upgraded, Restored, Unavailable, Failed }; +// --- Tooltip swap --- + +enum class StoreTooltipUpdate { Unchanged, Upgraded, Restored, Unavailable, Failed }; -bool IsStore(il2cpp::Object* object) +bool IsAllianceStore(il2cpp::Object* object) { return object && object->Is(); } bool SameStoreItem(InventoryTooltipTriggerWidget* tooltip, TooltipItem* item) { return unity::Alive(tooltip) && tooltip->Context() == item; } // Native research eligibility of the card's resource. Empty when it cannot be determined. -std::optional Eligible(TooltipItem* item) +std::optional ResearchEligible(TooltipItem* item) { const auto id = item->ResourceId(); if (id <= 0) @@ -55,7 +61,7 @@ std::optional Eligible(TooltipItem* item) } // A loaded tooltip widget that carries native research actions, whose locale settings the store cards borrow. -InventoryTooltipTriggerWidget* Template(il2cpp::GcRoots& roots) +InventoryTooltipTriggerWidget* ResearchTemplate(il2cpp::GcRoots& roots) { auto* found = roots.Keep(unity::FindObjectsOfTypeAll()); if (!found) @@ -67,8 +73,8 @@ InventoryTooltipTriggerWidget* Template(il2cpp::GcRoots& roots) return nullptr; } -// Weak component identity survives pooled reuse without retaining a scene. Only -// the original serialized assets stay rooted while our replacement is bound. +// Weak component identity survives pooled reuse without retaining a scene. Only the original serialized assets stay +// rooted while our replacement is bound. struct OwnedStoreTooltip { il2cpp::GcHandle widget, plain, action, store; il2cpp::GcHandle search, locale, settings; @@ -77,7 +83,7 @@ struct OwnedStoreTooltip { }; std::vector> alliance_store_tooltips; -std::shared_ptr OwnedTooltip(InventoryTooltipTriggerWidget* tooltip) +std::shared_ptr FindOwnedTooltip(InventoryTooltipTriggerWidget* tooltip) { for (auto& entry : alliance_store_tooltips) if (entry->widget.Get() == tooltip) @@ -119,8 +125,8 @@ bool RestoreStoreTooltip(OwnedStoreTooltip& entry) auto* trigger = widget->tooltipTrigger(); if ((trigger != plain && trigger != action) || (widget->tooltipActionTrigger() && widget->tooltipActionTrigger() != action)) { - // A new native owner replaced our arrangement. Disable only our component; - // never overwrite the replacement owner's fields or enabled state. + // A new native owner replaced our arrangement. Disable only our component; never overwrite the replacement owner's + // fields or enabled state. (void)action->TrySetEnabled(false); ReleaseStoreAssets(entry); return true; @@ -138,12 +144,12 @@ bool RestoreStoreTooltip(OwnedStoreTooltip& entry) return ok; } -ResearchUpdate ReconcileStoreTooltip(InventoryTooltipTriggerWidget* tooltip, InventoryTooltipTriggerWidget*& donor, - il2cpp::GcRoots& pass_roots) +StoreTooltipUpdate ReconcileStoreTooltip(InventoryTooltipTriggerWidget* tooltip, InventoryTooltipTriggerWidget*& donor, + il2cpp::GcRoots& pass_roots) { il2cpp::GcRoots roots; if (!roots.Keep(tooltip)) - return ResearchUpdate::Unavailable; + return StoreTooltipUpdate::Unavailable; const auto epoch = alliance_store_work.epoch; auto* store = roots.Keep(alliance_store_work.source.Get()); auto* item = unity::Alive(tooltip) ? roots.Keep(tooltip->Context()) : nullptr; @@ -155,43 +161,44 @@ ResearchUpdate ReconcileStoreTooltip(InventoryTooltipTriggerWidget* tooltip, Inv }; auto* plain = item ? roots.Keep(tooltip->PlainTrigger()) : nullptr; if (!plain) - return ResearchUpdate::Unchanged; + return StoreTooltipUpdate::Unchanged; auto* old_action = roots.Keep(tooltip->tooltipActionTrigger()); auto* old_trigger = roots.Keep(tooltip->tooltipTrigger()); - auto owned = OwnedTooltip(tooltip); + auto owned = FindOwnedTooltip(tooltip); // Existing native action components and unrelated arrangements remain native. if (!roots.healthy() || (old_action && (!owned || owned->action.Get() != old_action)) || (old_action ? old_trigger != old_action : old_trigger != plain)) - return ResearchUpdate::Unchanged; - const auto eligible = Eligible(item); + return StoreTooltipUpdate::Unchanged; + const auto eligible = ResearchEligible(item); if (!eligible || !same_item()) - return ResearchUpdate::Unavailable; + return StoreTooltipUpdate::Unavailable; if (!*eligible) - return owned && owned->bound ? (RestoreStoreTooltip(*owned) ? ResearchUpdate::Restored : ResearchUpdate::Failed) - : ResearchUpdate::Unchanged; + return owned && owned->bound + ? (RestoreStoreTooltip(*owned) ? StoreTooltipUpdate::Restored : StoreTooltipUpdate::Failed) + : StoreTooltipUpdate::Unchanged; if (!donor) - donor = Template(pass_roots); + donor = ResearchTemplate(pass_roots); if (!donor) - return ResearchUpdate::Unavailable; + return StoreTooltipUpdate::Unavailable; auto* search = roots.Keep(donor->searchActionLocaleSetting()); auto* locale = roots.Keep(donor->actionLocaleSetting()); auto* settings = roots.Keep(donor->settings()); auto* loader = roots.Keep(plain->loader()); const auto plain_enabled = plain->TryEnabled(); if (!roots.healthy() || !search || !loader || !plain_enabled || !same_item()) - return ResearchUpdate::Unavailable; + return StoreTooltipUpdate::Unavailable; // A failed lookup must not add a second component. const auto existing = tooltip->ActionTrigger(); if (!existing) - return ResearchUpdate::Unavailable; + return StoreTooltipUpdate::Unavailable; auto* action = roots.Keep(*existing); // Reuse only the disabled component this feature previously added. if (action && (!owned || owned->action.Get() != action)) - return ResearchUpdate::Unchanged; + return StoreTooltipUpdate::Unchanged; if (!action) action = roots.Keep(tooltip->AddActionTrigger()); if (!action) - return ResearchUpdate::Unavailable; + return StoreTooltipUpdate::Unavailable; if (!owned) { auto entry = std::make_shared(); entry->widget = il2cpp::GcHandle::Weak(tooltip); @@ -199,7 +206,7 @@ ResearchUpdate ReconcileStoreTooltip(InventoryTooltipTriggerWidget* tooltip, Inv entry->action = il2cpp::GcHandle::Weak(action); if (entry->widget.Get() != tooltip || entry->plain.Get() != plain || entry->action.Get() != action) { (void)action->TrySetEnabled(false); - return ResearchUpdate::Unavailable; + return StoreTooltipUpdate::Unavailable; } alliance_store_tooltips.push_back(std::move(entry)); owned = alliance_store_tooltips.back(); @@ -214,13 +221,13 @@ ResearchUpdate ReconcileStoreTooltip(InventoryTooltipTriggerWidget* tooltip, Inv || !retain(tooltip->actionLocaleSetting(), owned->locale) || !retain(tooltip->settings(), owned->settings)) { ReleaseStoreAssets(*owned); (void)action->TrySetEnabled(false); - return ResearchUpdate::Unavailable; + return StoreTooltipUpdate::Unavailable; } owned->store = il2cpp::GcHandle::Weak(store); if (owned->store.Get() != store) { ReleaseStoreAssets(*owned); (void)action->TrySetEnabled(false); - return ResearchUpdate::Unavailable; + return StoreTooltipUpdate::Unavailable; } owned->plain_enabled = *plain_enabled; owned->bound = true; @@ -238,10 +245,10 @@ ResearchUpdate ReconcileStoreTooltip(InventoryTooltipTriggerWidget* tooltip, Inv } rollback{owned}; if (!action->TrySetEnabled(false)) - return ResearchUpdate::Failed; + return StoreTooltipUpdate::Failed; action->CopyAppearance(plain); if (!current()) - return ResearchUpdate::Failed; + return StoreTooltipUpdate::Failed; tooltip->SetSearchActionLocaleSetting(search); tooltip->SetActionLocaleSetting(locale); tooltip->SetSettings(settings); @@ -249,16 +256,18 @@ ResearchUpdate ReconcileStoreTooltip(InventoryTooltipTriggerWidget* tooltip, Inv tooltip->SetTooltipActionTrigger(action); if (!tooltip->UpdateTooltipDescription() || !current() || !tooltip->SetupActionTooltipTrigger() || !current() || !plain->TrySetEnabled(false) || !action->TrySetEnabled(true) || !current()) - return ResearchUpdate::Failed; + return StoreTooltipUpdate::Failed; const auto enabled = action->TryEnabled(); if (!current() || !enabled || !*enabled || tooltip->tooltipTrigger() != action || tooltip->tooltipActionTrigger() != action) - return ResearchUpdate::Failed; + return StoreTooltipUpdate::Failed; rollback.committed = true; - return previously_bound ? ResearchUpdate::Unchanged : ResearchUpdate::Upgraded; + return previously_bound ? StoreTooltipUpdate::Unchanged : StoreTooltipUpdate::Upgraded; } -void QueueStore(AllianceGamesStoreScrollerViewController* source, bool currency) +// --- Hooks --- + +void QueueStoreWork(AllianceGamesStoreScrollerViewController* source, bool currency) { auto& work = alliance_store_work; if (work.source.Get() != source) { @@ -268,105 +277,108 @@ void QueueStore(AllianceGamesStoreScrollerViewController* source, bool currency) if (work.source.Get() != source) return; // Debounce through separate-frame native card spawning. - work.due = Clock::now() + std::chrono::milliseconds(250); + work.due = StoreClock::now() + std::chrono::milliseconds(250); work.pending = true; work.currency |= currency && !work.refreshing_currency; work.layout |= currency && !work.refreshing_currency; } -void AllianceStoreScroller_OnDidBindCanvasContext_Hook(auto original, AllianceGamesStoreScrollerViewController* self) +void AllianceGamesStoreScrollerViewController_OnDidBindCanvasContext_Hook( + auto original, AllianceGamesStoreScrollerViewController* _this) { - if (!StoreThread()) { - original(self); + if (!OnStoreThread()) { + original(_this); return; } - original(self); + original(_this); std::erase_if(alliance_store_tooltips, [](auto& entry) { return !entry->widget.Get(); }); - if (IsStore(self)) - QueueStore(self, true); + if (IsAllianceStore(_this)) + QueueStoreWork(_this, true); } -void AllianceStoreScroller_OnAboutToReleaseCanvasContext_Hook(auto original, - AllianceGamesStoreScrollerViewController* self) +void AllianceGamesStoreScrollerViewController_OnAboutToReleaseCanvasContext_Hook( + auto original, AllianceGamesStoreScrollerViewController* _this) { - if (!StoreThread()) { - original(self); + if (!OnStoreThread()) { + original(_this); return; } auto& work = alliance_store_work; - if (work.source.Get() == self) { + if (work.source.Get() == _this) { work.source.Reset(); work.pending = work.currency = work.layout = false; ++work.epoch; } - // Native restoration can reenter bind/pruning. Keep stable entry custody and - // invalidate the source before the first such callback. + // Native restoration can reenter bind/pruning. Keep stable entry custody and invalidate the source before the first + // such callback. const auto entries = alliance_store_tooltips; for (auto& entry : entries) - if (entry->store.Get() == self) + if (entry->store.Get() == _this) (void)RestoreStoreTooltip(*entry); - original(self); + original(_this); } -void ShopListScrollerViewController_RegenerateSortedBundles_Hook(auto original, ShopListScrollerViewController* self, +void ShopListScrollerViewController_RegenerateSortedBundles_Hook(auto original, ShopListScrollerViewController* _this, bool instant) { - if (!StoreThread()) { - original(self, instant); + if (!OnStoreThread()) { + original(_this, instant); return; } - original(self, instant); - if (IsStore(self) && !alliance_store_work.refreshing_currency) - QueueStore(static_cast(self), true); + original(_this, instant); + if (IsAllianceStore(_this) && !alliance_store_work.refreshing_currency) + QueueStoreWork(static_cast(_this), true); } -void BundleDataWidget_OnAboutToReleaseContext_Hook(auto original, BundleDataWidget* self) +void BundleDataWidget_OnAboutToReleaseContext_Hook(auto original, BundleDataWidget* _this) { - if (!StoreThread()) { - original(self); + if (!OnStoreThread()) { + original(_this); return; } - if (auto entry = OwnedTooltip(self->invTooltip())) + if (auto entry = FindOwnedTooltip(_this->invTooltip())) (void)RestoreStoreTooltip(*entry); - original(self); + original(_this); } -void QueueStoreCard(BundleDataWidget* self) +void QueueStoreCard(BundleDataWidget* card) { - // Native OnDidBindContext sets this AFTER SetupTooltip. Never use the - // RefreshBundle argument or a pre-bind owner as current-card identity. - auto* owner = self->scrollerController(); - if (owner && alliance_store_work.source.Get() == owner && IsStore(owner)) - QueueStore(static_cast(owner), false); + // Native OnDidBindContext sets this AFTER SetupTooltip. Never use the RefreshBundle argument or a pre-bind owner as + // current-card identity. + auto* owner = card->scrollerController(); + if (owner && alliance_store_work.source.Get() == owner && IsAllianceStore(owner)) + QueueStoreWork(static_cast(owner), false); } -void BundleDataWidget_RefreshBundle_Hook(auto original, BundleDataWidget* self, Bundle* bundle) +void BundleDataWidget_RefreshBundle_Hook(auto original, BundleDataWidget* _this, Bundle* bundle) { - if (!StoreThread()) { - original(self, bundle); + if (!OnStoreThread()) { + original(_this, bundle); return; } - original(self, bundle); - QueueStoreCard(self); + original(_this, bundle); + QueueStoreCard(_this); } -void BundleDataWidget_OnDidBindContext_Hook(auto original, BundleDataWidget* self) +void BundleDataWidget_OnDidBindContext_Hook(auto original, BundleDataWidget* _this) { - if (!StoreThread()) { - original(self); + if (!OnStoreThread()) { + original(_this); return; } - original(self); - QueueStoreCard(self); + original(_this); + QueueStoreCard(_this); } -std::string StoreCount(const auto& value) +// --- Frame work --- + +std::string StoreCountText(const auto& value) { return value ? std::to_string(*value) : "unavailable"; } void AllianceGamesStoreFrame() { - if (!StoreThread()) + if (!OnStoreThread()) return; auto& work = alliance_store_work; - if (!work.pending || Clock::now() < work.due) + if (!work.pending || StoreClock::now() < work.due) return; const bool currency = std::exchange(work.currency, false); const bool layout = std::exchange(work.layout, false); @@ -385,7 +397,7 @@ void AllianceGamesStoreFrame() work.pending = true; work.currency |= currency; work.layout |= layout; - work.due = Clock::now() + std::chrono::milliseconds(250); + work.due = StoreClock::now() + std::chrono::milliseconds(250); return; } bool position_notified = false; @@ -399,7 +411,7 @@ void AllianceGamesStoreFrame() if (work.epoch != epoch || work.source.Get() != source || source->smartScroller() != scroller) return; auto* pool = smart ? roots.Keep(smart->spawnedItems()) : nullptr; - const auto count = pool ? pool->TryCall<"get_Count", int32_t()>() : std::nullopt; + const auto count = pool ? smart->SpawnedCount() : std::nullopt; if (!count || *count < 0) return; int upgraded = 0, restored = 0, unavailable = 0, failed = 0; @@ -412,17 +424,17 @@ void AllianceGamesStoreFrame() if (!card || !unity::Alive(card)) continue; auto* tooltip = card_roots.Keep(SmartScrollerBase::CardTooltip(card)); - switch (tooltip ? ReconcileStoreTooltip(tooltip, donor, roots) : ResearchUpdate::Unchanged) { - case ResearchUpdate::Upgraded: + switch (tooltip ? ReconcileStoreTooltip(tooltip, donor, roots) : StoreTooltipUpdate::Unchanged) { + case StoreTooltipUpdate::Upgraded: ++upgraded; break; - case ResearchUpdate::Restored: + case StoreTooltipUpdate::Restored: ++restored; break; - case ResearchUpdate::Unavailable: + case StoreTooltipUpdate::Unavailable: ++unavailable; break; - case ResearchUpdate::Failed: + case StoreTooltipUpdate::Failed: ++failed; break; default: @@ -431,8 +443,8 @@ void AllianceGamesStoreFrame() } bool refreshed = false; std::optional currency_before, currency_after; - // Native bundle-derived discovery, not a fixed list of currency IDs. An - // empty/loading store waits for its real data/regeneration event. + // Native bundle-derived discovery, not a fixed list of currency IDs. An empty/loading store waits for its real + // data/regeneration event. const auto fetching = context->IsFetchingBundlesInProgress(); if (currency && work.epoch == epoch && work.source.Get() == source && fetching && !*fetching && context->BundlesCount() > 0) { @@ -455,8 +467,8 @@ void AllianceGamesStoreFrame() const auto position = smart ? smart->ScrollPosition() : std::nullopt; spdlog::info("[AllianceGamesStore] pool={} upgraded={} restored={} unavailable={} failed={} currency_refresh={} " "currencies={}->{} visible={} position={}", - *count, upgraded, restored, unavailable, failed, refreshed, StoreCount(currency_before), - StoreCount(currency_after), StoreCount(visible), StoreCount(position)); + *count, upgraded, restored, unavailable, failed, refreshed, StoreCountText(currency_before), + StoreCountText(currency_after), StoreCountText(visible), StoreCountText(position)); } bool InstallAllianceGamesStore() @@ -490,8 +502,8 @@ bool InstallAllianceGamesStore() // One transaction: every hook or none. The frame work runs from the frame tick; without the tick the hooks only // record pending work. il2cpp::HookSet hooks("AllianceGamesStore"); - IL2CPP_STAGE(hooks, targets[0], AllianceStoreScroller_OnDidBindCanvasContext_Hook); - IL2CPP_STAGE(hooks, targets[1], AllianceStoreScroller_OnAboutToReleaseCanvasContext_Hook); + IL2CPP_STAGE(hooks, targets[0], AllianceGamesStoreScrollerViewController_OnDidBindCanvasContext_Hook); + IL2CPP_STAGE(hooks, targets[1], AllianceGamesStoreScrollerViewController_OnAboutToReleaseCanvasContext_Hook); IL2CPP_STAGE(hooks, targets[2], ShopListScrollerViewController_RegenerateSortedBundles_Hook); IL2CPP_STAGE(hooks, targets[3], BundleDataWidget_RefreshBundle_Hook); IL2CPP_STAGE(hooks, targets[4], BundleDataWidget_OnAboutToReleaseContext_Hook); diff --git a/src/mod/features/buffs.cc b/src/mod/features/buffs.cc index 8b73c7aa7..0cdf28aa5 100644 --- a/src/mod/features/buffs.cc +++ b/src/mod/features/buffs.cc @@ -9,6 +9,8 @@ #include #include +#include + namespace { // Leaves out the buffs that only apply while docked at the station. @@ -25,13 +27,7 @@ bool BuffService_IsBuffConditionMet_Hook(auto original, il2cpp::Object* _this, B // with a null buff crashes the game, so it is answered with no buffs instead. il2cpp::List* BuffService_ExtractBuffsOfType_Hook(auto original, ClientModifierType modifier, il2cpp::List* list) -{ - if (list) - for (auto* buff : *list) - if (!buff) - return nullptr; - return original(modifier, list); -} +{ return std::ranges::contains(il2cpp::Items(list), nullptr) ? nullptr : original(modifier, list); } bool InstallOutOfDockPower() { diff --git a/src/mod/features/cargo.cc b/src/mod/features/cargo.cc index e7ed1b88d..f5965a4c5 100644 --- a/src/mod/features/cargo.cc +++ b/src/mod/features/cargo.cc @@ -13,22 +13,22 @@ #include #include -#include namespace { // The localization identifier of the "X of Y" cargo label. constexpr std::string_view kCargoIdentifier = "shared_x_of_y_x_coloured"; -int SignificantDecimals() -{ return std::clamp(Config::Get().ui.cargo_significant_decimals, 0, 6); } +// --- Cargo format --- -// m_significantDecimals is read by Localize(), which runs after SetLocalTextParameters, so set it here for good. +// Localize() reads m_significantDecimals after SetLocalTextParameters, so setting it first covers the label's +// next render. void ColourTextLocalizer_SetLocalTextParameters_Hook(auto original, ColourTextLocalizer* _this, bool parse_id, void* args) { - if (Config::Get().ui.format_cargo_values && _this->IdentifierIs(kCargoIdentifier)) - _this->SetSignificantDecimals(SignificantDecimals()); + const auto& ui = Config::Get().ui; + if (ui.format_cargo_values && _this->IdentifierIs(kCargoIdentifier)) + _this->SetSignificantDecimals(std::clamp(ui.cargo_significant_decimals, 0, 6)); original(_this, parse_id, args); } @@ -45,16 +45,20 @@ bool IsCargoText(LegacyComparableProgressBar* bar) if (context && context->klass == CargoProgressData::Raw()) return true; static bool reported = false; - if (!std::exchange(reported, true)) + if (!reported) { + reported = true; spdlog::warn("[InstantCargo] the cargo label shows a {}, not cargo; keeping the game's count-up", context ? il2cpp::ClassName(context->klass) : "null"); + } return false; } void LegacyComparableProgressBar_SetWidgetData_Hook(auto original, LegacyComparableProgressBar* _this) { - if (!Config::Get().ui.instant_cargo_counter || !IsCargoText(_this)) - return original(_this); + if (!Config::Get().ui.instant_cargo_counter || !IsCargoText(_this)) { + original(_this); + return; + } // Render the current total through the game's own formatting with the prefab's snap flag set, then restore // the flag so reward updates and interpolation carry on normally. const bool previous = _this->snapToValue(); diff --git a/src/mod/features/chat.cc b/src/mod/features/chat.cc index 91ef5a134..a4fca6288 100644 --- a/src/mod/features/chat.cc +++ b/src/mod/features/chat.cc @@ -13,11 +13,13 @@ #include "game/ui.h" #include +#include #include #include #include #include +#include namespace { @@ -85,27 +87,21 @@ void ChatFrame() // --- Disabled channels --- -// Tab positions in both the full-screen chat and the preview. Cadet and veil chat only exist for some players, -// and shift the tabs after them; a missing tab is -1. +// Tab positions in both the full-screen chat and the preview. Cadet chat, first, and veil chat, after galaxy, +// only exist for some players and shift the tabs after them; a missing veil tab is -1. struct ChatTabs { - int32_t cadet = -1; int32_t galaxy = 0; int32_t veil = -1; int32_t alliance = 1; static ChatTabs Current() { - ChatTabs tabs; - auto* chat = ChatManager::Instance(); + auto* chat = ChatManager::Instance(); if (!chat) - return tabs; - const bool cadet = chat->CanSeeNewbieChat(); - const bool veil = chat->CanSeeRegionalChat(); - tabs.cadet = cadet ? 0 : -1; - tabs.galaxy = cadet ? 1 : 0; - tabs.veil = veil ? tabs.galaxy + 1 : -1; - tabs.alliance = tabs.galaxy + 1 + (veil ? 1 : 0); - return tabs; + return {}; + const int32_t galaxy = chat->CanSeeNewbieChat() ? 1 : 0; + const bool veil = chat->CanSeeRegionalChat(); + return {.galaxy = galaxy, .veil = veil ? galaxy + 1 : -1, .alliance = galaxy + (veil ? 2 : 1)}; } // Whether `index` is a tab the settings disable. @@ -116,25 +112,20 @@ struct ChatTabs { } }; -bool AnyDisabled() -{ return Config::Get().ui.disable_galaxy_chat || Config::Get().ui.disable_veil_chat; } - void FullScreenChatViewController_AboutToShow_Hook(auto original, FullScreenChatViewController* _this) { original(_this); - if (!AnyDisabled()) + const auto& ui = Config::Get().ui; + if (!ui.disable_galaxy_chat && !ui.disable_veil_chat) return; - auto* tab_bar_view = _this->categoriesTabBarViewController(); - auto* tab_bar = tab_bar_view ? tab_bar_view->tabBar() : nullptr; - auto* buttons = tab_bar ? tab_bar->data() : nullptr; - if (!buttons) - return; - const auto tabs = ChatTabs::Current(); - for (int32_t index : {tabs.galaxy, tabs.veil}) - if (index >= 0 && index < buttons->Count() && tabs.Disabled(index)) - if (auto* button = (*buttons)[index]) - static_cast(button)->SetInteractable(false); + auto* tab_bar_view = _this->categoriesTabBarViewController(); + auto* tab_bar = tab_bar_view ? tab_bar_view->tabBar() : nullptr; + auto* buttons = tab_bar ? tab_bar->data() : nullptr; + const auto tabs = ChatTabs::Current(); + for (const auto index : {tabs.galaxy, tabs.veil}) + if (auto* button = tabs.Disabled(index) ? il2cpp::At(buttons, index) : nullptr) + static_cast(button)->SetInteractable(false); } // Selecting a disabled tab (with the keyboard, say) does nothing. @@ -145,6 +136,7 @@ void FullScreenChatViewController_OnDidChangeSelectedTab_Hook(auto original, Ful original(_this, index, tab); } +// Lines the preview's scroller up with the alliance panel. void FocusAlliance(ChatPreviewController* preview, int32_t alliance) { if (auto* scroller = preview->swipeScroller(); scroller && scroller->currentContentIndex() != alliance) @@ -237,15 +229,14 @@ bool InstallChat() return hooks.Commit(); } +// The feature is the update hook. The bind paths only catch titles bound without an update, so each goes in as +// a set of its own, and the feature does not depend on them. bool InstallChatTitles() { const auto update = UserProfileWidget::Class::Method("UpdatePlayerTitle", 0); il2cpp::HookSet hooks("ChatTitles"); IL2CPP_STAGE(hooks, update, UserProfileWidget_UpdatePlayerTitle_Hook); - // The feature is the update hook; the bind paths below only add to it. const bool installed = hooks.Commit(); - // The bind paths catch titles bound without an update, so each is a set of its own and adds to the update hook - // where SPUD can detour it. const auto set_data = PlayerTitleWidget::Class::Method("SetWidgetData", 0); const auto bind = PlayerTitleWidget::Class::Method("OnDidBindContext", 0); if (!set_data || !bind) @@ -281,10 +272,9 @@ void OpenChatShortcut() // With side chat already open, the shortcut focuses its input instead. if (chat->IsSideChatOpen()) { - auto* view = unity::Newest(); - auto* list = view ? view->messageList() : nullptr; - auto* input = list ? list->inputField() : nullptr; - if (input) + auto* view = unity::Newest(); + auto* list = view ? view->messageList() : nullptr; + if (auto* input = list ? list->inputField() : nullptr) input->ActivateInputField(); } else { chat->OpenChannel(ChatChannelCategory::Alliance, side ? ChatViewMode::Side : ChatViewMode::Fullscreen); @@ -296,9 +286,9 @@ bool ChatChannelShortcuts() auto* chat = ChatManager::Instance(); if (!chat) return false; - for (auto [shortcut, channel] : {std::pair{Action::kSelectChatGlobal, ChatChannelCategory::Global}, - std::pair{Action::kSelectChatAlliance, ChatChannelCategory::Alliance}, - std::pair{Action::kSelectChatPrivate, ChatChannelCategory::Private}}) { + for (const auto [shortcut, channel] : {std::pair{Action::kSelectChatGlobal, ChatChannelCategory::Global}, + std::pair{Action::kSelectChatAlliance, ChatChannelCategory::Alliance}, + std::pair{Action::kSelectChatPrivate, ChatChannelCategory::Private}}) { if (Shortcut::IsDown(shortcut)) { chat->OpenChannel(channel); return true; diff --git a/src/mod/features/confirmations.cc b/src/mod/features/confirmations.cc index e9aa57c8c..e72737ec0 100644 --- a/src/mod/features/confirmations.cc +++ b/src/mod/features/confirmations.cc @@ -19,7 +19,7 @@ namespace { // Runs a popup's callback the way its button would. False when the callback threw: the caller then shows the // popup, so the action is never lost silently. -template bool Answer(const char* feature, Il2CppDelegate* callback, A... args) +template bool AnswerConfirmation(const char* feature, Il2CppDelegate* callback, A... args) { try { il2cpp::InvokeBound(callback, args...); @@ -33,7 +33,8 @@ template bool Answer(const char* feature, Il2CppDelegate* callba // --- Message box confirmations --- // The closure `owner`.RequestAction passes as its confirmation callback, e.g. -// "Digit.Prime.ForbiddenTech.ForbiddenTechManager/<>c__DisplayClass12_0.b__0". +// "Digit.Prime.ForbiddenTech.ForbiddenTechManager/<>c__DisplayClass12_0.b__0". `owner` must be a whole +// class name, so another class whose name ends in it does not match. bool IsRequestActionCallback(const Il2CppDelegate* callback, std::string_view owner) { if (!callback || !callback->target || !callback->method || callback->method->parameters_count != 1) @@ -46,14 +47,14 @@ bool IsRequestActionCallback(const Il2CppDelegate* callback, std::string_view ow bool ConfirmForbiddenTechUpgrade(Il2CppDelegate* callback) { return Config::Get().ui.auto_confirm_ft_upgrade && IsRequestActionCallback(callback, "ForbiddenTechManager") - && Answer("ForbiddenTechConfirm", callback, MessageBoxResult::Confirm); + && AnswerConfirmation("ForbiddenTechConfirm", callback, MessageBoxResult::Confirm); } bool ConfirmAllianceContribution(Il2CppDelegate* callback) { return Config::Get().ui.auto_confirm_alliance_contribution && IsRequestActionCallback(callback, "AllianceContributionViewController") - && Answer("ForbiddenTechConfirm", callback, MessageBoxResult::Yes); + && AnswerConfirmation("ForbiddenTechConfirm", callback, MessageBoxResult::Yes); } void GameMessageBox_Show_Hook(auto original, MessageBoxContext* context) @@ -84,7 +85,7 @@ void MessageUtility_ShowFreeAbilityConfirmationPopup_Hook(auto original, int64_t Il2CppDelegate* action) { if (Config::Get().ui.auto_confirm_reveal_ability && action && action->method && IsExcelsiorDecloak(ability_id) - && Answer("RevealConfirm", action)) + && AnswerConfirmation("RevealConfirm", action)) return; original(ability_id, cooldown, action); } diff --git a/src/mod/features/dock.cc b/src/mod/features/dock.cc index cb79e43ed..bfe9df017 100644 --- a/src/mod/features/dock.cc +++ b/src/mod/features/dock.cc @@ -27,9 +27,12 @@ #include #include #include +#include #include #include #include +#include +#include #include namespace @@ -39,7 +42,7 @@ namespace ShipManagementViewController* VisibleShipManagement() { for (auto* controller : unity::Instances()) - if (controller->isActiveAndEnabled() && CanvasController::Shows(controller)) + if (CanvasController::OnScreen(controller)) return controller; return nullptr; } @@ -56,14 +59,13 @@ std::set warned_patterns; void PinShips(il2cpp::List* list) { const auto& pinned = Config::Get().ui.pinned_ships; - const auto count = list ? list->Count() : 0; - if (pinned.empty() || count <= 1) + if (pinned.empty() || !list || list->Count() <= 1) return; struct Ship { - FleetPlayerData* data; + FleetPlayerData* data = nullptr; std::vector words; - int64_t level; + int64_t level = 0; bool pinned = false; int8_t tech_applied = -1; @@ -74,30 +76,32 @@ void PinShips(il2cpp::List* list) tech_applied = data && data->HasAppliedTech() ? 1 : 0; return tech_applied == 1; } + + // Higher level first, then, within one hull and level, the ship with tech applied. + bool BetterThan(Ship& other) + { + if (level != other.level) + return level > other.level; + return data && other.data && data->HullId() == other.data->HullId() && TechApplied() && !other.TechApplied(); + } }; std::vector ships; - ships.reserve(count); for (auto* ship : *list) - ships.push_back( - {ship, ship ? ShipNameWords(ship->DisplayName()) : std::vector(), ship ? ship->Level() : 0}); + ships.push_back(ship ? Ship{.data = ship, .words = ShipNameWords(ship->DisplayName()), .level = ship->Level()} + : Ship{}); - // Each entry pins one ship: the highest-level idle ship it matches that an earlier entry has not taken. - bool any = false; + // Each entry pins one ship: the best idle ship it matches that an earlier entry has not taken. for (const auto& pattern : pinned) { Ship* best = nullptr; for (auto& ship : ships) - if (!ship.pinned && ShipNameMatches(ship.words, pattern) - && (!best || ship.level > best->level - || (ship.level == best->level && ship.data && best->data && ship.data->HullId() == best->data->HullId() - && ship.TechApplied() && !best->TechApplied()))) + if (!ship.pinned && ShipNameMatches(ship.words, pattern) && (!best || ship.BetterThan(*best))) best = &ship; - if (best) { - best->pinned = any = true; - } else if (const auto name = ShipNameText(pattern); warned_patterns.insert(name).second) { + if (best) + best->pinned = true; + else if (const auto name = ShipNameText(pattern); warned_patterns.insert(name).second) spdlog::warn("[PinnedShips] '{}' matches no idle ship; entries match the end of the game's ship name", name); - } } - if (!any) + if (std::ranges::none_of(ships, [](const Ship& ship) { return ship.pinned; })) return; // Read from the original snapshot in two passes to preserve both groups' order. Only changed slots cross the @@ -120,6 +124,9 @@ void ShipManagementViewContext_Sort_Hook(auto original, ShipManagementViewContex // --- Shift+click pinning --- +// The tile click hook is shared with double-click assignment, so it runs even when this feature did not install. +bool pin_click_installed = false; + // The settings file with only the pinned_ships line of [ui] replaced, or added, so the rest keeps its comments and // layout. A layout this line edit cannot handle, such as an inline [ui] table or a multi-line value, is caught by // SavePinnedShips checking the result. @@ -129,22 +136,24 @@ std::string WithPinnedShipsLine(const std::string& settings, const std::string& std::istringstream in(settings); for (std::string text; std::getline(in, text);) lines.push_back(text); + const auto trimmed = [](std::string_view text) { + text.remove_prefix(std::min(text.find_first_not_of(" \t"), text.size())); + return text; + }; + constexpr std::string_view kKey = "pinned_ships"; // Where [ui] ends, and its pinned_ships line, if any. size_t ui_end = lines.size(), pinned = lines.size(); bool has_ui = false, in_ui = false; for (size_t i = 0; i < lines.size(); ++i) { - auto text = std::string_view(lines[i]); - text.remove_prefix(std::min(text.find_first_not_of(" \t"), text.size())); + const auto text = trimmed(lines[i]); if (text.starts_with('[')) { if (in_ui) { ui_end = i; break; } in_ui = has_ui = text.starts_with("[ui]"); - } else if (in_ui && text.starts_with("pinned_ships")) { - const auto rest = text.substr(std::string_view("pinned_ships").size()); - if (const auto at = rest.find_first_not_of(" \t"); at != std::string_view::npos && rest[at] == '=') - pinned = i; + } else if (in_ui && text.starts_with(kKey) && trimmed(text.substr(kKey.size())).starts_with('=')) { + pinned = i; } } if (pinned < lines.size()) @@ -168,17 +177,11 @@ void SavePinnedShips(const std::vector>& names) std::string joined; for (const auto& words : names) joined += (joined.empty() ? "" : ", ") + ShipNameText(words); - std::ostringstream value; - value << toml::value(joined); - - const auto path = File::Path(File::Config(), true); - std::string settings; - { - std::ifstream in(path, std::ios::binary); + const auto text_of = [](const auto& value) { std::ostringstream text; - text << in.rdbuf(); - settings = text.str(); - } + text << value; + return text.str(); + }; const auto saves = [&](const std::string& text) { try { return toml::parse(text)["ui"]["pinned_ships"].value() == joined; @@ -186,16 +189,20 @@ void SavePinnedShips(const std::vector>& names) return false; } }; - auto updated = WithPinnedShipsLine(settings, "pinned_ships = " + value.str()); + + const auto path = File::Path(File::Config(), true); + const auto settings = [&] { + std::ifstream in(path, std::ios::binary); + return std::string(std::istreambuf_iterator(in), {}); + }(); + auto updated = WithPinnedShipsLine(settings, "pinned_ships = " + text_of(toml::value(joined))); if (!saves(updated)) { try { auto table = toml::parse(settings); if (!table["ui"].is_table()) table.insert_or_assign("ui", toml::table{}); table["ui"].as_table()->insert_or_assign("pinned_ships", joined); - std::ostringstream text; - text << table; - updated = text.str(); + updated = text_of(table); } catch (const toml::parse_error& error) { spdlog::warn("[PinnedShipClick] not saving pinned ships: {} does not parse: {}", File::Config(), error.description()); @@ -223,8 +230,6 @@ void RefreshDock(ShipManagementViewController* controller) list->Rebuild(controller); } -bool pin_click_installed = false; - void TogglePinOnShiftClick(FleetPlayerData* ship) { const bool shift = unity::Key::Pressed(unity::KeyCode::LeftShift) || unity::Key::Pressed(unity::KeyCode::RightShift); @@ -238,11 +243,11 @@ void TogglePinOnShiftClick(FleetPlayerData* ship) auto& names = Config::Get().ui.pinned_ships; if (const auto found = std::ranges::find(names, key); found != names.end()) { - names.erase(found); spdlog::info("[PinnedShipClick] unpinned '{}'", ShipNameText(key)); + names.erase(found); } else { + spdlog::info("[PinnedShipClick] pinned '{}'", ShipNameText(key)); names.push_back(std::move(key)); - spdlog::info("[PinnedShipClick] pinned '{}'", ShipNameText(names.back())); } SavePinnedShips(names); RefreshDock(controller); @@ -252,6 +257,7 @@ void TogglePinOnShiftClick(FleetPlayerData* ship) constexpr auto kDoubleClickWindow = std::chrono::milliseconds(400); +// See pin_click_installed. bool double_click_installed = false; // Compared only, never dereferenced. FleetPlayerData* last_clicked_ship = nullptr; @@ -260,11 +266,12 @@ std::chrono::steady_clock::time_point last_click_time{}; void PressAssignButton() { for (auto* widget : unity::Instances()) { - if (!widget->isActiveAndEnabled() || !CanvasController::Shows(widget)) + if (!CanvasController::OnScreen(widget)) continue; auto* wrapper = widget->assignButton(); - auto* button = wrapper && wrapper->Widget() ? wrapper->Widget()->UnityButton() : nullptr; - if (button) + auto* inner = wrapper ? wrapper->Widget() : nullptr; + // Only the first shown dock is pressed, even when its button cannot be read. + if (auto* button = inner ? inner->UnityButton() : nullptr) button->Press(); return; } @@ -287,16 +294,15 @@ void AssignOnDoubleClick(FleetPlayerData* ship) void AssignOnEnterFrame() { - if (!Config::Get().ui.double_click_to_assign_ship) - return; // Down, not Pressed: a held Enter must press Assign once, not every frame. - if (!unity::Key::Down(unity::KeyCode::Return) && !unity::Key::Down(unity::KeyCode::KeypadEnter)) - return; - if (unity::Key::IsInputFocused()) - return; - PressAssignButton(); + if (Config::Get().ui.double_click_to_assign_ship + && (unity::Key::Down(unity::KeyCode::Return) || unity::Key::Down(unity::KeyCode::KeypadEnter)) + && !unity::Key::IsInputFocused()) + PressAssignButton(); } +// --- Ship tile click --- + // Shift+click and double click share the tile's click handler: one detour, two features. void ShipTileWidget_HandleOnClick_Hook(auto original, ShipTileWidget* _this) { @@ -309,64 +315,56 @@ void ShipTileWidget_HandleOnClick_Hook(auto original, ShipTileWidget* _this) } } -// Shared by Shift+click pinning and double-click assignment, whichever of them install, so it is its own set, -// installed on the first call. -bool InstallShipTileClick() -{ - static const bool installed = [] { - il2cpp::HookSet hooks("ShipTileClick"); - IL2CPP_STAGE(hooks, ShipTileWidget::Class::Method("HandleOnClick", 0), ShipTileWidget_HandleOnClick_Hook); - return hooks.Commit(); - }(); - return installed; -} - // --- Dock selection --- -void DockSelectionFrame() +// The fleet open in ship management, when it holds a ship and has a dock slot. +FleetPlayerData* ManagedDockFleet() { - // Ten times a second; selecting a slot is not urgent. - static Throttle throttle(std::chrono::milliseconds(100)); - static int64_t last_fleet_id = 0; - if (!throttle.Due()) - return; auto* sections = Hub::Sections(); if (!Config::Get().ui.dock_syncmanagement || !sections - || sections->CurrentSection() != SectionID::ShipManagement_Details) { - last_fleet_id = 0; - return; - } + || sections->CurrentSection() != SectionID::ShipManagement_Details) + return nullptr; auto* controller = VisibleShipManagement(); auto* context = controller ? controller->Context() : nullptr; auto* fleet = context ? context->CurrentFleet() : nullptr; auto* manager = FleetsManager::Instance(); - if (!fleet || !fleet->HasShip() || !manager || !manager->HasFleetService()) { - last_fleet_id = 0; - return; - } + if (!fleet || !fleet->HasShip() || !manager || !manager->HasFleetService()) + return nullptr; // Unassigned and synthetic fleets have no dock slot; the management list order is not the dock order. const auto index = fleet->Index(); auto* dock_fleet = index >= 0 ? manager->GetFleetPlayerData(index) : nullptr; - if (!dock_fleet || dock_fleet->Id() != fleet->Id()) { + return dock_fleet && dock_fleet->Id() == fleet->Id() ? fleet : nullptr; +} + +void DockSelectionFrame() +{ + // Ten times a second; selecting a slot is not urgent. + static Throttle throttle(std::chrono::milliseconds(100)); + static int64_t last_fleet_id = 0; + if (!throttle.Due()) + return; + auto* fleet = ManagedDockFleet(); + if (!fleet) { last_fleet_id = 0; return; } - if (last_fleet_id == fleet->Id()) + const auto id = fleet->Id(); + auto* bar = unity::Newest(); + if (id == last_fleet_id || !bar) return; - auto* bar = unity::Newest(); - if (!bar) - return; - if (!bar->IsIndexSelected(index)) { + if (const auto index = fleet->Index(); !bar->IsIndexSelected(index)) { // The bar's own selection flow updates both the highlight and the fleet panel. bar->RequestSelect(index); // Blocked by the game: try again next time. if (!bar->IsIndexSelected(index)) return; - spdlog::debug("[DockSelection] selected dock slot {} for fleet {}", index + 1, fleet->Id()); + spdlog::debug("[DockSelection] selected dock slot {} for fleet {}", index + 1, id); } - last_fleet_id = fleet->Id(); + last_fleet_id = id; } +// --- Installation --- + bool InstallPinnedShips() { il2cpp::HookSet hooks("PinnedShips"); @@ -374,6 +372,18 @@ bool InstallPinnedShips() return hooks.Commit(); } +// Shared by Shift+click pinning and double-click assignment, whichever of them install, so it is its own set, +// installed on the first call. +bool InstallShipTileClick() +{ + static const bool tile_click_installed = [] { + il2cpp::HookSet hooks("ShipTileClick"); + IL2CPP_STAGE(hooks, ShipTileWidget::Class::Method("HandleOnClick", 0), ShipTileWidget_HandleOnClick_Hook); + return hooks.Commit(); + }(); + return tile_click_installed; +} + bool InstallPinnedShipClick() { pin_click_installed = InstallShipTileClick(); diff --git a/src/mod/features/features.h b/src/mod/features/features.h index 76324970c..d6cb9daf0 100644 --- a/src/mod/features/features.h +++ b/src/mod/features/features.h @@ -34,7 +34,7 @@ class Throttle : interval_(interval) { } - // True at most once per interval. + // True on the first call, then at most once per interval. bool Due() { const auto now = std::chrono::steady_clock::now(); @@ -113,9 +113,10 @@ bool FleetShortcuts(); // The actions on the selected fleet and the open viewer. True when the key must not reach the game. bool FleetActionShortcuts(); -// fleet_labels.cc, fed by camera.cc's zoom hooks. +// fleet_labels.cc extern const Feature kFleetLabels; - +// Fed by camera.cc's zoom hooks: the camera's depth on each update and system entry, then its zoom after the +// game's own update. void FleetLabelsFollowDepth(NavigationZoom* zoom, NodeDepth depth); void FleetLabelsAfterZoom(NavigationZoom* zoom); @@ -137,7 +138,7 @@ bool HandleShortcuts(); // instant_warp.cc extern const Feature kInstantWarp; - +// Steps auto_confirm_instant_warp through none, warp and jump, until the next settings reload. void CycleInstantWarpConfirmation(); // keybind_badges.cc, installed with the hotkeys. @@ -158,7 +159,7 @@ extern const Feature kDailyClaimFilter; // officers.cc extern const Feature kOfficerSort; -// screens.cc +// screens.cc: shortcut helpers only, so no descriptor. // Presses a button a screen shortcut opened a screen for, once the screen is up. True when one was pressed. bool PressAwaitedButton(); // The shortcuts that open, step through, or search game screens. True when the key must not reach the game. @@ -178,7 +179,8 @@ extern const Feature kObjectTracker; // ui_scale.cc extern const Feature kUiScale; - +// Steps the interface, ship or viewer scale by ui_scale_step (the viewer by a quarter of it), until the next +// settings reload. void AdjustUiScale(bool up); void AdjustUiShipScale(bool up); void AdjustUiViewerScale(bool up); diff --git a/src/mod/features/fleet_actions.cc b/src/mod/features/fleet_actions.cc index 080f674a8..cb08f3dc6 100644 --- a/src/mod/features/fleet_actions.cc +++ b/src/mod/features/fleet_actions.cc @@ -19,18 +19,18 @@ #include #include +#include #include #include +#include #include #include namespace { -// Frames left in which to show the cargo of a pre-scanned target. The rewards widget can appear a few frames -// after the pre-scan itself, so a request keeps trying for a while. -int show_cargo_frames = 0; +// --- Fleet actions --- -struct Pressed { +struct FleetPress { bool primary = false; bool secondary = false; bool queue = false; @@ -38,7 +38,7 @@ struct Pressed { bool recall = false; bool recall_cancel = false; bool repair = false; - // A replay of last frame's press, see replay. + // A replay of last frame's press, see pending_replay. bool retry = false; bool Any() const @@ -47,14 +47,14 @@ struct Pressed { // The target's hull type is only known once the target's data has arrived; until then it reads as Any. A press // that lands in that window is replayed, as itself, on the next frame only. -std::optional replay; +std::optional pending_replay; -bool Shown(VisibilityController* controller) +bool ControllerShown(VisibilityController* controller) { return controller && controller->IsShown(); } -template bool TargetShown(T* widget, const Pressed& pressed) +template bool TargetShown(T* widget, const FleetPress& pressed) { - if (!widget || !Shown(widget->visibilityController())) + if (!widget || !ControllerShown(widget->visibilityController())) return false; if (!pressed.primary || !Config::Get().control.primary_requires_visible_target) return true; @@ -65,26 +65,24 @@ template bool TargetShown(T* widget, const Pressed& pressed) // The cards that show a target or a place in space. template struct ViewerList { static bool AnyShown() - { - return (std::ranges::any_of(unity::Instances(), - [](Viewers* widget) { return Shown(widget->visibilityController()); }) - || ...); - } + { return (std::ranges::any_of(unity::Instances(), Shown) || ...); } // Hides the shown viewers of the first type that has any. static bool HideFirst() { return (Hide() || ...); } private: + template static bool Shown(T* widget) + { return ControllerShown(widget->visibilityController()); } + template static bool Hide() { bool hid = false; - for (auto* widget : unity::Instances()) { - if (Shown(widget->visibilityController())) { + for (auto* widget : unity::Instances()) + if (Shown(widget)) { widget->HideAllViewers(); hid = true; } - } return hid; } }; @@ -100,68 +98,59 @@ HullType TargetHullType(BattleTargetData* context) return hull ? hull->Type() : HullType::Any; } -// The armada viewer's state. A viewer without a visibility controller counts as visible. +// A viewer without a visibility controller counts as visible. bool ArmadaViewerShown(ArmadaObjectViewerWidget* armada) { if (!armada) return false; - auto* controller = armada->visibilityController(); - if (!controller) { - static bool reported = false; - if (!std::exchange(reported, true)) - spdlog::warn("[Hotkeys] the armada viewer has no visibility controller; treating it as shown"); - return true; + if (auto* controller = armada->visibilityController()) { + const auto state = controller->State(); + return state == VisibilityState::Visible || state == VisibilityState::Show; } - const auto state = controller->State(); - return state == VisibilityState::Visible || state == VisibilityState::Show; + static bool reported = false; + if (!std::exchange(reported, true)) + spdlog::warn("[Hotkeys] the armada viewer has no visibility controller; treating it as shown"); + return true; } // The primary action joins a wave defense first: through the set-course widget's join button when it listens, else -// the primary button of a shown wave defense viewer. Both press the game's own button, so its checks still run. +// through the primary button of a shown wave defense viewer. Both press the game's own button, so its checks run. bool JoinWaveDefense() { if (auto* navigation = unity::Newest(); navigation && navigation->TryPressJoinWaveDefense()) return true; - // Query active viewers without adding constructor detours for this popup or its subclasses. - if (auto* viewers = unity::FindObjectsOfType()) { - for (auto* widget : *viewers) { - auto* parent = widget && Shown(widget->visibilityController()) ? widget->Parent() : nullptr; - if (parent && parent->IsShowing()) { - // Use the displayed native action, including its eligibility and confirmation checks. - widget->PressPrimaryButton(); - return true; - } + // A scene query rather than a tracked type, which would add constructor detours for this popup and its subclasses. + for (auto* viewer : il2cpp::Items(unity::FindObjectsOfType())) { + auto* parent = viewer && ControllerShown(viewer->visibilityController()) ? viewer->Parent() : nullptr; + if (parent && parent->IsShowing()) { + viewer->PressPrimaryButton(); + return true; } } return false; } -// Requests `type` on the selected fleet when it is in one of `states`. With `help_state`, a fleet in that state -// (or one the action was just requested for) also asks its alliance for help. -bool RequestFleetAction(FleetBarViewController* fleet_bar, ActionType type, std::span states, - FleetState help_state = FleetState::Unknown) +// Requests `type` on the fleet when it is in one of `states`. With `help_state`, a fleet in that state (or one the +// action was just requested for) also asks its alliance for help. +bool RequestFleetAction(FleetLocalViewController* panel, FleetPlayerData* fleet, ActionType type, + std::span states, FleetState help_state = FleetState::Unknown) { - auto* panel = fleet_bar->fleetPanelController(); - auto* fleet = panel ? panel->fleet() : nullptr; - if (!fleet) - return false; - bool requested = false; - if (std::ranges::find(states, fleet->CurrentState()) != states.end()) { + if (std::ranges::contains(states, fleet->CurrentState())) { NavigationSectionManager::HideInteraction(); requested = panel->RequestAction(fleet, type, 0, ActionBehaviour::Default); } if (help_state != FleetState::Unknown && (requested || fleet->CurrentState() == help_state)) requested = panel->RequestAction(fleet, type, 0, ActionBehaviour::AskHelp) || requested; spdlog::trace("[Hotkeys] action {} on fleet {} in state {}: {}", type.flagValue, fleet->Id(), - static_cast(fleet->CurrentState()), requested); + std::to_underlying(fleet->CurrentState()), requested); return requested; } // Acts on a shown pre-scan card. Returns true when the press was used, including when it was deferred to the next // frame. -bool ActOnPreScan(PreScanTargetWidget* pre_scan, const Pressed& pressed, FleetPlayerData* fleet, +bool ActOnPreScan(PreScanTargetWidget* pre_scan, const FleetPress& pressed, FleetPlayerData* fleet, ActionQueueManager* queue) { auto* buttons = pre_scan->scanEngageButtonsWidget(); @@ -178,8 +167,8 @@ bool ActOnPreScan(PreScanTargetWidget* pre_scan, const Pressed& pressed, FleetPl } } - auto* add_to_queue = pre_scan->addToQueueButtonWidget(); - if (pressed.queue && queue->IsQueueUnlocked() && add_to_queue && buttons) { + if (auto* add_to_queue = pre_scan->addToQueueButtonWidget(); + pressed.queue && queue->IsQueueUnlocked() && add_to_queue && buttons) { const auto type = TargetHullType(buttons->Context()); if (type != HullType::ArmadaTarget && (type != HullType::Any || pressed.retry)) { if (add_to_queue->isActiveAndEnabled()) { @@ -192,7 +181,7 @@ bool ActOnPreScan(PreScanTargetWidget* pre_scan, const Pressed& pressed, FleetPl return true; } if (type == HullType::Any) { - replay = pressed; + pending_replay = pressed; return true; } } @@ -206,13 +195,9 @@ bool ActOnPreScan(PreScanTargetWidget* pre_scan, const Pressed& pressed, FleetPl if (pressed.primary && buttons && buttons->enabled()) { const auto type = TargetHullType(buttons->Context()); // An armada target whose armada card is already open is joined from that card instead. - bool can_engage = type != HullType::Any; - if (type == HullType::ArmadaTarget && ArmadaViewerShown(unity::Newest())) - can_engage = false; - else if (pressed.retry) - can_engage = true; - - if (can_engage) { + const bool joins_from_card = + type == HullType::ArmadaTarget && ArmadaViewerShown(unity::Newest()); + if (!joins_from_card && (type != HullType::Any || pressed.retry)) { if (type != HullType::ArmadaTarget) { buttons->OnEngageButtonClicked(); } else if (auto* attack = pre_scan->armadaAttackButton(); attack && attack->isActiveAndEnabled()) { @@ -224,7 +209,7 @@ bool ActOnPreScan(PreScanTargetWidget* pre_scan, const Pressed& pressed, FleetPl return true; } if (type == HullType::Any) { - replay = pressed; + pending_replay = pressed; return true; } } @@ -233,7 +218,7 @@ bool ActOnPreScan(PreScanTargetWidget* pre_scan, const Pressed& pressed, FleetPl // Acts on whatever other viewer is open: a mining node, a system node, or an empty spot in space. Returns true // when the press was used. -bool ActOnViewer(const Pressed& pressed) +bool ActOnViewer(const FleetPress& pressed) { if (auto* mine = unity::Newest(); TargetShown(mine, pressed)) { if (auto* buttons = mine->scanEngageButtonsWidget(); pressed.secondary && buttons) { @@ -281,16 +266,16 @@ bool ActOnViewer(const Pressed& pressed) return false; } -void ActOnSelectedFleet(FleetBarViewController* fleet_bar, const Pressed& pressed) +void ActOnSelectedFleet(const FleetPress& pressed) { if (Shortcut::IsDown(Action::kActionPrimary) && JoinWaveDefense()) return; - if (!fleet_bar) - return; - auto* panel = fleet_bar->fleetPanelController(); + auto* panel = FleetBarViewController::NewestPanel(); auto* fleet = panel ? panel->fleet() : nullptr; + if (!fleet) + return; auto* queue = ActionQueueManager::Instance(); - if (!fleet || !queue) + if (!queue) return; if (pressed.queue_clear) { @@ -310,38 +295,53 @@ void ActOnSelectedFleet(FleetBarViewController* fleet_bar, const Pressed& presse if (ActOnViewer(pressed)) return; - static constexpr FleetState kRecallable[] = {FleetState::IdleInSpace, FleetState::Impulsing, FleetState::Mining, - FleetState::Capturing}; - static constexpr FleetState kRepairable[] = {FleetState::Docked, FleetState::Destroyed}; - if (pressed.recall && RequestFleetAction(fleet_bar, ActionType::Recall, kRecallable)) + static constexpr std::array kRecallable{FleetState::IdleInSpace, FleetState::Impulsing, FleetState::Mining, + FleetState::Capturing}; + static constexpr std::array kRepairable{FleetState::Docked, FleetState::Destroyed}; + if (pressed.recall && RequestFleetAction(panel, fleet, ActionType::Recall, kRecallable)) return; if (pressed.repair) - RequestFleetAction(fleet_bar, ActionType::Repair, kRepairable, FleetState::Repairing); + RequestFleetAction(panel, fleet, ActionType::Repair, kRepairable, FleetState::Repairing); +} + +void ViewSelectedFleet(FleetPlayerData* fleet) +{ + NavigationSectionManager::HideInteraction(); + if (auto* fleets = FleetsManager::Instance()) + fleets->RequestViewFleet(fleet, true); +} + +// --- Target cargo --- + +// Frames left in which to show the cargo of a pre-scanned target. The rewards widget can appear a few frames +// after the pre-scan itself, so a request keeps trying for a while. +int show_cargo_frames = 0; + +// The cargo panel of a pre-scan card that is showing, or null. +VisibilityController* ShownTargetCargo(PreScanTargetWidget* pre_scan) +{ + auto* rewards = pre_scan->rewardsButtonWidget(); + auto* cargo = rewards ? rewards->rewardsController() : nullptr; + return cargo && ControllerShown(pre_scan->visibilityController()) ? cargo : nullptr; } -// Toggles a shown pre-scan card's cargo panel. Opening is retried for a few frames, see show_cargo_frames. void ToggleTargetCargo() { for (auto* pre_scan : unity::Instances()) { - auto* rewards = pre_scan->rewardsButtonWidget(); - auto* cargo = rewards ? rewards->rewardsController() : nullptr; - if (!cargo || !Shown(pre_scan->visibilityController())) - continue; - if (cargo->IsShown()) - cargo->Hide(); - else - show_cargo_frames = 5; + if (auto* cargo = ShownTargetCargo(pre_scan)) { + if (cargo->IsShown()) + cargo->Hide(); + else + show_cargo_frames = 5; + } } } void ShowPendingTargetCargo() { - for (auto* pre_scan : unity::Instances()) { - auto* rewards = pre_scan->rewardsButtonWidget(); - auto* cargo = rewards ? rewards->rewardsController() : nullptr; - if (cargo && Shown(pre_scan->visibilityController()) && !cargo->IsShown()) + for (auto* pre_scan : unity::Instances()) + if (auto* cargo = ShownTargetCargo(pre_scan); cargo && !cargo->IsShown()) cargo->Show(true); - } --show_cargo_frames; } @@ -371,8 +371,7 @@ bool ShowsCargoByDefault(RewardsButtonWidget* widget) void ShowCargoByDefault(RewardsButtonWidget* widget) { - auto* cargo = widget ? widget->rewardsController() : nullptr; - if (cargo && ShowsCargoByDefault(widget)) { + if (auto* cargo = widget ? widget->rewardsController() : nullptr; cargo && ShowsCargoByDefault(widget)) { cargo->Show(true); show_cargo_frames = 1; } @@ -390,24 +389,12 @@ void PreScanTargetWidget_ShowWithFleet_Hook(auto original, PreScanTargetWidget* ShowCargoByDefault(_this->rewardsButtonWidget()); } +// --- Queue switch --- + // queue_enabled switches the game's action queue off by telling it the queue is locked. bool ActionQueueManager_IsQueueUnlocked_Hook(auto original, ActionQueueManager* _this) { return Config::Get().control.queue_enabled && original(_this); } -void ViewSelectedFleet(FleetPlayerData* fleet) -{ - NavigationSectionManager::HideInteraction(); - if (auto* fleets = FleetsManager::Instance()) - fleets->RequestViewFleet(fleet, true); -} - -bool InstallQueueSwitch() -{ - il2cpp::HookSet hooks("QueueSwitch"); - IL2CPP_STAGE(hooks, ActionQueueManager::Class::Method("IsQueueUnlocked"), ActionQueueManager_IsQueueUnlocked_Hook); - return hooks.Commit(); -} - // --- Ability buttons --- // The game can leave an ability's button disabled after its cooldown ends; this enables it again once the ability is @@ -417,31 +404,34 @@ void AbilityButtonsFrame() static Throttle throttle(std::chrono::milliseconds(250)); if (!throttle.Due()) return; - auto* bar = unity::Newest(); - auto* controller = bar ? bar->fleetPanelController() : nullptr; + auto* controller = FleetBarViewController::NewestPanel(); auto* fleet = controller ? controller->fleet() : nullptr; - if (!fleet || (static_cast(fleet->CurrentState()) & static_cast(FleetState::CanActivateAbility)) == 0) + if (!fleet || (std::to_underlying(fleet->CurrentState()) & std::to_underlying(FleetState::CanActivateAbility)) == 0) return; auto* manager = ActivatedAbilityManager::Instance(); auto* panel = controller->shipActionPanelLocalViewController(); if (!manager || !panel || !CanvasController::Shows(panel) || panel->HasShipAnActiveAbilityWithActiveEffect()) return; + // The panel's ability ids and buttons are parallel arrays. auto* ids = panel->abilityIDs(); auto* buttons = panel->buttons(); - if (!ids || !buttons) - return; - for (size_t index = 0; index < ids->size() && index < buttons->size(); ++index) { - auto* button = (*buttons)[index]; + for (const auto [ability_id, button] : std::views::zip(il2cpp::Items(ids), il2cpp::Items(buttons))) { auto* context = button ? button->Context() : nullptr; - if (!button || !button->isActiveAndEnabled() || !context || context->Interactable()) + if (!context || !button->isActiveAndEnabled() || context->Interactable()) continue; - const auto ability_id = (*ids)[index]; - auto* timer = panel->GetRemainingCooldown(ability_id); + auto* timer = panel->GetRemainingCooldown(ability_id); // TimeSpan is an eight-byte value in 100 ns ticks. Repair at -1 second or below. - if (timer && timer->RemainingTime().ticks <= -10000000 && manager->CanUseAbility(fleet, ability_id)) + if (timer && timer->RemainingTime().ticks <= -10'000'000 && manager->CanUseAbility(fleet, ability_id)) context->SetInteractable(true); } } + +bool InstallQueueSwitch() +{ + il2cpp::HookSet hooks("QueueSwitch"); + IL2CPP_STAGE(hooks, ActionQueueManager::Class::Method("IsQueueUnlocked"), ActionQueueManager_IsQueueUnlocked_Hook); + return hooks.Commit(); +} } // namespace bool TargetViewerShown() @@ -452,18 +442,17 @@ bool SelectShipShortcuts() using Clock = std::chrono::steady_clock; // The number keys select a ship in the fleet bar; pressing the same one again within select_timer flies the // camera to it. With disable_preview_locate, the second press only selects while a target card is open. - constexpr Action kShips[] = {Action::kSelectShip1, Action::kSelectShip2, Action::kSelectShip3, Action::kSelectShip4, - Action::kSelectShip5, Action::kSelectShip6, Action::kSelectShip7, Action::kSelectShip8}; - static auto last_press = Clock::now(); - static int32_t last_index = -1; - - int32_t index = 0; - while (index < 8 && !Shortcut::IsDown(kShips[index])) - ++index; - // TODO: Shift+ship used to request a tow by the Discovery. Its bindings had drifted, so it never ran on + constexpr std::array kShips{Action::kSelectShip1, Action::kSelectShip2, Action::kSelectShip3, Action::kSelectShip4, + Action::kSelectShip5, Action::kSelectShip6, Action::kSelectShip7, Action::kSelectShip8}; + static auto last_press = Clock::now(); + static int32_t last_index = -1; + + const auto ship = std::ranges::find_if(kShips, [](Action action) { return Shortcut::IsDown(action); }); + // TODO(tashcan): Shift+ship used to request a tow by the Discovery. Its bindings had drifted, so it never ran on // recent clients, and it was disabled until someone verifies it in game. Shift+ship does nothing meanwhile. - if (index == 8 || unity::Key::IsInputFocused()) + if (ship == kShips.end() || unity::Key::IsInputFocused()) return false; + const auto index = static_cast(ship - kShips.begin()); auto* fleet_bar = unity::Newest(); if (!fleet_bar) @@ -488,8 +477,8 @@ bool SelectShipShortcuts() bool FleetShortcuts() { if (Shortcut::IsDown(Action::kSelectCurrent)) { - auto* fleet_bar = unity::Newest(); - if (auto* fleet = fleet_bar ? fleet_bar->SelectedFleet() : nullptr) { + auto* panel = FleetBarViewController::NewestPanel(); + if (auto* fleet = panel ? panel->fleet() : nullptr) { ViewSelectedFleet(fleet); return true; } @@ -506,7 +495,7 @@ bool FleetShortcuts() bool FleetActionShortcuts() { // A replay lives for exactly the next frame, whatever that frame does. - const auto replayed = std::exchange(replay, std::nullopt); + const auto replayed = std::exchange(pending_replay, std::nullopt); if (unity::Key::IsInputFocused()) return false; @@ -522,7 +511,7 @@ bool FleetActionShortcuts() } } - Pressed pressed{ + FleetPress pressed{ .primary = Shortcut::IsDown(Action::kActionPrimary), .secondary = Shortcut::IsDown(Action::kActionSecondary), .queue = Shortcut::IsDown(Action::kActionQueue), @@ -538,7 +527,7 @@ bool FleetActionShortcuts() pressed.retry = true; } if (pressed.Any() && Hub::IsInSystemOrGalaxyOrStarbase() && !Hub::IsInChat()) - ActOnSelectedFleet(unity::Newest(), pressed); + ActOnSelectedFleet(pressed); if (Shortcut::IsDown(Action::kActionView)) ToggleTargetCargo(); diff --git a/src/mod/features/fleet_labels.cc b/src/mod/features/fleet_labels.cc index de0067ddd..c111cdfa8 100644 --- a/src/mod/features/fleet_labels.cc +++ b/src/mod/features/fleet_labels.cc @@ -8,7 +8,6 @@ #include "game/navigation.h" #include -#include #include #include @@ -46,7 +45,7 @@ class FleetLabels case FleetLabelDetail::kCompact: return ZoomLevels::Far; case FleetLabelDetail::kThreshold: - if (!zoom_known_) + if (!camera_.zoom_known) return native; return ExpandedAt(profile->zoom_threshold) ? expanded : ZoomLevels::Far; default: @@ -58,25 +57,25 @@ class FleetLabels { // Fresh pooled widgets are enabled before BindDataContext assigns their context; forcing an LOD before then // fires OnLODChanged callbacks that dereference the unbound fleet data. - auto* lod = widget && widget->Context() ? widget->lod() : nullptr; + auto* lod = widget->Context() ? widget->lod() : nullptr; if (!lod) return; widgets_.insert_or_assign(lod, widget); - if (zoom_known_ && Enabled()) - lod->UpdateLOD(zoom_level_); + if (camera_.zoom_known && Enabled()) + lod->UpdateLOD(camera_.zoom_level); } void Untrack(NavigationFleetWidget* widget) { - auto* lod = widget ? widget->lod() : nullptr; + auto* lod = widget->lod(); if (!lod) return; widgets_.erase(lod); // Give a reused widget back to the game at the game's own level. - if (widget->Context() && zoom_known_ && Enabled()) { - lod->SetTargetLevel(zoom_level_); - lod->UpdateLOD(zoom_level_); - lod->SetTargetLevel(zoom_level_); + if (widget->Context() && camera_.zoom_known && Enabled()) { + lod->SetTargetLevel(camera_.zoom_level); + lod->UpdateLOD(camera_.zoom_level); + lod->SetTargetLevel(camera_.zoom_level); } } @@ -85,11 +84,10 @@ class FleetLabels { if (!installed_) return; - if (depth == NodeDepth::SolarSystem) { + if (depth == NodeDepth::SolarSystem) Follow(zoom); - } else if (camera_ == zoom) { - Forget(); - } + else if (camera_.zoom == zoom) + camera_ = {}; } // After the camera's own update: picks up the zoom level and re-applies labels when they change. @@ -98,11 +96,11 @@ class FleetLabels if (!installed_ || zoom->depth() != NodeDepth::SolarSystem) return; Follow(zoom); - const bool was_known = zoom_known_; - zoom_level_ = zoom->zoomLevel(); - zoom_known_ = true; + const bool was_known = camera_.zoom_known; + camera_.zoom_level = zoom->zoomLevel(); + camera_.zoom_known = true; if (UsesThreshold()) { - normalized_zoom_ = zoom->NormalizedZoom(); + camera_.normalized_zoom = zoom->NormalizedZoom(); UpdateThreshold(); } else if (!was_known) { ApplyAll(); @@ -112,9 +110,8 @@ class FleetLabels private: const FleetLabelProfile* Profile(NavigationLOD* lod) const { - const auto it = widgets_.find(lod); - auto* widget = it == widgets_.end() ? nullptr : it->second; - auto* fleet = widget ? widget->Context() : nullptr; + const auto it = widgets_.find(lod); + auto* fleet = it == widgets_.end() ? nullptr : it->second->Context(); if (!fleet) return nullptr; // A missing FleetType property reads as Nonexistent, which counts as non-player. @@ -132,23 +129,12 @@ class FleetLabels // Labels expand when zoomed in at least as far as the threshold. 0 never expands, 1 always does. bool ExpandedAt(float threshold) const - { return threshold > 0.0f && (threshold >= 1.0f || normalized_zoom_ <= threshold); } + { return threshold > 0.0f && (threshold >= 1.0f || camera_.normalized_zoom <= threshold); } void Follow(NavigationZoom* zoom) { - if (camera_ != zoom) { - Forget(); - camera_ = zoom; - } - } - - void Forget() - { - camera_ = nullptr; - zoom_known_ = false; - zoom_level_ = ZoomLevels::None; - normalized_zoom_ = 0.0f; - threshold_known_ = false; + if (camera_.zoom != zoom) + camera_ = {.zoom = zoom}; } // UpdateLOD goes through the hook, which picks each label's level. @@ -158,38 +144,46 @@ class FleetLabels return; for (const auto& [lod, widget] : widgets_) if (widget->Context()) - lod->UpdateLOD(zoom_level_); + lod->UpdateLOD(camera_.zoom_level); } // Re-applies labels only when a threshold profile crosses its threshold, not on every zoom step. void UpdateThreshold() { - const auto& config = Config::Get(); - const bool player = config.graphics.zoom_label_player.detail == FleetLabelDetail::kThreshold - && ExpandedAt(config.graphics.zoom_label_player.zoom_threshold); - const bool non_player = config.graphics.zoom_label_non_player.detail == FleetLabelDetail::kThreshold - && ExpandedAt(config.graphics.zoom_label_non_player.zoom_threshold); - if (threshold_known_ && player == player_expanded_ && non_player == non_player_expanded_) + const auto& graphics = Config::Get().graphics; + const auto expanded = [this](const FleetLabelProfile& profile) { + return profile.detail == FleetLabelDetail::kThreshold && ExpandedAt(profile.zoom_threshold); + }; + const bool player = expanded(graphics.zoom_label_player); + const bool non_player = expanded(graphics.zoom_label_non_player); + if (camera_.threshold_known && player == camera_.player_expanded && non_player == camera_.non_player_expanded) return; - threshold_known_ = true; - player_expanded_ = player; - non_player_expanded_ = non_player; + camera_.threshold_known = true; + camera_.player_expanded = player; + camera_.non_player_expanded = non_player; ApplyAll(); - spdlog::debug("[FleetLabels] threshold crossed at normalized zoom {:.4f}", normalized_zoom_); + spdlog::debug("[FleetLabels] threshold crossed at normalized zoom {:.4f}", camera_.normalized_zoom); } + // What is known of the system camera's zoom. A new camera starts over. + struct Camera { + NavigationZoom* zoom = nullptr; + bool zoom_known = false; + ZoomLevels zoom_level = ZoomLevels::None; + float normalized_zoom = 0.0f; + // Whether player and non-player threshold labels were last expanded, once threshold_known. + bool threshold_known = false; + bool player_expanded = false; + bool non_player_expanded = false; + }; + bool installed_ = false; std::unordered_map widgets_; - // The system camera the zoom state below belongs to. - NavigationZoom* camera_ = nullptr; - bool zoom_known_ = false; - ZoomLevels zoom_level_ = ZoomLevels::None; - float normalized_zoom_ = 0.0f; - bool threshold_known_ = false; - bool player_expanded_ = false; - bool non_player_expanded_ = false; + Camera camera_; } fleet_labels; +// --- Hooks --- + void NavigationLOD_UpdateLOD_Hook(auto original, NavigationLOD* _this, ZoomLevels level) { const auto effective = fleet_labels.LevelFor(_this, level); @@ -227,10 +221,9 @@ void NavigationFleetWidget_OnAboutToReleaseContext_Hook(auto original, Navigatio bool InstallFleetLabels() { - // Installed as a set, whether or not zoom_label_* is set, so labels can be switched at runtime. Missing fields - // and properties (_lod, Context, FleetType) read as null or zero, which the hooks treat as native labels, so - // only the methods gate installation. The zoom reports come from camera.cc's NavigationZoom.Update hook, so it - // has to exist too. + // Installed whether or not zoom_label_* is set, so labels can be switched at runtime. Missing fields and + // properties (_lod, Context, FleetType) read as null or zero, which the hooks treat as native labels. The zoom + // reports come from camera.cc's NavigationZoom.Update hook, so that method has to exist too. if (!NavigationZoom::Class::Method("Update")) return false; il2cpp::HookSet hooks("FleetLabels"); diff --git a/src/mod/features/forbidden_tech_ship_name.cc b/src/mod/features/forbidden_tech_ship_name.cc index fd843c9cd..adfaa6348 100644 --- a/src/mod/features/forbidden_tech_ship_name.cc +++ b/src/mod/features/forbidden_tech_ship_name.cc @@ -1,5 +1,5 @@ -// forbidden_tech_ship_name: shows the ship a forbidden tech is equipped on below the header of the large selected-item -// panel, including ships without a dock. Inventory tiles keep their native dock badges. +// [ui] forbidden_tech_ship_name: shows the ship a forbidden tech is equipped on below the header of the large +// selected-item panel, including ships without a dock. Inventory tiles keep their native dock badges. #include "config.h" #include "features/features.h" @@ -16,69 +16,64 @@ #include #include -#include #include +#include #include namespace { -constexpr float RowHeight = 30.0f; - -unity::RectTransform* RectOf(il2cpp::Object* component) -{ - auto* as_component = component ? component->As() : nullptr; - auto* transform = as_component ? as_component->transform() : nullptr; - return transform ? transform->As() : nullptr; -} - -void SetActive(il2cpp::Object* component, bool active) -{ - auto* as_component = component->As(); - auto* object = as_component ? as_component->gameObject() : nullptr; - if (!object) - throw std::runtime_error("Forbidden Tech label object unavailable"); - object->SetActive(active); -} +// --- Equipped ship --- +// The ship the widget's tech is fitted to, by name in the player's language; empty when it is not fitted. std::string EquippedShipName(ForbiddenTechInventoryItemWidget* widget) { auto* context = widget->Context(); auto* tech = context ? context->ForbiddenTech() : nullptr; - if (!tech) - return {}; - const auto id = tech->Id(); - auto* manager = ForbiddenTechManager::Instance(); + auto* manager = tech ? ForbiddenTechManager::Instance() : nullptr; if (!manager) return {}; - - // The fleet-index API returns -1 for a ship in the hangar. Resolve the slot's - // OwnerId instead; it identifies the actual ship regardless of dock assignment. + // The fleet-index API returns -1 for a ship in the hangar. Resolve the slot's OwnerId instead; it identifies the + // actual ship regardless of dock assignment. EntitySlot* slot = nullptr; - if (!manager->TryGetEntitySlotFromForbiddenTech(id, slot) || !slot) - return {}; - auto* params = slot->ForbiddenTechSlotParams(); - if (!params) + if (!manager->TryGetEntitySlotFromForbiddenTech(tech->Id(), slot) || !slot) return {}; - const auto owner = params->OwnerId(); - - auto* service = manager->FleetService(); - if (!service) - return {}; - auto* ship = service->GetShip(owner); - auto* hull = ship ? ship->Hull() : nullptr; + auto* params = slot->ForbiddenTechSlotParams(); + auto* service = params ? manager->FleetService() : nullptr; + auto* ship = service ? service->GetShip(params->OwnerId()) : nullptr; + auto* hull = ship ? ship->Hull() : nullptr; if (!hull) return {}; // Hull IDs and localisation IDs differ on many ships. Mirror FleetPlayerData.GetLocaId. - auto* refs = hull->idRefs(); - const auto loca_id = refs ? refs->locaId() : hull->id(); - return LocalizedShipName(loca_id); + auto* refs = hull->idRefs(); + return LocalizedShipName(refs ? refs->locaId() : hull->id()); } -struct Row { +// --- Ship name rows --- + +// The row takes this much from the top of the panel's scrollable buff list. +constexpr float kShipNameRowHeight = 30.0f; + +// The panel's own labels and lists, as the components they are. +unity::GameObject* ShipNameObjectOf(il2cpp::Object* component) +{ + auto* as_component = component->As(); + return as_component ? as_component->gameObject() : nullptr; +} +unity::RectTransform* ShipNameRectOf(il2cpp::Object* component) +{ + auto* as_component = component->As(); + auto* transform = as_component ? as_component->transform() : nullptr; + return transform ? transform->As() : nullptr; +} + +// The ship name row added to one large panel, found again by the panel's buff list. The handles are weak: the panel +// owns both objects and may destroy them. +struct ShipNameRow { il2cpp::GcHandle scroller; il2cpp::GcHandle label; - unity::Vector2 original_top{}; - bool visible = false; + // The buff list's top edge without the row. + unity::Vector2 original_top{}; + bool visible = false; unity::RectTransform* Scroller() const { return scroller.Get(); } @@ -87,95 +82,47 @@ struct Row { void Hide() { - if (unity::Alive(Label())) - SetActive(Label(), false); + if (auto* label_object = unity::Alive(Label()) ? ShipNameObjectOf(Label()) : nullptr) + label_object->SetActive(false); if (visible && unity::Alive(Scroller())) Scroller()->SetOffsetMax(original_top); visible = false; } -}; -std::vector rows; - -void HideWidgetRow(ForbiddenTechInventoryItemWidget* widget) -{ - auto* scroller = widget->buffListScroller(); - if (!unity::Alive(scroller)) - return; - auto* rect = RectOf(scroller); - for (auto& row : rows) { - if (row.Scroller() == rect) - row.Hide(); - } -} - -void UpdateRow(ForbiddenTechInventoryItemWidget* widget) -{ - // Only the large selected-item panel: inventory tiles retain their native dock badges. - if (widget->widgetType() != ForbiddenTechInventoryItemWidget::Type::LargeShowcase) - return; - auto* scroller = widget->buffListScroller(); - if (!unity::Alive(scroller)) - return; - auto* rect = RectOf(scroller); - if (!rect) - throw std::runtime_error("Forbidden Tech buff layout unavailable"); - - std::erase_if(rows, [](const Row& row) { return !unity::Alive(row.Scroller()); }); - auto found = std::find_if(rows.begin(), rows.end(), [rect](const Row& row) { return row.Scroller() == rect; }); - if (found == rows.end()) { - Row row; - row.original_top = rect->offsetMax(); - row.scroller = il2cpp::GcHandle::Weak(rect); - if (!row.scroller) - return; - rows.push_back(std::move(row)); - found = std::prev(rows.end()); - } - auto& row = *found; - // Clear before resolving a new assignment so errors/unassigned tech cannot retain an old ship name. - row.Hide(); - // Native refresh may have resized the panel since the previous selection. - row.original_top = rect->offsetMax(); - if (!Config::Get().ui.forbidden_tech_ship_name) - return; - const auto name = EquippedShipName(widget); - if (name.empty()) - return; - - try { - auto* label = row.Label(); - if (!unity::Alive(label)) { + // Shows `name` in the row, cloning the panel's name label on first use. Null when shown, otherwise what failed. + const char* Show(ForbiddenTechInventoryItemWidget* widget, unity::RectTransform* list, const std::string& name) + { + auto* text_label = Label(); + if (!unity::Alive(text_label)) { auto* source = widget->name(); if (!unity::Alive(source)) - return; - label = unity::Instantiate(source, rect->parent()); - if (!unity::Alive(label)) - throw std::runtime_error("Forbidden Tech ship label could not be created"); - SetActive(label, false); - row.label = il2cpp::GcHandle::Weak(label); - if (!row.label) - throw std::runtime_error("Forbidden Tech label handle unavailable"); + return nullptr; + text_label = unity::Instantiate(source, list->parent()); + if (!unity::Alive(text_label)) + return "Forbidden Tech ship label could not be created"; + label = il2cpp::GcHandle::Weak(text_label); } - auto* label_rect = RectOf(label); + auto* label_object = ShipNameObjectOf(text_label); + if (!label_object) + return "Forbidden Tech label object unavailable"; + label_object->SetActive(false); + auto* label_rect = ShipNameRectOf(text_label); if (!label_rect) - throw std::runtime_error("Forbidden Tech ship label layout unavailable"); - const auto min = rect->offsetMin(); - const auto max = row.original_top; - const auto anchors = rect->anchorMax(); - auto anchor_min = rect->anchorMin(); - anchor_min.y = anchors.y; - label_rect->SetAnchorMin(anchor_min); + return "Forbidden Tech ship label layout unavailable"; + const auto min = list->offsetMin(); + const auto max = original_top; + const auto anchors = list->anchorMax(); + label_rect->SetAnchorMin({list->anchorMin().x, anchors.y}); label_rect->SetAnchorMax(anchors); label_rect->SetPivot({0.5f, 1.0f}); - label_rect->SetOffsetMin({min.x, max.y - RowHeight}); + label_rect->SetOffsetMin({min.x, max.y - kShipNameRowHeight}); label_rect->SetOffsetMax(max); // Target resolves the graphic lazily, even when the source label was inactive at clone time. - auto* target = label->Target(); + auto* target = text_label->Target(); auto* text = target ? target->As() : nullptr; if (!text) - throw std::runtime_error("Forbidden Tech ship label text unavailable"); + return "Forbidden Tech ship label text unavailable"; text->SetAlignment(unity::TMP_Text::Alignment::Left); // Left, vertically centred. text->SetRaycastTarget(false); text->SetRichText(false); // Ship names are plain text, never markup. @@ -184,46 +131,82 @@ void UpdateRow(ForbiddenTechInventoryItemWidget* widget) text->SetEnableAutoSizing(false); text->SetFontSize(20.0f); text->SetColor({1.0f, 1.0f, 1.0f, 1.0f}); - label->OverrideLocalizedText(il2cpp::NewString("Equipped on: " + name)); + text_label->OverrideLocalizedText(il2cpp::NewString("Equipped on: " + name)); // Keep the panel's outer size, reserving one row from the scrollable buff area. - rect->SetOffsetMax({max.x, max.y - RowHeight}); - row.visible = true; - SetActive(label, true); + list->SetOffsetMax({max.x, max.y - kShipNameRowHeight}); + visible = true; + label_object->SetActive(true); static bool reported = false; - if (!reported) { - reported = true; + if (!std::exchange(reported, true)) spdlog::info("[ForbiddenTechShipName] equipped-ship row created and populated"); - } - } catch (...) { - row.Hide(); - throw; + return nullptr; + } +}; +std::vector ship_name_rows; + +// Hides the row of the widget's panel, if it has one. +void HideShipNameRow(ForbiddenTechInventoryItemWidget* widget) +{ + auto* scroller = widget->buffListScroller(); + if (!unity::Alive(scroller)) + return; + auto* list = ShipNameRectOf(scroller); + for (auto& row : ship_name_rows) + if (row.Scroller() == list) + row.Hide(); +} + +// Shows the equipped ship in the large selected-item panel after the game refreshed it. Null unless something +// failed, which then says what. +const char* UpdateShipNameRow(ForbiddenTechInventoryItemWidget* widget) +{ + // Only the large selected-item panel: inventory tiles retain their native dock badges. + if (widget->widgetType() != ForbiddenTechInventoryItemWidget::Type::LargeShowcase) + return nullptr; + auto* scroller = widget->buffListScroller(); + if (!unity::Alive(scroller)) + return nullptr; + auto* list = ShipNameRectOf(scroller); + if (!list) + return "Forbidden Tech buff layout unavailable"; + + std::erase_if(ship_name_rows, [](const ShipNameRow& row) { return !unity::Alive(row.Scroller()); }); + auto row = std::ranges::find(ship_name_rows, list, &ShipNameRow::Scroller); + if (row == ship_name_rows.end()) { + ship_name_rows.push_back({.scroller = il2cpp::GcHandle::Weak(list), .original_top = list->offsetMax()}); + row = std::prev(ship_name_rows.end()); } + // Clear before resolving a new assignment so errors/unassigned tech cannot retain an old ship name. + row->Hide(); + // Native refresh may have resized the panel since the previous selection. + row->original_top = list->offsetMax(); + if (!Config::Get().ui.forbidden_tech_ship_name) + return nullptr; + const auto name = EquippedShipName(widget); + if (name.empty()) + return nullptr; + const char* failure = row->Show(widget, list, name); + if (failure) + row->Hide(); + return failure; } +// --- Hooks --- + void ForbiddenTechInventoryItemWidget_SetWidgetData_Hook(auto original, ForbiddenTechInventoryItemWidget* _this) { - try { - HideWidgetRow(_this); - } catch (const std::exception&) { - } + HideShipNameRow(_this); original(_this); - try { - UpdateRow(_this); - } catch (const std::exception& error) { + if (const char* failure = UpdateShipNameRow(_this)) { static bool warned = false; - if (!warned) { - warned = true; - spdlog::warn("[ForbiddenTechShipName] native display retained: {}", error.what()); - } + if (!std::exchange(warned, true)) + spdlog::warn("[ForbiddenTechShipName] native display retained: {}", failure); } } void ForbiddenTechInventoryItemWidget_ClearWidgetData_Hook(auto original, ForbiddenTechInventoryItemWidget* _this) { - try { - HideWidgetRow(_this); - } catch (const std::exception&) { - } + HideShipNameRow(_this); original(_this); } diff --git a/src/mod/features/game_config.cc b/src/mod/features/game_config.cc index e67dfcd17..ec7cbc918 100644 --- a/src/mod/features/game_config.cc +++ b/src/mod/features/game_config.cc @@ -19,9 +19,9 @@ void Model_LoadConfigs_Hook(auto original, Model* _this) return; const auto& game = Config::Get().game; if (!game.settings_url.empty()) - config->SetPlatformSettingsUrl(il2cpp::NewString(game.settings_url.c_str())); + config->SetPlatformSettingsUrl(il2cpp::NewString(game.settings_url)); if (!game.assets_url_override.empty()) - config->SetAssetUrlOverride(il2cpp::NewString(game.assets_url_override.c_str())); + config->SetAssetUrlOverride(il2cpp::NewString(game.assets_url_override)); } bool InstallGameConfigOverrides() diff --git a/src/mod/features/hotkeys.cc b/src/mod/features/hotkeys.cc index 3209aefc6..699930d6b 100644 --- a/src/mod/features/hotkeys.cc +++ b/src/mod/features/hotkeys.cc @@ -14,6 +14,7 @@ #include #include +#include #if _WIN32 #include @@ -21,11 +22,12 @@ namespace { -using Clock = std::chrono::steady_clock; - +// The frame tick dispatches shortcuts whether or not this feature installed; this says whether it did. bool hotkeys_installed = false; -template void Toggle() +// --- Setting shortcuts --- + +template void ToggleSetting() { auto& section = Config::Get().*Section; section.*Setting = !(section.*Setting); @@ -37,12 +39,6 @@ template void SetLogLevel() spdlog::flush_on(Level); } -template void Scale() -{ Adjust(Up); } - -void ReloadSettings() -{ Config::Reload(); } - struct SettingShortcut { Action shortcut; void (*run)(); @@ -51,33 +47,33 @@ struct SettingShortcut { }; // Shortcuts that change a setting. The game sees their keys too. The first pressed one runs. -constexpr SettingShortcut kSettings[] = { - {Action::kUiScaleUp, Scale, true}, - {Action::kUiScaleDown, Scale, true}, - {Action::kUiShipScaleUp, Scale, true}, - {Action::kUiShipScaleDown, Scale, true}, - {Action::kUiViewerScaleUp, Scale, true}, - {Action::kUiViewerScaleDown, Scale, true}, +constexpr SettingShortcut kSettingShortcuts[] = { + {Action::kUiScaleUp, [] { AdjustUiScale(true); }, true}, + {Action::kUiScaleDown, [] { AdjustUiScale(false); }, true}, + {Action::kUiShipScaleUp, [] { AdjustUiShipScale(true); }, true}, + {Action::kUiShipScaleDown, [] { AdjustUiShipScale(false); }, true}, + {Action::kUiViewerScaleUp, [] { AdjustUiViewerScale(true); }, true}, + {Action::kUiViewerScaleDown, [] { AdjustUiViewerScale(false); }, true}, {Action::kToggleAutoConfirmInstantWarp, CycleInstantWarpConfirmation}, - {Action::kTogglePreviewLocate, Toggle<&Config::ui, &Config::Ui::disable_preview_locate>}, - {Action::kTogglePreviewRecall, Toggle<&Config::ui, &Config::Ui::disable_preview_recall>}, - {Action::kToggleCargoDefault, Toggle<&Config::ui, &Config::Ui::show_cargo_default>}, - {Action::kToggleCargoPlayer, Toggle<&Config::ui, &Config::Ui::show_player_cargo>}, - {Action::kToggleCargoStation, Toggle<&Config::ui, &Config::Ui::show_station_cargo>}, - {Action::kToggleCargoHostile, Toggle<&Config::ui, &Config::Ui::show_hostile_cargo>}, - {Action::kToggleCargoArmada, Toggle<&Config::ui, &Config::Ui::show_armada_cargo>}, + {Action::kTogglePreviewLocate, ToggleSetting<&Config::ui, &Config::Ui::disable_preview_locate>}, + {Action::kTogglePreviewRecall, ToggleSetting<&Config::ui, &Config::Ui::disable_preview_recall>}, + {Action::kToggleCargoDefault, ToggleSetting<&Config::ui, &Config::Ui::show_cargo_default>}, + {Action::kToggleCargoPlayer, ToggleSetting<&Config::ui, &Config::Ui::show_player_cargo>}, + {Action::kToggleCargoStation, ToggleSetting<&Config::ui, &Config::Ui::show_station_cargo>}, + {Action::kToggleCargoHostile, ToggleSetting<&Config::ui, &Config::Ui::show_hostile_cargo>}, + {Action::kToggleCargoArmada, ToggleSetting<&Config::ui, &Config::Ui::show_armada_cargo>}, {Action::kLogLevelOff, SetLogLevel}, {Action::kLogLevelError, SetLogLevel}, {Action::kLogLevelWarn, SetLogLevel}, {Action::kLogLevelInfo, SetLogLevel}, {Action::kLogLevelDebug, SetLogLevel}, {Action::kLogLevelTrace, SetLogLevel}, - {Action::kReloadSettings, ReloadSettings}, + {Action::kReloadSettings, [] { Config::Reload(); }}, }; void SettingShortcuts() { - for (const auto& setting : kSettings) { + for (const auto& setting : kSettingShortcuts) { if (setting.held ? Shortcut::IsPressed(setting.shortcut) : Shortcut::IsDown(setting.shortcut)) { setting.run(); return; @@ -85,6 +81,8 @@ void SettingShortcuts() } } +// --- Escape exit --- + // With disable_escape_exit, Escape no longer reaches the game's exit prompt. disable_escape_exit_timer > 0 lets // a double tap within that many milliseconds through. Returns true when Escape is swallowed. bool SwallowEscape() @@ -96,22 +94,45 @@ bool SwallowEscape() if (config.ui.disable_escape_exit_timer <= 0 || !unity::Key::Down(unity::KeyCode::Escape)) return true; - static Clock::time_point first_press{}; - const auto now = Clock::now(); - if (first_press == Clock::time_point{} - || now - first_press > std::chrono::milliseconds(config.ui.disable_escape_exit_timer)) { + static std::optional first_press; + const auto now = std::chrono::steady_clock::now(); + if (!first_press || now - *first_press > std::chrono::milliseconds(config.ui.disable_escape_exit_timer)) { first_press = now; return true; } // Consume the pair so a quick third press starts a new one. - first_press = {}; + first_press.reset(); return false; } -// The shortcut dispatch for one frame. Returns true when the game's Update must not run, so it does not also -// handle the key. -bool DispatchShortcuts() +// --- Scopely hotkeys --- + +// With Scopely hotkeys off, the game never registers its own input actions, so its shortcuts cannot fire. +void ShortcutsManager_InitializeActions_Hook(auto original, ShortcutsManager* _this) +{ + if (Config::Get().control.use_scopely_hotkeys) + original(_this); +} + +bool InstallHotkeys() +{ + il2cpp::HookSet hooks("Hotkeys"); + IL2CPP_STAGE(hooks, ShortcutsManager::Class::Method("InitializeActions"), ShortcutsManager_InitializeActions_Hook); + // The badges only make sense when the game's own input actions are under the mod's control. + if (hooks.Commit()) + InstallKeybindBadges(); + InstallFleetActions(); + // The dispatch itself needs no hook: the frame tick runs it. The hint badges and pre-scan cargo are extras whose + // own sets report themselves. + hotkeys_installed = true; + return true; +} +} // namespace + +bool HandleShortcuts() { + if (!hotkeys_installed) + return false; auto& config = Config::Get(); if (Shortcut::IsDown(Action::kDisableHotKeys)) { @@ -143,7 +164,8 @@ bool DispatchShortcuts() if (!typing && Shortcut::IsDown(Action::kRestart)) { spdlog::info("[Hotkeys] clearing the localisation cache and restarting"); LanguageManager::ClearCache(); - Hub::App()->Reload(); + if (auto* app = Hub::App()) + app->Reload(); return true; } if (!typing && Shortcut::IsDown(Action::kQuit)) { @@ -151,7 +173,8 @@ bool DispatchShortcuts() #if _WIN32 TerminateProcess(GetCurrentProcess(), 1); #else - Hub::App()->Quit(); + if (auto* app = Hub::App()) + app->Quit(); #endif return true; } @@ -181,29 +204,4 @@ bool DispatchShortcuts() return FleetActionShortcuts() || SwallowEscape(); } -// With Scopely hotkeys off, the game never registers its own input actions, so its shortcuts cannot fire. -void ShortcutsManager_InitializeActions_Hook(auto original, ShortcutsManager* _this) -{ - if (Config::Get().control.use_scopely_hotkeys) - original(_this); -} - -bool InstallHotkeys() -{ - il2cpp::HookSet hooks("Hotkeys"); - IL2CPP_STAGE(hooks, ShortcutsManager::Class::Method("InitializeActions"), ShortcutsManager_InitializeActions_Hook); - // The badges only make sense when the game's own input actions are under the mod's control. - if (hooks.Commit()) - InstallKeybindBadges(); - InstallFleetActions(); - // The dispatch itself needs no hook: the frame tick runs it. The hint badges and pre-scan cargo are extras whose - // own sets report themselves. - hotkeys_installed = true; - return true; -} -} // namespace - -bool HandleShortcuts() -{ return hotkeys_installed && DispatchShortcuts(); } - const Feature kHotkeys{"Hotkeys", &Config::Patches::hotkeyhooks, InstallHotkeys}; diff --git a/src/mod/features/instant_warp.cc b/src/mod/features/instant_warp.cc index 1c4e806be..f0f70556c 100644 --- a/src/mod/features/instant_warp.cc +++ b/src/mod/features/instant_warp.cc @@ -16,12 +16,19 @@ #include #include +#include #include #include +#include #include namespace { +// Indexed by InstantWarpConfirmation, for the log. +constexpr std::array kInstantWarpNames{"none", "warp", "jump"}; + +// --- Course prompt --- + // The action auto-confirm takes for `fleet`: always-ask wins, then auto-jump, then auto-warp, then the default. InstantWarpConfirmation ResolveConfirmation(FleetPlayerData* fleet) { @@ -50,7 +57,7 @@ void CoursePromptPopupViewController_AboutToShow_Hook(auto original, CoursePromp auto* course = context->CourseData(); const auto action = ResolveConfirmation(course ? course->PlayerFleet() : nullptr); - spdlog::debug("[InstantWarp] answering the prompt with {}", static_cast(action)); + spdlog::debug("[InstantWarp] answering the prompt with {}", kInstantWarpNames[std::to_underlying(action)]); switch (action) { case InstantWarpConfirmation::kWarp: widget->InitiateRegularWarp(); @@ -69,7 +76,7 @@ void CoursePromptPopupViewController_AboutToShow_Hook(auto original, CoursePromp // rather than another UI detour, and only overrides the label text: never the button's context, timer, resource // cost, or click handler. -std::string CurrentText(TextLocalizer* label) +std::string CurrentLabelText(TextLocalizer* label) { return il2cpp::ToUtf8(label->CurrentText()); } // The label text this file set, so it is only cleared while it still reads that. @@ -82,11 +89,10 @@ struct OwnedLabel { void Clear() { - auto* label = Get(); - auto previous = std::move(text); + auto* label = Get(); + const auto previous = std::exchange(text, {}); handle.Reset(); - text.clear(); - if (unity::Alive(label) && label->textOverride() && CurrentText(label) == previous) + if (unity::Alive(label) && label->textOverride() && CurrentLabelText(label) == previous) label->ClearTextOverride(); } @@ -105,7 +111,7 @@ struct OwnedLabel { if (!handle) return; } - const auto current = CurrentText(label); + const auto current = CurrentLabelText(label); if (!text.empty() && label->textOverride() && current != text) { Clear(); return; @@ -119,7 +125,7 @@ struct OwnedLabel { // Never destroyed: freeing a GC handle in a static destructor can run after the runtime has shut down. auto& action_label = *new OwnedLabel; -void UpdateWarpLabel() +void InstantWarpFrame() { // Ten times a second is quick enough for a label. static Throttle throttle(std::chrono::milliseconds(100)); @@ -127,22 +133,20 @@ void UpdateWarpLabel() return; // Without a default or a jump/warp filter every prompt resolves to "ask"; skip the fleet lookups. - const auto& ui = Config::Get().ui; - if (ui.auto_confirm_instant_warp == InstantWarpConfirmation::kNone && !ui.instant_warp_auto_jump.all - && ui.instant_warp_auto_jump.names.empty() && !ui.instant_warp_auto_warp.all - && ui.instant_warp_auto_warp.names.empty()) { + const auto& ui = Config::Get().ui; + const auto empty = [](const ShipFilter& filter) { return !filter.all && filter.names.empty(); }; + if (ui.auto_confirm_instant_warp == InstantWarpConfirmation::kNone && empty(ui.instant_warp_auto_jump) + && empty(ui.instant_warp_auto_warp)) { action_label.Clear(); return; } auto* widget = unity::Newest(); - const bool active = unity::Alive(widget) && widget->isActiveAndEnabled(); - auto* button = active ? widget->visitButton() : nullptr; - const bool enabled = unity::Alive(button) && button->isActiveAndEnabled() && button->Interactable(); + auto* button = unity::Alive(widget) && widget->isActiveAndEnabled() ? widget->visitButton() : nullptr; const char* desired = nullptr; // The ordinary visit path uses the currently selected fleet. The card's cached popup CourseData is only filled on // click, so it cannot preview. Toll and wormhole buttons confirm their own way and keep the game's text. - if (enabled) { + if (unity::Alive(button) && button->isActiveAndEnabled() && button->Interactable()) { auto* fleets = FleetsManager::Instance(); auto* deployment = DeploymentManager::Instance(); auto* fleet = unity::Alive(fleets) ? fleets->GetSelectedFleetData() : nullptr; @@ -186,9 +190,8 @@ void CycleInstantWarpConfirmation() setting = InstantWarpConfirmation::kNone; break; } - constexpr const char* kNames[] = {"none", "warp", "jump"}; - spdlog::info("[InstantWarp] answering prompts with {}", kNames[static_cast(setting)]); + spdlog::info("[InstantWarp] answering prompts with {}", kInstantWarpNames[std::to_underlying(setting)]); } // Always installed: its only hook answers a prompt the player asked the mod to answer. -const Feature kInstantWarp{"InstantWarp", nullptr, InstallInstantWarp, UpdateWarpLabel}; +const Feature kInstantWarp{"InstantWarp", nullptr, InstallInstantWarp, InstantWarpFrame}; diff --git a/src/mod/features/keybind_badges.cc b/src/mod/features/keybind_badges.cc index 79349907d..bd7b683f3 100644 --- a/src/mod/features/keybind_badges.cc +++ b/src/mod/features/keybind_badges.cc @@ -15,11 +15,17 @@ #include #include +#include #include +#include #include namespace { +bool badges_installed = false; + +// --- Badge text --- + struct NativeAction { std::string_view name; Action primary; @@ -65,22 +71,13 @@ constexpr auto kNativeActions = std::to_array({ constexpr const NativeAction* FindNativeAction(std::string_view name) { - for (const auto& action : kNativeActions) - if (action.name == name) - return &action; - return nullptr; + const auto action = std::ranges::find(kNativeActions, name, &NativeAction::name); + return action == kNativeActions.end() ? nullptr : &*action; } -consteval bool NativeActionsAreUnique() -{ - for (size_t i = 0; i < kNativeActions.size(); ++i) - for (size_t j = i + 1; j < kNativeActions.size(); ++j) - if (kNativeActions[i].name == kNativeActions[j].name) - return false; - return true; -} - -static_assert(NativeActionsAreUnique()); +static_assert(std::ranges::all_of(kNativeActions, [](const NativeAction& action) { + return std::ranges::count(kNativeActions, action.name, &NativeAction::name) == 1; +})); static_assert(FindNativeAction("side_chat")->fallback == Action::kShowChatSide2); static_assert(FindNativeAction("unknown") == nullptr); @@ -92,12 +89,12 @@ std::string BadgeText(std::string_view action_name) return "-"; auto hint_action = action->primary; if (hint_action >= Action::kSelectShip1 && hint_action <= Action::kSelectShip8) { - // Use the same context array as FleetBarViewController::RequestSelect(int, bool), not the native dock index. - auto* bar = unity::Newest(); - auto* context = bar ? bar->CanvasContext() : nullptr; - auto* fleets = context ? context->FleetData() : nullptr; - hint_action = ShipShortcutHintAction(hint_action, fleets ? fleets->size() : 0, [fleets](std::size_t position) { - auto* fleet = (*fleets)[position]; + // The mod's ship keys select by position in the bar's context array, as RequestSelect does, not by dock index. + auto* bar = unity::Newest(); + auto* context = bar ? bar->CanvasContext() : nullptr; + const auto fleets = il2cpp::Items(context ? context->FleetData() : nullptr); + hint_action = ShipShortcutHintAction(hint_action, fleets.size(), [fleets](std::size_t position) { + auto* fleet = fleets[position]; return fleet ? fleet->Index() : -1; }); } @@ -108,7 +105,7 @@ std::string BadgeText(std::string_view action_name) return hint.empty() ? "-" : hint; } -bool badges_installed = false; +// --- Hooks --- void ShortcutKeybindHint_UpdateText_Hook(auto original, ShortcutKeybindHint* _this) { @@ -127,8 +124,7 @@ void ShortcutKeybindHint_UpdateText_Hook(auto original, ShortcutKeybindHint* _th auto* reference = _this->inputAction(); auto* action = reference ? reference->action() : nullptr; - auto* name = action ? action->name() : nullptr; - if (name) + if (auto* name = action ? action->name() : nullptr) label->OverrideLocalizedText(il2cpp::NewString(BadgeText(il2cpp::ToUtf8(name)).c_str())); } @@ -147,10 +143,12 @@ bool ToggleKeybindBadges() if (!badges_installed) return false; auto* manager = ShortcutsManager::Instance(); - if (!manager) + if (!manager) { spdlog::warn("[Hotkeys] the game's shortcut manager is not up; no badges to toggle"); + return true; + } // The same gate the game's own show_keybindings action checks. - else if (ShortcutsManager::CanUseShortcuts()) + if (ShortcutsManager::CanUseShortcuts()) manager->SetShowKeybindings(!manager->ShowKeybindings()); return true; } diff --git a/src/mod/features/officers.cc b/src/mod/features/officers.cc index 50928a9d5..25147fbdc 100644 --- a/src/mod/features/officers.cc +++ b/src/mod/features/officers.cc @@ -10,6 +10,7 @@ #include #include +#include #include namespace @@ -17,24 +18,26 @@ namespace constexpr char kRosterKey[] = "below_deck_ability"; constexpr char kAssignmentKey[] = "_below_deck_ability"; +// --- Comparers --- + // The native code of SortingPredicates' static comparers. The game's own delegates call them through the managed -// signature; the ones the mod adds jump straight to Compare below, which chains them. -using Comparer = int (*)(void* a, void* b, void* method); +// signature; the ones the mod adds jump straight to CompareOfficers below, which chains them. +using OfficerComparer = int (*)(void* a, void* b, void* method); -il2cpp::Method below_deck_ascending_method, below_deck_descending_method; -Comparer below_deck_ascending = nullptr, below_deck_descending = nullptr; +il2cpp::Method below_deck_ascending_method, below_deck_descending_method; +OfficerComparer below_deck_ascending = nullptr, below_deck_descending = nullptr; // Tiebreakers; either may be missing, which the binding layer has already reported. -Comparer unlocked_first = nullptr, by_index = nullptr; +OfficerComparer unlocked_first = nullptr, by_index = nullptr; // Func, taken from the game's own options: there is no binding for the inflated generic class. -Il2CppClass* func_class = nullptr; +Il2CppClass* officer_func_class = nullptr; -Comparer NativeCode(il2cpp::Method method) -{ return reinterpret_cast(method.Pointer()); } +OfficerComparer NativeComparer(il2cpp::Method method) +{ return reinterpret_cast(method.Pointer()); } // SortComparer.Compare always calls the _ascending delegate and negates it for Descending; _descending is never // called. So each delegate is a complete comparator: unlocked officers first, then below-deck ability in the given // direction, then roster index (reversed for the roster, whose default direction is Ascending). -template int Compare(void* a, void* b, void* /*method*/) +template int CompareOfficers(void* a, void* b, void* /*method*/) { if (unlocked_first) if (const int order = unlocked_first(a, b, nullptr)) @@ -51,10 +54,10 @@ bool ResolveComparers() below_deck_descending_method = SortingPredicates::Method("OfficerSortByBelowDeckAbilityDescending", 2); if (!below_deck_ascending_method || !below_deck_descending_method) return false; - below_deck_ascending = NativeCode(below_deck_ascending_method); - below_deck_descending = NativeCode(below_deck_descending_method); - unlocked_first = NativeCode(SortingPredicates::Method("OfficerSortByUnlockedStateAscending", 2)); - by_index = NativeCode(SortingPredicates::Method("OfficerSortByIndexAscending", 2)); + below_deck_ascending = NativeComparer(below_deck_ascending_method); + below_deck_descending = NativeComparer(below_deck_descending_method); + unlocked_first = NativeComparer(SortingPredicates::Method("OfficerSortByUnlockedStateAscending", 2)); + by_index = NativeComparer(SortingPredicates::Method("OfficerSortByIndexAscending", 2)); // The sort function and option are allocated without a constructor, so both classes must exist. return SortFunction::Class::Raw() && ProcessingOption::Class::Raw(); } @@ -64,13 +67,13 @@ bool ResolveComparers() // brings them first. SortFunction* NewSortFunction(const char* key, bool roster) { - const auto native = [](auto compare) { return reinterpret_cast(compare); }; - auto* ascending = - il2cpp::NewDelegate(func_class, below_deck_ascending_method, - native(roster ? &Compare<&below_deck_descending, -1> : &Compare<&below_deck_ascending, 1>)); - auto* descending = - il2cpp::NewDelegate(func_class, below_deck_descending_method, - native(roster ? &Compare<&below_deck_ascending, -1> : &Compare<&below_deck_descending, 1>)); + const auto native = [](auto compare) { return reinterpret_cast(compare); }; + auto* ascending = il2cpp::NewDelegate( + officer_func_class, below_deck_ascending_method, + native(roster ? &CompareOfficers<&below_deck_descending, -1> : &CompareOfficers<&below_deck_ascending, 1>)); + auto* descending = il2cpp::NewDelegate( + officer_func_class, below_deck_descending_method, + native(roster ? &CompareOfficers<&below_deck_ascending, -1> : &CompareOfficers<&below_deck_descending, 1>)); if (!ascending || !descending) return nullptr; @@ -82,14 +85,14 @@ SortFunction* NewSortFunction(const char* key, bool roster) return sort; } +// --- Sort menus --- + bool HasBelowDeckOption(il2cpp::List* options) { - for (auto* option : *options) { + return std::ranges::any_of(*options, [](ProcessingOption* option) { const auto key = option ? il2cpp::ToUtf8(option->displayKey()) : std::string(); - if (key == kRosterKey || key == kAssignmentKey) - return true; - } - return false; + return key == kRosterKey || key == kAssignmentKey; + }); } // Appends the option to `options`. The roster builds its SortComparer from `sorters` rather than from the options, @@ -101,12 +104,12 @@ void AddBelowDeckOption(il2cpp::List* options, const char* ke spdlog::warn("[OfficerSort] the screen has no sort options; not adding Below Deck Ability"); return; } - if (!func_class) { - auto* first = options->Count() > 0 ? (*options)[0] : nullptr; - auto* sort = first ? first->sortingOption() : nullptr; - auto* ascending = sort ? sort->ascending() : nullptr; - func_class = ascending ? ascending->klass : nullptr; - if (!func_class) { + if (!officer_func_class) { + auto* first = il2cpp::At(options, 0); + auto* sort = first ? first->sortingOption() : nullptr; + auto* ascending = sort ? sort->ascending() : nullptr; + officer_func_class = ascending ? ascending->klass : nullptr; + if (!officer_func_class) { spdlog::warn("[OfficerSort] no existing option shows the Func class; not adding Below " "Deck Ability"); return; diff --git a/src/mod/features/screens.cc b/src/mod/features/screens.cc index 16fc3c239..b828fe815 100644 --- a/src/mod/features/screens.cc +++ b/src/mod/features/screens.cc @@ -17,31 +17,21 @@ #include #include +#include #include namespace { -// Ship Swap and Officer Swap live on the Manage Ships screen, and Officer Presets on the officer assignment screen -// that Officer Swap opens. Their shortcuts open those screens first, then press the button once it exists. 300 -// frames is a few seconds at the game's frame rate. -constexpr int kButtonWaitFrames = 300; - -enum class ButtonStage { kShipSwap, kOfficerSwap, kOfficerAssignment, kOfficerPresets }; - -struct ButtonWait { - ButtonStage stage; - int frames = kButtonWaitFrames; -}; -std::optional pending_button; +// --- Opening screens --- // 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. -template void Native() +template void Native() { if (auto* manager = ShortcutsManager::Instance()) - manager->Run(); + manager->Run(); else - spdlog::warn("[Hotkeys] native {} shortcut is unavailable", Action.View()); + spdlog::warn("[Hotkeys] native {} shortcut is unavailable", Handler.View()); } template void Goto() @@ -76,15 +66,38 @@ void ShowCoordinateSearch() } } -void ShowHaven() -{ PlanetaryBaseManager::ViewOwnHaven(); } +// Opens Manage Ships for the fleet selected in the fleet bar. False when none is selected. +bool OpenManageShips() +{ + auto* panel = FleetBarViewController::NewestPanel(); + auto* fleet = panel ? panel->fleet() : nullptr; + if (!fleet) + return false; + panel->RequestAction(fleet, ActionType::Manage, 0, ActionBehaviour::Default); + return true; +} + +// --- Swap and preset buttons --- + +// Ship Swap and Officer Swap live on the Manage Ships screen, and Officer Presets on the officer assignment screen +// that Officer Swap opens. Their shortcuts open those screens first, then press the button once it exists. 300 +// frames is a few seconds at the game's frame rate. +constexpr int kButtonWaitFrames = 300; + +// kOfficerAssignment waits on Manage Ships for Officer Swap, on the way to Officer Presets. +enum class ButtonStage { kShipSwap, kOfficerSwap, kOfficerAssignment, kOfficerPresets }; + +struct ButtonWait { + ButtonStage stage; + int frames = kButtonWaitFrames; +}; +std::optional pending_button; // Presses the button `pick` returns on the first visible, active Controller. template bool PressButton(unity::Button* (*pick)(Controller*)) { for (auto* controller : unity::Instances()) { - auto* button = controller->isActiveAndEnabled() && CanvasController::Shows(controller) ? pick(controller) : nullptr; - if (button) { + if (auto* button = CanvasController::OnScreen(controller) ? pick(controller) : nullptr) { button->Press(); return true; } @@ -113,24 +126,13 @@ bool PressOfficerPresetsButton() }); } -void OpenManageShips() -{ - auto* fleet_bar = unity::Newest(); - auto* fleet = fleet_bar ? fleet_bar->SelectedFleet() : nullptr; - if (auto* panel = fleet ? fleet_bar->fleetPanelController() : nullptr) - panel->RequestAction(fleet, ActionType::Manage, 0, ActionBehaviour::Default); -} - -// Opens Manage Ships for the selected fleet, for a button that is only there. False, with a warning, without one. +// Opens Manage Ships for a button that only it has. False, with a warning, when no fleet is selected. bool OpenManageShipsFor(const char* what) { - auto* fleet_bar = unity::Newest(); - if (!fleet_bar || !fleet_bar->SelectedFleet()) { - spdlog::warn("[Hotkeys] no fleet is selected, so Manage Ships cannot open for {}", what); - return false; - } - OpenManageShips(); - return true; + if (OpenManageShips()) + return true; + spdlog::warn("[Hotkeys] no fleet is selected, so Manage Ships cannot open for {}", what); + return false; } void ShowShipSwap() @@ -155,6 +157,8 @@ void ShowOfficerPresets() pending_button = ButtonWait{ButtonStage::kOfficerAssignment}; } +// --- Shortcut table --- + struct ScreenShortcut { Action shortcut; void (*run)(); @@ -173,7 +177,7 @@ constexpr ScreenShortcut kScreens[] = { {Action::kNativeShortcutGalaxy, Native<"OnGalaxyAction">}, {Action::kShowGalaxy, GotoNavigation}, {Action::kShowStationInterior, Goto}, - {Action::kShowHaven, ShowHaven}, + {Action::kShowHaven, PlanetaryBaseManager::ViewOwnHaven}, {Action::kShowSystem, GotoNavigation}, {Action::kShowArtifacts, Goto}, {Action::kShowShipConstruction, Native<"OnShipsAction">}, @@ -199,96 +203,96 @@ constexpr ScreenShortcut kScreens[] = { {Action::kShowAllianceHelp, Goto}, {Action::kShowAllianceArmada, Goto}, {Action::kShowSettings, Goto}, - {Action::kShowShips, OpenManageShips, false}, + {Action::kShowShips, [] { OpenManageShips(); }, false}, }; -// Left and right step through docks, ship lists, artifacts and officer pages on the screens that have them, -// before they pan the camera. Returns true when something moved. -bool StepScreen(bool left) +// --- Arrow keys and search --- + +// Left and right step through docks, ship lists, artifacts and officer pages on the screens that have them, before +// they pan the camera. Each Step* moves one screen by `step`, -1 or 1, and says whether it moved. + +bool StepShipDetails(ShipManagementViewController* controller, int32_t step) { - const auto& config = Config::Get(); - - if (config.ui.arrow_keys_to_select_ship && Hub::CurrentSection() == SectionID::ShipManagement_Details) { - bool moved = false; - for (auto* controller : unity::Instances()) { - auto* context = - controller->isActiveAndEnabled() && CanvasController::Shows(controller) ? controller->Context() : nullptr; - if (!context) - continue; - const int32_t count = context->ListCount(); - const int32_t next = context->CurrentIndex() + (left ? -1 : 1); - if (count <= 1 || context->CurrentIndex() < 0 || next < 0 || next >= count) - continue; - context->SetLastMovementWasToIncrement(!left); - context->SetCurrentIndex(next); - moved = true; - } - if (moved) - return true; - } + auto* context = CanvasController::OnScreen(controller) ? controller->Context() : nullptr; + if (!context) + return false; + const int32_t count = context->ListCount(); + const int32_t current = context->CurrentIndex(); + const int32_t next = current + step; + if (count <= 1 || current < 0 || next < 0 || next >= count) + return false; + context->SetLastMovementWasToIncrement(step > 0); + context->SetCurrentIndex(next); + return true; +} - if (config.ui.arrow_keys_to_select_ship) { - bool moved = false; - for (auto* widget : unity::Instances()) { - auto* list = widget->isActiveAndEnabled() && CanvasController::Shows(widget) ? widget->selectableList() : nullptr; - if (!list || list->Count() <= 0) - continue; - // Skip empty dock slots. - const int32_t count = list->Count(); - int32_t index = list->SelectedIndex(); - do - index += left ? -1 : 1; - while (index >= 0 && index < count - && !(list->DataItem(index) && static_cast(list->DataItem(index))->HasShip())); - if (index < 0 || index >= count) - continue; +// Skips empty dock slots. +bool StepDock(AssignShipsWidget* widget, int32_t step) +{ + auto* list = CanvasController::OnScreen(widget) ? widget->selectableList() : nullptr; + if (!list) + return false; + const int32_t count = list->Count(); + for (int32_t index = list->SelectedIndex() + step; index >= 0 && index < count; index += step) { + if (auto* fleet = static_cast(list->DataItem(index)); fleet && fleet->HasShip()) { list->RequestSelect(index); - moved = true; - } - if (moved) return true; + } } + return false; +} + +bool StepArtifact(ArtifactHallDetailsViewController* controller, int32_t step) +{ + if (!controller->isActiveAndEnabled()) + return false; + step < 0 ? controller->PressLeftArrow() : controller->PressRightArrow(); + return true; +} + +bool StepSelector(ElementSelectorViewController* selector, int32_t step) +{ + if (!selector->isActiveAndEnabled()) + return false; + step < 0 ? selector->PressDecrement() : selector->PressIncrement(); + return true; +} +// Steps every live Screen, and says whether any of them moved. +template bool StepAll(bool (*step_one)(Screen*, int32_t), int32_t step) +{ bool moved = false; - for (auto* controller : unity::Instances()) { - if (!controller->isActiveAndEnabled()) - continue; - left ? controller->PressLeftArrow() : controller->PressRightArrow(); - moved = true; - } - if (moved) - return true; + for (auto* screen : unity::Instances()) + moved |= step_one(screen, step); + return moved; +} - for (auto* selector : unity::Instances()) { - if (!selector->isActiveAndEnabled()) - continue; - left ? selector->PressDecrement() : selector->PressIncrement(); - moved = true; +// The first kind of screen that moves wins. +bool StepScreen(int32_t step) +{ + if (Config::Get().ui.arrow_keys_to_select_ship) { + if (Hub::CurrentSection() == SectionID::ShipManagement_Details && StepAll(StepShipDetails, step)) + return true; + if (StepAll(StepDock, step)) + return true; } - return moved; + return StepAll(StepArtifact, step) || StepAll(StepSelector, step); } // Focuses the search box of whichever inventory, officer or ship list is on screen. bool FocusSearchBox() { const auto focus = [](auto* owner) { - auto* field = owner->inputField(); - if (!field || !owner->isActiveAndEnabled() || !CanvasController::Shows(owner) || !field->isActiveAndEnabled()) + auto* field = CanvasController::OnScreen(owner) ? owner->inputField() : nullptr; + if (!field || !field->isActiveAndEnabled()) return false; field->Focus(); return true; }; // InventoryListViewController also covers the officer roster and the other inventory screens. - for (auto* controller : unity::Instances()) - if (focus(controller)) - return true; - for (auto* controller : unity::Instances()) - if (focus(controller)) - return true; - for (auto* widget : unity::Instances()) - if (focus(widget)) - return true; - return false; + return std::ranges::any_of(unity::Instances(), focus) + || std::ranges::any_of(unity::Instances(), focus) + || std::ranges::any_of(unity::Instances(), focus); } } // namespace @@ -335,9 +339,9 @@ bool PressAwaitedButton() bool ScreenShortcuts() { - if (Shortcut::IsDown(Action::kMoveLeft) && StepScreen(true)) + if (Shortcut::IsDown(Action::kMoveLeft) && StepScreen(-1)) return true; - if (Shortcut::IsDown(Action::kMoveRight) && StepScreen(false)) + if (Shortcut::IsDown(Action::kMoveRight) && StepScreen(1)) return true; if (Config::Get().ui.focus_search && Shortcut::IsDown(Action::kFocusSearch) && FocusSearchBox()) return true; diff --git a/src/mod/features/ship_shortcut_hint.h b/src/mod/features/ship_shortcut_hint.h index 9d157b214..6233248e7 100644 --- a/src/mod/features/ship_shortcut_hint.h +++ b/src/mod/features/ship_shortcut_hint.h @@ -7,15 +7,17 @@ #include // Native ship actions identify FleetPlayerData::Index. Mod selection passes a position in FleetBarContext::FleetData -// to RequestSelect instead; locked docks can leave gaps in the native indices. +// to RequestSelect instead; locked docks can leave gaps in the native indices. kCount when none of the eight ship +// keys reaches that fleet. template Action ShipShortcutHintAction(Action native_action, std::size_t fleet_count, FleetIndexAt fleet_index_at) { if (native_action < Action::kSelectShip1 || native_action > Action::kSelectShip8) return native_action; - const auto fleet_index = static_cast(native_action) - static_cast(Action::kSelectShip1); - constexpr auto shortcut_count = static_cast(Action::kSelectShip8) - static_cast(Action::kSelectShip1) + 1; + const auto fleet_index = static_cast(native_action) - static_cast(Action::kSelectShip1); + constexpr std::size_t shortcut_count = + static_cast(Action::kSelectShip8) - static_cast(Action::kSelectShip1) + 1; for (std::size_t position = 0; position < fleet_count && position < shortcut_count; ++position) { if (fleet_index_at(position) == fleet_index) return static_cast(static_cast(Action::kSelectShip1) + position); diff --git a/src/mod/features/shop.cc b/src/mod/features/shop.cc index 376b33501..7f9add0f6 100644 --- a/src/mod/features/shop.cc +++ b/src/mod/features/shop.cc @@ -24,7 +24,7 @@ bool drawer_opened = false; void OpenBulkClaimDrawer(ShopListScrollerViewController* controller) { - if (!Config::Get().ui.auto_open_bulk_claim_flyout || !controller || drawer_opened) + if (!Config::Get().ui.auto_open_bulk_claim_flyout || drawer_opened) return; auto* drawer = controller->selectionDrawerWidget(); @@ -74,17 +74,16 @@ void InventoryUseRowWidget_SetWidgetData_Hook(auto original, InventoryUseRowWidg auto* context = _this->Context(); const auto& ui = Config::Get().ui; if (context && context->IsChestPurchase()) { - const auto native_max = context->MaxItemsToUse(); - const auto bounded_max = std::min(static_cast(ui.extend_chest_purchase_max), kMaximumChestPurchaseMax); - if (native_max > 0 && bounded_max > native_max) - context->SetMaxItemsToUse(bounded_max); + const auto native_max = context->MaxItemsToUse(); + const auto extended = std::min(int64_t{ui.extend_chest_purchase_max}, kMaximumChestPurchaseMax); + if (native_max > 0 && extended > native_max) + context->SetMaxItemsToUse(extended); } else if (context && ui.extend_donation_slider && context->IsDonationUse()) { // extend_donation_max replaces the game's donation cap; 0 or less means no cap. Either way the slider stops at // what the player owns. const auto owned = context->AmountOwned(); - auto target = ui.extend_donation_max > 0 ? static_cast(ui.extend_donation_max) : owned; - if (owned > 0) - target = std::min(target, owned); + const auto cap = ui.extend_donation_max > 0 ? int64_t{ui.extend_donation_max} : owned; + const auto target = owned > 0 ? std::min(cap, owned) : cap; if (target > 0 && target != context->MaxItemsToUse()) context->SetMaxItemsToUse(target); } @@ -104,10 +103,7 @@ void BundleDataWidget_OnActionButtonPressedCallback_Hook(auto original, BundleDa // --- Reveal sequence --- bool ShopSceneManager_ShouldShowRevealSequence_Hook(auto original, il2cpp::Object* _this, bool ignore) -{ - const auto result = original(_this, ignore); - return result && !Config::Get().ui.always_skip_reveal_sequence; -} +{ return original(_this, ignore) && !Config::Get().ui.always_skip_reveal_sequence; } // --- First popup only --- diff --git a/src/mod/features/toast_text.h b/src/mod/features/toast_text.h index d01d9bac5..93824289b 100644 --- a/src/mod/features/toast_text.h +++ b/src/mod/features/toast_text.h @@ -16,48 +16,45 @@ inline std::string FormatPlaceholders(std::string_view text, std::span(after - text.data()); - const auto end = text.find('}', offset); - if (end == std::string_view::npos) { - result += text.substr(pos); - break; - } - 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; - } + for (auto open = text.find('{'); open != std::string_view::npos; open = text.find('{')) { + result += text.substr(0, open); + text.remove_prefix(open + 1); + // An index without leading zeros, then '}' or ":format}". Digits hold no '}', so `close` is never before + // `digits` and a found one leaves text[digits] in range. + size_t index = 0; + const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), index); + const auto digits = static_cast(end - text.data()); + const auto close = text.find('}'); + if (error != std::errc{} || (digits > 1 && text[0] == '0') || close == std::string_view::npos + || (text[digits] != '}' && text[digits] != ':') || index >= values.size() || values[index].empty()) { + result += '{'; + continue; } - result += text[pos++]; + const auto& value = values[index]; + const auto format = text[digits] == ':' ? text.substr(digits + 1, close - digits - 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; + text.remove_prefix(close + 1); } + result += text; return result; } -// Removes Unity rich text tags such as , and . +// Removes Unity rich text tags such as , and . An unclosed '<' is text. inline std::string StripRichText(std::string_view text) { std::string result; result.reserve(text.size()); - for (size_t i = 0; i < text.size();) { - if (text[i] == '<') { - if (const auto end = text.find('>', i); end != std::string_view::npos) { - i = end + 1; - continue; - } - } - result += text[i++]; + for (auto open = text.find('<'); open != std::string_view::npos; open = text.find('<')) { + const auto close = text.find('>', open); + if (close == std::string_view::npos) + break; + result += text.substr(0, open); + text.remove_prefix(close + 1); } + result += text; return result; } diff --git a/src/mod/features/toasts.cc b/src/mod/features/toasts.cc index 1627c49f0..765a3939e 100644 --- a/src/mod/features/toasts.cc +++ b/src/mod/features/toasts.cc @@ -5,9 +5,7 @@ #include "features/features.h" #include "features/notification_audio.h" #include "features/toast_text.h" -#include "text.h" -#include "game/fleet.h" #include "game/server.h" #include "game/ui.h" @@ -16,8 +14,10 @@ #include #include -#include +#include #include +#include +#include #include #if _WIN32 @@ -28,7 +28,7 @@ namespace { -using toast_text::FormatPlaceholders; +// --- Toast states --- constexpr std::pair kTitles[] = { {ToastState::Standard, "Notification"}, @@ -122,14 +122,14 @@ constexpr BattleType kPvpBattles[] = {BattleType::Fleet, BattleType::Base, BattleType::ArmadaAsb, BattleType::ArmadaMta, BattleType::PvpCuttingBeam, BattleType::PvpChainShot}; -template bool Contains(const T (&list)[N], T value) -{ return std::ranges::find(list, value) != std::end(list); } - +// --- Notification text --- +// // Toast data comes from a dozen game types whose layouts drift between builds. On Windows, an access violation -// while reading one is contained and logged instead of taking the game down. +// while reading one is contained and logged instead of taking the game down, so every read below is Guarded. + using il2cpp::Guarded; -// Localizes `identifier` with one parameter, guarded like the rest of the toast reads. +// Localizes `identifier` with one parameter. std::string Localize(const char* identifier, const char* category, Il2CppObject* parameter) { std::string result; @@ -151,19 +151,19 @@ std::string HullName(BattleResultHeader* header, int64_t hull_id) if (!hull) return fmt::format("Hull#{}", hull_id); - // An unknown id localizes to the identifier itself. + // An unknown id localizes to the identifier itself, which counts as no name. + const auto localized = [](Il2CppObject* id, const std::string& id_text) { + auto name = Localize("ship_name_{0}", "ships", id); + return name == "ship_name_" + id_text ? std::string{} : name; + }; if (auto* ids = hull->idRefs()) { - if (const auto loca_id = ids->locaId()) { - auto name = Localize("ship_name_{0}", "ships", il2cpp::Box(loca_id)); - if (!name.empty() && name != fmt::format("ship_name_{}", loca_id)) + if (const auto loca_id = ids->locaId()) + if (auto name = localized(il2cpp::Box(loca_id), std::to_string(loca_id)); !name.empty()) return name; - } - if (auto* loca_string = ids->locaStringId(); loca_string && loca_string->length) { - const auto id = il2cpp::ToUtf8(loca_string); - auto name = Localize("ship_name_{0}", "ships", reinterpret_cast(loca_string)); - if (!name.empty() && name != "ship_name_" + id) + if (auto* loca_string = ids->locaStringId(); loca_string && loca_string->length) + if (auto name = localized(reinterpret_cast(loca_string), il2cpp::ToUtf8(loca_string)); + !name.empty()) return name; - } } auto* key = hull->name(); return key ? toast_text::HullKeyName(il2cpp::ToUtf8(key)) : fmt::format("Hull#{}", hull_id); @@ -190,7 +190,7 @@ std::string BattleBody(BattleResultHeader* header) enemy = std::move(name); } }); - Guarded("the battle type", [&] { pvp = Contains(kPvpBattles, header->Type()); }); + Guarded("the battle type", [&] { pvp = std::ranges::contains(kPvpBattles, header->Type()); }); Guarded("the player hull", [&] { player_ship = toast_text::TitleCase(HullName(header, header->PlayerShipHullId())); }); Guarded("the enemy hull", [&] { enemy_ship = toast_text::TitleCase(HullName(header, header->EnemyShipHullId())); }); @@ -236,9 +236,8 @@ std::string ArmadaCreatedBody(ArmadaCreatedData* data) std::vector ParameterValues(il2cpp::Array* parameters) { std::vector values; - for (il2cpp_array_size_t i = 0; parameters && i < parameters->size(); ++i) { - auto* element = (*parameters)[i]; - Il2CppString* text = nullptr; + for (auto* element : il2cpp::Items(parameters)) { + Il2CppString* text = nullptr; if (element) Guarded("a text parameter", [&] { text = il2cpp::ToString(static_cast(element)); }); values.push_back(il2cpp::ToUtf8(text)); @@ -246,47 +245,48 @@ std::vector ParameterValues(il2cpp::Array* parameter return values; } -// Use the named object[] binding. Other array fields may contain unboxed values, not object pointers. +// Fills `text` from the toast's TextParameters, its object[] field. Other array fields may hold unboxed values +// rather than object pointers, so they are not read as parameters. std::string FormatToastText(const std::string& text, Toast* toast) { if (auto* parameters = toast->TextParameters(); parameters && parameters->size() > 0) - return FormatPlaceholders(text, ParameterValues(parameters)); - if (text.find('{') != std::string::npos) + return toast_text::FormatPlaceholders(text, ParameterValues(parameters)); + if (text.contains('{')) spdlog::warn("[Toasts] TextParameters unavailable; placeholders unresolved in template: \"{}\"", text); return text; } -// The toast's own localized text with its parameters filled in. +// The toast's own localized text with its parameters filled in, for toasts without a body of their own. std::string ToastText(Toast* toast) { auto* context = toast->TextLocaleTextContext(); if (!context) { - spdlog::debug("[Toasts] No TextLocaleTextContext for toast state {}", static_cast(toast->State())); + spdlog::debug("[Toasts] No TextLocaleTextContext for toast state {}", std::to_underlying(toast->State())); return {}; } // 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); + return result.contains('{') ? FormatToastText(result, toast) : result; } Il2CppString* text = nullptr; auto* manager = LanguageManager::Instance(); if (!manager || !manager->Localize(text, context) || !text) { - spdlog::debug("[Toasts] toast state {} could not be localized", static_cast(toast->State())); + spdlog::debug("[Toasts] toast state {} could not be localized", std::to_underlying(toast->State())); return {}; } return FormatToastText(il2cpp::ToUtf8(text), toast); } -std::string EventCategory(Toast* toast) +// Empty for a category this table does not name. +std::string_view EventCategory(Toast* toast) { auto* data = toast->Data(); auto* event = data ? data->As() : nullptr; const auto category = event ? event->category() : -1; - return category >= 0 && category < static_cast(std::size(kEventCategories)) ? kEventCategories[category] - : ""; + return category >= 0 && std::cmp_less(category, std::size(kEventCategories)) ? kEventCategories[category] : ""; } std::string NotificationBody(Toast* toast) @@ -296,25 +296,28 @@ std::string NotificationBody(Toast* toast) std::string body; if (state == ToastState::ArmadaCreated) body = ArmadaCreatedBody(static_cast(data)); - else if (auto* header = data && Contains(kBattleToasts, state) ? data->As() : nullptr) + else if (auto* header = + data && std::ranges::contains(kBattleToasts, state) ? data->As() : nullptr) body = BattleBody(header); if (body.empty()) body = toast_text::StripRichText(ToastText(toast)); if (body.empty()) body = "(no details available)"; - if (Contains(kEventToasts, state)) + if (std::ranges::contains(kEventToasts, state)) if (const auto category = EventCategory(toast); !category.empty()) - body = category + " - " + body; + body = fmt::format("{} - {}", category, body); return body; } +// --- Windows notifications --- + #if _WIN32 void ShowWindowsNotification(const char* title, const std::string& body) { // Notifications need a WinRT apartment on the game thread, set up by the first one. The game may already have // one in another mode, which only matters if notifications then fail. - static const bool apartment = [] { + [[maybe_unused]] static const bool apartment = [] { try { winrt::init_apartment(winrt::apartment_type::single_threaded); } catch (const winrt::hresult_error& e) { @@ -325,7 +328,6 @@ void ShowWindowsNotification(const char* title, const std::string& body) } return true; }(); - (void)apartment; try { using namespace winrt::Windows::UI::Notifications; auto xml = ToastNotificationManager::GetTemplateContent(ToastTemplateType::ToastText02); @@ -341,17 +343,17 @@ void ShowWindowsNotification(const char* title, const std::string& body) } #endif -// TODO: macOS has no notification delivery yet; the body code above runs on Windows only. +// TODO: macOS has no notification delivery yet, so notify_banner_types does nothing there. void Notify(Toast* toast) { #if _WIN32 const auto state = toast->State(); const auto& types = Config::Get().ui.notify_banner_types; - if (std::ranges::find(types, static_cast(state)) == types.end()) + if (!std::ranges::contains(types, std::to_underlying(state))) return; const auto title = std::ranges::find(kTitles, state, &std::pair::first); if (title == std::end(kTitles)) { - spdlog::debug("[Toasts] No title mapping for toast state {}, skipping", static_cast(state)); + spdlog::debug("[Toasts] No title mapping for toast state {}, skipping", std::to_underlying(state)); return; } ShowWindowsNotification(title->second, NotificationBody(toast)); @@ -360,7 +362,9 @@ void Notify(Toast* toast) #endif } -// Notifies, then reports whether the in-game banner should be shown. +// --- Banners --- + +// Plays the toast's alert and sends its notification, then says whether the game should show its banner. bool ShowBanner(Toast* toast) { if (!toast) @@ -388,8 +392,7 @@ bool ShowBanner(Toast* toast) } PlayNotificationSound(sound); Notify(toast); - const auto& disabled = Config::Get().ui.disabled_banner_types; - return std::ranges::find(disabled, static_cast(toast->State())) == disabled.end(); + return !std::ranges::contains(Config::Get().ui.disabled_banner_types, std::to_underlying(toast->State())); } void ToastObserver_EnqueueToast_Hook(auto original, ToastObserver* _this, Toast* toast) diff --git a/src/mod/game/inventory.h b/src/mod/game/inventory.h index e19a4dbcb..fa070f921 100644 --- a/src/mod/game/inventory.h +++ b/src/mod/game/inventory.h @@ -244,6 +244,13 @@ struct SmartScrollerBase : il2cpp::Object { std::optional ScrollPosition() { return TryCall<"get_ScrollPosition", float()>(); } + // How many cards are spawned; empty when the pool is missing or its count could not be read. + std::optional SpawnedCount() + { + auto* items = spawnedItems(); + return items ? items->TryCall<"get_Count", int32_t()>() : std::nullopt; + } + // The tooltip widget on a spawned card, active or not. static InventoryTooltipTriggerWidget* CardTooltip(unity::Component* card) { diff --git a/src/mod/sync/fleet_watch.cc b/src/mod/sync/fleet_watch.cc deleted file mode 100644 index 5ecb42a22..000000000 --- a/src/mod/sync/fleet_watch.cc +++ /dev/null @@ -1,320 +0,0 @@ -#include "sync/fleet_watch.h" - -#include "game/fleet.h" - -#include - -#include -#include -#include -#include -#include -#include - -namespace -{ -constexpr int kFleetSlotCount = 10; -constexpr int64_t kManagerProbeIntervalMs = 250; -constexpr int64_t kManagerDiscoveryIntervalMs = 5'000; -constexpr int64_t kSeedIntervalMs = 1'000; -constexpr int64_t kSeedBackoffIntervalMs = 5'000; -constexpr int64_t kSeedFastPeriodMs = 30'000; -constexpr int64_t kSeedLifetimeMs = 120'000; -constexpr int64_t kSeedStabilizationMs = 5'000; -constexpr int64_t kSeedMaxStabilizationMs = 30'000; - -struct ObservedFleet { - int64_t fleet_id = 0; - FleetState state = FleetState::Unknown; - bool occupied = false; -}; - -std::array s_slots{}; -std::vector s_subscriptions; -bool s_observer_installed = false; -bool s_seed_pending = false; -bool s_seed_has_observation = false; -bool s_seed_manager_backed = false; -bool s_seed_finalize_candidate = false; -int64_t s_seed_started_ms = 0; -int64_t s_seed_first_observed_ms = 0; -int64_t s_seed_last_change_ms = 0; -int64_t s_last_seed_attempt_ms = 0; -int64_t s_last_manager_probe_ms = 0; -int64_t s_last_manager_discovery_ms = 0; -int s_manager_probe_slot = 0; -int s_dispatch_depth = 0; - -class CallbackScope -{ -public: - CallbackScope() - { ++s_dispatch_depth; } - - ~CallbackScope() - { --s_dispatch_depth; } -}; - -int64_t now_milliseconds() -{ - return std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) - .count(); -} - -void reset_observation() -{ - s_slots = {}; - s_seed_pending = true; - s_seed_has_observation = false; - s_seed_manager_backed = false; - s_seed_finalize_candidate = false; - s_seed_started_ms = now_milliseconds(); - s_seed_first_observed_ms = 0; - s_seed_last_change_ms = 0; - s_last_seed_attempt_ms = 0; - s_last_manager_probe_ms = 0; - s_last_manager_discovery_ms = 0; - s_manager_probe_slot = 0; -} - -void restart_seed(int slot, int64_t fleet_id) -{ - spdlog::debug("[FleetWatch] fleet topology changed; restarting baseline slot={} fleet={}", slot, fleet_id); - reset_observation(); -} - -int occupied_slot_count() -{ - return static_cast( - std::count_if(s_slots.begin(), s_slots.end(), [](const auto& slot) { return slot.occupied; })); -} - -void dispatch_transition(int slot, FleetPlayerData* fleet, FleetState before, FleetState after) -{ - const fleet_watch::Transition transition{ - .before = fleet_watch::Snapshot{slot, fleet->Id(), before}, - .after = fleet_watch::Snapshot{slot, fleet->Id(), after}, - .fleet = fleet, - }; - CallbackScope callback_scope; - for (const auto& subscription : s_subscriptions) { - try { - subscription.on_transition(transition); - } catch (const std::exception& error) { - spdlog::warn("[FleetWatch] transition callback failed: {}", error.what()); - } catch (...) { - spdlog::warn("[FleetWatch] transition callback failed with an unknown exception"); - } - } -} - -void dispatch_baseline() -{ - CallbackScope callback_scope; - for (const auto& subscription : s_subscriptions) { - if (!subscription.on_baseline) { - continue; - } - try { - subscription.on_baseline(); - } catch (const std::exception& error) { - spdlog::warn("[FleetWatch] baseline callback failed: {}", error.what()); - } catch (...) { - spdlog::warn("[FleetWatch] baseline callback failed with an unknown exception"); - } - } -} - -void observe_fleet(FleetPlayerData* fleet, int requested_slot, bool publish) -{ - if (!fleet || requested_slot < 0 || requested_slot >= kFleetSlotCount) { - return; - } - const auto slot = requested_slot; - - auto& previous = s_slots[slot]; - const auto fleet_id = fleet->Id(); - const auto state = fleet->CurrentState(); - const bool same_fleet = previous.occupied && previous.fleet_id == fleet_id; - const bool same_state = same_fleet && previous.state == state; - if (!same_fleet && !s_seed_pending) { - restart_seed(slot, fleet_id); - } - - const bool changed = !same_state; - const auto previous_state = previous.state; - previous = ObservedFleet{fleet_id, state, true}; - - if (s_seed_pending && changed) { - const auto now_ms = now_milliseconds(); - if (!s_seed_has_observation) { - s_seed_has_observation = true; - s_seed_first_observed_ms = now_ms; - } - s_seed_last_change_ms = now_ms; - s_seed_finalize_candidate = false; - } - if (same_fleet && publish && !s_seed_pending && previous_state != state) { - dispatch_transition(slot, fleet, previous_state, state); - } -} - -struct ScanResult { - int observed_count = 0; - bool manager_available = false; -}; - -// Reads every slot while a baseline is being established. Transitions are not published from here. -ScanResult scan_manager() -{ - ScanResult result; - auto* manager = FleetsManager::Instance(); - if (!manager || !manager->HasFleetService()) { - return result; - } - result.manager_available = true; - - std::array fleets{}; - bool topology_changed = false; - for (int slot = 0; slot < kFleetSlotCount; ++slot) { - fleets[slot] = manager->GetFleetPlayerData(slot); - if (fleets[slot]) { - ++result.observed_count; - } - const bool occupied = fleets[slot] != nullptr; - const auto fleet_id = occupied ? fleets[slot]->Id() : 0; - if (s_slots[slot].occupied != occupied || (occupied && s_slots[slot].fleet_id != fleet_id)) { - topology_changed = true; - } - } - - if (topology_changed && s_seed_pending && s_seed_has_observation) { - s_seed_last_change_ms = now_milliseconds(); - s_seed_finalize_candidate = false; - } - - for (int slot = 0; slot < kFleetSlotCount; ++slot) { - if (fleets[slot]) { - observe_fleet(fleets[slot], slot, false); - } else { - s_slots[slot] = {}; - } - } - s_seed_manager_backed = true; - return result; -} -} // namespace - -namespace fleet_watch -{ -bool Subscribe(Subscription subscription) -{ - if (!subscription.on_transition || s_dispatch_depth != 0) { - return false; - } - const auto duplicate = std::find_if(s_subscriptions.begin(), s_subscriptions.end(), [&](const auto& existing) { - return existing.on_transition == subscription.on_transition; - }); - if (duplicate != s_subscriptions.end()) { - return false; - } - - if (!s_observer_installed) { - s_observer_installed = true; - reset_observation(); - } - s_subscriptions.push_back(subscription); - return true; -} - -void Tick() -{ - if (s_subscriptions.empty()) { - return; - } - - const auto now_ms = now_milliseconds(); - if (s_seed_pending) { - if (s_seed_started_ms == 0) { - s_seed_started_ms = now_ms; - } - const auto lifetime = now_ms - s_seed_started_ms; - const auto interval = lifetime < kSeedFastPeriodMs ? kSeedIntervalMs : kSeedBackoffIntervalMs; - if (s_last_seed_attempt_ms == 0 || now_ms - s_last_seed_attempt_ms >= interval) { - s_last_seed_attempt_ms = now_ms; - const auto scan = scan_manager(); - if (s_seed_manager_backed && !scan.manager_available) { - reset_observation(); - return; - } - if (scan.manager_available && !s_seed_has_observation) { - s_seed_has_observation = true; - s_seed_first_observed_ms = now_ms; - s_seed_last_change_ms = now_ms; - } - } - - if (s_seed_has_observation) { - const bool quiet = now_ms - s_seed_last_change_ms >= kSeedStabilizationMs; - const bool forced = now_ms - s_seed_first_observed_ms >= kSeedMaxStabilizationMs; - if (quiet || forced) { - if (!s_seed_finalize_candidate) { - s_seed_finalize_candidate = true; - return; - } - const auto final_scan = scan_manager(); - if (!forced && now_ms - s_seed_last_change_ms < kSeedStabilizationMs) { - s_seed_finalize_candidate = false; - return; - } - if (final_scan.manager_available || !s_seed_manager_backed) { - const auto baseline_count = final_scan.observed_count > 0 ? final_scan.observed_count : occupied_slot_count(); - s_seed_pending = false; - s_last_manager_probe_ms = now_ms; - spdlog::debug("[FleetWatch] established stable baseline for {} fleet slots", baseline_count); - if (final_scan.manager_available) { - dispatch_baseline(); - } - } - } - } else if (lifetime >= kSeedLifetimeMs) { - s_seed_pending = false; - s_last_manager_discovery_ms = now_ms; - spdlog::warn("[FleetWatch] baseline stopped after {}ms without an available fleet service", kSeedLifetimeMs); - } - return; - } - - if (!s_seed_manager_backed - && (s_last_manager_discovery_ms == 0 || now_ms - s_last_manager_discovery_ms >= kManagerDiscoveryIntervalMs)) { - s_last_manager_discovery_ms = now_ms; - auto* manager = FleetsManager::Instance(); - if (manager && manager->HasFleetService()) { - restart_seed(-1, 0); - return; - } - } - - if (s_seed_manager_backed - && (s_last_manager_probe_ms == 0 || now_ms - s_last_manager_probe_ms >= kManagerProbeIntervalMs)) { - s_last_manager_probe_ms = now_ms; - auto* manager = FleetsManager::Instance(); - if (!manager || !manager->HasFleetService()) { - restart_seed(-1, 0); - return; - } - const int slot = s_manager_probe_slot; - s_manager_probe_slot = (slot + 1) % kFleetSlotCount; - auto* fleet = manager->GetFleetPlayerData(slot); - const bool occupied = fleet != nullptr; - const auto fleet_id = occupied ? fleet->Id() : 0; - if (s_slots[slot].occupied != occupied || (occupied && s_slots[slot].fleet_id != fleet_id)) { - restart_seed(slot, fleet_id); - return; - } - if (fleet) { - observe_fleet(fleet, slot, true); - } - } -} -} // namespace fleet_watch diff --git a/src/mod/sync/fleet_watch.h b/src/mod/sync/fleet_watch.h deleted file mode 100644 index 87e980658..000000000 --- a/src/mod/sync/fleet_watch.h +++ /dev/null @@ -1,38 +0,0 @@ -#pragma once - -#include "game/fleet.h" - -namespace fleet_watch -{ -struct Snapshot { - int slot = -1; - int64_t fleet_id = 0; - FleetState state = FleetState::Unknown; -}; - -struct Transition { - Snapshot before; - Snapshot after; - FleetPlayerData* fleet = nullptr; -}; - -using TransitionCallback = void (*)(const Transition& transition); -using BaselineCallback = void (*)(); - -struct Subscription { - TransitionCallback on_transition = nullptr; - // Optional. Runs on the game thread each time a stable baseline is established while the fleet service is available - // (after login, and again after any fleet-slot topology change). Transitions are only dispatched after a baseline, so - // a consumer that needs the complete fleet list reads FleetsManager here. - BaselineCallback on_baseline = nullptr; -}; - -// Registers a process-lifetime fleet-state observer. With no subscriptions, Fleet Watch performs no scans or IL2CPP -// work. -// Register during patch installation. Callbacks run synchronously on the game thread, and Transition::fleet is valid -// only for the duration of the callback. -bool Subscribe(Subscription subscription); - -// Runs once a frame from the subscriber's frame work (fleet sync, the only subscriber). -void Tick(); -} // namespace fleet_watch diff --git a/src/mod/sync/fleets.cc b/src/mod/sync/fleets.cc index 5ab882267..65382b9e3 100644 --- a/src/mod/sync/fleets.cc +++ b/src/mod/sync/fleets.cc @@ -1,14 +1,15 @@ -// 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 publishes; uploads stay on the target workers. +// [sync] fleets: watches the fleet slots, sends one record per FleetState change, a full snapshot as a first sync, +// and late re-reads after server updates. Everything that reads the game runs on the game thread from sync's frame +// work and only publishes; uploads stay on the target workers. #include "sync/sync.h" -#include "sync/fleet_watch.h" - #include "game/fleet.h" +#include + #include +#include #include #include #include @@ -21,7 +22,7 @@ namespace data_sync { namespace { - using json = nlohmann::json; + using namespace std::chrono_literals; constexpr int kFleetSlotCount = 10; @@ -31,13 +32,15 @@ namespace // Fleets the server just sent new data for. The client fills in some values (mining and travel timers, damage) from // that data, so the game thread re-reads these fleets shortly after. - std::mutex reread_mutex; - std::unordered_map reread_at; + std::mutex reread_mutex; + std::unordered_map reread_at; - // 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. + // Set once fleet sync started watching. Sync's frame work then polls the fleets, and the processing thread's + // re-read requests are kept. std::atomic_bool fleet_sync_installed{false}; + // --- Records --- + const char* StateName(FleetState state) { switch (state) { @@ -178,7 +181,7 @@ namespace } // 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. + // Repairing between two uploads still sends all three. void Append(json records, bool snapshot) { static uint64_t sequence = 0; @@ -203,21 +206,6 @@ namespace } } - 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)); - Append(std::move(records), false); - } - - void OnBaseline() - { - snapshot_requested.store(false, std::memory_order_relaxed); - QueueSnapshot("baseline"); - } - void RereadDue() { std::vector due; @@ -225,7 +213,7 @@ namespace std::scoped_lock lock(reread_mutex); if (reread_at.empty()) return; - const auto now = std::chrono::steady_clock::now(); + const auto now = Clock::now(); std::erase_if(reread_at, [&](const auto& entry) { if (entry.second > now) return false; @@ -254,18 +242,212 @@ namespace Append(std::move(records), false); } - // Subscribes to fleet_watch the first frame a target wants fleets, so a reload that turns them on starts the - // watcher. It then keeps running, and delivery filters per target as for every other category. Game thread. + // --- Fleet watch --- + // + // The client fills in the fleet slots over several frames after login, so a change seen meanwhile is not a + // transition. A baseline waits until the slots have been quiet for kFleetStableFor, or kFleetStableAtMost after the + // first fleet appeared, then sends a snapshot. Only after that is each state change of a known fleet sent. A slot + // that changes hands, or a fleet service that goes away, starts a new baseline. + + // While waiting for a baseline every slot is read each second for the first 30 s, then every 5 s. Without a fleet + // service for two minutes (the login screen) only a check every 5 s remains. + constexpr auto kFleetScanInterval = 1s; + constexpr auto kFleetSlowScanInterval = 5s; + constexpr auto kFleetFastScanPeriod = 30s; + constexpr auto kFleetServiceWait = 120s; + constexpr auto kFleetDiscoveryEvery = 5s; + constexpr auto kFleetStableFor = 5s; + constexpr auto kFleetStableAtMost = 30s; + // After the baseline one slot is read per probe, so every slot every 2.5 s. + constexpr auto kFleetProbeEvery = 250ms; + + class FleetWatcher + { + public: + // Forgets every slot and starts a new baseline. + void Reset(Clock::time_point now) + { + *this = {}; + seed_started_ = now; + } + + void Tick(Clock::time_point now) + { + if (seeding_) + Seed(now); + else if (!serviced_) + Discover(now); + else + Probe(now); + } + + private: + struct Slot { + int64_t fleet_id = 0; + FleetState state = FleetState::Unknown; + bool occupied = false; + }; + + void Restart(Clock::time_point now, int slot, int64_t fleet_id) + { + logging::Debug("process", "fleets", "fleet slots changed (slot {}, fleet {}); waiting for a new baseline", slot, + fleet_id); + Reset(now); + } + + // A slot that emptied, filled, or holds another fleet than last seen. + bool Moved(int slot, FleetPlayerData* fleet) const + { + const auto& known = slots_[slot]; + return known.occupied != (fleet != nullptr) || (fleet && known.fleet_id != fleet->Id()); + } + + // Records a fleet's state. While seeding a change only delays the baseline; afterwards it is sent. + void Observe(Clock::time_point now, int slot, FleetPlayerData* fleet) + { + auto& known = slots_[slot]; + const auto state = fleet->CurrentState(); + const bool same_fleet = known.occupied && known.fleet_id == fleet->Id(); + if (same_fleet && known.state == state) + return; + const auto before = known.state; + known = {fleet->Id(), state, true}; + if (!seeding_) { + Append(json::array({Record(slot, fleet, state, before)}), false); + return; + } + if (!observed_) { + observed_ = true; + first_seen_ = now; + } + last_change_ = now; + finalizing_ = false; + } + + // Reads every slot while seeding. False without a fleet service. + bool Scan(Clock::time_point now) + { + auto* manager = FleetsManager::Instance(); + if (!manager || !manager->HasFleetService()) + return false; + std::array fleets{}; + bool moved = false; + for (int slot = 0; slot < kFleetSlotCount; ++slot) { + fleets[slot] = manager->GetFleetPlayerData(slot); + moved |= Moved(slot, fleets[slot]); + } + // A fleet arriving or leaving delays the baseline like a state change. + if (moved && observed_) { + last_change_ = now; + finalizing_ = false; + } + for (int slot = 0; slot < kFleetSlotCount; ++slot) + if (fleets[slot]) + Observe(now, slot, fleets[slot]); + else + slots_[slot] = {}; + serviced_ = true; + return true; + } + + void Seed(Clock::time_point now) + { + const auto lifetime = now - seed_started_; + if (now - last_scan_ >= (lifetime < kFleetFastScanPeriod ? kFleetScanInterval : kFleetSlowScanInterval)) { + last_scan_ = now; + const bool available = Scan(now); + // The fleet service went away mid-baseline, as on logout. + if (serviced_ && !available) { + Reset(now); + return; + } + if (available && !observed_) { + observed_ = true; + first_seen_ = last_change_ = now; + } + } + + if (!observed_) { + if (lifetime >= kFleetServiceWait) { + seeding_ = false; + last_discovery_ = now; + logging::Debug("process", "fleets", "no fleet service after {} s; checking every {} s", + kFleetServiceWait.count(), kFleetDiscoveryEvery.count()); + } + return; + } + const bool forced = now - first_seen_ >= kFleetStableAtMost; + if (!forced && now - last_change_ < kFleetStableFor) + return; + // One more quiet frame, then a closing scan that must not see a change either. + if (!finalizing_) { + finalizing_ = true; + return; + } + const bool available = Scan(now); + if (!forced && now - last_change_ < kFleetStableFor) { + finalizing_ = false; + return; + } + if (!available) + return; + seeding_ = false; + last_probe_ = now; + logging::Debug("process", "fleets", "stable baseline for {} fleet slots", + std::ranges::count_if(slots_, &Slot::occupied)); + snapshot_requested.store(false, std::memory_order_relaxed); + QueueSnapshot("baseline"); + } + + // After giving up on the fleet service, looks for it again. + void Discover(Clock::time_point now) + { + if (now - last_discovery_ < kFleetDiscoveryEvery) + return; + last_discovery_ = now; + auto* manager = FleetsManager::Instance(); + if (manager && manager->HasFleetService()) + Restart(now, -1, 0); + } + + void Probe(Clock::time_point now) + { + if (now - last_probe_ < kFleetProbeEvery) + return; + last_probe_ = now; + auto* manager = FleetsManager::Instance(); + if (!manager || !manager->HasFleetService()) { + Restart(now, -1, 0); + return; + } + const int slot = probe_slot_; + probe_slot_ = (slot + 1) % kFleetSlotCount; + auto* fleet = manager->GetFleetPlayerData(slot); + if (Moved(slot, fleet)) + Restart(now, slot, fleet ? fleet->Id() : 0); + else if (fleet) + Observe(now, slot, fleet); + } + + std::array slots_{}; + // seeding_ until a baseline is sent; serviced_ once a scan found the fleet service since the last reset; observed_ + // once the slots were first read; finalizing_ for the frame before the closing scan. + bool seeding_ = true, serviced_ = false, observed_ = false, finalizing_ = false; + Clock::time_point seed_started_{}, first_seen_{}, last_change_{}, last_scan_{}, last_probe_{}, last_discovery_{}; + int probe_slot_ = 0; + }; + + FleetWatcher fleet_watcher; + + // Starts watching the first frame a target wants fleets, so a reload that turns them on starts it too. It then + // keeps running, and delivery filters per target as for every other category. bool FleetSyncInstalled() { - static bool tried = false; - if (fleet_sync_installed || tried || !Config::Sync::Get()->options.fleets) - return fleet_sync_installed; - tried = true; - if (!fleet_watch::Subscribe({.on_transition = OnTransition, .on_baseline = OnBaseline})) { - spdlog::warn("[Sync] fleet state subscriber could not be installed; fleet sync disabled"); + if (fleet_sync_installed) + return true; + if (!Config::Sync::Get()->options.fleets) return false; - } + fleet_watcher.Reset(Clock::now()); fleet_sync_installed = true; spdlog::info("[Sync] fleet state sync enabled"); return true; @@ -281,14 +463,14 @@ void RereadFleet(int64_t fleet_id) 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)); + reread_at.try_emplace(fleet_id, Clock::now() + 1s); } void UpdateFleetSync() { if (!FleetSyncInstalled()) return; - fleet_watch::Tick(); + fleet_watcher.Tick(Clock::now()); RereadDue(); if (snapshot_requested.load(std::memory_order_relaxed) && snapshot_requested.exchange(false)) QueueSnapshot("first sync"); diff --git a/src/mod/sync/sync.h b/src/mod/sync/sync.h index f0953cef0..a09334c0c 100644 --- a/src/mod/sync/sync.h +++ b/src/mod/sync/sync.h @@ -383,10 +383,10 @@ void QueueNewBattles(const std::vector& ids, 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. +// fleets.cc. Client fleet records are read on the game thread, from sync's frame work. -// Sync's frame work for fleets: subscribes to fleet_watch once a target wants fleets, then polls it and re-reads and -// snapshots what was asked for. Returns at once until then. +// Sync's frame work for fleets: once a target wants fleets, watches the fleet slots, then re-reads and snapshots what +// was asked for. Returns at once until then. void UpdateFleetSync(); // Asks the game thread for a full fleet snapshot, sent as a first sync. Any thread. void RequestFleetSnapshot(); diff --git a/tests/action_queue.cc b/tests/action_queue.cc index 9d60704aa..1b73e3e0a 100644 --- a/tests/action_queue.cc +++ b/tests/action_queue.cc @@ -3,6 +3,7 @@ #include "features/action_queue.h" +#include #include #include #include @@ -62,25 +63,33 @@ class FakeHandle } void Reset() { - if (id_) - assert(live.erase(std::exchange(id_, 0)) == 1); + if (!id_) + return; + // Outside the assert, so the handle is released even when asserts are compiled out. + [[maybe_unused]] const auto erased = live.erase(id_); + assert(erased == 1); + id_ = 0; } unsigned id_ = 0; }; using Requests = action_queue::Requests; const auto now = Clock::time_point{} + 100s; -RetryState dispatch{true, 10, 100, 2.5f, 2, false, false}; -RetryState remaining{true, 10, 200, 2.5f, 1, false, false}; +// Fleet 10's queue when it dispatches its attempt on target 100, and after target 100 has gone. +const RetryState dispatch{.valid = true, .fleet = 10, .front = 100, .attempt = 2.5f, .count = 2}; +const RetryState remaining{.valid = true, .fleet = 10, .front = 200, .attempt = 2.5f, .count = 1}; +// A copy of `value` with one change, for the cases that differ from a baseline in a single field. +template T With(T value, Change change) +{ + change(value); + return value; +} + +// A queue of `count` targets of which only the head is captured. QueueSnapshot Queue(int count, std::int64_t head) { - auto queue = QueueSnapshot{ - .present = true, - .player_fleet_id = 42, - .count = count, - .head_target_id = head, - }; + QueueSnapshot queue{.present = true, .player_fleet_id = 42, .count = count, .head_target_id = head}; if (count > 0) { queue.target_ids[0] = head; queue.captured_target_count = count; @@ -88,19 +97,16 @@ QueueSnapshot Queue(int count, std::int64_t head) return queue; } +// A fully captured queue of `targets`. QueueSnapshot Queue(std::initializer_list targets) { - QueueSnapshot queue{ - .present = true, - .player_fleet_id = 42, - .count = static_cast(targets.size()), - }; - int index = 0; - for (const auto target : targets) { - queue.target_ids[index++] = target; - } - queue.captured_target_count = queue.count; - queue.head_target_id = queue.count > 0 ? queue.target_ids[0] : 0; + const auto count = static_cast(targets.size()); + QueueSnapshot queue{.present = true, + .player_fleet_id = 42, + .count = count, + .head_target_id = count > 0 ? *targets.begin() : 0, + .captured_target_count = count}; + std::ranges::copy(targets, queue.target_ids.begin()); return queue; } @@ -115,9 +121,7 @@ void TestRecovery() assert(FakeHandle::live.empty()); // Native retry remains responsible while the target is still queued, even after reordering. - auto reordered = remaining; - reordered.contains_target = true; - assert(!CanAdvance(reordered, 10, 100, true, true, true)); + assert(!CanAdvance(With(remaining, [](auto& s) { s.contains_target = true; }), 10, 100, true, true, true)); assert(!CanAdvance(dispatch, 10, 100, true, true, true)); // Cross-fleet target cleanup must retain native false-branch behavior without consuming the request. requests.Remember(1, dispatch, now); @@ -128,15 +132,9 @@ void TestRecovery() assert(!CanAdvance(remaining, 10, 100, false, true, true)); assert(!CanAdvance(remaining, 10, 100, true, false, true)); assert(!CanAdvance(remaining, 10, 0, true, true, true)); - auto invalid = remaining; - invalid.valid = false; - assert(!CanAdvance(invalid, 10, 100, true, true, true)); - auto empty = remaining; - empty.count = 0; - assert(!CanAdvance(empty, 10, 100, true, true, true)); - auto active = remaining; - active.engaging = true; - assert(!CanAdvance(active, 10, 100, true, true, true)); + assert(!CanAdvance(With(remaining, [](auto& s) { s.valid = false; }), 10, 100, true, true, true)); + assert(!CanAdvance(With(remaining, [](auto& s) { s.count = 0; }), 10, 100, true, true, true)); + assert(!CanAdvance(With(remaining, [](auto& s) { s.engaging = true; }), 10, 100, true, true, true)); // Latest target/attempt supersedes old failures; pointer reuse after collection cannot resurrect identity. requests.Remember(1, dispatch, now); @@ -147,9 +145,7 @@ void TestRecovery() requests.Remember(1, newer, now + 1s); assert(!requests.Consume(1, remaining, 100, now + 2s)); requests.Remember(1, dispatch, now); - auto differentAttempt = remaining; - differentAttempt.attempt = 9; - assert(!requests.Consume(1, differentAttempt, 100, now)); + assert(!requests.Consume(1, With(remaining, [](auto& s) { s.attempt = 9; }), 100, now)); FakeHandle::Collect(1); assert(!requests.Consume(1, remaining, 100, now)); assert(FakeHandle::live.empty()); @@ -167,18 +163,12 @@ void TestRecovery() assert(!requests.Consume(1, remaining, 100, now + 30s)); assert(FakeHandle::live.empty()); // Bounded capacity, independent fleets and session cleanup release all handles. - for (int i = 1; i <= 9; ++i) { - auto s = dispatch; - s.fleet = i; - requests.Remember(i, s, now + std::chrono::seconds(i)); + for (int fleet = 1; fleet <= 9; ++fleet) { + requests.Remember(fleet, With(dispatch, [&](auto& s) { s.fleet = fleet; }), now + std::chrono::seconds(fleet)); assert(FakeHandle::live.size() <= 8); } - auto first = remaining; - first.fleet = 1; - assert(!requests.Consume(1, first, 100, now + 10s)); - auto last = remaining; - last.fleet = 9; - assert(requests.Consume(9, last, 100, now + 10s)); + assert(!requests.Consume(1, With(remaining, [](auto& s) { s.fleet = 1; }), 100, now + 10s)); + assert(requests.Consume(9, With(remaining, [](auto& s) { s.fleet = 9; }), 100, now + 10s)); requests.Clear(); requests.Clear(); assert(FakeHandle::live.empty()); @@ -187,16 +177,14 @@ void TestRecovery() void TestThinProtection() { - // native engage results have stable diagnostic names - { - assert(EngageResultName(0).compare("success") == 0); - assert(EngageResultName(1).compare("skip-target") == 0); - assert(EngageResultName(2).compare("stop") == 0); - assert(EngageResultName(-1).compare("not-attempted") == 0); - assert(EngageResultName(99).compare("unknown") == 0); - } - - // destroyed head processing is exact and feature gated + // Native engage results have stable diagnostic names. + static_assert(EngageResultName(0) == "success"); + static_assert(EngageResultName(1) == "skip-target"); + static_assert(EngageResultName(2) == "stop"); + static_assert(EngageResultName(-1) == "not-attempted"); + static_assert(EngageResultName(99) == "unknown"); + + // Destroyed-head processing is exact and gated on the setting. { const auto queue = Queue(2, 101); @@ -207,7 +195,7 @@ void TestThinProtection() assert(!ShouldProcessDestroyedHead(true, true, 0, queue)); } - // destroyed head processing refuses active queue latches + // Destroyed-head processing refuses active queue latches. { auto queue = Queue(2, 101); @@ -223,7 +211,7 @@ void TestThinProtection() assert(!ShouldProcessDestroyedHead(true, true, 101, queue)); } - // local kill and repeated disposal are no-ops once the exact target is absent + // A local kill and repeated disposal are no-ops once the exact target is absent. { const auto next_head = Queue(1, 202); const auto empty = Queue(0, 0); @@ -232,7 +220,7 @@ void TestThinProtection() assert(!ShouldProcessDestroyedHead(true, true, 101, empty)); } - // native prune identifies a stranded new-head resume candidate + // A native prune leaves a stranded new head to resume. { const auto before = Queue({101, 202, 303, 404}); const auto after = Queue({303, 404}); @@ -240,7 +228,7 @@ void TestThinProtection() assert(IsNativePruneResumeCandidate(true, true, before, after)); } - // temporary unavailability without native removal is not a candidate + // Temporary unavailability without a native removal is not a candidate. { const auto before = Queue({101, 202}); const auto after = Queue({101, 202}); @@ -248,7 +236,7 @@ void TestThinProtection() assert(!IsNativePruneResumeCandidate(true, true, before, after)); } - // resume requires idle state and rejects latches on the surviving suffix + // Resuming requires an idle fleet and rejects latches on the surviving suffix. { const auto before = Queue({101, 202, 303}); @@ -268,7 +256,7 @@ void TestThinProtection() assert(!IsNativePruneResumeCandidate(true, true, before, after)); } - // resume accepts no-target sentinels and stale latches from the removed prefix + // Resuming accepts no-target sentinels and stale latches from the removed prefix. { const auto before = Queue({101, 202, 303, 404}); @@ -282,7 +270,7 @@ void TestThinProtection() assert(IsNativePruneResumeCandidate(true, true, before, after)); } - // resume rejects unrelated positive native latches + // Resuming rejects unrelated positive native latches. { const auto before = Queue({101, 202, 303}); @@ -295,7 +283,7 @@ void TestThinProtection() assert(!IsNativePruneResumeCandidate(true, true, before, after)); } - // manual clear and repeated watchdog callbacks are no-ops + // A manual clear and repeated watchdog callbacks are no-ops. { const auto before = Queue({101, 202}); const auto empty = Queue(0, 0); @@ -305,7 +293,7 @@ void TestThinProtection() assert(!IsNativePruneResumeCandidate(true, true, stable, stable)); } - // resume fails closed on reorder truncation and fleet replacement + // Resuming fails closed on reordering, truncation and fleet replacement. { const auto before = Queue({101, 202, 303, 404}); @@ -321,7 +309,7 @@ void TestThinProtection() assert(!IsNativePruneResumeCandidate(true, true, before, after)); } - // resume postcondition requires the exact same idle surviving suffix + // The resume postcondition requires the exact same idle surviving suffix. { const auto expected = Queue({303, 404}); @@ -367,83 +355,50 @@ void TestThinProtection() void TestAddressGuard() { - const Address base{true, 0, 71088, 71237, 710}; - const Address system{true, 0, 925162490, 0, 710}; - const AddressEvidence reproduced{true, true, true, true, false, 512, 1, 0, base, system, system}; + const Address base{.valid = true, .galaxy = 0, .system = 71088, .planet = 71237, .instance = 710}; + const Address system{.valid = true, .galaxy = 0, .system = 925162490, .planet = 0, .instance = 710}; + // The reproduced case: an impulsing fleet reported at its starbase planet, in another system, while its deployment + // and its target are both still in space. + const AddressEvidence reproduced{.enabled = true, + .player_state_event = true, + .owned_queue = true, + .owned_deployment = true, + .planning_recall = false, + .player_state = kFleetImpulsing, + .deployed_state = kDeployedMoving, + .removal_reason = 0, + .reported = base, + .deployed = system, + .target = system}; assert(UseDeployedAddress(reproduced)); - for (int state : {1, 64, 512}) { - auto e = reproduced; - e.player_state = state; - assert(UseDeployedAddress(e)); - } + for (int state : {1, 64, 512}) + assert(UseDeployedAddress(With(reproduced, [&](auto& e) { e.player_state = state; }))); // Destruction, dock/repair, and an intentional warp must retain native clearing. - for (int state : {-1, 0, 2, 4, 8, 16, 32, 128, 256}) { - auto e = reproduced; - e.player_state = state; - assert(!UseDeployedAddress(e)); - } - for (int state : {-1, 2, 4, 5, 99}) { - auto e = reproduced; - e.deployed_state = state; - assert(!UseDeployedAddress(e)); - } - for (int reason : {-1, 1, 2, 3, 4, 5}) { - auto e = reproduced; - e.removal_reason = reason; - assert(!UseDeployedAddress(e)); - } - auto reject = [](AddressEvidence e) { assert(!UseDeployedAddress(e)); }; - auto e = reproduced; - e.enabled = false; - reject(e); - e = reproduced; - e.player_state_event = false; - reject(e); // manual clear/UI path - e = reproduced; - e.owned_queue = false; - reject(e); - e = reproduced; - e.owned_deployment = false; - reject(e); // wrong fleet or unknown ownership - e = reproduced; - e.planning_recall = true; - reject(e); - e = reproduced; - e.reported.valid = false; - reject(e); - e = reproduced; - e.deployed.valid = false; - reject(e); - e = reproduced; - e.target.valid = false; - reject(e); - e = reproduced; - e.target.system = 123; - reject(e); // real foreign target - e = reproduced; - e.target.instance = 711; - reject(e); - e = reproduced; - e.target.planet = 123; - reject(e); - e = reproduced; - e.reported.planet = 0; - reject(e); // scope is the observed base substitution - e = reproduced; - e.reported.system = system.system; - reject(e); - e = reproduced; - e.reported.instance = 711; - reject(e); - e = reproduced; - e.reported.galaxy = 1; - reject(e); - e = reproduced; - e.deployed = base; - reject(e); // own deployment really moved to base - e = reproduced; - e.reported = system; - reject(e); // no wrong address to correct + for (int state : {-1, 0, 2, 4, 8, 16, 32, 128, 256}) + assert(!UseDeployedAddress(With(reproduced, [&](auto& e) { e.player_state = state; }))); + for (int state : {-1, 2, 4, 5, 99}) + assert(!UseDeployedAddress(With(reproduced, [&](auto& e) { e.deployed_state = state; }))); + for (int reason : {-1, 1, 2, 3, 4, 5}) + assert(!UseDeployedAddress(With(reproduced, [&](auto& e) { e.removal_reason = reason; }))); + + const auto rejected = [&](auto change) { return !UseDeployedAddress(With(reproduced, change)); }; + assert(rejected([](auto& e) { e.enabled = false; })); + assert(rejected([](auto& e) { e.player_state_event = false; })); // manual clear/UI path + assert(rejected([](auto& e) { e.owned_queue = false; })); + assert(rejected([](auto& e) { e.owned_deployment = false; })); // wrong fleet or unknown ownership + assert(rejected([](auto& e) { e.planning_recall = true; })); + assert(rejected([](auto& e) { e.reported.valid = false; })); + assert(rejected([](auto& e) { e.deployed.valid = false; })); + assert(rejected([](auto& e) { e.target.valid = false; })); + assert(rejected([](auto& e) { e.target.system = 123; })); // real foreign target + assert(rejected([](auto& e) { e.target.instance = 711; })); + assert(rejected([](auto& e) { e.target.planet = 123; })); + assert(rejected([](auto& e) { e.reported.planet = 0; })); // scope is the observed base substitution + assert(rejected([&](auto& e) { e.reported.system = system.system; })); + assert(rejected([](auto& e) { e.reported.instance = 711; })); + assert(rejected([](auto& e) { e.reported.galaxy = 1; })); + assert(rejected([&](auto& e) { e.deployed = base; })); // own deployment really moved to base + assert(rejected([&](auto& e) { e.reported = system; })); // no wrong address to correct } } // namespace From 82d3c2f802503a91da2740ae27591f9ee46f2504 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 15:25:33 -0700 Subject: [PATCH 10/13] Report launcher failures instead of crashing The launcher no longer force-unwraps loader paths, update plan attributes or the installed version. Failures carry readable descriptions, and entitlement repair signs the bundle the user actually selected. Loader failures fail with a message instead of a crash, a double click can no longer start two updates, and dead helpers and the moot macOS 13 check are gone. --- src/launcher/macos/ActionView.swift | 374 ++++---- src/launcher/macos/ContentView.swift | 118 +-- src/launcher/macos/LauncherMain.swift | 26 +- src/launcher/macos/PatchEntitlements.swift | 650 +++++--------- src/launcher/macos/TemporaryFolderURL.swift | 15 - .../macos/UpdaterFileOperations.swift | 85 +- src/launcher/macos/XsollaLib.swift | 818 +++++++++--------- src/launcher/macos/ini.swift | 64 +- src/launcher/macos/macOSLauncher.entitlements | 12 - src/launcher/macos/rsync.cc | 5 +- src/launcher/macos/rsync.h | 1 + src/launcher/macos/rsync.swift | 2 + src/platform/macos/loader/library_dir.mm | 2 + src/platform/macos/loader/main.cc | 13 +- tests/dependency_smoke.cc | 23 +- tests/launcher_archive.swift | 5 +- tests/launcher_rsync.cc | 2 + 17 files changed, 888 insertions(+), 1327 deletions(-) delete mode 100644 src/launcher/macos/TemporaryFolderURL.swift delete mode 100644 src/launcher/macos/macOSLauncher.entitlements diff --git a/src/launcher/macos/ActionView.swift b/src/launcher/macos/ActionView.swift index f9233725a..1a68b160e 100644 --- a/src/launcher/macos/ActionView.swift +++ b/src/launcher/macos/ActionView.swift @@ -1,166 +1,74 @@ -import Foundation +// The launcher's buttons: configure the mod, update the game, and launch it through the mod's loader. + import SwiftUI import os -/// Logger for launcher actions private let logger = Logger(subsystem: "com.stfcmod.startrekpatch", category: "launcher") -struct ActionView: View, XSollaUpdaterDelegate { - - private let gameUpdater = XsollaUpdater("Star Trek Fleet Command") - @State private var gameVersion: Int = 0 - @State private var gameUpdateAvailable: Bool = false - @State private var updating: Bool = false - @State private var updateAction: String = "" - @State private var updateSubAction: String = "" - @State private var updateProgress: Float = 0.0 - @State private var gameInstalled: Bool = false - @State private var gameRunning: Bool = false - @State private var showErrorAlert: Bool = false - @State private var errorMessage: String = "" - private var model = PulsatingViewModel() - - func updateProgress(progress: XsollaUpdateProgress) { - switch progress { - case .Start(let totalActions): - updateAction = "Updating" - updateSubAction = "Planning" - updateProgress = totalActions > 0 ? 0.0 : 1.0 - case .Progress(let currentAction, let totalActions): - updateAction = "Updating \(currentAction) of \(totalActions)" - updateProgress = Float(currentAction) / Float(totalActions) - break - case .Extracting(_): - updateSubAction = "Extracting" - break - case .ExtractComplete(_): - break - case .Downloading(_): - updateSubAction = "Downloading" - break - case .DownloadComplete(_): - break - case .Patching(_): - updateSubAction = "Patching" - break - case .PatchingProgress(_, _): - break - case .PatchStepComplete: - break - case .PatchComplete: - break - case .Waiting: - updateSubAction = "Waiting" - break - case .ApplyVersion: - updateSubAction = "Applying Version" - case .VersionApplied: - updateSubAction = "Version Applied" - case .Finalizing: - updateSubAction = "Finalizing" - break - case .CleaningUp: - updateSubAction = "Cleaning Up" - break - case .Complete: - updateSubAction = "Complete" - updateProgress = 1.0 - break - } - } +struct ActionView: View { + private let gameUpdater = XsollaUpdater(gameName: "Star Trek Fleet Command") + @State private var gameVersion = 0 + @State private var gameUpdateAvailable = false + @State private var updating = false + @State private var updateAction = "" + @State private var updateSubAction = "" + @State private var gameInstalled = false + @State private var gameRunning = false + @State private var showErrorAlert = false + @State private var errorMessage = "" var body: some View { GeometryReader { geo in Grid { GridRow { - Button { - withAnimation { - openConfigSite() - } - } label: { - commonButton(text: "Configure Mod") - .foregroundColor(.lcarViolet) - }.buttonStyle(PlainButtonStyle()) + lcarsButton("Configure Mod", color: .lcarViolet, action: openConfigSite) if gameInstalled { Group { - Button { - withAnimation { - if gameUpdateAvailable && !updating { - Task { - do { - logger.info("User requested game update") - updating = true - updateAction = "Starting" - updateSubAction = "Planning" - try await gameUpdater.updateGame(delegate: self) - logger.info("Game update finished successfully") - } catch { - logger.error("Error updating game: \(error.localizedDescription)") - updateAction = "Update Failed" - updateSubAction = "Failed" - errorMessage = "Game update failed: \(error.localizedDescription)" - showErrorAlert = true - } - updating = false - gameVersion = gameUpdater.installedVersion() - gameUpdateAvailable = await gameUpdater.checkForUpdateAvailable() - } - } - } - } label: { - if gameUpdateAvailable { - commonButton(text: "Update Game!") - .foregroundColor(.lcarTan) - } else { - commonButton() - .foregroundColor(.lcarTan) - } - }.buttonStyle(PlainButtonStyle()) - Button { - withAnimation { - launchGame() - } - } label: { - commonButton(text: "Engage!") - .foregroundColor(.lcarOrange) - }.buttonStyle(PlainButtonStyle()) + lcarsButton(gameUpdateAvailable ? "Update Game!" : "", color: .lcarTan) { + guard gameUpdateAvailable, !updating else { return } + // Set before the task starts, so a second click cannot start a second update. + updating = true + Task { await updateGame() } + } + lcarsButton("Engage!", color: .lcarOrange) { + withAnimation { launchGame() } + } } .opacity(updating || gameRunning ? 0.5 : 1.0) .allowsHitTesting(!updating && !gameRunning) } else { Text("Game not installed") - .font(.custom("HelveticaNeue-CondensedBold", size: 40)) - .foregroundColor(.lcarTan) + .font(.lcars(size: 40)) + .foregroundStyle(Color.lcarTan) .offset(x: -45) } - } } .frame(width: geo.size.width, height: 150) .offset(x: 85, y: 20) .task { + // The update service is polled once a minute while the launcher is open. repeat { gameVersion = gameUpdater.installedVersion() gameInstalled = gameVersion > 0 gameUpdateAvailable = await gameUpdater.checkForUpdateAvailable() try? await Task.sleep(for: .seconds(60)) } while !Task.isCancelled - } .overlay(alignment: .bottomTrailing) { Text("Game Version: \(String(format: "%02d", gameVersion))") - .font(.custom("HelveticaNeue-CondensedBold", size: 17)) - .foregroundColor(.lcarTan) + .font(.lcars(size: 17)) + .foregroundStyle(Color.lcarTan) .offset(x: -10) } .overlay(alignment: .bottomLeading) { if updating { HStack { - PulsatingView(viewModel: model) + PulsatingView() Text("\(updateAction) (\(updateSubAction))") - .font(.custom("HelveticaNeue-CondensedBold", size: 17)) - .foregroundColor(.lcarTan) + .font(.lcars(size: 17)) + .foregroundStyle(Color.lcarTan) .offset(x: -10) } .offset(x: 115, y: 10) @@ -174,26 +82,25 @@ struct ActionView: View, XSollaUpdaterDelegate { } } - private func commonButton(text: String = "") -> some View { - RoundedRectangle(cornerRadius: 20) - .frame(width: 125, height: 50) - .overlay(alignment: .bottomTrailing) { - HStack { - Spacer() - Text(text.count > 0 ? text : "\(randomDigits(4))-\(randomDigits(3))") - .font(.custom("HelveticaNeue-CondensedBold", size: 17)) - .foregroundColor(.black) + // A rounded LCARS button in `color`. Without text it shows random LCARS-style digits. + private func lcarsButton(_ text: String, color: Color, action: @escaping @MainActor () -> Void) -> some View { + Button(action: action) { + RoundedRectangle(cornerRadius: 20) + .frame(width: 125, height: 50) + .overlay(alignment: .bottomTrailing) { + HStack { + Spacer() + Text(text.isEmpty ? "\(randomDigits(4))-\(randomDigits(3))" : text) + .font(.lcars(size: 17)) + .foregroundStyle(.black) + } + .scaleEffect(x: 0.7, anchor: .trailing) + .padding(.bottom, 5) + .padding(.trailing, 20) } - .scaleEffect(x: 0.7, anchor: .trailing) - .padding(.bottom, 5) - .padding(.trailing, 20) - } - } - - private func randomDigits(_ count: Int) -> String { - (1...count) - .map { _ in "\(Int.random(in: 0...9))" } - .joined() + .foregroundStyle(color) + } + .buttonStyle(.plain) } private func openConfigSite() { @@ -201,99 +108,126 @@ struct ActionView: View, XSollaUpdaterDelegate { NSWorkspace.shared.open(configSiteURL) } + private func showProgress(_ progress: XsollaUpdateProgress) { + switch progress { + case .start: + updateAction = "Updating" + updateSubAction = "Planning" + case .progress(let currentAction, let totalActions): + updateAction = "Updating \(currentAction) of \(totalActions)" + case .extracting: + updateSubAction = "Extracting" + case .downloading: + updateSubAction = "Downloading" + case .patching: + updateSubAction = "Patching" + case .waiting: + updateSubAction = "Waiting" + case .applyVersion: + updateSubAction = "Applying Version" + case .versionApplied: + updateSubAction = "Version Applied" + case .finalizing: + updateSubAction = "Finalizing" + case .cleaningUp: + updateSubAction = "Cleaning Up" + case .complete: + updateSubAction = "Complete" + } + } + + @MainActor + private func updateGame() async { + logger.info("User requested game update") + updateAction = "Starting" + updateSubAction = "Planning" + do { + try await gameUpdater.updateGame(onProgress: showProgress) + logger.info("Game update finished successfully") + } catch { + logger.error("Error updating game: \(error.localizedDescription, privacy: .public)") + updateAction = "Update Failed" + updateSubAction = "Failed" + showError("Game update failed: \(error.localizedDescription)") + } + updating = false + gameVersion = gameUpdater.installedVersion() + gameUpdateAvailable = await gameUpdater.checkForUpdateAvailable() + } + + private func showError(_ message: String) { + errorMessage = message + showErrorAlert = true + } + + // A GCD thread rather than a Task: runGame blocks until the game exits, which must not tie up a Swift concurrency + // thread, and it waits for the entitlements panel, which needs the main thread free. private func launchGame() { + gameRunning = true DispatchQueue.global().async { + let result = Result { try runGame() } DispatchQueue.main.async { - gameRunning = true - } - - // Ensure the game has the required entitlements before launching - do { - try ensureGameHasLoaderEntitlements() - } catch let error as EntitlementError { - logger.error("Error ensuring game entitlements: \(error.localizedDescription)") - DispatchQueue.main.async { - errorMessage = error.localizedDescription - showErrorAlert = true - gameRunning = false - } - return - } catch { - logger.error("Error ensuring game entitlements: \(error.localizedDescription)") - DispatchQueue.main.async { - errorMessage = "Failed to prepare game for launch: \(error.localizedDescription)" - showErrorAlert = true - gameRunning = false - } - return - } - - let process = Process() - let helper = Bundle.main.path(forAuxiliaryExecutable: "stfc-community-mod-loader") - process.executableURL = URL(fileURLWithPath: helper!) - DispatchQueue.global().async { - do { - try process.run() - } catch { - DispatchQueue.main.async { - gameRunning = false - } - return - } - process.waitUntilExit() - DispatchQueue.main.async { - gameRunning = false + gameRunning = false + if case .failure(let error) = result { + showError(error.localizedDescription) } } } } } -class PulsatingViewModel: ObservableObject { - @Published var colorIndex = 1 -} +private enum LaunchError: LocalizedError { + case loaderMissing + case loaderFailed(Error) -struct PulsatingView: View { + var errorDescription: String? { + switch self { + case .loaderMissing: + return "The mod loader is missing from the launcher. Reinstall the community mod." + case .loaderFailed(let error): + return "Could not start the game: \(error.localizedDescription)" + } + } +} - @ObservedObject var viewModel: PulsatingViewModel +// Runs the game through the mod's loader until it exits. A free function, so it can run off the main actor. +private func runGame() throws { + do { + try ensureGameHasLoaderEntitlements() + } catch { + logger.error("Error ensuring game entitlements: \(error.localizedDescription, privacy: .public)") + throw error + } - func colourToShow() -> Color { - switch viewModel.colorIndex { - case 0: - return Color.lcarOrange - case 1: - return Color.lcarTan - case 2: - return Color.lcarViolet - default: - return Color.lcarPink - } + guard let loader = Bundle.main.path(forAuxiliaryExecutable: "stfc-community-mod-loader") else { + logger.error("The mod loader is missing from the launcher bundle") + throw LaunchError.loaderMissing + } + let process = Process() + process.executableURL = URL(fileURLWithPath: loader) + do { + try process.run() + } catch { + logger.error("Could not start the mod loader: \(error.localizedDescription, privacy: .public)") + throw LaunchError.loaderFailed(error) } + process.waitUntilExit() +} - @State var animate = false - var body: some View { - VStack { - ZStack { - Circle().fill(colourToShow().opacity(0.25)).frame(width: 40, height: 40).scaleEffect( - self.animate ? 1 : 0) - Circle().fill(colourToShow().opacity(0.35)).frame(width: 30, height: 30).scaleEffect( - self.animate ? 1 : 0) - Circle().fill(colourToShow().opacity(0.45)).frame(width: 15, height: 15).scaleEffect( - self.animate ? 1 : 0) - Circle().fill(colourToShow()).frame(width: 16.25, height: 16.25) - } - .onAppear { self.animate = true } - .animation( - animate ? Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true) : .default, - value: animate - ) - .onChange(of: viewModel.colorIndex) { _ in - self.animate = false - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.animate = true - } - } +// The pulsing dot shown while an update runs. +struct PulsatingView: View { + @State private var animate = false + var body: some View { + ZStack { + Circle().fill(Color.lcarTan.opacity(0.25)).frame(width: 40, height: 40).scaleEffect(animate ? 1 : 0) + Circle().fill(Color.lcarTan.opacity(0.35)).frame(width: 30, height: 30).scaleEffect(animate ? 1 : 0) + Circle().fill(Color.lcarTan.opacity(0.45)).frame(width: 15, height: 15).scaleEffect(animate ? 1 : 0) + Circle().fill(Color.lcarTan).frame(width: 16.25, height: 16.25) } + .onAppear { animate = true } + .animation( + animate ? Animation.easeInOut(duration: 1.5).repeatForever(autoreverses: true) : .default, + value: animate) } } diff --git a/src/launcher/macos/ContentView.swift b/src/launcher/macos/ContentView.swift index 1c1ffa283..893eabc9b 100644 --- a/src/launcher/macos/ContentView.swift +++ b/src/launcher/macos/ContentView.swift @@ -1,11 +1,5 @@ -// -// ContentView.swift -// STFC Community Mod Launcher -// -// Created by tashcan on 3/22/24. -// +// The launcher window: the LCARS-styled frame, with the action buttons along its bottom. -import Foundation import SwiftUI struct ContentView: View { @@ -16,12 +10,12 @@ struct ContentView: View { topContent .frame(height: geo.size.height / 1.5) bottomContent - .frame(height: geo.size.height * (2 / 2) - 10) + .frame(height: geo.size.height - 10) } ActionView().offset(y: geo.size.height - 150) } } - .edgesIgnoringSafeArea(.all) + .ignoresSafeArea() } private var topContent: some View { @@ -31,10 +25,10 @@ struct ContentView: View { Color.lcarPink Color.lcarViolet } - .cornerRadius(70, corners: .bottomLeft) + .clipShape(UnevenRoundedRectangle(bottomLeadingRadius: 70, style: .circular)) .overlay(alignment: .topTrailing) { Color.black - .cornerRadius(35, corners: .bottomLeft) + .clipShape(UnevenRoundedRectangle(bottomLeadingRadius: 35, style: .circular)) .frame(width: geo.size.width - 100, height: geo.size.height - 20) } .overlay(alignment: .topLeading) { @@ -62,16 +56,16 @@ struct ContentView: View { Text("LCARS \(randomDigits(5))") Text("02-\(randomDigits(6))") } - .font(.custom("HelveticaNeue-CondensedBold", size: 17)) - .foregroundColor(.black) + .font(.lcars(size: 17)) + .foregroundStyle(.black) .scaleEffect(x: 0.7, anchor: .trailing) .frame(width: 90) } .overlay(alignment: .topTrailing) { Text("LCARS ACCESS 333") - .font(.custom("HelveticaNeue-CondensedBold", size: 35)) + .font(.lcars(size: 35)) .padding(.top, 45) - .foregroundColor(.lcarOrange) + .foregroundStyle(Color.lcarOrange) .scaleEffect(x: 0.7, anchor: .trailing) .offset(x: -15, y: 0) } @@ -81,12 +75,12 @@ struct ContentView: View { GridRow { ForEach(0..<8) { _ in Text(randomDigits(Int.random(in: 1...6))) - .foregroundColor((row == 3 || row == 4) ? .lcarWhite : .lcarOrange) + .foregroundStyle(row == 3 || row == 4 ? Color.lcarWhite : Color.lcarOrange) } } } } - .font(.custom("HelveticaNeue-CondensedBold", size: 17)) + .font(.lcars(size: 17)) .padding(.top, 45) .offset(x: -15, y: 0) } @@ -121,10 +115,10 @@ struct ContentView: View { .padding(.top, 5) } } - .cornerRadius(70, corners: .topLeft) + .clipShape(UnevenRoundedRectangle(topLeadingRadius: 70, style: .circular)) .overlay(alignment: .bottomTrailing) { Color.black - .cornerRadius(35, corners: .topLeft) + .clipShape(UnevenRoundedRectangle(topLeadingRadius: 35, style: .circular)) .frame(width: geo.size.width - 100, height: geo.size.height - 20) } .overlay(alignment: .bottomLeading) { @@ -160,20 +154,26 @@ struct ContentView: View { HStack { Spacer() Text("\(prefix)-\(randomDigits(6))") - .font(.custom("HelveticaNeue-CondensedBold", size: 17)) - .foregroundColor(.black) + .font(.lcars(size: 17)) + .foregroundStyle(.black) } .frame(width: 90) .scaleEffect(x: 0.7, anchor: .trailing) } +} + +// Filler digits for the LCARS labels. Views call it from body, so the digits change whenever they redraw. +func randomDigits(_ count: Int) -> String { + (0.. String { - (1...count) - .map { _ in "\(Int.random(in: 0...9))" } - .joined() +extension Font { + static func lcars(size: CGFloat) -> Font { + .custom("HelveticaNeue-CondensedBold", size: size) } } +// The LCARS palette, from Assets.xcassets. extension Color { static let lcarLightOrange = Color("lcarLightOrange") static let lcarOrange = Color("lcarOrange") @@ -183,71 +183,3 @@ extension Color { static let lcarViolet = Color("lcarViolet") static let lcarWhite = Color("lcarWhite") } - -extension View { - func cornerRadius(_ radius: CGFloat, corners: RectCorner) -> some View { - clipShape(RoundedCornersShape(radius: radius, corners: corners)) - } -} - -// draws shape with specified rounded corners applying corner radius -struct RoundedCornersShape: SwiftUI.Shape { - - var radius: CGFloat = .zero - var corners: RectCorner = .allCorners - - func path(in rect: CGRect) -> SwiftUI.Path { - var path = SwiftUI.Path() - - let p1 = CGPoint(x: rect.minX, y: corners.contains(.topLeft) ? rect.minY + radius : rect.minY) - let p2 = CGPoint(x: corners.contains(.topLeft) ? rect.minX + radius : rect.minX, y: rect.minY) - - let p3 = CGPoint(x: corners.contains(.topRight) ? rect.maxX - radius : rect.maxX, y: rect.minY) - let p4 = CGPoint(x: rect.maxX, y: corners.contains(.topRight) ? rect.minY + radius : rect.minY) - - let p5 = CGPoint( - x: rect.maxX, y: corners.contains(.bottomRight) ? rect.maxY - radius : rect.maxY) - let p6 = CGPoint( - x: corners.contains(.bottomRight) ? rect.maxX - radius : rect.maxX, y: rect.maxY) - - let p7 = CGPoint( - x: corners.contains(.bottomLeft) ? rect.minX + radius : rect.minX, y: rect.maxY) - let p8 = CGPoint( - x: rect.minX, y: corners.contains(.bottomLeft) ? rect.maxY - radius : rect.maxY) - - path.move(to: p1) - path.addArc( - tangent1End: CGPoint(x: rect.minX, y: rect.minY), - tangent2End: p2, - radius: radius) - path.addLine(to: p3) - path.addArc( - tangent1End: CGPoint(x: rect.maxX, y: rect.minY), - tangent2End: p4, - radius: radius) - path.addLine(to: p5) - path.addArc( - tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), - tangent2End: p6, - radius: radius) - path.addLine(to: p7) - path.addArc( - tangent1End: CGPoint(x: rect.minX, y: rect.maxY), - tangent2End: p8, - radius: radius) - path.closeSubpath() - - return path - } -} - -struct RectCorner: OptionSet { - let rawValue: Int - - static let topLeft = RectCorner(rawValue: 1 << 0) - static let topRight = RectCorner(rawValue: 1 << 1) - static let bottomRight = RectCorner(rawValue: 1 << 2) - static let bottomLeft = RectCorner(rawValue: 1 << 3) - - static let allCorners: RectCorner = [.topLeft, topRight, .bottomLeft, .bottomRight] -} diff --git a/src/launcher/macos/LauncherMain.swift b/src/launcher/macos/LauncherMain.swift index c593fe5b5..31db51ddc 100644 --- a/src/launcher/macos/LauncherMain.swift +++ b/src/launcher/macos/LauncherMain.swift @@ -1,28 +1,11 @@ -// -// LauncherMain.swift -// STFC Community Mod Launcher -// -// Created by tashcan on 3/22/24. -// +// The launcher app: one fixed-size window, and quitting when it closes. import AppKit -import Foundation import SwiftUI -extension Scene { - func windowResizabilityContentSize() -> some Scene { - if #available(macOS 13.0, *) { - return windowResizability(.contentSize) - } else { - return self - } - } -} - -class AppDelegate: NSObject, NSApplicationDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - NSApplication.shared.terminate(self) - return true + true } } @@ -36,7 +19,6 @@ struct STFC_Community_Patch_LauncherApp: App { .frame(width: 600, height: 400) .fixedSize() } - .windowResizabilityContentSize() + .windowResizability(.contentSize) } - } diff --git a/src/launcher/macos/PatchEntitlements.swift b/src/launcher/macos/PatchEntitlements.swift index 5e561b39f..277699ffd 100644 --- a/src/launcher/macos/PatchEntitlements.swift +++ b/src/launcher/macos/PatchEntitlements.swift @@ -1,462 +1,230 @@ -import Foundation -import Security +// Makes sure the game may load the mod: its executable needs the entitlements below. Scopely's own signature +// normally has them, so this usually only checks. When they are missing, the executable is re-signed ad hoc with +// them, after the user confirms the game bundle in a file panel. + import AppKit +import Foundation import os -/// Logger for entitlement management operations private let logger = Logger(subsystem: "com.stfcmod.startrekpatch", category: "entitlements") -/// Entitlements required to enable the loader for STFC. -/// The game ships with these from Scopely's Developer ID signature, so normally -/// no re-signing is needed — we just verify they're present. -let loaderEntitlements: [String: Bool] = [ - "com.apple.security.cs.allow-dyld-environment-variables": true, - "com.apple.security.cs.allow-unsigned-executable-memory": true, - "com.apple.security.cs.disable-library-validation": true, +// What DYLD_INSERT_LIBRARIES and the mod's runtime patching need. +private let loaderEntitlements: [String: Bool] = [ + "com.apple.security.cs.allow-dyld-environment-variables": true, + "com.apple.security.cs.allow-unsigned-executable-memory": true, + "com.apple.security.cs.disable-library-validation": true, ] -/// Errors that can occur during entitlement management -enum EntitlementError: Error { - case gamePathNotFound - case entitlementVerificationFailed - case entitlementApplicationFailed - - var localizedDescription: String { - switch self { - case .gamePathNotFound: - return "Could not find the game executable path" - case .entitlementVerificationFailed: - return "Failed to verify game entitlements" - case .entitlementApplicationFailed: - return "Failed to apply entitlements to the game" - } - } -} - -/// Ensures the game has the required loader entitlements, applying them only if missing. -/// The game's original Scopely signature already includes the entitlements we need, -/// so this typically just verifies and returns without modifying anything. -/// - Parameter signingIdentity: Optional signing identity (defaults to "-" for ad-hoc signing) -/// - Throws: EntitlementError if unable to find, verify, or apply entitlements -func ensureGameHasLoaderEntitlements(signingIdentity: String = "-") throws { - // Get the game path - guard let gamePath = try fetchGamePath() else { - throw EntitlementError.gamePathNotFound - } - - // Always verify entitlements first — even after a game update, the new binary - // typically ships with the entitlements we need from Scopely's signature. - if checkEntitlements(appPath: gamePath, expectedEntitlements: loaderEntitlements) { - logger.info("Game already has the required loader entitlements, no re-signing needed") - return - } - - // Entitlements are missing — fall back to re-signing. - // This should be rare; it means the game shipped without the expected entitlements. - logger.warning("Game is missing required loader entitlements, re-signing is needed") - - // Check if the game is currently running - let gameAppPath = (gamePath as NSString).deletingLastPathComponent - .replacingOccurrences(of: "/Contents/MacOS", with: "") - if isGameRunning(bundlePath: gameAppPath) { - logger.error("Game is currently running, cannot modify signature") - throw EntitlementError.entitlementApplicationFailed - } - - // Request user to grant access to the game bundle via file picker - // This serves as explicit user consent for the modification and provides security-scoped access - guard let confirmedURL = requestGameBundleAccess(gameAppPath: gameAppPath, gamePath: gamePath) else { - logger.error("User did not confirm access to game bundle") - throw EntitlementError.entitlementApplicationFailed - } - - // Verify the user selected the correct path - if confirmedURL.path != gameAppPath { - logger.warning("User selected different path: \(confirmedURL.path) vs expected \(gameAppPath)") - // Continue with the user's selection - } - - // Start accessing the security-scoped resource - guard confirmedURL.startAccessingSecurityScopedResource() else { - logger.error("Failed to access security-scoped resource") - throw EntitlementError.entitlementApplicationFailed - } - defer { - confirmedURL.stopAccessingSecurityScopedResource() - } - - // Apply the entitlements using the security-scoped access - // We only sign the main executable, preserving nested framework/plugin signatures - logger.info("Applying loader entitlements to the game binary...") - let applySuccess = applyEntitlementsDirectly( - mainExecutablePath: gamePath, - entitlements: loaderEntitlements, - signingIdentity: signingIdentity - ) - guard applySuccess else { - throw EntitlementError.entitlementApplicationFailed - } - - // Verify the entitlements were applied successfully to the main executable - guard checkEntitlements(appPath: gamePath, expectedEntitlements: loaderEntitlements) else { - throw EntitlementError.entitlementVerificationFailed - } - - logger.info("Successfully applied loader entitlements to the game") +private let gameExecutableInBundle = "Contents/MacOS/Star Trek Fleet Command" + +enum EntitlementError: LocalizedError { + case gamePathNotFound + case gameRunning + case accessNotGranted + case wrongBundleSelected(String) + case entitlementVerificationFailed + case entitlementApplicationFailed + + var errorDescription: String? { + switch self { + case .gamePathNotFound: + return "Could not find the game executable path" + case .gameRunning: + return "Quit the game before the launcher updates its signature" + case .accessNotGranted: + return "The launcher needs access to the game bundle to enable the mod" + case .wrongBundleSelected(let path): + return "\(path) is not the Star Trek Fleet Command app" + case .entitlementVerificationFailed: + return "Failed to verify game entitlements" + case .entitlementApplicationFailed: + return "Failed to apply entitlements to the game" + } + } } -/// Fetches the path to the Star Trek Fleet Command game executable -/// - Returns: The full path to the game executable, or nil if not found -/// - Throws: Can throw errors related to file reading or parsing -func fetchGamePath() throws -> String? { - // Get the Library directory path - guard let libraryPath = FileManager.default.urls( - for: .libraryDirectory, - in: .userDomainMask - ).first else { - logger.error("Could not find Library directory") - return nil - } - - // Construct path to launcher_settings.ini - let launcherSettingsPath = libraryPath - .appendingPathComponent("Preferences") - .appendingPathComponent("Star Trek Fleet Command") - .appendingPathComponent("launcher_settings.ini") - .path - - // Check if the file exists - guard FileManager.default.fileExists(atPath: launcherSettingsPath) else { - logger.error("launcher_settings.ini not found at \(launcherSettingsPath)") - return nil - } - - // Parse the INI file - let config = try parseConfig(launcherSettingsPath) - - // Get the game install path from the [General] section - guard let generalSection = config["General"], - let gameInstallPath = generalSection["152033..GAME_PATH"] else { - logger.error("Could not find game path in launcher_settings.ini") - return nil - } - - // Append the app path - let appPath = "Star Trek Fleet Command.app/Contents/MacOS/Star Trek Fleet Command" - let fullGamePath = (gameInstallPath as NSString).appendingPathComponent(appPath) - - return fullGamePath +// Checks the game's entitlements and re-signs its executable only when they are missing. Even after a game update +// the new binary normally still carries them. Blocks while the file panel is open, so call it off the main thread. +func ensureGameHasLoaderEntitlements() throws(EntitlementError) { + guard let installPath = try? XsollaUpdater(gameName: "Star Trek Fleet Command").gamePath() else { + throw EntitlementError.gamePathNotFound + } + let gameAppPath = (installPath as NSString).appendingPathComponent("Star Trek Fleet Command.app") + let gamePath = (gameAppPath as NSString).appendingPathComponent(gameExecutableInBundle) + + if checkEntitlements(executablePath: gamePath, expected: loaderEntitlements) { + logger.info("Game already has the required loader entitlements, no re-signing needed") + return + } + logger.warning("Game is missing required loader entitlements, re-signing is needed") + + if isGameRunning(bundlePath: gameAppPath) { + logger.error("Game is currently running, cannot modify signature") + throw EntitlementError.gameRunning + } + + // The panel is the user's consent, and grants the security-scoped access that signing needs. + guard let confirmedURL = requestGameBundleAccess(gameAppPath: gameAppPath, gamePath: gamePath) else { + logger.error("User did not confirm access to game bundle") + throw EntitlementError.accessNotGranted + } + // Access is granted to the selected bundle only, so that is the one signed. It must still be the game. + let selectedExecutable = confirmedURL.appendingPathComponent(gameExecutableInBundle).path + guard FileManager.default.isExecutableFile(atPath: selectedExecutable) else { + logger.error("Selected bundle has no game executable: \(confirmedURL.path, privacy: .private)") + throw EntitlementError.wrongBundleSelected(confirmedURL.path) + } + if confirmedURL.standardizedFileURL.path != URL(fileURLWithPath: gameAppPath).standardizedFileURL.path { + logger.warning( + "User selected \(confirmedURL.path, privacy: .private) instead of \(gameAppPath, privacy: .private)") + } + + guard confirmedURL.startAccessingSecurityScopedResource() else { + logger.error("Failed to access security-scoped resource") + throw EntitlementError.entitlementApplicationFailed + } + defer { + confirmedURL.stopAccessingSecurityScopedResource() + } + + // Only the main executable is signed, so nested frameworks and plugins keep their own signatures. + logger.info("Applying loader entitlements to the game binary") + guard sign(executablePath: selectedExecutable, entitlements: loaderEntitlements) else { + throw EntitlementError.entitlementApplicationFailed + } + guard checkEntitlements(executablePath: selectedExecutable, expected: loaderEntitlements) else { + throw EntitlementError.entitlementVerificationFailed + } + logger.info("Successfully applied loader entitlements to the game") } -/// Checks if an application's entitlements match the provided dictionary of expected values -/// - Parameters: -/// - appPath: Path to the .app bundle or executable to check -/// - expectedEntitlements: Dictionary of entitlement keys and their expected values -/// - Returns: true if all expected entitlements match, false otherwise -func checkEntitlements(appPath: String, expectedEntitlements: [String: Bool]) -> Bool { - // Create process to run codesign command - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") - process.arguments = ["-d", "--entitlements", ":-", "--xml", appPath] - - let outputPipe = Pipe() - let errorPipe = Pipe() - process.standardOutput = outputPipe - process.standardError = errorPipe - - do { - try process.run() - process.waitUntilExit() - - // Check if codesign succeeded - guard process.terminationStatus == 0 else { - logger.error("codesign failed with status \(process.terminationStatus)") - if let errorData = try? errorPipe.fileHandleForReading.readToEnd(), - let errorString = String(data: errorData, encoding: .utf8) { - logger.error("codesign error output: \(errorString)") - } - return false - } - - // Read the output - let outputData = try outputPipe.fileHandleForReading.readToEnd() - guard let data = outputData, !data.isEmpty else { - logger.error("No entitlements data received from codesign") - return false - } - - // Parse the plist - guard let plist = try? PropertyListSerialization.propertyList(from: data, format: nil), - let entitlementsDict = plist as? [String: Any] else { - logger.error("Failed to parse entitlements plist") - return false - } - - // Compare each expected entitlement - for (key, expectedValue) in expectedEntitlements { - guard let actualValue = entitlementsDict[key] else { - logger.debug("Entitlement '\(key)' not found in app") - return false - } - - if (actualValue as? Bool) != expectedValue { - let expected = String(describing: expectedValue) - let actual = String(describing: actualValue) - logger.debug("Entitlement '\(key)' mismatch: expected \(expected), got \(actual)") - return false - } - } - - return true - - } catch { - logger.error("Error running codesign: \(error.localizedDescription)") - return false - } +// Runs a tool and returns its exit status and output; nil when it could not start. +private func run(_ tool: String, _ arguments: [String]) -> (status: Int32, output: Data, errors: String)? { + let process = Process() + process.executableURL = URL(fileURLWithPath: tool) + process.arguments = arguments + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + do { + try process.run() + } catch { + logger.error("Could not run \(tool, privacy: .public): \(error.localizedDescription, privacy: .public)") + return nil + } + // Read before waiting: a full pipe would otherwise block the tool forever. + let output = (try? outputPipe.fileHandleForReading.readToEnd()) ?? Data() + let errors = (try? errorPipe.fileHandleForReading.readToEnd()).flatMap { String(data: $0, encoding: .utf8) } ?? "" + process.waitUntilExit() + return (process.terminationStatus, output, errors) } -/// Requests user to grant access to the game bundle via file picker -/// - Parameters: -/// - gameAppPath: Path to the game .app bundle -/// - gamePath: Path to the game executable that will be modified -/// - Returns: URL with security-scoped access, or nil if user cancelled -func requestGameBundleAccess(gameAppPath: String, gamePath: String) -> URL? { - let semaphore = DispatchSemaphore(value: 0) - var result: URL? - - DispatchQueue.main.async { - requestGameBundleAccessOnMainThread(gameAppPath: gameAppPath, gamePath: gamePath) { selectedURL in - result = selectedURL - semaphore.signal() - } - } - - semaphore.wait() - return result +// True when the executable's signature has every expected entitlement with its expected value. +private func checkEntitlements(executablePath: String, expected: [String: Bool]) -> Bool { + guard let result = run("/usr/bin/codesign", ["-d", "--entitlements", ":-", "--xml", executablePath]) else { + return false + } + guard result.status == 0 else { + logger.error("codesign failed with status \(result.status): \(result.errors, privacy: .public)") + return false + } + guard + !result.output.isEmpty, + let plist = try? PropertyListSerialization.propertyList(from: result.output, format: nil), + let entitlements = plist as? [String: Any] + else { + logger.error("Could not read the game's entitlements") + return false + } + for (key, value) in expected where (entitlements[key] as? Bool) != value { + logger.debug("Entitlement \(key, privacy: .public) is not \(value)") + return false + } + return true } -/// Internal function that shows the file picker (must be called on main thread) -/// - Parameters: -/// - gameAppPath: Path to the game .app bundle -/// - gamePath: Path to the game executable that will be modified -/// - completion: Completion handler called with the selected URL or nil if cancelled -private func requestGameBundleAccessOnMainThread( - gameAppPath: String, - gamePath: String, - completion: @escaping (URL?) -> Void -) { - let openPanel = NSOpenPanel() - openPanel.message = """ - The STFC Community Mod needs to modify the game's code signature to enable mod loading. - - File to be modified: \(gamePath) - - Changes: Enable DYLD environment variables, allow unsigned code, disable library validation - - This is safe - only the game's signature is modified (reversible via Repair). - You'll be asked for your password to authorize this change. - - If this is your first time running the launcher, macOS security will ask you to authorize the - App Management permission for this app. After allowing this, you must quit the launcher and - start it again for the change to take effect. - - The default selection below should not need to be changed unless we did not detect - the game path correctly. Press "Authorize and Continue" to grant access to the game bundle. - """ - openPanel.prompt = "Authorize and Continue" - openPanel.canChooseFiles = false - openPanel.canChooseDirectories = true - openPanel.allowsMultipleSelection = false - openPanel.canCreateDirectories = false - - // Pre-select the game path if it exists - let gameURL = URL(fileURLWithPath: gameAppPath) +// Shows the file panel on the main thread and waits for the answer. Must be called off the main thread. +private func requestGameBundleAccess(gameAppPath: String, gamePath: String) -> URL? { + let semaphore = DispatchSemaphore(value: 0) + var result: URL? + DispatchQueue.main.async { + let panel = NSOpenPanel() + panel.message = """ + The STFC Community Mod needs to re-sign the game so that it can load the mod. + + File to be modified: \(gamePath) + + Changes: allow DYLD environment variables, allow unsigned executable memory, and disable library validation. + Only the game's signature changes. A game update or reinstall restores the original. + + The first time, macOS asks you to allow App Management for this launcher. After allowing it, quit the launcher + and start it again. + + The selection below should only need changing if the game was not found where expected. + Press "Authorize and Continue" to grant access to the game bundle. + """ + panel.prompt = "Authorize and Continue" + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.canCreateDirectories = false if FileManager.default.fileExists(atPath: gameAppPath) { - openPanel.directoryURL = gameURL.deletingLastPathComponent() - openPanel.nameFieldStringValue = gameURL.lastPathComponent - } - - // Use async begin() instead of blocking runModal() to avoid main thread blocking - openPanel.begin { response in - if response == .OK, let selectedURL = openPanel.url { - logger.info("User granted access to: \(selectedURL.path)") - completion(selectedURL) - } else { - logger.info("User cancelled file access dialog") - completion(nil) - } - } -} - -/// Applies entitlements to an application using codesign directly (no elevation) -/// - Parameters: -/// - mainExecutablePath: Path to the main executable within the bundle -/// - entitlements: Dictionary of entitlement keys and values to apply -/// - signingIdentity: Optional signing identity (defaults to "-" for ad-hoc signing) -/// - Returns: true if signing succeeded, false otherwise -/// - Note: Must be called while security-scoped resource access is active -func applyEntitlementsDirectly( - mainExecutablePath: String, - entitlements: [String: Bool], - signingIdentity: String = "-" -) -> Bool { - // Create temporary entitlements plist file - let tempDir = NSTemporaryDirectory() - let tempFileName = "temp_entitlements_\(UUID().uuidString).plist" - let tempEntitlementsPath = (tempDir as NSString).appendingPathComponent(tempFileName) - - do { - // Serialize the entitlements dictionary to plist format - let plistData = try PropertyListSerialization.data( - fromPropertyList: entitlements, - format: .xml, - options: 0 - ) - - // Write the plist to the temporary file - try plistData.write(to: URL(fileURLWithPath: tempEntitlementsPath)) - - // Ensure cleanup happens even if signing fails - defer { - try? FileManager.default.removeItem(atPath: tempEntitlementsPath) - } - - // Execute codesign directly (no elevation needed - user owns these files) - let success = executeCodesignDirectly( - mainExecutablePath: mainExecutablePath, - entitlementsPath: tempEntitlementsPath, - signingIdentity: signingIdentity - ) - - if success { - logger.info("Successfully applied entitlements to \(mainExecutablePath)") - } else { - logger.error("Failed to apply entitlements") - } - - return success - - } catch { - logger.error("Error applying entitlements: \(error.localizedDescription)") - // Clean up temp file if it exists - try? FileManager.default.removeItem(atPath: tempEntitlementsPath) - return false - } + let gameURL = URL(fileURLWithPath: gameAppPath) + panel.directoryURL = gameURL.deletingLastPathComponent() + panel.nameFieldStringValue = gameURL.lastPathComponent + } + // begin, not runModal, so the main thread keeps serving the launcher's UI. + panel.begin { response in + if response == .OK, let selected = panel.url { + logger.info("User granted access to: \(selected.path, privacy: .private)") + result = selected + } else { + logger.info("User cancelled file access dialog") + } + semaphore.signal() + } + } + semaphore.wait() + return result } -/// Executes codesign directly without elevation -/// - Parameters: -/// - mainExecutablePath: Path to the main executable within the bundle -/// - entitlementsPath: Path to temporary entitlements plist -/// - signingIdentity: Code signing identity -/// - Returns: true if successful, false otherwise -/// - Note: Must be called while security-scoped resource access is active -func executeCodesignDirectly( - mainExecutablePath: String, - entitlementsPath: String, - signingIdentity: String -) -> Bool { - // Strategy: Sign just the main executable with entitlements - // We preserve existing signatures on nested frameworks/plugins - - // First, try to remove the old signature from the main executable only - logger.info("Removing old signature from: \(mainExecutablePath)") - let removeProcess = Process() - removeProcess.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") - removeProcess.arguments = ["--remove-signature", mainExecutablePath] - - let removeOutputPipe = Pipe() - let removeErrorPipe = Pipe() - removeProcess.standardOutput = removeOutputPipe - removeProcess.standardError = removeErrorPipe - - try? removeProcess.run() - removeProcess.waitUntilExit() - - // Log output from removal (informational, errors are expected if no signature) - if let outputData = try? removeOutputPipe.fileHandleForReading.readToEnd(), - !outputData.isEmpty, - let outputString = String(data: outputData, encoding: .utf8), !outputString.isEmpty { - logger.info("Remove signature stdout: \(outputString)") - } - if let errorData = try? removeErrorPipe.fileHandleForReading.readToEnd(), - !errorData.isEmpty, - let errorString = String(data: errorData, encoding: .utf8), !errorString.isEmpty { - logger.debug("Remove signature stderr: \(errorString)") - } - - // Now sign just the main executable with the entitlements - // This preserves nested code signatures and only modifies what we need - logger.info("Signing executable with entitlements...") - logger.info(""" - Command: codesign --force --sign \(signingIdentity) \ - --options runtime \ - --entitlements \(entitlementsPath) \(mainExecutablePath) - """) - let signProcess = Process() - signProcess.executableURL = URL(fileURLWithPath: "/usr/bin/codesign") - signProcess.arguments = [ - "--force", - "--options", "runtime", - "--sign", signingIdentity, - "--entitlements", entitlementsPath, - mainExecutablePath - ] - - let outputPipe = Pipe() - let errorPipe = Pipe() - signProcess.standardOutput = outputPipe - signProcess.standardError = errorPipe - - do { - try signProcess.run() - signProcess.waitUntilExit() - - // Always log stdout if present - if let outputData = try? outputPipe.fileHandleForReading.readToEnd(), - !outputData.isEmpty, - let outputString = String(data: outputData, encoding: .utf8), !outputString.isEmpty { - logger.info("codesign stdout: \(outputString)") - } - - // Always log stderr if present - if let errorData = try? errorPipe.fileHandleForReading.readToEnd(), - !errorData.isEmpty, - let errorString = String(data: errorData, encoding: .utf8), !errorString.isEmpty { - if signProcess.terminationStatus != 0 { - logger.error("codesign stderr: \(errorString)") - } else { - logger.info("codesign stderr: \(errorString)") - } - } - - if signProcess.terminationStatus != 0 { - logger.error("codesign failed with exit code: \(signProcess.terminationStatus)") - return false - } - - logger.info("Successfully signed main executable") - return true - - } catch { - logger.error("Error running codesign: \(error.localizedDescription)") - return false - } +// Re-signs one executable ad hoc with `entitlements`. Must run while the bundle's security-scoped access is held. +private func sign(executablePath: String, entitlements: [String: Bool]) -> Bool { + let entitlementsURL = FileManager.default.temporaryDirectory + .appendingPathComponent("stfc_entitlements_\(UUID().uuidString).plist") + defer { + try? FileManager.default.removeItem(at: entitlementsURL) + } + do { + try PropertyListSerialization.data(fromPropertyList: entitlements, format: .xml, options: 0) + .write(to: entitlementsURL) + } catch { + logger.error("Could not write the entitlements: \(error.localizedDescription, privacy: .public)") + return false + } + + // Fails harmlessly when there is no signature to remove. + if let removed = run("/usr/bin/codesign", ["--remove-signature", executablePath]), !removed.errors.isEmpty { + logger.debug("Remove signature: \(removed.errors, privacy: .public)") + } + + let arguments = [ + "--force", "--options", "runtime", "--sign", "-", "--entitlements", entitlementsURL.path, executablePath, + ] + logger.info("Running codesign \(arguments.joined(separator: " "), privacy: .private)") + guard let signed = run("/usr/bin/codesign", arguments) else { + return false + } + guard signed.status == 0 else { + logger.error("codesign failed with exit code \(signed.status): \(signed.errors, privacy: .public)") + return false + } + logger.info("Signed the game executable") + return true } -/// Checks if the game is currently running -/// - Parameter bundlePath: Path to the .app bundle -/// - Returns: true if the game is running, false otherwise -func isGameRunning(bundlePath: String) -> Bool { - let runningApps = NSWorkspace.shared.runningApplications - let bundleURL = URL(fileURLWithPath: bundlePath) - - for app in runningApps { - if let appBundleURL = app.bundleURL, appBundleURL == bundleURL { - logger.info("Game is currently running at \(bundlePath)") - return true - } - } - - return false +private func isGameRunning(bundlePath: String) -> Bool { + let bundleURL = URL(fileURLWithPath: bundlePath).standardizedFileURL + return NSWorkspace.shared.runningApplications.contains { $0.bundleURL?.standardizedFileURL == bundleURL } } diff --git a/src/launcher/macos/TemporaryFolderURL.swift b/src/launcher/macos/TemporaryFolderURL.swift deleted file mode 100644 index f7b143868..000000000 --- a/src/launcher/macos/TemporaryFolderURL.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -public final class TemporaryFolderURL { - public let contentURL: URL - public init() { - contentURL = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(UUID().uuidString) - } - - deinit { - DispatchQueue.global(qos: .utility).async { [contentURL = self.contentURL] in - try? FileManager.default.removeItem(at: contentURL) - } - } -} diff --git a/src/launcher/macos/UpdaterFileOperations.swift b/src/launcher/macos/UpdaterFileOperations.swift index 4658de998..37e102ec3 100644 --- a/src/launcher/macos/UpdaterFileOperations.swift +++ b/src/launcher/macos/UpdaterFileOperations.swift @@ -1,79 +1,84 @@ +// The updater's file work. It depends on nothing else in the launcher, so the archive tests compile it alone. + import Foundation import OSLog -private let fileOperationLogger = Logger(subsystem: "com.stfcmod.startrekpatch", category: "xsolla") +private let logger = Logger(subsystem: "com.stfcmod.startrekpatch", category: "xsolla") -func copyContentsOfDirectory(from sourceURL: URL, to targetURL: URL) throws { - let fileManager = FileManager.default +extension FileManager { + func removeItemIfPresent(at url: URL) throws { + if fileExists(atPath: url.path) { + try removeItem(at: url) + } + } - try fileManager.createDirectory(at: targetURL, withIntermediateDirectories: true) - let sourceContents = try fileManager.contentsOfDirectory( + func createParentDirectory(of url: URL) throws { + try createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + } +} + +// Merges sourceURL into targetURL, replacing files that exist in both. +func copyContentsOfDirectory(from sourceURL: URL, to targetURL: URL) throws { + let files = FileManager.default + try files.createDirectory(at: targetURL, withIntermediateDirectories: true) + let sourceContents = try files.contentsOfDirectory( at: sourceURL, includingPropertiesForKeys: [.isSymbolicLinkKey, .isDirectoryKey]) for sourceItem in sourceContents { let destinationItem = targetURL.appendingPathComponent(sourceItem.lastPathComponent) - do { - let resourceValues = try sourceItem.resourceValues( - forKeys: [.isSymbolicLinkKey, .isDirectoryKey]) - if resourceValues.isSymbolicLink ?? false { - // Copy symlinks as links; do not follow them to their target. Check isSymbolicLink - // before isDirectory because isDirectoryKey resolves the link and would otherwise - // recurse into a symlinked directory instead of preserving the link. - if fileManager.fileExists(atPath: destinationItem.path) { - try fileManager.removeItem(at: destinationItem) - } - try fileManager.copyItem(at: sourceItem, to: destinationItem) - } else if resourceValues.isDirectory ?? false { - // The recursive call creates destinationItem itself, so don't create it here. + let resourceValues = try sourceItem.resourceValues(forKeys: [.isSymbolicLinkKey, .isDirectoryKey]) + // isDirectoryKey resolves symlinks, so a symlinked directory must be ruled out before recursing. copyItem + // copies a symlink as a link rather than following it. + if resourceValues.isDirectory == true && resourceValues.isSymbolicLink != true { + // The recursive call creates destinationItem itself. try copyContentsOfDirectory(from: sourceItem, to: destinationItem) } else { - if fileManager.fileExists(atPath: destinationItem.path) { - try fileManager.removeItem(at: destinationItem) - } - try fileManager.copyItem(at: sourceItem, to: destinationItem) + try files.removeItemIfPresent(at: destinationItem) + try files.copyItem(at: sourceItem, to: destinationItem) } } catch { - fileOperationLogger.error( - "Error copying \(sourceItem.lastPathComponent, privacy: .public) to \(destinationItem.path, privacy: .private): \(error.localizedDescription, privacy: .public)") + logger.error( + "Error copying \(sourceItem.lastPathComponent, privacy: .public) to \(destinationItem.path, privacy: .private): \(error.localizedDescription, privacy: .public)" + ) throw error } } } +// Deletes game files the update removed, except those it also staged a replacement for. func deleteGamePaths(_ relativePaths: [String], gamePath: String, preservingStagedRoot stagedRoot: URL) throws { - let fileManager = FileManager.default + let files = FileManager.default let gameRoot = URL(fileURLWithPath: gamePath) for relativePath in relativePaths { - let stagedPath = stagedRoot.appendingPathComponent(relativePath) - if fileManager.fileExists(atPath: stagedPath.path) { - fileOperationLogger.info( - "Skipping delete for \(relativePath, privacy: .public) because a staged replacement exists") + if files.fileExists(atPath: stagedRoot.appendingPathComponent(relativePath).path) { + logger.info("Skipping delete for \(relativePath, privacy: .public) because a staged replacement exists") continue } let targetPath = gameRoot.appendingPathComponent(relativePath) - if !fileManager.fileExists(atPath: targetPath.path) { - fileOperationLogger.debug("Skipping delete for missing file \(relativePath, privacy: .public)") + guard files.fileExists(atPath: targetPath.path) else { + logger.debug("Skipping delete for missing file \(relativePath, privacy: .public)") continue } do { - try fileManager.removeItem(at: targetPath) - fileOperationLogger.debug("Deleted obsolete game file \(relativePath, privacy: .public)") + try files.removeItem(at: targetPath) + logger.debug("Deleted obsolete game file \(relativePath, privacy: .public)") } catch { - fileOperationLogger.error( - "Error deleting obsolete game file \(relativePath, privacy: .public): \(error.localizedDescription, privacy: .public)") + logger.error( + "Error deleting obsolete game file \(relativePath, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) throw error } } } +// The same "&game=" form the Xsolla launcher writes. func writeInstalledGameVersion(_ version: Int, gamePath: String) throws { let versionPath = URL(fileURLWithPath: gamePath).appendingPathComponent(".version") - let fileVersion = String(format: "&game=%d", version) - try fileVersion.write(to: versionPath, atomically: true, encoding: .utf8) + try "&game=\(version)".write(to: versionPath, atomically: true, encoding: .utf8) } // Apple's bsdtar reads the update service's 7z payloads without a bundled archive runtime. @@ -85,8 +90,8 @@ func extractUpdateArchive(from archive: String, to directory: String) throws { try extractor.run() extractor.waitUntilExit() guard extractor.terminationReason == .exit && extractor.terminationStatus == 0 else { - throw NSError(domain: "XsollaUpdater", code: Int(extractor.terminationStatus), userInfo: [ - NSLocalizedDescriptionKey: "Could not extract update archive \(archive)." - ]) + throw NSError( + domain: "XsollaUpdater", code: Int(extractor.terminationStatus), + userInfo: [NSLocalizedDescriptionKey: "Could not extract update archive \(archive)."]) } } diff --git a/src/launcher/macos/XsollaLib.swift b/src/launcher/macos/XsollaLib.swift index 8f781715d..b25906b60 100644 --- a/src/launcher/macos/XsollaLib.swift +++ b/src/launcher/macos/XsollaLib.swift @@ -1,94 +1,98 @@ +// Updates the game the way the Xsolla launcher does: reads its settings, fetches the update plan for the installed +// version, then downloads, extracts and patches into a staging folder before copying over the install. + import Foundation import os -/// Logger for Xsolla operations private let logger = Logger(subsystem: "com.stfcmod.startrekpatch", category: "xsolla") -struct DownloadAction { - var url: String - var size: Int - var to: String -} - -struct ExtractAction { - var file: String - var to: String -} - -struct PatchAction { - var binaries: String - var patch: String -} - -struct VersionAction { - var version: Int -} - -enum XsollaUpdateAction { - case Download(DownloadAction) - case Extract(ExtractAction) - case Patch(PatchAction) - case Version(VersionAction) - case WaitActions -} - -private func updateActionLogName(_ action: XsollaUpdateAction) -> String { - switch action { - case .Download: - return "download" - case .Extract: - return "extract" - case .Patch: - return "patch" - case .Version: - return "version" - case .WaitActions: - return "wait" +// Xsolla's id for Star Trek Fleet Command, in its settings keys and update URLs. +private let projectID = "152033" + +enum XsollaError: LocalizedError { + case missingLauncherSetting(String) + case invalidUpdateURL(String) + case invalidUpdatePlan(String) + case invalidPatchPath(String) + + var errorDescription: String? { + switch self { + case .missingLauncherSetting(let key): + return "The Xsolla launcher settings have no \(key). Install the game with the Xsolla launcher first." + case .invalidUpdateURL(let url): + return "The update service returned an invalid URL: \(url)" + case .invalidUpdatePlan(let reason): + return "The update plan could not be read: \(reason)" + case .invalidPatchPath(let path): + return "The update contains an unsafe file path: \(path)" + } } } -struct PatchRule: Decodable { - var file_size: Optional - var relative_path: String - var rule: String - var sha512: Optional +// What an update is doing, for the launcher's status line. +enum XsollaUpdateProgress { + case start + case progress(currentAction: Int, totalActions: Int) + case downloading + case extracting + case patching + case waiting + case applyVersion + case versionApplied + case finalizing + case cleaningUp + case complete } -func normalizedRelativePatchPath(_ path: String) throws -> String { - var components: [String] = [] - - for component in path.trimmingCharacters(in: .whitespacesAndNewlines).split(whereSeparator: { $0 == "/" || $0 == "\\" }) { - switch component { - case "", ".", "..": - throw NSError(domain: "XsollaUpdater", code: 4, userInfo: [NSLocalizedDescriptionKey: "Invalid patch path: \(path)"]) - default: - components.append(String(component)) +// --- Update plan --- + +// One step of an update plan. Paths may hold $temp_path (the Xsolla temp folder) and $game_path. +private enum UpdateAction { + case download(url: String, size: Int, to: String) + case extract(file: String, to: String) + case patch(binaries: String, patch: String) + case version(Int) + case waitActions + + var logName: String { + switch self { + case .download: return "download" + case .extract: return "extract" + case .patch: return "patch" + case .version: return "version" + case .waitActions: return "wait" } } - - if components.isEmpty { - throw NSError(domain: "XsollaUpdater", code: 4, userInfo: [NSLocalizedDescriptionKey: "Invalid patch path: \(path)"]) - } - - return components.joined(separator: "/") -} - -func stagedPatchURL(root: URL, relativePath: String) throws -> URL { - let rootURL = root.standardizedFileURL - let targetURL = rootURL.appendingPathComponent(relativePath).standardizedFileURL - let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/" - - if targetURL.path != rootURL.path && targetURL.path.hasPrefix(rootPath) { - return targetURL - } - - throw NSError(domain: "XsollaUpdater", code: 4, userInfo: [NSLocalizedDescriptionKey: "Patch path escapes staging directory: \(relativePath)"]) } -class XsollaUpdateParser: NSObject, XMLParserDelegate { +private struct UpdatePlan { + // The version the plan ends at; 0 when it has no version action. var gameVersion = 0 + var actions: [UpdateAction] = [] +} - var actions: [XsollaUpdateAction] = [] +// Reads the update plan. An action missing an attribute it needs fails the whole plan: applying part of a plan +// could leave the install between versions. +private final class UpdatePlanReader: NSObject, XMLParserDelegate { + private var plan = UpdatePlan() + private var error: XsollaError? + + static func read(_ data: Data) throws -> UpdatePlan { + let xml = XMLParser(data: data) + let reader = UpdatePlanReader() + xml.delegate = reader + let parsed = xml.parse() + if let error = reader.error { + logger.error("Rejected the Xsolla update plan: \(error.localizedDescription, privacy: .public)") + throw error + } + guard parsed else { + let message = xml.parserError?.localizedDescription ?? "unknown XML parser error" + logger.error("Failed to parse the Xsolla update plan: \(message, privacy: .public)") + throw XsollaError.invalidUpdatePlan(message) + } + return reader.plan + } func parser( _ parser: XMLParser, @@ -97,70 +101,95 @@ class XsollaUpdateParser: NSObject, XMLParserDelegate { qualifiedName qName: String?, attributes attributeDict: [String: String] = [:] ) { - if elementName == "action" { - switch attributeDict["type"] { - case "torrent_download": - actions.append( - XsollaUpdateAction.Download( - DownloadAction( - url: attributeDict["alt_data_link"]!, - size: Int(attributeDict["data_size"]!)!, - to: attributeDict["alt_to"]!))) - break - case "extract": - actions.append( - XsollaUpdateAction.Extract( - ExtractAction( - file: attributeDict["file"]!, - to: attributeDict["to"]!))) - break - case "patch": - actions.append( - XsollaUpdateAction.Patch( - PatchAction(binaries: attributeDict["binaries"]!, patch: attributeDict["patch"]!))) - break - case "wait_actions": - actions.append(XsollaUpdateAction.WaitActions) - break - case "extracted_size": - break - case "version": - gameVersion = Int(attributeDict["version"]!)! - actions.append(XsollaUpdateAction.Version(VersionAction(version: gameVersion))) - break - default: - logger.warning("Unknown action type: \(attributeDict["type"]!)") - break + guard elementName == "action", error == nil else { return } + let type = attributeDict["type"] ?? "" + + func fail(_ reason: String) { + error = .invalidUpdatePlan(reason) + parser.abortParsing() + } + func text(_ key: String) -> String? { + guard let value = attributeDict[key] else { + fail("\(type) action has no \(key)") + return nil + } + return value + } + func number(_ key: String) -> Int? { + guard let value = text(key) else { return nil } + guard let number = Int(value) else { + fail("\(type) action has a non-numeric \(key): \(value)") + return nil + } + return number + } + + switch type { + case "torrent_download": + if let url = text("alt_data_link"), let size = number("data_size"), let to = text("alt_to") { + plan.actions.append(.download(url: url, size: size, to: to)) + } + case "extract": + if let file = text("file"), let to = text("to") { + plan.actions.append(.extract(file: file, to: to)) + } + case "patch": + if let binaries = text("binaries"), let patch = text("patch") { + plan.actions.append(.patch(binaries: binaries, patch: patch)) } + case "wait_actions": + plan.actions.append(.waitActions) + case "extracted_size": + break + case "version": + if let version = number("version") { + plan.gameVersion = version + plan.actions.append(.version(version)) + } + default: + logger.warning("Unknown action type: \(type, privacy: .public)") } } } -enum XsollaUpdateProgress { - case Start(totalActions: Int) - case Progress(currentAction: Int, totalActions: Int) - case Extracting(currentFile: String) - case ExtractComplete(currentFile: String) - case Downloading(url: String) - case DownloadComplete(url: String) - case Patching(totalFiles: Int) - case PatchingProgress(currentBytes: Int, totalBytes: Int) - case PatchStepComplete - case PatchComplete - case Waiting - case ApplyVersion - case VersionApplied - case Finalizing - case CleaningUp - case Complete +// --- Patch rules --- + +// One entry of a patch folder's patchRules.json. +private struct PatchRule: Decodable { + var relativePath: String + var rule: String + + enum CodingKeys: String, CodingKey { + case relativePath = "relative_path" + case rule + } +} + +// A patch rule's path, normalized to forward slashes. Rejects anything that could climb out of the staging folder. +// Empty components are dropped, so "a//b" is "a/b". +private func normalizedRelativePatchPath(_ path: String) throws -> String { + let components = path.trimmingCharacters(in: .whitespacesAndNewlines).split { $0 == "/" || $0 == "\\" } + guard !components.isEmpty, !components.contains(where: { $0 == "." || $0 == ".." }) else { + throw XsollaError.invalidPatchPath(path) + } + return components.joined(separator: "/") } -protocol XSollaUpdaterDelegate { - func updateProgress(progress: XsollaUpdateProgress) +// The rule's file in the staging folder. A second guard after normalization: the result must lie strictly inside root. +private func stagedPatchURL(root: URL, relativePath: String) throws -> URL { + let rootURL = root.standardizedFileURL + let targetURL = rootURL.appendingPathComponent(relativePath).standardizedFileURL + let rootPath = rootURL.path.hasSuffix("/") ? rootURL.path : rootURL.path + "/" + guard targetURL.path != rootURL.path, targetURL.path.hasPrefix(rootPath) else { + throw XsollaError.invalidPatchPath(relativePath) + } + return targetURL } +// --- Updater --- + private func httpStatus(_ response: URLResponse) -> Int { - return (response as? HTTPURLResponse)?.statusCode ?? -1 + (response as? HTTPURLResponse)?.statusCode ?? -1 } private func fileSize(at url: URL) -> Int { @@ -173,96 +202,78 @@ private func fileSize(at url: URL) -> Int { return size.intValue } -struct XsollaUpdater { - var gameName: String +private func withoutTrailingSeparator(_ path: String) -> String { + path.hasSuffix("/") || path.hasSuffix("\\") ? String(path.dropLast()) : path +} - public init(_ gameName: String) { - self.gameName = gameName +struct XsollaUpdater { + // The game's folder under ~/Library/Preferences, where the Xsolla launcher keeps launcher_settings.ini. + let gameName: String + + // A path from the Xsolla launcher's settings. The launcher sometimes writes them with a doubled leading slash; + // the mod's loader strips it the same way. + private func launcherSetting(_ name: String) throws -> String { + let key = "\(projectID)..\(name)" + guard let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first else { + throw XsollaError.missingLauncherSetting(key) + } + let settings = library.appendingPathComponent("Preferences").appendingPathComponent(gameName) + .appendingPathComponent("launcher_settings.ini") + guard let value = try readIni(atPath: settings.path)["General"]?[key] else { + throw XsollaError.missingLauncherSetting(key) + } + return value.hasPrefix("//") ? String(value.dropFirst()) : value } func gamePath() throws -> String { - let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first - if let library { - let preferences = library.appendingPathComponent("Preferences").appendingPathComponent( - self.gameName) - let settingsIniPath = preferences.appendingPathComponent("launcher_settings.ini") - let settingsIni = try parseConfig(settingsIniPath.path) - let gamePath = settingsIni["General"]?["152033..GAME_PATH"] - if let gamePath { - if gamePath.starts(with: "//") { - return String(gamePath.dropFirst()) - } - return gamePath - } - } - throw NSError(domain: "XsollaUpdater", code: 1, userInfo: nil) + try launcherSetting("GAME_PATH") } func gameTempPath() throws -> String { - let library = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first - if let library { - let preferences = library.appendingPathComponent("Preferences").appendingPathComponent( - self.gameName) - let settingsIniPath = preferences.appendingPathComponent("launcher_settings.ini") - let settingsIni = try parseConfig(settingsIniPath.path) - let gameTempPath = settingsIni["General"]?["152033..GAME_TEMP_PATH"] - if let gameTempPath { - if gameTempPath.starts(with: "//") { - return String(gameTempPath.dropFirst()) - } - return gameTempPath - } - } - throw NSError(domain: "XsollaUpdater", code: 1, userInfo: nil) + try launcherSetting("GAME_TEMP_PATH") } - @MainActor + // The installed version from the game's .version file ("&game="); 0 when it cannot be read. func installedVersion() -> Int { - do { - var gamePath = URL(fileURLWithPath: try self.gamePath()) - gamePath.appendPathComponent(".version") - let versionFile = try String(contentsOf: gamePath, encoding: String.Encoding.utf8) - let versionSegments = versionFile.split(separator: "=") - return Int(versionSegments[1]) ?? 0 - } catch { + guard + let gamePath = try? self.gamePath(), + let versionFile = try? String( + contentsOf: URL(fileURLWithPath: gamePath).appendingPathComponent(".version"), encoding: .utf8) + else { return 0 } + let segments = versionFile.split(separator: "=") + guard segments.count == 2 else { return 0 } + return Int(segments[1].trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0 } - private func latestGameVersion(installedVersion: Int) async throws -> Int { - let url = URL( - string: String( - format: - "https://gus.xsolla.com/updates?version=%d&project_id=152033®ion=&platform=mac_os", - installedVersion)) - if let url { - logger.info("Checking latest game version from installed version \(installedVersion, privacy: .public)") - let (data, response) = try await URLSession.shared.data(from: url) - logger.debug( - "Xsolla latest-version response status=\(httpStatus(response), privacy: .public) bytes=\(data.count, privacy: .public)") - - let xml = XMLParser(data: data) - let parser = XsollaUpdateParser() - xml.delegate = parser - guard xml.parse() else { - let message = xml.parserError?.localizedDescription ?? "unknown XML parser error" - logger.error("Failed to parse Xsolla latest-version XML: \(message, privacy: .public)") - throw NSError(domain: "XsollaUpdater", code: 5, userInfo: [NSLocalizedDescriptionKey: message]) - } - logger.info("Latest game version parsed as \(parser.gameVersion, privacy: .public)") - return parser.gameVersion + // The plan that takes `installedVersion` to the latest version. + private func fetchUpdatePlan(from installedVersion: Int) async throws -> UpdatePlan { + let address = + "https://gus.xsolla.com/updates?version=\(installedVersion)&project_id=\(projectID)®ion=&platform=mac_os" + guard let url = URL(string: address) else { + throw XsollaError.invalidUpdateURL(address) } - throw NSError(domain: "XsollaUpdater", code: 2, userInfo: nil) + let (data, response) = try await URLSession.shared.data(from: url) + logger.info( + "Fetched Xsolla update plan status=\(httpStatus(response), privacy: .public) bytes=\(data.count, privacy: .public)" + ) + let plan = try UpdatePlanReader.read(data) + logger.info( + "Parsed Xsolla update plan targetVersion=\(plan.gameVersion, privacy: .public) actions=\(plan.actions.count, privacy: .public)" + ) + return plan } @MainActor func checkForUpdateAvailable() async -> Bool { do { let installedVersion = self.installedVersion() - let latestGameVersion = try await self.latestGameVersion(installedVersion: installedVersion) + let latestGameVersion = try await fetchUpdatePlan(from: installedVersion).gameVersion let available = installedVersion < latestGameVersion logger.info( - "Game update availability installed=\(installedVersion, privacy: .public) latest=\(latestGameVersion, privacy: .public) available=\(available, privacy: .public)") + "Game update availability installed=\(installedVersion, privacy: .public) latest=\(latestGameVersion, privacy: .public) available=\(available, privacy: .public)" + ) return available } catch { logger.error("Game update availability check failed: \(error.localizedDescription, privacy: .public)") @@ -270,253 +281,218 @@ struct XsollaUpdater { } } + // The update runs on the main actor, so `progress` can update the UI directly. Its file work blocks the UI while + // it runs; only the network waits suspend. @MainActor - func updateGame(delegate: XSollaUpdaterDelegate? = nil) async throws { + func updateGame(onProgress progress: @MainActor (XsollaUpdateProgress) -> Void) async throws { let installedVersion = self.installedVersion() logger.info("Starting game update from installed version \(installedVersion, privacy: .public)") - do { - guard - let url = URL( - string: String( - format: - "https://gus.xsolla.com/updates?version=%d&project_id=152033®ion=&platform=mac_os", - installedVersion)) - else { - throw NSError(domain: "XsollaUpdater", code: 3, userInfo: nil) - } + try await runUpdate(from: installedVersion, onProgress: progress) + } catch { + logger.error( + "Game update failed from installed version \(installedVersion, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + throw error + } + } - let (data, response) = try await URLSession.shared.data(from: url) - logger.info( - "Fetched Xsolla update plan status=\(httpStatus(response), privacy: .public) bytes=\(data.count, privacy: .public)") - - let xml = XMLParser(data: data) - let parser = XsollaUpdateParser() - xml.delegate = parser - guard xml.parse() else { - let message = xml.parserError?.localizedDescription ?? "unknown XML parser error" - logger.error("Failed to parse Xsolla update XML: \(message, privacy: .public)") - throw NSError(domain: "XsollaUpdater", code: 5, userInfo: [NSLocalizedDescriptionKey: message]) + @MainActor + private func runUpdate(from installedVersion: Int, onProgress progress: @MainActor (XsollaUpdateProgress) -> Void) + async throws + { + let files = FileManager.default + let plan = try await fetchUpdatePlan(from: installedVersion) + let tempGamePath = withoutTrailingSeparator(try gameTempPath()) + let gamePath = withoutTrailingSeparator(try self.gamePath()) + // Patched and copied files wait here until every action has succeeded. It can hold much of the game, so it is + // removed in the background once the update ends, whether or not it succeeded. + let staging = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + defer { + DispatchQueue.global(qos: .utility).async { + try? FileManager.default.removeItem(at: staging) } - logger.info( - "Parsed Xsolla update plan targetVersion=\(parser.gameVersion, privacy: .public) actions=\(parser.actions.count, privacy: .public)") + } - var tempGamePath = try self.gameTempPath() - if tempGamePath.hasSuffix("/") || tempGamePath.hasSuffix("\\") { - tempGamePath = String(tempGamePath.dropLast()) - } - var gamePath = try self.gamePath() - if gamePath.hasSuffix("/") || gamePath.hasSuffix("\\") { - gamePath = String(gamePath.dropLast()) - } - let tempPath = TemporaryFolderURL() + if files.fileExists(atPath: tempGamePath) { + logger.info("Removing previous Xsolla temp path \(tempGamePath, privacy: .private)") + try files.removeItem(atPath: tempGamePath) + } + try files.createDirectory(atPath: tempGamePath, withIntermediateDirectories: true) + try files.createDirectory(at: staging, withIntermediateDirectories: true) + logger.info( + "Prepared updater directories gamePath=\(gamePath, privacy: .private) xsollaTemp=\(tempGamePath, privacy: .private) staging=\(staging.path, privacy: .private)" + ) + + progress(.start) + + func inTemp(_ path: String) -> String { + path.replacingOccurrences(of: "$temp_path", with: tempGamePath) + } - if FileManager.default.fileExists(atPath: tempGamePath) { - logger.info("Removing previous Xsolla temp path \(tempGamePath, privacy: .private)") - try FileManager.default.removeItem(atPath: tempGamePath) + var pendingGameVersion: Int? + var pendingDeletes: [String] = [] + for (index, action) in plan.actions.enumerated() { + let currentAction = index + 1 + logger.info( + "Starting Xsolla action \(currentAction, privacy: .public)/\(plan.actions.count, privacy: .public): \(action.logName, privacy: .public)" + ) + progress(.progress(currentAction: currentAction, totalActions: plan.actions.count)) + + switch action { + case .download(let url, let size, let to): + progress(.downloading) + guard let downloadURL = URL(string: url) else { + throw XsollaError.invalidUpdateURL(url) + } + logger.info( + "Downloading update payload expectedBytes=\(size, privacy: .public) url=\(url, privacy: .private)") + let (localURL, response) = try await URLSession.shared.download(from: downloadURL) + let toPath = inTemp(to) + logger.info( + "Downloaded update payload status=\(httpStatus(response), privacy: .public) bytes=\(fileSize(at: localURL), privacy: .public) target=\(toPath, privacy: .private)" + ) + try files.moveItem(at: localURL, to: URL(fileURLWithPath: toPath)) + + case .extract(let file, let to): + let fromPath = inTemp(file) + let toPath = inTemp(to) + logger.info("Extracting update archive from=\(fromPath, privacy: .private) to=\(toPath, privacy: .private)") + progress(.extracting) + try extractUpdateArchive(from: fromPath, to: toPath) + logger.info("Extracted update archive to=\(toPath, privacy: .private)") + + case .patch(let binaries, let patch): + // binaries only shows up in the log: rules patch from the installed game, or from an earlier staged copy. + let binariesPath = inTemp(binaries).replacingOccurrences(of: "$game_path", with: gamePath) + pendingDeletes += try applyPatch( + folder: inTemp(patch), binaries: binariesPath, gamePath: gamePath, staging: staging, onProgress: progress) + + case .waitActions: + progress(.waiting) + logger.debug("Observed Xsolla wait action") + + case .version(let version): + pendingGameVersion = version + logger.info("Deferred installed version write until finalization: \(version, privacy: .public)") } - try FileManager.default.createDirectory( - atPath: tempGamePath, withIntermediateDirectories: true, attributes: nil) - try FileManager.default.createDirectory( - at: tempPath.contentURL, withIntermediateDirectories: true, attributes: nil) + logger.info( - "Prepared updater directories gamePath=\(gamePath, privacy: .private) xsollaTemp=\(tempGamePath, privacy: .private) staging=\(tempPath.contentURL.path, privacy: .private)") + "Finished Xsolla action \(currentAction, privacy: .public)/\(plan.actions.count, privacy: .public): \(action.logName, privacy: .public)" + ) + } - delegate?.updateProgress( - progress: XsollaUpdateProgress.Start(totalActions: parser.actions.count)) + progress(.finalizing) + // Not atomic: staged files replace installed ones one at a time, so a failure partway leaves the install mixed. + // The version file is written only after the copy, so the next run repeats the update. + logger.info("Copying staged update files into the game directory") + try copyContentsOfDirectory(from: staging, to: URL(fileURLWithPath: gamePath)) + logger.info("Copied staged update files into the game directory") - var currentAction = 0 - var pendingGameVersion: Int? - var pendingDeletes: [String] = [] + if !pendingDeletes.isEmpty { + logger.info("Applying deferred delete rules count=\(pendingDeletes.count, privacy: .public)") + try deleteGamePaths(pendingDeletes, gamePath: gamePath, preservingStagedRoot: staging) + } - for action in parser.actions { - currentAction += 1 - let actionName = updateActionLogName(action) - logger.info( - "Starting Xsolla action \(currentAction, privacy: .public)/\(parser.actions.count, privacy: .public): \(actionName, privacy: .public)") - delegate?.updateProgress( - progress: XsollaUpdateProgress.Progress( - currentAction: currentAction, totalActions: parser.actions.count)) - - switch action { - case .Download(let downloadAction): - delegate?.updateProgress( - progress: XsollaUpdateProgress.Downloading(url: downloadAction.url)) - guard let downloadURL = URL(string: downloadAction.url) else { - throw NSError(domain: "XsollaUpdater", code: 3, userInfo: nil) - } - logger.info( - "Downloading update payload expectedBytes=\(downloadAction.size, privacy: .public) url=\(downloadAction.url, privacy: .private)") - let (localURL, response) = try await URLSession.shared.download(from: downloadURL) - let toPath = downloadAction.to.replacingOccurrences(of: "$temp_path", with: tempGamePath) - let downloadedBytes = fileSize(at: localURL) - logger.info( - "Downloaded update payload status=\(httpStatus(response), privacy: .public) bytes=\(downloadedBytes, privacy: .public) target=\(toPath, privacy: .private)") - delegate?.updateProgress( - progress: XsollaUpdateProgress.DownloadComplete(url: downloadAction.url)) - try FileManager.default.moveItem(at: localURL, to: URL(fileURLWithPath: toPath)) - - case .Extract(let extractAction): - let fromPath = extractAction.file.replacingOccurrences(of: "$temp_path", with: tempGamePath) - let toPath = extractAction.to.replacingOccurrences(of: "$temp_path", with: tempGamePath) - logger.info( - "Extracting update archive from=\(fromPath, privacy: .private) to=\(toPath, privacy: .private)") - delegate?.updateProgress(progress: XsollaUpdateProgress.Extracting(currentFile: fromPath)) - try extractUpdateArchive(from: fromPath, to: toPath) - delegate?.updateProgress( - progress: XsollaUpdateProgress.ExtractComplete(currentFile: fromPath)) - logger.info("Extracted update archive to=\(toPath, privacy: .private)") - - case .Patch(let patchAction): - var binaries = patchAction.binaries - binaries = binaries.replacingOccurrences(of: "$temp_path", with: tempGamePath) - binaries = binaries.replacingOccurrences(of: "$game_path", with: gamePath) - let patch = patchAction.patch.replacingOccurrences(of: "$temp_path", with: tempGamePath) - let patchRulesJSON = URL(fileURLWithPath: patch).appendingPathComponent("patchRules.json") - let patchRules = try JSONDecoder().decode( - [PatchRule].self, from: Data(contentsOf: patchRulesJSON)) - logger.info( - "Applying Xsolla patch rules count=\(patchRules.count, privacy: .public) binaries=\(binaries, privacy: .private) patch=\(patch, privacy: .private)") - delegate?.updateProgress( - progress: XsollaUpdateProgress.Patching(totalFiles: patchRules.count)) - - for rule in patchRules { - let relativePath = try normalizedRelativePatchPath(rule.relative_path) - - // Skip files in _CodeSignature directory as they will be regenerated when we re-sign. - if relativePath.contains("_CodeSignature") { - logger.info("Skipping _CodeSignature patch rule for \(relativePath, privacy: .public)") - delegate?.updateProgress(progress: XsollaUpdateProgress.PatchStepComplete) - continue - } - - let targetPath = try stagedPatchURL(root: tempPath.contentURL, relativePath: relativePath) - let sourcePath = URL(fileURLWithPath: gamePath).appendingPathComponent(relativePath) - let patchPath = URL(fileURLWithPath: patch).appendingPathComponent(relativePath) - - logger.debug( - "Patch rule type=\(rule.rule, privacy: .public) relativePath=\(relativePath, privacy: .public) sourceExists=\(FileManager.default.fileExists(atPath: sourcePath.path), privacy: .public) stagedExists=\(FileManager.default.fileExists(atPath: targetPath.path), privacy: .public) patchExists=\(FileManager.default.fileExists(atPath: patchPath.path), privacy: .public)") - - switch rule.rule { - case "patch": - let basisPath = - FileManager.default.fileExists(atPath: targetPath.path) ? targetPath : sourcePath - let patchedPath = targetPath.appendingPathExtension("patching") - do { - try FileManager.default.createDirectory( - at: targetPath.deletingLastPathComponent(), withIntermediateDirectories: true, - attributes: nil) - if FileManager.default.fileExists(atPath: patchedPath.path) { - try FileManager.default.removeItem(at: patchedPath) - } - logger.debug( - "Applying rsync patch relativePath=\(relativePath, privacy: .public) basis=\(basisPath.path, privacy: .private) output=\(patchedPath.path, privacy: .private)") - try rsyncApply(source: basisPath, patch: patchPath, output: patchedPath) - let basisAttributes = try FileManager.default.attributesOfItem(atPath: basisPath.path) - if let permissions = basisAttributes[.posixPermissions] { - try FileManager.default.setAttributes( - [.posixPermissions: permissions], ofItemAtPath: patchedPath.path) - } - if FileManager.default.fileExists(atPath: targetPath.path) { - try FileManager.default.removeItem(at: targetPath) - } - try FileManager.default.moveItem(at: patchedPath, to: targetPath) - } catch { - try? FileManager.default.removeItem(at: patchedPath) - logger.error( - "Error patching \(relativePath, privacy: .public): \(error.localizedDescription, privacy: .public)") - throw error - } - - case "create": - if !FileManager.default.fileExists(atPath: targetPath.path) { - try FileManager.default.createDirectory( - at: targetPath.deletingLastPathComponent(), withIntermediateDirectories: true, - attributes: nil) - try Data().write(to: targetPath) - } - - case "delete": - pendingDeletes.append(relativePath) - logger.debug("Queued deferred delete for \(relativePath, privacy: .public)") - - case "copy": - try FileManager.default.createDirectory( - at: targetPath.deletingLastPathComponent(), withIntermediateDirectories: true, - attributes: nil) - if FileManager.default.fileExists(atPath: targetPath.path) { - try FileManager.default.removeItem(at: targetPath) - } - try FileManager.default.copyItem(at: patchPath, to: targetPath) - - default: - logger.warning("Unknown patch rule \(rule.rule, privacy: .public)") - } - delegate?.updateProgress(progress: XsollaUpdateProgress.PatchStepComplete) - } - delegate?.updateProgress(progress: XsollaUpdateProgress.PatchComplete) + if let pendingGameVersion { + progress(.applyVersion) + logger.info("Writing installed game version \(pendingGameVersion, privacy: .public) after final file operations") + try writeInstalledGameVersion(pendingGameVersion, gamePath: gamePath) + progress(.versionApplied) + } else { + logger.warning("Update plan did not include a version action; leaving installed version unchanged") + } - case .WaitActions: - delegate?.updateProgress(progress: XsollaUpdateProgress.Waiting) - logger.debug("Observed Xsolla wait action") + progress(.cleaningUp) + do { + logger.info("Removing Xsolla temp path \(tempGamePath, privacy: .private)") + try files.removeItem(atPath: tempGamePath) + } catch { + logger.warning("Failed to remove Xsolla temp path: \(error.localizedDescription, privacy: .public)") + } - case .Version(let versionAction): - pendingGameVersion = versionAction.version - logger.info("Deferred installed version write until finalization: \(versionAction.version, privacy: .public)") - } + progress(.complete) + logger.info( + "Game update completed from \(installedVersion, privacy: .public) to \(pendingGameVersion ?? plan.gameVersion, privacy: .public)" + ) + } - logger.info( - "Finished Xsolla action \(currentAction, privacy: .public)/\(parser.actions.count, privacy: .public): \(actionName, privacy: .public)") + // Stages the results of one patch folder's rules and returns the paths its delete rules name. Those are deleted + // only after the staged copy, so a failed update never loses files it did not replace. + @MainActor + private func applyPatch( + folder patch: String, binaries: String, gamePath: String, staging: URL, + onProgress progress: @MainActor (XsollaUpdateProgress) -> Void + ) throws -> [String] { + let files = FileManager.default + let patchRulesJSON = URL(fileURLWithPath: patch).appendingPathComponent("patchRules.json") + let patchRules = try JSONDecoder().decode([PatchRule].self, from: Data(contentsOf: patchRulesJSON)) + logger.info( + "Applying Xsolla patch rules count=\(patchRules.count, privacy: .public) binaries=\(binaries, privacy: .private) patch=\(patch, privacy: .private)" + ) + progress(.patching) + + var deletes: [String] = [] + for rule in patchRules { + let relativePath = try normalizedRelativePatchPath(rule.relativePath) + + // Signatures are regenerated when the launcher re-signs the game. + if relativePath.contains("_CodeSignature") { + logger.info("Skipping _CodeSignature patch rule for \(relativePath, privacy: .public)") + continue } - delegate?.updateProgress(progress: XsollaUpdateProgress.Finalizing) - // `tempPath` deletes its backing staging folder in TemporaryFolderURL.deinit. Both the - // finalize copy and the deferred deletes read from that folder, so keep `tempPath` - // explicitly alive across them with withExtendedLifetime rather than relying on ARC's - // last-use lifetime, which could otherwise release (and asynchronously delete) the - // staging folder out from under these operations. - try withExtendedLifetime(tempPath) { - // NOTE: copying into the live game directory is not atomic — staged files replace the - // installed ones one at a time, so a failure partway through leaves the install in a - // mixed old/new state. Staging keeps all download/extract/patch work out of the live - // directory until this point, and the version file is only written after this copy - // succeeds, but recovering from a mid-copy failure still requires re-running the update. - logger.info("Copying staged update files into the game directory") - try copyContentsOfDirectory(from: tempPath.contentURL, to: URL(fileURLWithPath: gamePath)) - logger.info("Copied staged update files into the game directory") - - if !pendingDeletes.isEmpty { - logger.info("Applying deferred delete rules count=\(pendingDeletes.count, privacy: .public)") - try deleteGamePaths( - pendingDeletes, gamePath: gamePath, preservingStagedRoot: tempPath.contentURL) + let targetPath = try stagedPatchURL(root: staging, relativePath: relativePath) + let sourcePath = URL(fileURLWithPath: gamePath).appendingPathComponent(relativePath) + let patchPath = URL(fileURLWithPath: patch).appendingPathComponent(relativePath) + + logger.debug( + "Patch rule type=\(rule.rule, privacy: .public) relativePath=\(relativePath, privacy: .public) sourceExists=\(files.fileExists(atPath: sourcePath.path), privacy: .public) stagedExists=\(files.fileExists(atPath: targetPath.path), privacy: .public) patchExists=\(files.fileExists(atPath: patchPath.path), privacy: .public)" + ) + + switch rule.rule { + case "patch": + // A file patched earlier in this update is patched again from its staged copy. + let basisPath = files.fileExists(atPath: targetPath.path) ? targetPath : sourcePath + let patchedPath = targetPath.appendingPathExtension("patching") + do { + try files.createParentDirectory(of: targetPath) + try files.removeItemIfPresent(at: patchedPath) + logger.debug( + "Applying rsync patch relativePath=\(relativePath, privacy: .public) basis=\(basisPath.path, privacy: .private) output=\(patchedPath.path, privacy: .private)" + ) + try rsyncApply(source: basisPath, patch: patchPath, output: patchedPath) + // librsync writes a fresh file; give it the basis's mode so executables stay executable. + if let permissions = try files.attributesOfItem(atPath: basisPath.path)[.posixPermissions] { + try files.setAttributes([.posixPermissions: permissions], ofItemAtPath: patchedPath.path) + } + try files.removeItemIfPresent(at: targetPath) + try files.moveItem(at: patchedPath, to: targetPath) + } catch { + try? files.removeItem(at: patchedPath) + logger.error("Error patching \(relativePath, privacy: .public): \(error.localizedDescription, privacy: .public)") + throw error } - } - if let pendingGameVersion { - delegate?.updateProgress(progress: XsollaUpdateProgress.ApplyVersion) - logger.info( - "Writing installed game version \(pendingGameVersion, privacy: .public) after final file operations") - try writeInstalledGameVersion(pendingGameVersion, gamePath: gamePath) - delegate?.updateProgress(progress: XsollaUpdateProgress.VersionApplied) - } else { - logger.warning("Update plan did not include a version action; leaving installed version unchanged") - } + case "create": + if !files.fileExists(atPath: targetPath.path) { + try files.createParentDirectory(of: targetPath) + try Data().write(to: targetPath) + } - delegate?.updateProgress(progress: XsollaUpdateProgress.CleaningUp) - do { - logger.info("Removing Xsolla temp path \(tempGamePath, privacy: .private)") - try FileManager.default.removeItem(atPath: tempGamePath) - } catch { - logger.warning("Failed to remove Xsolla temp path: \(error.localizedDescription, privacy: .public)") - } + case "delete": + deletes.append(relativePath) + logger.debug("Queued deferred delete for \(relativePath, privacy: .public)") - delegate?.updateProgress(progress: XsollaUpdateProgress.Complete) - logger.info( - "Game update completed from \(installedVersion, privacy: .public) to \(pendingGameVersion ?? parser.gameVersion, privacy: .public)") - } catch { - logger.error( - "Game update failed from installed version \(installedVersion, privacy: .public): \(error.localizedDescription, privacy: .public)") - throw error + case "copy": + try files.createParentDirectory(of: targetPath) + try files.removeItemIfPresent(at: targetPath) + try files.copyItem(at: patchPath, to: targetPath) + + default: + logger.warning("Unknown patch rule \(rule.rule, privacy: .public)") + } } + return deletes } } diff --git a/src/launcher/macos/ini.swift b/src/launcher/macos/ini.swift index 92bc45ebd..c13fa6030 100644 --- a/src/launcher/macos/ini.swift +++ b/src/launcher/macos/ini.swift @@ -1,53 +1,29 @@ -import Foundation - -typealias SectionConfig = [String: String] -typealias Config = [String: SectionConfig] +// A minimal INI reader for the Xsolla launcher's launcher_settings.ini. The mod's C++ loader reads the same file +// with inicpp; only the [General] paths matter to either. -private func trim(_ s: String) -> String { - let whitespaces = CharacterSet(charactersIn: " \n\r\t") - return s.trimmingCharacters(in: whitespaces) -} - -private func stripComment(_ line: String) -> String { - let parts = line.split( - separator: "#", - maxSplits: 1, - omittingEmptySubsequences: false) - if parts.count > 0 { - return String(parts[0]) - } - return "" -} +import Foundation -private func parseSectionHeader(_ line: String) -> String { - let from = line.index(after: line.startIndex) - let to = line.index(before: line.endIndex) - return String(line[from.. (String, String)? { - let parts = stripComment(line).split(separator: "=", maxSplits: 1) - if parts.count == 2 { - let k = trim(String(parts[0])) - let v = trim(String(parts[1])) - return (k, v) - } - return nil +private func trim(_ text: S) -> String { + text.trimmingCharacters(in: CharacterSet(charactersIn: " \n\r\t")) } -func parseConfig(_ filename: String) throws -> Config { - let f = try String(contentsOfFile: filename) - var config = Config() - var currentSectionName = "main" - for line in f.components(separatedBy: "\n") { - let line = trim(line) +func readIni(atPath path: String) throws -> IniSections { + let contents = try String(contentsOfFile: path) + var sections = IniSections() + var section = "main" + for rawLine in contents.components(separatedBy: "\n") { + let line = trim(rawLine) if line.hasPrefix("[") && line.hasSuffix("]") { - currentSectionName = parseSectionHeader(line) - } else if let (k, v) = parseLine(line) { - var section = config[currentSectionName] ?? [:] - section[k] = v - config[currentSectionName] = section + section = String(line.dropFirst().dropLast()) + continue } + // '#' starts a comment. Lines that are not key=value, with something on both sides, are ignored. + let pair = line.prefix { $0 != "#" }.split(separator: "=", maxSplits: 1) + guard pair.count == 2 else { continue } + sections[section, default: [:]][trim(pair[0])] = trim(pair[1]) } - return config + return sections } diff --git a/src/launcher/macos/macOSLauncher.entitlements b/src/launcher/macos/macOSLauncher.entitlements deleted file mode 100644 index 1d0626919..000000000 --- a/src/launcher/macos/macOSLauncher.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-write - - com.apple.security.automation.apple-events - - - diff --git a/src/launcher/macos/rsync.cc b/src/launcher/macos/rsync.cc index 51934f22f..6331be686 100644 --- a/src/launcher/macos/rsync.cc +++ b/src/launcher/macos/rsync.cc @@ -1,5 +1,8 @@ -#include +// Applies one librsync delta for the Swift launcher; see rsync.h. The tests link it without Swift. + #include + +#include #include extern "C" { diff --git a/src/launcher/macos/rsync.h b/src/launcher/macos/rsync.h index 726b17951..2ecb4b688 100644 --- a/src/launcher/macos/rsync.h +++ b/src/launcher/macos/rsync.h @@ -1,2 +1,3 @@ +// The C entry point Swift imports through module.modulemap. Returns librsync's rs_result: 0 (RS_DONE) on success. int rsync_apply(const char* source_file, const char* patch_file, const char* target_file); diff --git a/src/launcher/macos/rsync.swift b/src/launcher/macos/rsync.swift index 8b401ef19..08d431ea8 100644 --- a/src/launcher/macos/rsync.swift +++ b/src/launcher/macos/rsync.swift @@ -1,3 +1,5 @@ +// Applies an Xsolla rsync delta through the C++ bridge in rsync.cc. + import Foundation import librsync diff --git a/src/platform/macos/loader/library_dir.mm b/src/platform/macos/loader/library_dir.mm index 96539f246..bad58b74d 100644 --- a/src/platform/macos/loader/library_dir.mm +++ b/src/platform/macos/loader/library_dir.mm @@ -1,3 +1,5 @@ +// The loader's one Foundation call, kept in Objective-C++ so main.cc stays plain C++. + #include #import diff --git a/src/platform/macos/loader/main.cc b/src/platform/macos/loader/main.cc index cee7803e2..f1c154bf1 100644 --- a/src/platform/macos/loader/main.cc +++ b/src/platform/macos/loader/main.cc @@ -30,12 +30,8 @@ int Fail(const std::string& message) // `value` prefixed to the variable's current value, colon-separated. void Prepend(const char* variable, const std::string& value) { - std::string combined = value; - if (const char* old = getenv(variable)) { - combined += ":"; - combined += old; - } - setenv(variable, combined.c_str(), 1); + const char* old = getenv(variable); + setenv(variable, (old ? value + ":" + old : value).c_str(), 1); } } // namespace @@ -47,7 +43,10 @@ int main() return Fail("cannot read " + settings_path.string() + "; install the game with the Xsolla launcher first"); ini::IniFile settings(settings_stream); - const auto install_dir = settings["General"]["152033..GAME_PATH"].as(); + auto install_dir = settings["General"]["152033..GAME_PATH"].as(); + // The Xsolla launcher sometimes writes the path with a doubled leading slash; the Swift launcher strips it too. + if (install_dir.starts_with("//")) + install_dir.erase(0, 1); if (install_dir.empty()) return Fail("no game path (152033..GAME_PATH) in " + settings_path.string()); const auto game = (std::filesystem::path(install_dir) / kAppPath).string(); diff --git a/tests/dependency_smoke.cc b/tests/dependency_smoke.cc index ac7ac520d..8c3a34542 100644 --- a/tests/dependency_smoke.cc +++ b/tests/dependency_smoke.cc @@ -1,4 +1,4 @@ -// Verify the migrated build's serialization, compression, and HTTPS contracts. +// Checks protobuf, zlib and the HTTPS backend as the platform build links them, without the game. #include #include #include @@ -10,11 +10,14 @@ #include #include -void require(bool condition, const char* message) +namespace +{ +void Require(bool condition, const char* message) { if (!condition) throw std::runtime_error(message); } +} // namespace int main(int argc, char** argv) { @@ -22,29 +25,29 @@ int main(int argc, char** argv) google::protobuf::Int64Value value; value.set_value(42); google::protobuf::Int64Value decoded; - require(decoded.ParseFromString(value.SerializeAsString()) && decoded.value() == 42, "protobuf wrappers failed"); + Require(decoded.ParseFromString(value.SerializeAsString()) && decoded.value() == 42, "protobuf wrappers failed"); google::protobuf::Timestamp timestamp; timestamp.set_seconds(123456789); google::protobuf::Timestamp restored; - require(restored.ParseFromString(timestamp.SerializeAsString()) && restored.seconds() == timestamp.seconds(), + Require(restored.ParseFromString(timestamp.SerializeAsString()) && restored.seconds() == timestamp.seconds(), "protobuf timestamp failed"); const std::string input = "zig-network-ok"; unsigned char compressed[128], output[128]; uLongf compressed_size = sizeof(compressed), output_size = sizeof(output); - require(compress(compressed, &compressed_size, reinterpret_cast(input.data()), input.size()) == Z_OK, + Require(compress(compressed, &compressed_size, reinterpret_cast(input.data()), input.size()) == Z_OK, "zlib compression failed"); - require(uncompress(output, &output_size, compressed, compressed_size) == Z_OK && output_size == input.size() + Require(uncompress(output, &output_size, compressed, compressed_size) == Z_OK && output_size == input.size() && std::memcmp(output, input.data(), input.size()) == 0, "zlib round trip failed"); const auto* version = curl_version_info(CURLVERSION_NOW); - require(version && (version->features & CURL_VERSION_SSL) && (version->features & CURL_VERSION_LIBZ), + Require(version && (version->features & CURL_VERSION_SSL) && (version->features & CURL_VERSION_LIBZ), "curl must support TLS and zlib"); #if _WIN32 - require(version->ssl_version && std::strstr(version->ssl_version, "Schannel"), "Windows must use Schannel"); + Require(version->ssl_version && std::strstr(version->ssl_version, "Schannel"), "Windows must use Schannel"); #else - require(version->ssl_version != nullptr, "macOS system curl must provide TLS"); + Require(version->ssl_version != nullptr, "macOS system curl must provide TLS"); #endif if (argc > 1) { cpr::Session session; @@ -55,7 +58,7 @@ int main(int argc, char** argv) const auto response = session.Get(); if (response.error.code != cpr::ErrorCode::OK) std::cerr << response.error.message << '\n'; - require(response.error.code == cpr::ErrorCode::OK && response.status_code == 200 && response.text == input, + Require(response.error.code == cpr::ErrorCode::OK && response.status_code == 200 && response.text == input, "HTTP(S) round trip failed"); } std::cout << "Dependency smoke checks passed: " << version->ssl_version << '\n'; diff --git a/tests/launcher_archive.swift b/tests/launcher_archive.swift index 6fc9fe54c..1bb273e74 100644 --- a/tests/launcher_archive.swift +++ b/tests/launcher_archive.swift @@ -1,6 +1,9 @@ +// Extracts the 7z fixtures with the launcher's extractor, and checks that it rejects corrupt and missing archives. +// Run with the fixtures folder as the only argument. + import Foundation -// Generated independently with 7zz a -t7z -m0=METHOD -ms=on. No archive encoder is needed to run these fixtures. +// The fixtures were generated independently with 7zz a -t7z -m0=METHOD -ms=on, so no archive encoder is needed here. @main struct ArchiveTests { static func check(_ ok: Bool, _ message: String) throws { guard ok else { throw NSError(domain: "ArchiveTests", code: 1, userInfo: [NSLocalizedDescriptionKey: message]) } diff --git a/tests/launcher_rsync.cc b/tests/launcher_rsync.cc index b8ed1f165..cd92a42df 100644 --- a/tests/launcher_rsync.cc +++ b/tests/launcher_rsync.cc @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include extern "C" int rsync_apply(const char*, const char*, const char*); From c48178eb6c881434fadee8fc49bc32736fabc368 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 15:25:33 -0700 Subject: [PATCH 11/13] Resolve build inputs from the repository Embedded images resolve from the repository root, so the build works from any directory. The macOS deployment target is one constant feeding Swift, assets and packaging. macOS packaging goes through one script shared by CI and the development helper, and CI runs the native tests on Windows and macOS plus a Linux job for the SDK-free fixtures. Build tools name the binary fields behind their offsets and document their arguments. --- .github/workflows/ci.yaml | 221 ++----- .../workflows/validate-example-configs.yaml | 2 +- scripts/assemble-mac-app.sh | 48 ++ scripts/check-il2cpp-dump.py | 356 +++++++---- scripts/check-mac-linkage.sh | 20 + scripts/create-mac-dmg.sh | 32 +- scripts/mac-build-test-debug.sh | 598 ++++-------------- scripts/package-mac-app.sh | 45 +- scripts/validate-example-configs.py | 36 +- scripts/verify-version-substitution.py | 42 ++ scripts/verify-windows-binary.py | 126 ++-- scripts/verify_version_substitution.py | 25 - src/launcher/macos/Info.plist.template | 2 +- src/platform/windows/version.rc | 52 +- zig/ctx.zig | 109 +++- zig/deps.zig | 88 +-- zig/launcher.zig | 22 +- zig/mod.zig | 64 +- zig/protobuf.zig | 83 +-- zig/tests.zig | 69 +- zig/toolchains.zig | 30 +- zig/tools/extract_macho_archive.zig | 13 +- zig/tools/fetch_toolchain.zig | 85 ++- zig/tools/lipo.zig | 9 +- zig/tools/select_proto.zig | 48 +- zig/tools/select_proto_tests.zig | 8 +- 26 files changed, 997 insertions(+), 1236 deletions(-) create mode 100755 scripts/assemble-mac-app.sh create mode 100755 scripts/check-mac-linkage.sh create mode 100755 scripts/verify-version-substitution.py delete mode 100755 scripts/verify_version_substitution.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c6b1b8182..aae461323 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,7 +43,7 @@ jobs: shell: pwsh run: | "run_attempt=${{ github.run_attempt }}" >> $env:GITHUB_OUTPUT - zig build install test-toolchains test-codegen test-deps test-il2cpp test-mod test-spud ` + zig build install test-toolchains test-codegen test-deps test-il2cpp test-mod test-sync test-spud ` -Dtarget=x86_64-windows-msvc -Doptimize=ReleaseFast -Dtest-optimize=Debug ` "-Dstfc_source_state_id=git:${{ github.sha }}" ` "-Dstfc_base_commit=${{ github.sha }}" ` @@ -75,20 +75,34 @@ jobs: zig build install build-tests -Dtarget=x86_64-windows-msvc -Doptimize=ReleaseFast -Dtest-optimize=Debug python3 scripts/verify-windows-binary.py + # The native fixtures need no SDK, so they run on Linux too, where they are cheapest. + test-linux: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + with: + version: ${{ env.ZIG_VERSION }} + - name: Test + run: zig build test-toolchains test-codegen test-il2cpp test-mod test-sync test-launcher test-spud + build-mac: name: build-mac (${{ matrix.arch }}) strategy: fail-fast: false matrix: include: + # The relocation test only has work on x86_64. - arch: arm64 target: aarch64-macos.14.6 runner: macos-26 artifact: macos-arm64-build + tests: test-sync test-launcher - arch: x86_64 target: x86_64-macos.14.6 runner: macos-26-intel artifact: macos-x86_64-build + tests: test-sync test-launcher test-spud runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v7 @@ -99,26 +113,17 @@ jobs: run: python3 scripts/stamp-version-hash.py "${{ github.sha }}" - name: Build and test run: | - zig build install launcher test-toolchains test-codegen test-deps test-il2cpp test-mod test-spud \ + zig build install launcher test-toolchains test-codegen test-deps test-il2cpp test-mod ${{ matrix.tests }} \ -Dtarget=${{ matrix.target }} -Doptimize=ReleaseFast -Dtest-optimize=Debug --summary all - name: Verify portable linkage run: | - for binary in zig-out/macOSLauncher.app/Contents/MacOS/macOSLauncher zig-out/bin/stfc-community-mod-loader zig-out/lib/libstfc-community-mod.dylib; do - otool -L "$binary" - if otool -L "$binary" | grep -vE '^[[:space:]]+/usr/lib/libcurl(\.[0-9]+)*\.dylib[[:space:]]' | grep -E '/opt/homebrew/|/usr/local/(opt|Cellar)/|(^|/)lib(ssl|crypto|curl)(\.[0-9]+)*\.dylib'; then - echo "::error::Forbidden dynamic dependency in $binary" - exit 1 - fi - done + scripts/check-mac-linkage.sh zig-out/macOSLauncher.app/Contents/MacOS/macOSLauncher \ + zig-out/bin/stfc-community-mod-loader zig-out/lib/libstfc-community-mod.dylib + # The install prefix as scripts/assemble-mac-app.sh expects it. - name: Package intermediate build run: | - mkdir -p .artifacts/mac - cp zig-out/macOSLauncher.app/Contents/MacOS/macOSLauncher .artifacts/mac/ - cp zig-out/bin/stfc-community-mod-loader zig-out/lib/libstfc-community-mod.dylib .artifacts/mac/ - if [[ '${{ matrix.arch }}' == 'arm64' ]]; then - cp -R zig-out/macOSLauncher.app .artifacts/mac/ - fi - tar -cf macos-${{ matrix.arch }}-build.tar -C .artifacts/mac . + tar -cf macos-${{ matrix.arch }}-build.tar -C zig-out \ + macOSLauncher.app bin/stfc-community-mod-loader lib/libstfc-community-mod.dylib - uses: actions/upload-artifact@v7.0.1 with: name: ${{ matrix.artifact }} @@ -211,141 +216,26 @@ jobs: path: .artifacts/x86_64 - name: Restore architecture builds - shell: bash run: | - set -euo pipefail - - mkdir -p zig-out/staging/arm64 - mkdir -p zig-out/staging/x86_64 - - tar -xf .artifacts/arm64/macos-arm64-build.tar \ - -C zig-out/staging/arm64 - - tar -xf .artifacts/x86_64/macos-x86_64-build.tar \ - -C zig-out/staging/x86_64 - - test -f zig-out/staging/arm64/macOSLauncher - test -f zig-out/staging/x86_64/macOSLauncher - - test -f zig-out/staging/arm64/stfc-community-mod-loader - test -f zig-out/staging/x86_64/stfc-community-mod-loader - - test -f zig-out/staging/arm64/libstfc-community-mod.dylib - test -f zig-out/staging/x86_64/libstfc-community-mod.dylib - - test -f \ - zig-out/staging/arm64/macOSLauncher.app/Contents/Resources/Assets.car - - test -f \ - zig-out/staging/arm64/macOSLauncher.app/Contents/Info.plist + for arch in arm64 x86_64; do + mkdir -p "zig-out/staging/$arch" + tar -xf ".artifacts/$arch/macos-$arch-build.tar" -C "zig-out/staging/$arch" + done - - name: Create universal App Bundle - shell: bash + - name: Assemble universal app bundle run: | - set -euo pipefail - - APP="zig-out/staging/arm64/macOSLauncher.app" - - lipo -create \ - zig-out/staging/arm64/macOSLauncher \ - zig-out/staging/x86_64/macOSLauncher \ - -output "$APP/Contents/MacOS/macOSLauncher" - - lipo -create \ - zig-out/staging/arm64/stfc-community-mod-loader \ - zig-out/staging/x86_64/stfc-community-mod-loader \ - -output "$APP/Contents/stfc-community-mod-loader" - - lipo -create \ - zig-out/staging/arm64/libstfc-community-mod.dylib \ - zig-out/staging/x86_64/libstfc-community-mod.dylib \ - -output "$APP/Contents/libstfc-community-mod.dylib" - - cp assets/launcher.icns \ - "$APP/Contents/Resources/" - - mv \ - zig-out/staging/arm64/macOSLauncher.app \ - "zig-out/staging/arm64/STFC Community Mod.app" - - FINAL_APP="zig-out/staging/arm64/STFC Community Mod.app" - - test -f "$FINAL_APP/Contents/Info.plist" - test -f "$FINAL_APP/Contents/Resources/Assets.car" - test -f "$FINAL_APP/Contents/Resources/launcher.icns" - test -f "$FINAL_APP/Contents/MacOS/macOSLauncher" - test -f "$FINAL_APP/Contents/stfc-community-mod-loader" - test -f "$FINAL_APP/Contents/libstfc-community-mod.dylib" + scripts/assemble-mac-app.sh "zig-out/staging/STFC Community Mod.app" \ + zig-out/staging/arm64 zig-out/staging/x86_64 - name: Verify universal binaries - shell: bash run: | set -euo pipefail - - APP="zig-out/staging/arm64/STFC Community Mod.app" - - verify_universal() { - local file="$1" - local archs - - archs="$(lipo -archs "$file")" - echo "$file: $archs" - - [[ " $archs " == *" arm64 "* ]] - [[ " $archs " == *" x86_64 "* ]] - } - - verify_universal \ - "$APP/Contents/MacOS/macOSLauncher" - - verify_universal \ - "$APP/Contents/stfc-community-mod-loader" - - verify_universal \ - "$APP/Contents/libstfc-community-mod.dylib" - - - name: Verify universal app linkage - shell: bash - run: | - set -euo pipefail - - APP="zig-out/staging/arm64/STFC Community Mod.app" - - check_linkage() { - local binary="$1" - local deps - - echo - echo "==> $binary" - deps="$(otool -L "$binary")" - printf '%s\n' "$deps" - - if printf '%s\n' "$deps" \ - | grep -vE '^[[:space:]]+/usr/lib/libcurl(\.[0-9]+)*\.dylib[[:space:]]' \ - | grep -E \ - '/opt/homebrew/|/usr/local/(opt|Cellar)/|(^|/)libssl(\.[0-9]+)*\.dylib|(^|/)libcrypto(\.[0-9]+)*\.dylib|(^|/)libcurl(\.[0-9]+)*\.dylib' - then - echo "::error file=$binary::Forbidden dynamic dependency detected" - return 1 - fi - } - - check_linkage "$APP/Contents/MacOS/macOSLauncher" - check_linkage "$APP/Contents/stfc-community-mod-loader" - check_linkage "$APP/Contents/libstfc-community-mod.dylib" - - - name: Sign App Bundle - shell: bash - run: | - set -euo pipefail - - codesign \ - --force \ - --verify \ - --verbose \ - --deep \ - --sign "-" \ - "zig-out/staging/arm64/STFC Community Mod.app" + APP="zig-out/staging/STFC Community Mod.app" + for binary in MacOS/macOSLauncher stfc-community-mod-loader libstfc-community-mod.dylib; do + archs="$(lipo -archs "$APP/Contents/$binary")" + echo "$binary: $archs" + [[ " $archs " == *" arm64 "* && " $archs " == *" x86_64 "* ]] + done - name: Install packaging tools shell: bash @@ -361,52 +251,17 @@ jobs: command -v create-dmg >/dev/null 2>&1 || brew install create-dmg - name: Package macOS mod archive - shell: bash run: | set -euo pipefail - mkdir -p stfc-community-mod-macos-universal - - tar -cf - \ - -C "zig-out/staging/arm64/STFC Community Mod.app/Contents" \ - libstfc-community-mod.dylib \ - | zstd -15 -T0 \ - -o stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst - - shasum -a 256 \ - stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst \ + tar -cf - -C "zig-out/staging/STFC Community Mod.app/Contents" libstfc-community-mod.dylib \ + | zstd -15 -T0 -o stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst + shasum -a 256 stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst \ | awk '{print $1}' \ > stfc-community-mod-macos-universal/stfc-community-mod-macos-universal.tar.zst.sha256 - name: Create installer - shell: bash - run: | - set -euo pipefail - - APP="zig-out/staging/arm64/STFC Community Mod.app" - DMG_SOURCE="zig-out/staging/arm64/dmg-source" - - mkdir -p "$DMG_SOURCE" - mv "$APP" "$DMG_SOURCE/" - - create_dmg_options=( - --filesystem APFS - --format ULFO - --volname "STFC Community Mod Installer" - --volicon assets/launcher.icns - --no-internet-enable - --background assets/mac_installer_background.png - --window-pos 200 120 - --window-size 800 400 - --icon-size 100 - --icon "STFC Community Mod.app" 200 190 - --app-drop-link 600 185 - --applescript-sleep-duration 1 - ) - - create-dmg "${create_dmg_options[@]}" \ - stfc-community-mod-installer.dmg \ - "$DMG_SOURCE/" + run: scripts/create-mac-dmg.sh "zig-out/staging/STFC Community Mod.app" stfc-community-mod-installer.dmg - name: Upload installer uses: actions/upload-artifact@v7.0.1 diff --git a/.github/workflows/validate-example-configs.yaml b/.github/workflows/validate-example-configs.yaml index 691e12e88..7ca0dccd4 100644 --- a/.github/workflows/validate-example-configs.yaml +++ b/.github/workflows/validate-example-configs.yaml @@ -10,7 +10,7 @@ permissions: jobs: validate-example-configs: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v7 diff --git a/scripts/assemble-mac-app.sh b/scripts/assemble-mac-app.sh new file mode 100755 index 000000000..f67dc175b --- /dev/null +++ b/scripts/assemble-mac-app.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Assembles and signs the launcher bundle from Zig install prefixes, one per architecture. Each prefix holds +# macOSLauncher.app, bin/stfc-community-mod-loader and lib/libstfc-community-mod.dylib. With several prefixes the +# binaries are merged into universal ones; the first prefix supplies the bundle's resources. +# +# Usage: scripts/assemble-mac-app.sh ... +# CI, package-mac-app.sh and mac-build-test-debug.sh all package through this script. +set -euo pipefail + +if (($# < 2)); then + echo "usage: $0 ..." >&2 + exit 1 +fi +APP="$1" +shift +PREFIXES=("$@") +SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# merge +merge() { + local inputs=() prefix + for prefix in "${PREFIXES[@]}"; do + inputs+=("$prefix/$1") + done + lipo -create "${inputs[@]}" -output "$2" +} + +rm -rf "$APP" +ditto "${PREFIXES[0]}/macOSLauncher.app" "$APP" +merge macOSLauncher.app/Contents/MacOS/macOSLauncher "$APP/Contents/MacOS/macOSLauncher" +merge bin/stfc-community-mod-loader "$APP/Contents/stfc-community-mod-loader" +merge lib/libstfc-community-mod.dylib "$APP/Contents/libstfc-community-mod.dylib" + +for file in Info.plist Resources/Assets.car Resources/launcher.icns MacOS/macOSLauncher \ + stfc-community-mod-loader libstfc-community-mod.dylib; do + if [[ ! -f "$APP/Contents/$file" ]]; then + echo "$APP is missing Contents/$file" >&2 + exit 1 + fi +done + +"$SCRIPTS/check-mac-linkage.sh" "$APP/Contents/MacOS/macOSLauncher" "$APP/Contents/stfc-community-mod-loader" \ + "$APP/Contents/libstfc-community-mod.dylib" + +# Ad hoc: the bundle is not notarized, and the launcher re-signs the game itself when it has to. +codesign --force --deep --sign - "$APP" +codesign --verify --deep --strict "$APP" +echo "Assembled $APP" diff --git a/scripts/check-il2cpp-dump.py b/scripts/check-il2cpp-dump.py index 07abcf269..14e30509a 100755 --- a/scripts/check-il2cpp-dump.py +++ b/scripts/check-il2cpp-dump.py @@ -15,13 +15,20 @@ a clean run is necessary but not sufficient. Members reached only through unbound wrappers (Widget, List, ...) are not checked. """ +# Postponed annotations keep the `X | None` hints working on the Python 3.9 that ships with Xcode. +from __future__ import annotations + import argparse import difflib import os import re from collections import defaultdict +from dataclasses import dataclass, field +from pathlib import Path + +SRC_ROOT = Path(__file__).resolve().parent.parent / 'src' -SRC_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src') +# --- dump.cs --- TYPE_HEAD = re.compile( r'^(?:\[.*\]\s*)?(?:(?:public|private|protected|internal|static|sealed|abstract|readonly|ref|unsafe|partial)\s+)*' @@ -34,9 +41,11 @@ METHOD = re.compile( r'^((?:(?:public|private|protected|internal|static|virtual|override|abstract|sealed|extern|new|async|unsafe)\s+)*)' r'(.+?)\s+([\w.<>`$|]+?)(<[^(]*>)?\((.*)\)\s*[{;]') +NAMESPACE_LINE = '// Namespace:' -def split_params(params): +def split_params(params: str) -> list[str]: + """Splits a parameter or argument list at its top-level commas.""" depth, out, buf = 0, [], '' for ch in params: if ch in '<([': @@ -52,51 +61,86 @@ def split_params(params): return out +@dataclass +class Member: + static: bool + decl: str + # Methods only. + generic: bool = False + arity: int = 0 + + +@dataclass +class DumpClass: + ns: str | None + name: str + base: str | None + generic: bool + generic_base: bool + # 'field' and 'prop' map a name to one Member; 'method' maps a name to every overload. + members: dict = field(default_factory=lambda: {'field': {}, 'prop': {}, 'method': defaultdict(list)}) + + class Dump: """Classes from dump.cs, keyed by (namespace, name). Nested types are named Outer.Inner.""" - def __init__(self, path): - self.classes = {} - self.by_name = defaultdict(list) + def __init__(self, path: str): + self.classes: dict[tuple[str | None, str], DumpClass] = {} + self.by_name: dict[str, list[DumpClass]] = defaultdict(list) # dump.cs names bases without their namespace. il2cpp.h, written next to it, spells out # each concrete class's base with namespace (and generic arguments) mangled into one name. - self.bases = {} - header = os.path.join(os.path.dirname(path), 'il2cpp.h') - if os.path.exists(header): - for line in open(header, encoding='utf-8', errors='replace'): - if m := re.match(r'struct (\w+)_Fields : (\w+)_Fields \{', line): - self.bases[m.group(1)] = m.group(2) + self.bases: dict[str, str] = {} + header = Path(path).parent / 'il2cpp.h' + if header.exists(): + with header.open(encoding='utf-8', errors='replace') as lines: + for line in lines: + if m := re.match(r'struct (\w+)_Fields : (\w+)_Fields \{', line): + self.bases[m.group(1)] = m.group(2) + namespace, cur = None, None - for line in open(path, encoding='utf-8', errors='replace'): - if line.startswith('// Namespace:'): - namespace = line[len('// Namespace:'):].strip() - continue - m = TYPE_HEAD.match(line) - if m: - raw_base = m.group(3).split(',')[0].strip() if m.group(3) else None - cur = {'ns': namespace, 'name': m.group(2), 'base': raw_base and re.sub(r'<.*', '', raw_base), - 'generic': '<' in line.split('//')[0].split(':')[0], 'generic_base': bool(raw_base and '<' in raw_base), - 'fields': {}, 'props': {}, 'methods': defaultdict(list)} - self.classes.setdefault((namespace, cur['name']), cur) - self.by_name[cur['name']].append(cur) - continue - s = line.strip() - if cur is None or not s or s.startswith(('//', '[')): - continue - decl = s.split('//')[0].strip() - if (fm := FIELD.match(s)) and '(' not in decl: - head = decl[:decl.rfind(fm.group(2))] - cur['fields'][fm.group(2)] = {'static': bool(re.search(r'\b(static|const)\b', head)), 'decl': decl} - elif pm := PROPERTY.match(s): - cur['props'][pm.group(3).split('.')[-1]] = {'static': 'static' in pm.group(1), 'decl': s} - elif mm := METHOD.match(s): - name = mm.group(3) if mm.group(3) in ('.ctor', '.cctor') else mm.group(3).split('.')[-1] - params = mm.group(5).strip() - cur['methods'][name].append({'static': 'static' in mm.group(1), 'generic': bool(mm.group(4)), - 'arity': len(split_params(params)) if params else 0, - 'decl': s.rstrip(' {};')}) - - def find(self, namespace, name): + with open(path, encoding='utf-8', errors='replace') as lines: + for line in lines: + if line.startswith(NAMESPACE_LINE): + namespace = line[len(NAMESPACE_LINE):].strip() + continue + if m := TYPE_HEAD.match(line): + cur = self._add_class(namespace, line, m) + continue + s = line.strip() + if cur is None or not s or s.startswith(('//', '[')): + continue + self._add_member(cur, s) + + def _add_class(self, namespace: str | None, line: str, m: re.Match) -> DumpClass: + raw_base = m.group(3).split(',')[0].strip() if m.group(3) else None + # The type's own name, before any base list, carries its generic parameters. + own_name = line.split('//')[0].split(':')[0] + cls = DumpClass(ns=namespace, name=m.group(2), + base=re.sub(r'<.*', '', raw_base) if raw_base else raw_base, + generic='<' in own_name, + generic_base=bool(raw_base and '<' in raw_base)) + self.classes.setdefault((namespace, cls.name), cls) + self.by_name[cls.name].append(cls) + return cls + + @staticmethod + def _add_member(cls: DumpClass, s: str) -> None: + decl = s.split('//')[0].strip() + if (fm := FIELD.match(s)) and '(' not in decl: + head = decl[:decl.rfind(fm.group(2))] + cls.members['field'][fm.group(2)] = Member(static=bool(re.search(r'\b(static|const)\b', head)), decl=decl) + elif pm := PROPERTY.match(s): + cls.members['prop'][pm.group(3).split('.')[-1]] = Member(static='static' in pm.group(1), decl=s) + elif mm := METHOD.match(s): + name = mm.group(3) + if name not in ('.ctor', '.cctor'): + name = name.split('.')[-1] # Explicit interface implementations: IFoo.Bar + params = mm.group(5).strip() + cls.members['method'][name].append(Member(static='static' in mm.group(1), decl=s.rstrip(' {};'), + generic=bool(mm.group(4)), + arity=len(split_params(params)) if params else 0)) + + def find(self, namespace: str, name: str) -> DumpClass | None: name = name.replace('/', '.') if (namespace, name) in self.classes: return self.classes[(namespace, name)] @@ -106,61 +150,112 @@ def find(self, namespace, name): return None @staticmethod - def mangle(c): - return re.sub(r'\W', '_', f"{c['ns']}.{c['name']}" if c['ns'] else c['name']) + def mangle(c: DumpClass) -> str: + return re.sub(r'\W', '_', f"{c.ns}.{c.name}" if c.ns else c.name) - def base_of(self, c): - if not c['base']: + def base_of(self, c: DumpClass) -> DumpClass | None: + if not c.base: return None - candidates = self.by_name.get(c['base'].split('.')[-1], []) + self.by_name.get(c['base'], []) + candidates = self.by_name.get(c.base.split('.')[-1], []) + self.by_name.get(c.base, []) # ViewController and ViewController share a name; the base's own generic-ness picks one. - candidates = [b for b in candidates if b['generic'] == c['generic_base']] or candidates + candidates = [b for b in candidates if b.generic == c.generic_base] or candidates if mangled := self.bases.get(self.mangle(c)): exact = [b for b in candidates if mangled == self.mangle(b) or mangled.startswith(self.mangle(b) + '_')] if exact: return max(exact, key=lambda b: len(self.mangle(b))) # Open generic classes have no il2cpp.h entry; fall back to the same namespace. - same = [b for b in candidates if b['ns'] == c['ns']] - return (same or candidates or [None])[0] + same = [b for b in candidates if b.ns == c.ns] + if same: + return same[0] + return candidates[0] if candidates else None - def lookup(self, cls, kind, member): - table = {'field': 'fields', 'prop': 'props', 'method': 'methods'}[kind] + def lookup(self, cls: DumpClass | None, kind: str, member: str): + """The member on cls or its nearest base: a Member, or a list of overloads for 'method'.""" + # 64 levels bounds a base chain that loops back on itself. for _ in range(64): if cls is None: return None - if member in cls[table]: - return cls[table][member] + if member in cls.members[kind]: + return cls.members[kind][member] cls = self.base_of(cls) return None +def class_blocks(path: str) -> dict[tuple[str | None, str], list[str]]: + """Member lines per (namespace, class), without RVA/offset comments that change every build.""" + out, namespace, cur, buf = {}, None, None, [] + with open(path, encoding='utf-8', errors='replace') as lines: + for line in lines: + if line.startswith(NAMESPACE_LINE): + namespace = line[len(NAMESPACE_LINE):].strip() + continue + if m := TYPE_HEAD.match(line): + if cur: + out.setdefault(cur, buf) + cur, buf = (namespace, m.group(2)), [] + continue + s = line.strip() + if cur and s and not s.startswith(('//', '/*', '|', '*/', '[')): + buf.append(re.sub(r'\s*//.*', '', s)) + if cur: + out.setdefault(cur, buf) + return out + + +# --- the bindings in src/ --- + CLASS = r'il2cpp::Class<\s*"([^"]*)"\s*,\s*"([^"]*)"\s*,\s*"([^"]*)"\s*>' USE = re.compile(r'(?("ShouldRetry..."). +TEMPLATE = r'<((?:[^<>]|<(?:[^<>]|<[^<>]*>)*>)*)>' +HOOK = re.compile(r'(?(). +HAS = re.compile(r'(?(object). ALIAS_USE = re.compile(r'(? str | None: + """The parameter count of a C++ signature R(P...), as hook arities are compared: a string of digits.""" + if not signature or '(' not in signature: + return None + params = signature[signature.index('(') + 1:signature.rindex(')')] + return str(len(split_params(params)) if params.strip() else 0) + + +def hook_use(path: str, owner: str, match: re.Match) -> tuple: + """A hook lookup's use entry. A signature gives the arity; otherwise the explicit count or parameter list.""" + signature, member, arity = match.group(2, 3, 4) + return (path, owner, 'hook', member, None, signature_arity(signature) or arity) + + +def call_arity(text: str) -> int | None: """Counts the arguments of the call that starts right after the template argument list.""" close = text.find('>(') if close == -1: return None i, depth, args = close + 2, 1, '' while i < len(text) and depth: - depth += {'(': 1, ')': -1}.get(text[i], 0) + if text[i] == '(': + depth += 1 + elif text[i] == ')': + depth -= 1 if depth: args += text[i] i += 1 return len(split_params(args)) if args.strip() else 0 -def scan_source(root): +def scan_source(root: Path) -> tuple[dict[str, tuple[str, str, str]], list[tuple]]: """Returns ({binding name: (asm, ns, class)}, [(file, owner, kind, member, static, arity)]).""" classes, uses = {}, [] + # os.walk order decides which file wins when two bind the same name; keep it. for dirpath, _, files in os.walk(root): if os.sep + 'proto' in dirpath: continue @@ -168,42 +263,66 @@ def scan_source(root): if not f.endswith(('.h', '.cc', '.mm')): continue path = os.path.join(dirpath, f) - text = open(path, encoding='utf-8', errors='replace').read() - aliases = set() - for m in re.finditer(r'using\s+(\w+)\s*=\s*' + CLASS, text): - classes.setdefault(m.group(1), m.group(2, 3, 4)) - if m.group(1) != 'Class': - aliases.add(m.group(1)) - for u in ALIAS_USE.finditer(text): - if u.group(1) in aliases: - kind = KIND[u.group(2)] - arity = call_arity(text[u.end():]) if kind == 'method' else None - uses.append((path, u.group(1), kind, u.group(3), True, arity)) - for m in re.finditer(r'^\s*struct\s+(\w+)[^;{]*\{', text, re.M): - i, depth = m.end(), 1 - while i < len(text) and depth: - depth += {'{': 1, '}': -1}.get(text[i], 0) - i += 1 - # Nested structs name their own Class; keep their members out of the outer binding. - body = re.sub(r'struct\s+\w+[^;{]*\{(?:[^{}]|\{[^{}]*\})*\};', '', text[m.end():i]) - cm = re.search(r'using\s+Class\s*=\s*' + CLASS, body) - if not cm: - continue - classes.setdefault(m.group(1), cm.group(1, 2, 3)) - for u in USE.finditer(body): - kind = KIND[u.group(2)] - arity = call_arity(body[u.end():]) if kind == 'method' else None - uses.append((path, m.group(1), kind, u.group(3), bool(u.group(1)), arity)) - for h in HOOK.finditer(body): - if h.group(1) == 'Class': - uses.append((path, m.group(1), 'hook', h.group(2), None, h.group(3))) - for h in HOOK.finditer(text): - if h.group(1) != 'Class': - uses.append((path, h.group(1), 'hook', h.group(2), None, h.group(3))) + with open(path, encoding='utf-8', errors='replace') as source: + text = source.read() + scan_file(path, text, classes, uses) return classes, uses -def check(args): +def scan_file(path: str, text: str, classes: dict, uses: list) -> None: + aliases = set() + # Lookups through a local alias of a binding's Class count as lookups on the binding. Aliases are + # usually function-local and may be reused, so each lookup takes the nearest definition above it. + alias_defs = [(m.start(), m.group(1), m.group(2)) for m in CLASS_ALIAS.finditer(text)] + + def owner_at(name, pos): + found = name + for start, alias, owner in alias_defs: + if start < pos and alias == name: + found = owner + return found + + for m in re.finditer(r'using\s+(\w+)\s*=\s*' + CLASS, text): + classes.setdefault(m.group(1), m.group(2, 3, 4)) + if m.group(1) != 'Class': + aliases.add(m.group(1)) + for u in ALIAS_USE.finditer(text): + if u.group(1) in aliases: + kind = KIND[u.group(2)] + arity = call_arity(text[u.end():]) if kind == 'method' else None + uses.append((path, u.group(1), kind, u.group(3), True, arity)) + for m in re.finditer(r'^\s*struct\s+(\w+)[^;{]*\{', text, re.M): + i, depth = m.end(), 1 + while i < len(text) and depth: + if text[i] == '{': + depth += 1 + elif text[i] == '}': + depth -= 1 + i += 1 + # Nested structs name their own Class; keep their members out of the outer binding. + body = re.sub(r'struct\s+\w+[^;{]*\{(?:[^{}]|\{[^{}]*\})*\};', '', text[m.end():i]) + cm = re.search(r'using\s+Class\s*=\s*' + CLASS, body) + if not cm: + continue + classes.setdefault(m.group(1), cm.group(1, 2, 3)) + for u in USE.finditer(body): + kind = KIND[u.group(2)] + arity = call_arity(body[u.end():]) if kind == 'method' else None + uses.append((path, m.group(1), kind, u.group(3), bool(u.group(1)), arity)) + for h in HOOK.finditer(body): + if h.group(1) == 'Class': + uses.append(hook_use(path, m.group(1), h)) + for h in HOOK.finditer(text): + if h.group(1) != 'Class': + uses.append(hook_use(path, owner_at(h.group(1), h.start()), h)) + for h in HAS.finditer(text): + kind = 'field' if h.group(2) == 'Field' else 'prop' + uses.append((path, owner_at(h.group(1), h.start()), kind, h.group(3), False, None)) + + +# --- commands --- + +def check(args: argparse.Namespace) -> None: dump = Dump(args.dump) old = Dump(args.old) if args.old else None bound, uses = scan_source(SRC_ROOT) @@ -222,32 +341,32 @@ def check(args): problems.add(f"{where}: {kind} {member} not found on {ns}.{name} or its bases") continue if kind == 'hook': - if arity and arity.isdigit() and not any(m['arity'] == int(arity) for m in info): + if arity and arity.isdigit() and not any(m.arity == int(arity) for m in info): problems.add(f"{where}: hook {member} has no {arity}-argument overload: " - + ' | '.join(m['decl'] for m in info)) + + ' | '.join(m.decl for m in info)) continue if kind == 'method': - candidates = [m for m in info if not m['generic'] and (arity is None or m['arity'] == arity)] + candidates = [m for m in info if not m.generic and (arity is None or m.arity == arity)] if not candidates: problems.add(f"{where}: Call<\"{member}\"> with {arity} arguments matches nothing: " - + ' | '.join(m['decl'] for m in info)) + + ' | '.join(m.decl for m in info)) continue - if not any(m['static'] == static for m in candidates): + if not any(m.static == static for m in candidates): problems.add(f"{where}: Call<\"{member}\"> static={static} does not match: " - + ' | '.join(m['decl'] for m in candidates)) - decls = sorted(m['decl'] for m in candidates) + + ' | '.join(m.decl for m in candidates)) + decls = sorted(m.decl for m in candidates) else: - if info['static'] != static: - problems.add(f"{where}: {kind} {member} static={static} does not match: {info['decl']}") - decls = [info['decl']] + if info.static != static: + problems.add(f"{where}: {kind} {member} static={static} does not match: {info.decl}") + decls = [info.decl] if old and (old_cls := old.find(ns, name)): old_info = old.lookup(old_cls, kind, member) if old_info is None: continue if kind == 'method': - old_decls = sorted(m['decl'] for m in old_info if not m['generic'] and (arity is None or m['arity'] == arity)) + old_decls = sorted(m.decl for m in old_info if not m.generic and (arity is None or m.arity == arity)) else: - old_decls = [old_info['decl']] + old_decls = [old_info.decl] if old_decls != decls: changes.add(f"{where}: {ns}.{name}.{member}: {' | '.join(old_decls)} -> {' | '.join(decls)}") @@ -259,35 +378,18 @@ def check(args): print('\n'.join(sorted(changes))) -def class_blocks(path): - """Member lines per (namespace, class), without RVA/offset comments that change every build.""" - out, namespace, cur, buf = {}, None, None, [] - for line in open(path, encoding='utf-8', errors='replace'): - if line.startswith('// Namespace:'): - namespace = line[len('// Namespace:'):].strip() - continue - m = TYPE_HEAD.match(line) - if m: - if cur: - out.setdefault(cur, buf) - cur, buf = (namespace, m.group(2)), [] - continue - s = line.strip() - if cur and s and not s.startswith(('//', '/*', '|', '*/', '[')): - buf.append(re.sub(r'\s*//.*', '', s)) - if cur: - out.setdefault(cur, buf) - return out - - -def diff(args): +def diff(args: argparse.Namespace) -> None: old, new = class_blocks(args.old), class_blocks(args.new) bound, _ = scan_source(SRC_ROOT) changed = 0 for ns, name in sorted({(ns, name.replace('/', '.')) for _, ns, name in bound.values()}): key = (ns, name) if key not in old and key not in new: - key = next((k for k in {**old, **new} if k[1] == name), key) + # A nested type may sit under another namespace; take the first class of that name. + for candidate in [*old, *new]: + if candidate[1] == name: + key = candidate + break before, after = old.get(key), new.get(key) if before is None or after is None: print(f"!! {ns}.{name}: in old dump={before is not None}, in new dump={after is not None}") @@ -301,7 +403,7 @@ def diff(args): print(f"{len(bound)} bindings, {changed} bound classes changed") -def main(): +def main() -> None: parser = argparse.ArgumentParser(description=__doc__.split('\n')[0]) sub = parser.add_subparsers(required=True) p = sub.add_parser('check', help='check bindings against a dump') diff --git a/scripts/check-mac-linkage.sh b/scripts/check-mac-linkage.sh new file mode 100755 index 000000000..56ec0e7c9 --- /dev/null +++ b/scripts/check-mac-linkage.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Fails when a macOS binary links a library users may not have: anything from Homebrew, or OpenSSL. The system +# libcurl is the one allowed exception; every other libcurl, libssl or libcrypto is forbidden. +# +# Usage: scripts/check-mac-linkage.sh ... +set -euo pipefail + +status=0 +for binary in "$@"; do + echo "==> $binary" + libraries="$(otool -L "$binary")" + printf '%s\n' "$libraries" + if printf '%s\n' "$libraries" | + grep -vE '^[[:space:]]+/usr/lib/libcurl(\.[0-9]+)*\.dylib[[:space:]]' | + grep -E '/opt/homebrew/|/usr/local/(opt|Cellar)/|(^|/)lib(ssl|crypto|curl)(\.[0-9]+)*\.dylib'; then + echo "::error file=$binary::Forbidden dynamic dependency in $binary" + status=1 + fi +done +exit "$status" diff --git a/scripts/create-mac-dmg.sh b/scripts/create-mac-dmg.sh index c88ecd29b..a379537dd 100755 --- a/scripts/create-mac-dmg.sh +++ b/scripts/create-mac-dmg.sh @@ -1,19 +1,27 @@ #!/bin/bash +# Wraps an assembled launcher bundle in the installer disk image. Needs create-dmg (brew install create-dmg). +# +# Usage: scripts/create-mac-dmg.sh [output.dmg] set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT" -MODE="${1:-release}" -scripts/package-mac-app.sh "$MODE" -OUT="zig-out/universal/$MODE" -DMG_SOURCE="$OUT/dmg-source" -rm -rf "$DMG_SOURCE" -mkdir -p "$DMG_SOURCE" -ditto "$OUT/STFC Community Mod.app" "$DMG_SOURCE/STFC Community Mod.app" -rm -f stfc-community-mod-installer.dmg + +if (($# < 1)); then + echo "usage: $0 [output.dmg]" >&2 + exit 1 +fi +APP="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +DMG="${2:-stfc-community-mod-installer.dmg}" + +# create-dmg packs a whole folder, so the bundle gets one of its own. +SOURCE="$(mktemp -d)" +trap 'rm -rf "$SOURCE"' EXIT +ditto "$APP" "$SOURCE/STFC Community Mod.app" + +rm -f "$DMG" create-dmg --filesystem APFS --format ULFO --volname "STFC Community Mod Installer" \ - --volicon assets/launcher.icns --no-internet-enable \ - --background assets/mac_installer_background.png \ + --volicon "$ROOT/assets/launcher.icns" --no-internet-enable \ + --background "$ROOT/assets/mac_installer_background.png" \ --window-pos 200 120 --window-size 800 400 --icon-size 100 \ --icon "STFC Community Mod.app" 200 190 \ --app-drop-link 600 185 --applescript-sleep-duration 1 \ - stfc-community-mod-installer.dmg "$DMG_SOURCE/" + "$DMG" "$SOURCE/" diff --git a/scripts/mac-build-test-debug.sh b/scripts/mac-build-test-debug.sh index 8339ee6e9..86e9cb4d3 100755 --- a/scripts/mac-build-test-debug.sh +++ b/scripts/mac-build-test-debug.sh @@ -1,502 +1,142 @@ #!/bin/bash +# Builds the mod, loader and launcher for local testing on a Mac, then runs or debugs them. +set -euo pipefail -# STFC Community Mod - Development Build Script -# This script provides an easy way to configure, build, run, and debug the mod during development - -set -e # Exit on error - -# Color codes for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Default values -BUILD_MODE="debug" -ARCH=$(uname -m) # Use native architecture (arm64 or x86_64) -ACTION="build" -CLEAN=false -VERBOSE=false -ATTACH_DEBUGGER=false -USE_LAUNCHER=false # Default to using loader for direct mod testing - -# Project paths -PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BUILD_DIR="${PROJECT_ROOT}/zig-out/${ARCH}" - -# Print colored output -print_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Print usage information -print_usage() { - cat << EOF +usage() { + cat <&2 && usage && exit 1 ;; + esac done -# Validate build mode -case $BUILD_MODE in - debug|release|releasedbg|check) - ;; - *) - print_error "Invalid build mode: $BUILD_MODE" - print_error "Valid modes: debug, release, releasedbg, check" - exit 1 - ;; +case "$MODE" in + debug) OPTIMIZE=Debug MODDBG=false ;; + release) OPTIMIZE=ReleaseFast MODDBG=false ;; + releasedbg) OPTIMIZE=ReleaseFast MODDBG=true ;; + *) echo "Invalid mode: $MODE (debug, release, releasedbg)" >&2 && exit 1 ;; esac -# Update build directory based on architecture -BUILD_DIR="${PROJECT_ROOT}/zig-out/${ARCH}/${BUILD_MODE}" -APP_PATH="${BUILD_DIR}/macOSLauncher.app" -LOADER_PATH="${BUILD_DIR}/bin/stfc-community-mod-loader" -PACKAGED_APP_PATH="${BUILD_DIR}/STFC Community Mod.app" - -# Configure the project -configure_project() { - print_info "Configuring project for ${ARCH} in ${BUILD_MODE} mode..." - - cd "$PROJECT_ROOT" - - local target_arch="$ARCH" - [[ "$ARCH" != arm64 ]] || target_arch=aarch64 - local optimize=ReleaseFast moddbg=false - case "$BUILD_MODE" in debug|check) optimize=Debug ;; releasedbg) moddbg=true ;; esac - ZIG_FLAGS=("-Dtarget=${target_arch}-macos.14.6" "-Doptimize=$optimize" "-Dmoddbg=$moddbg" --prefix "$BUILD_DIR") - if [[ "$VERBOSE" == true ]]; then ZIG_FLAGS+=(--verbose); fi - [[ "$(zig version)" == 0.16.0 ]] || { print_error "Zig 0.16.0 is required"; exit 1; } - - print_success "Configuration complete" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +PREFIX="zig-out/$ARCH/$MODE" +APP="$PREFIX/STFC Community Mod.app" +if [[ "$ARCH" == universal ]]; then + APP="zig-out/universal/$MODE/STFC Community Mod.app" +fi +# The loader injects the dylib that sits next to it, so it runs from the bundle, not from bin/. +LOADER="$APP/Contents/stfc-community-mod-loader" + +build() { + if [[ "$(zig version)" != 0.16.0 ]]; then + echo "Zig 0.16.0 is required" >&2 + exit 1 + fi + if [[ "$ARCH" == universal ]]; then + scripts/package-mac-app.sh "$MODE" + return + fi + local target="$ARCH" + if [[ "$ARCH" == arm64 ]]; then + target=aarch64 + fi + # Keep in step with macos_min in zig/ctx.zig. + zig build install launcher -Dtarget="$target-macos.14.6" -Doptimize="$OPTIMIZE" -Dmoddbg="$MODDBG" \ + --prefix "$PREFIX" + scripts/assemble-mac-app.sh "$APP" "$PREFIX" } -# Clean build artifacts -clean_build() { - print_info "Cleaning build artifacts..." - - cd "$PROJECT_ROOT" - rm -rf "$PROJECT_ROOT/.zig-cache" "$PROJECT_ROOT/zig-out" - - print_success "Clean complete" +executable() { + if [[ "$USE_LAUNCHER" == true ]]; then + echo "$APP/Contents/MacOS/macOSLauncher" + else + echo "$LOADER" + fi } -# Build the project -build_project() { - print_info "Building project for ${ARCH} in ${BUILD_MODE} mode..." - - cd "$PROJECT_ROOT" - - if [[ "$ARCH" == universal ]]; then - scripts/package-mac-app.sh "$BUILD_MODE" - print_success "Universal application bundle prepared" - return - fi - zig build "${ZIG_FLAGS[@]}" - zig build launcher "${ZIG_FLAGS[@]}" - - print_success "Build complete" - - # Show build output location - print_info "Build artifacts location: ${BUILD_DIR}" - - # List built files - if [[ -d "$BUILD_DIR" ]]; then - print_info "Built files:" - ls -lh "${BUILD_DIR}" | grep -E '\.(app|dylib)$' || true - fi - - package_app_bundle +run() { + echo "Crash reports go to ~/Library/Logs/DiagnosticReports; list them with: $(basename "$0") crashlogs" + echo "Game log stream: log stream --predicate 'process == \"Star Trek Fleet Command\"' --level debug" + if [[ "$USE_LAUNCHER" == true ]]; then + open "$APP" + else + "$LOADER" + fi } -# Package the launcher bundle the same way CI does, but keep Zig's -# macOSLauncher.app in place so local incremental builds still work. -package_app_bundle() { - print_info "Packaging launcher application bundle..." - - if [[ ! -d "$APP_PATH" ]]; then - print_error "Launcher app not found at: $APP_PATH" - print_info "Make sure the launcher was built successfully" - exit 1 - fi - - if [[ ! -f "$LOADER_PATH" ]]; then - print_error "Loader not found at: $LOADER_PATH" - print_info "Make sure the loader was built successfully" - exit 1 - fi - - local dylib_path="${BUILD_DIR}/lib/libstfc-community-mod.dylib" - if [[ ! -f "$dylib_path" ]]; then - print_error "Mod library not found at: $dylib_path" - print_info "Make sure the mod library was built successfully" - exit 1 - fi - - local icon_path="${PROJECT_ROOT}/assets/launcher.icns" - if [[ ! -f "$icon_path" ]]; then - print_error "Launcher icon not found at: $icon_path" - exit 1 - fi - - local info_plist_path="${APP_PATH}/Contents/Info.plist" - if [[ ! -f "$info_plist_path" ]]; then - print_error "Generated Info.plist not found at: $info_plist_path" - print_info "Run the configure/build step so Zig can generate it from Info.plist.template" - exit 1 - fi - - rm -rf "$PACKAGED_APP_PATH" - ditto "$APP_PATH" "$PACKAGED_APP_PATH" - - mkdir -p "${PACKAGED_APP_PATH}/Contents/Resources" - cp "$LOADER_PATH" "${PACKAGED_APP_PATH}/Contents/stfc-community-mod-loader" - cp "$dylib_path" "${PACKAGED_APP_PATH}/Contents/libstfc-community-mod.dylib" - cp "$icon_path" "${PACKAGED_APP_PATH}/Contents/Resources/" - cp "$info_plist_path" "${PACKAGED_APP_PATH}/Contents/" - - print_info "Code signing packaged application..." - codesign --force --verify --verbose --deep --sign "-" "$PACKAGED_APP_PATH" - codesign --verify --deep --strict --verbose=2 "$PACKAGED_APP_PATH" - - print_success "Packaged app prepared at: $PACKAGED_APP_PATH" +debug() { + # The loader execs the game; lldb follows the exec, so the game itself runs under the debugger. + lldb -o "settings set target.process.stop-on-exec false" -o "settings set stop-line-count-before 3" \ + -o "settings set stop-line-count-after 3" -- "$(executable)" } -# Prepare the app bundle for running -prepare_app() { - if [[ "$USE_LAUNCHER" == true ]]; then - print_info "Preparing launcher application bundle..." - - # Check if app was built - if [[ ! -d "$PACKAGED_APP_PATH" ]]; then - print_warning "Packaged app not found at: $PACKAGED_APP_PATH" - package_app_bundle - fi - - if [[ ! -d "$PACKAGED_APP_PATH" ]]; then - print_error "Launcher app not found at: $PACKAGED_APP_PATH" - print_info "Make sure the launcher was built successfully" - exit 1 - fi - - print_success "Launcher prepared at: $PACKAGED_APP_PATH" - else - print_info "Preparing loader..." - - # Check if loader was built - if [[ ! -f "$LOADER_PATH" ]]; then - print_error "Loader not found at: $LOADER_PATH" - print_info "Make sure the loader was built successfully" - exit 1 - fi - - # Check if dylib was built - local dylib_path="${BUILD_DIR}/lib/libstfc-community-mod.dylib" - if [[ ! -f "$dylib_path" ]]; then - print_warning "Mod library not found at: $dylib_path" - print_warning "The loader may not work correctly without the mod library" - fi - - print_success "Loader prepared at: $LOADER_PATH" - print_info "Loader will inject: $dylib_path" - fi +crashlogs() { + local reports="$HOME/Library/Logs/DiagnosticReports" + local found + found="$(ls -t "$reports"/{macOSLauncher,stfc-community-mod-loader,*"Star Trek Fleet Command"}*.{crash,ips} \ + 2>/dev/null | head -10 || true)" + if [[ -z "$found" ]]; then + echo "No crash reports in $reports" + return + fi + echo "$found" + local newest + newest="$(head -1 <<<"$found")" + read -p "View $newest? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + less "$newest" + fi } -# Run the application -run_app() { - prepare_app - - if [[ "$USE_LAUNCHER" == true ]]; then - print_info "Launching application with launcher..." - print_info "Crash dumps will be generated at: ~/Library/Logs/DiagnosticReports/" - open "$PACKAGED_APP_PATH" - - print_success "Launcher launched" - print_info "To view logs, use: log stream --predicate 'process == \"macOSLauncher\"' --level debug" - print_info "To view crash logs after a crash, run: $(basename "$0") crashlogs" - else - print_info "Running loader directly..." - print_info "The loader will launch the game with the mod injected" - print_info "Crash dumps will be generated at: ~/Library/Logs/DiagnosticReports/" - - # Run the loader - "$LOADER_PATH" - - print_success "Loader executed" - print_info "To view game logs, use: log stream --predicate 'process == \"Star Trek Fleet Command\"' --level debug" - print_info "To view crash logs after a crash, run: $(basename "$0") crashlogs" - fi +clean() { + rm -rf "$ROOT/.zig-cache" "$ROOT/zig-out" } -# Show crash logs -show_crash_logs() { - local crash_dir="${HOME}/Library/Logs/DiagnosticReports" - - print_info "Searching for recent crash logs..." - echo - - # Look for crash logs from our apps - local found_crashes=false - - # Search for macOSLauncher crashes - if compgen -G "${crash_dir}/macOSLauncher*.crash" > /dev/null 2>&1; then - print_info "Recent macOSLauncher crashes:" - ls -lht "${crash_dir}"/macOSLauncher*.crash 2>/dev/null | head -5 | while read -r line; do - echo " $line" - done - echo - found_crashes=true - fi - - # Search for loader crashes - if compgen -G "${crash_dir}/stfc-community-mod-loader*.crash" > /dev/null 2>&1; then - print_info "Recent stfc-community-mod-loader crashes:" - ls -lht "${crash_dir}"/stfc-community-mod-loader*.crash 2>/dev/null | head -5 | while read -r line; do - echo " $line" - done - echo - found_crashes=true - fi - - # Search for game crashes - if compgen -G "${crash_dir}/*Star Trek Fleet Command*.crash" > /dev/null 2>&1; then - print_info "Recent Star Trek Fleet Command crashes:" - ls -lht "${crash_dir}"/*"Star Trek Fleet Command"*.crash 2>/dev/null | head -5 | while read -r line; do - echo " $line" - done - echo - found_crashes=true - fi - - if [[ "$found_crashes" == false ]]; then - print_warning "No crash logs found in ${crash_dir}" - echo - else - # Get the most recent crash file - local most_recent=$(ls -t "${crash_dir}"/{macOSLauncher,stfc-community-mod-loader,*"Star Trek Fleet Command"}*.crash 2>/dev/null | head -1) - - if [[ -f "$most_recent" ]]; then - print_info "Most recent crash log: ${most_recent}" - echo - read -p "View most recent crash log? [y/N] " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - less "$most_recent" - fi - fi - fi - - print_info "Crash logs location: ${crash_dir}" - print_info "To view a specific crash: less '${crash_dir}/'" -} - -# Debug the application with lldb -debug_app() { - prepare_app - - local executable - local exec_name - - if [[ "$USE_LAUNCHER" == true ]]; then - executable="${PACKAGED_APP_PATH}/Contents/MacOS/macOSLauncher" - exec_name="Launcher" - else - executable="$LOADER_PATH" - exec_name="Loader" - fi - - if [[ ! -f "$executable" ]]; then - print_error "Executable not found at: $executable" - exit 1 - fi - - print_warning "Running under lldb - system crash reports will NOT be generated" - print_warning "Use 'run' action instead of 'debug' if you need crash dumps" - echo - print_info "Starting debugger for ${exec_name}..." - print_info "The ${exec_name} will launch under lldb" - print_info "Use 'continue' or 'c' to start execution" - print_info "Use 'breakpoint set -n ' to set breakpoints" - - # Create lldb command file for better debugging experience - local lldb_script=$(mktemp) - cat > "$lldb_script" << 'LLDB_EOF' -# Set up better debugging environment -settings set target.process.stop-on-exec false -settings set target.disable-aslr false - -# Display more information on crash -settings set stop-disassembly-display never - -# Print colored output -settings set use-color true - -# Show source when stopping -settings set stop-line-count-before 3 -settings set stop-line-count-after 3 - -# Load the executable -target create "%EXECUTABLE%" - -# Set any initial breakpoints here if needed -# breakpoint set -n main -LLDB_EOF - - # Replace placeholders with actual values - sed -i '' "s|%EXECUTABLE%|${executable}|g" "$lldb_script" - sed -i '' "s|%EXEC_NAME%|${exec_name}|g" "$lldb_script" - - lldb -s "$lldb_script" - - rm "$lldb_script" -} - -# Main execution flow -main() { - print_info "STFC Community Mod Development Script" - - local exec_type="Loader" - if [[ "$USE_LAUNCHER" == true ]]; then - exec_type="Launcher" - fi - - print_info "Action: ${ACTION}, Mode: ${BUILD_MODE}, Arch: ${ARCH}, Using: ${exec_type}" - echo - - case $ACTION in - config) - configure_project - ;; - clean) - clean_build - ;; - rebuild) - clean_build - configure_project - build_project - ;; - build) - if [[ "$CLEAN" == true ]]; then - clean_build - fi - configure_project - build_project - ;; - run) - if [[ "$CLEAN" == true ]]; then - clean_build - fi - configure_project - build_project - run_app - ;; - debug) - if [[ "$CLEAN" == true ]]; then - clean_build - fi - configure_project - build_project - debug_app - ;; - crashlogs) - show_crash_logs - ;; - *) - print_error "Unknown action: $ACTION" - print_usage - exit 1 - ;; - esac - - echo - print_success "All operations completed successfully!" -} - -# Run main function -main +if [[ "$CLEAN" == true && "$ACTION" != clean ]]; then + clean +fi +# Separate commands, not `build && run`: set -e does not apply inside a function called on the left of &&, so a +# failed build would go on to run a stale bundle. +case "$ACTION" in + build) build ;; + run) + build + run + ;; + debug) + build + debug + ;; + crashlogs) crashlogs ;; + clean) clean ;; +esac diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index 82fbb9545..98bf12433 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -1,31 +1,34 @@ #!/bin/bash -# Build both architectures and assemble a signed universal launcher bundle. +# Builds both macOS architectures and assembles the signed universal launcher bundle at +# zig-out/universal//STFC Community Mod.app. +# +# Usage: scripts/package-mac-app.sh [debug|release|releasedbg] set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" + MODE="${1:-release}" case "$MODE" in - debug|check) OPTIMIZE=Debug; MODDBG=false ;; - release) OPTIMIZE=ReleaseFast; MODDBG=false ;; - releasedbg) OPTIMIZE=ReleaseFast; MODDBG=true ;; - *) echo "Unknown build mode: $MODE" >&2; exit 1 ;; + debug) OPTIMIZE=Debug MODDBG=false ;; + release) OPTIMIZE=ReleaseFast MODDBG=false ;; + releasedbg) OPTIMIZE=ReleaseFast MODDBG=true ;; + *) + echo "Unknown build mode: $MODE" >&2 + exit 1 + ;; esac -OUT="zig-out/universal/$MODE" -zig build macos-universal -Doptimize="$OPTIMIZE" -Dmoddbg="$MODDBG" --prefix "$OUT" + +# Keep in step with macos_min in zig/ctx.zig. +MACOS_MIN=14.6 +PREFIXES=() for ARCH in arm64 x86_64; do TARGET="$ARCH" - [[ "$ARCH" != arm64 ]] || TARGET=aarch64 - zig build launcher -Dtarget="$TARGET-macos.14.6" -Doptimize="$OPTIMIZE" -Dmoddbg="$MODDBG" --prefix "zig-out/$ARCH/$MODE" + if [[ "$ARCH" == arm64 ]]; then + TARGET=aarch64 + fi + PREFIX="zig-out/$ARCH/$MODE" + zig build install launcher -Dtarget="$TARGET-macos.$MACOS_MIN" -Doptimize="$OPTIMIZE" -Dmoddbg="$MODDBG" \ + --prefix "$PREFIX" + PREFIXES+=("$PREFIX") done -APP="$OUT/STFC Community Mod.app" -rm -rf "$APP" -ditto "zig-out/arm64/$MODE/macOSLauncher.app" "$APP" -lipo -create \ - "zig-out/arm64/$MODE/macOSLauncher.app/Contents/MacOS/macOSLauncher" \ - "zig-out/x86_64/$MODE/macOSLauncher.app/Contents/MacOS/macOSLauncher" \ - -output "$APP/Contents/MacOS/macOSLauncher" -cp "$OUT/bin/stfc-community-mod-loader" "$APP/Contents/" -cp "$OUT/lib/libstfc-community-mod.dylib" "$APP/Contents/" -codesign --force --verify --deep --sign - "$APP" -codesign --verify --deep --strict "$APP" -echo "Built $APP" +scripts/assemble-mac-app.sh "zig-out/universal/$MODE/STFC Community Mod.app" "${PREFIXES[@]}" diff --git a/scripts/validate-example-configs.py b/scripts/validate-example-configs.py index ca2240274..0ecbc8662 100644 --- a/scripts/validate-example-configs.py +++ b/scripts/validate-example-configs.py @@ -1,16 +1,8 @@ #!/usr/bin/env python3 -"""Validate that every bundled example config is well-formed TOML. +"""Fails when an example settings file is not valid TOML: users copy them, and toml++ rejects the whole file.""" -The mod loads user configs with toml++, which -- per the TOML spec -- rejects -duplicate keys and other malformed input outright (it does not silently ignore -them). The example files exist to be copied into community_patch_settings.toml, -so a malformed example is a copy-paste trap. This check parses each example and -fails if any of them is not valid TOML, guarding against that at PR time. -""" - -import glob -import os import sys +from pathlib import Path try: import tomllib # Python 3.11+ @@ -19,31 +11,27 @@ def main() -> int: - root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - pattern = os.path.join(root, "docs", "configuration", "example_community_patch_settings*.toml") - files = sorted(glob.glob(pattern)) + root = Path(__file__).resolve().parent.parent + pattern = "example_community_patch_settings*.toml" + configs = root / "docs" / "configuration" + files = sorted(configs.glob(pattern)) if not files: - print(f"no example config files matched {pattern!r}", file=sys.stderr) + print(f"no example config files matched {str(configs / pattern)!r}", file=sys.stderr) return 1 failures = [] for path in files: - name = os.path.basename(path) try: - with open(path, "rb") as handle: + with path.open("rb") as handle: tomllib.load(handle) except tomllib.TOMLDecodeError as exc: - print(f" FAIL {name}: {exc}") - failures.append(name) + print(f" FAIL {path.name}: {exc}") + failures.append(path.name) else: - print(f" PASS {name}") + print(f" PASS {path.name}") if failures: - print( - f"\n{len(failures)} example config(s) are not valid TOML: " - + ", ".join(failures), - file=sys.stderr, - ) + print(f"\n{len(failures)} example config(s) are not valid TOML: {', '.join(failures)}", file=sys.stderr) return 1 print(f"\nall {len(files)} example config(s) are valid TOML") diff --git a/scripts/verify-version-substitution.py b/scripts/verify-version-substitution.py new file mode 100755 index 000000000..2e5e6edc3 --- /dev/null +++ b/scripts/verify-version-substitution.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Check a built launcher's plist against the mod's version header.""" +import argparse +import plistlib +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def header_version() -> str: + """The dotted MAJOR.MINOR.REVISION.PATCH version from src/mod/version.h.""" + header = (ROOT / "src/mod/version.h").read_text() + parts = [] + for component in ("MAJOR", "MINOR", "REVISION", "PATCH"): + match = re.search(rf"^#define\s+VERSION_{component}\s+(\d+)\s*$", header, re.MULTILINE) + if not match: + raise SystemExit(f"Missing VERSION_{component}") + parts.append(match.group(1)) + return ".".join(parts) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("plist", nargs="?", default="zig-out/macOSLauncher.app/Contents/Info.plist") + args = parser.parse_args() + + version = header_version() + with Path(args.plist).open("rb") as file: + plist = plistlib.load(file) + for key in ("CFBundleShortVersionString", "CFBundleVersion"): + if plist.get(key) != version: + raise SystemExit(f"{key}: expected {version}, found {plist.get(key)!r}") + # The build also substitutes the minimum macOS version; nothing may be left as a template placeholder. + leftover = sorted(key for key, value in plist.items() if isinstance(value, str) and "${" in value) + if leftover: + raise SystemExit(f"Unsubstituted placeholders in {', '.join(leftover)}") + print(f"Verified launcher version {version}") + + +if __name__ == "__main__": + main() diff --git a/scripts/verify-windows-binary.py b/scripts/verify-windows-binary.py index 94bd37a72..c9fa96215 100644 --- a/scripts/verify-windows-binary.py +++ b/scripts/verify-windows-binary.py @@ -1,58 +1,78 @@ #!/usr/bin/env python3 """Verify the Windows proxy's architecture, exports, static CRT and provenance marker.""" import argparse -from pathlib import Path import struct +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_MARKER = "stfc-identity-v1;distribution=netniv.stfc-community-mod;" +CRT_PREFIXES = ("msvcp", "vcruntime", "ucrtbase", "api-ms-win-crt-") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary", nargs="?", default="zig-out/bin/version.dll") + parser.add_argument("--identity", help="Expected complete stfc-identity-v1 version-resource marker") + args = parser.parse_args() + data = Path(args.binary).read_bytes() + + def u16(position: int) -> int: + return struct.unpack_from(" int: + return struct.unpack_from(" int: + for virtual_size, virtual_address, raw_size, raw_address in sections: + if virtual_address <= rva < virtual_address + max(virtual_size, raw_size): + return raw_address + rva - virtual_address + raise ValueError(f"Unmapped RVA {rva:#x}") + + def string(rva: int) -> str: + start = offset(rva) + return data[start:data.index(b"\0", start)].decode("ascii") + + # The import directory is a zero-terminated array of 20-byte descriptors; the DLL name's RVA is at +12. + imports = [] + position = offset(u32(optional + 120)) + while any(data[position:position + 20]): + imports.append(string(u32(position + 12))) + position += 20 + if any(name.lower().startswith(CRT_PREFIXES) for name in imports): + raise SystemExit(f"Dynamic CRT dependency: {imports}") + + # The export directory holds NumberOfNames at +24 and AddressOfNames, a table of name RVAs, at +32. + export_directory = offset(u32(optional + 112)) + name_count = u32(export_directory + 24) + name_table = offset(u32(export_directory + 32)) + exports = {string(u32(name_table + i * 4)) for i in range(name_count)} + definition = (ROOT / "src/platform/windows/version.def").read_text() + expected = set(definition.split("EXPORTS", 1)[1].split()) + if exports != expected: + raise SystemExit(f"Export mismatch: missing={expected - exports}, extra={exports - expected}") + + marker = args.identity or DEFAULT_MARKER + if marker.encode("utf-16le") not in data: + raise SystemExit("Missing or incorrect UTF-16 version-resource provenance marker") + print(f"Verified x86_64 proxy: {len(exports)} exports, static CRT, provenance marker") + -parser = argparse.ArgumentParser(description=__doc__) -parser.add_argument("binary", nargs="?", default="zig-out/bin/version.dll") -parser.add_argument("--identity", help="Expected complete stfc-identity-v1 version-resource marker") -args = parser.parse_args() -data = Path(args.binary).read_bytes() -pe = struct.unpack_from("MacOSX LSMinimumSystemVersion - 14.6 + ${MACOS_MIN} CFBundleIconFile launcher diff --git a/src/platform/windows/version.rc b/src/platform/windows/version.rc index 86841ff61..17328d83b 100644 --- a/src/platform/windows/version.rc +++ b/src/platform/windows/version.rc @@ -1,43 +1,29 @@ -// -// Include the necessary resources -// +// The version resource of version.dll. Values come from src/mod/version.h and the build identity defines that +// zig/mod.zig passes to the resource compiler. #include -#ifdef RC_INVOKED - -// -// Set up debug information -// -#if DBG -#define VER_DBG VS_FF_DEBUG -#else -#define VER_DBG 0 -#endif - -// ------- version info ------------------------------------------------------- - VS_VERSION_INFO VERSIONINFO FILEVERSION VER_FILE_VERSION PRODUCTVERSION VER_PRODUCT_VERSION FILEFLAGS VER_FILEFLAGS BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904b0" + BLOCK "StringFileInfo" + BEGIN + // US English, Unicode: matches the Translation entry below. + BLOCK "040904b0" BEGIN - VALUE "FileDescription", VER_FILE_DESCRIPTION_STR - VALUE "FileVersion", VER_FILE_VERSION_STR - VALUE "Comments", STFC_IDENTITY_COMMENT_STR - VALUE "InternalName", VER_INTERNAL_NAME_STR - VALUE "LegalCopyright", VER_COPYRIGHT_STR - VALUE "OriginalFilename", VER_ORIGINAL_FILENAME_STR - VALUE "ProductName", VER_PRODUCTNAME_STR - VALUE "ProductVersion", VER_PRODUCT_VERSION_STR + VALUE "FileDescription", VER_FILE_DESCRIPTION_STR + VALUE "FileVersion", VER_FILE_VERSION_STR + VALUE "Comments", STFC_IDENTITY_COMMENT_STR + VALUE "InternalName", VER_INTERNAL_NAME_STR + VALUE "LegalCopyright", VER_COPYRIGHT_STR + VALUE "OriginalFilename", VER_ORIGINAL_FILENAME_STR + VALUE "ProductName", VER_PRODUCTNAME_STR + VALUE "ProductVersion", VER_PRODUCT_VERSION_STR END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x0409,1200 - END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x0409, 1200 + END END -#endif diff --git a/zig/ctx.zig b/zig/ctx.zig index b9451b80e..caf3cf15f 100644 --- a/zig/ctx.zig +++ b/zig/ctx.zig @@ -5,12 +5,17 @@ const std = @import("std"); const Build = std.Build; const toolchains = @import("toolchains.zig"); +/// The oldest macOS the mod, loader and launcher support. Scripts and CI pass `-Dtarget=-macos.14.6` and the +/// launcher's Info.plist gets it from here; change them together. +pub const macos_min = "14.6"; + /// Project options that apply to every target. Options that configure a single step stay with that step. pub const Options = struct { /// null keeps the per-target default: on for Windows release builds, off elsewhere. lto: ?bool, moddbg: bool, use_original_bg: bool, + /// A custom loading screen, relative to the working directory; empty for the built-in one. bg_image: []const u8, /// Fixture optimization; null follows -Doptimize. test_optimize: ?std.builtin.OptimizeMode, @@ -19,7 +24,6 @@ pub const Options = struct { /// Declare the shared options. Call once from `build`. pub fn parse(b: *Build) Options { - const bg_image = b.option([]const u8, "bg_image", "Custom loading screen image") orelse ""; return .{ .lto = b.option(bool, "lto", "Enable LTO (Windows release default; unsupported for macOS in Zig 0.16)"), .moddbg = b.option(bool, "moddbg", "Enable diagnostic patch toggles (_MODDBG)") orelse false, @@ -28,7 +32,7 @@ pub const Options = struct { "use_original_bg", "Use the original game loading background", ) orelse false, - .bg_image = if (bg_image.len == 0) "assets/loadingscreen.png" else bg_image, + .bg_image = b.option([]const u8, "bg_image", "Custom loading screen image") orelse "", .test_optimize = b.option( std.builtin.OptimizeMode, "test-optimize", @@ -81,6 +85,7 @@ pub const Ctx = struct { return copy; } + // Target checks. The build host is `builtin.os.tag`; host-only steps such as swiftc and protoc check that. pub fn isWindows(ctx: *const Ctx) bool { return ctx.target.result.os.tag == .windows; } @@ -89,6 +94,10 @@ pub const Ctx = struct { return ctx.target.result.os.tag == .macos; } + pub fn isLinux(ctx: *const Ctx) bool { + return ctx.target.result.os.tag == .linux; + } + pub fn isAarch64(ctx: *const Ctx) bool { return ctx.target.result.cpu.arch == .aarch64; } @@ -103,8 +112,7 @@ pub const Ctx = struct { .link_libc = true, .link_libcpp = if (cpp and !ctx.isWindows()) true else null, .pic = true, - // Zig turns UBSan on in Debug. The xmake build never had it, and a trap inside a game detour takes the - // client down. + // Zig turns UBSan on in Debug, but a trap inside a game detour takes the client down. .sanitize_c = .off, }); @@ -114,19 +122,16 @@ pub const Ctx = struct { module.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ root, "winsdk/include", dir }) }); inline for (.{ "winsdk/lib/um/x64", "winsdk/lib/ucrt/x64", "msvc/lib/x64" }) |dir| module.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ root, dir }) }); - } else { + } else if (b.graph.environ_map.get("INCLUDE")) |includes| { // No cached SDK on a Windows host: a Visual Studio developer shell supplies the paths, including - // C++/WinRT, which Zig's own MSVC detection leaves out. - const env = &b.graph.environ_map; - if (env.get("INCLUDE")) |includes| { - var it = std.mem.splitScalar(u8, includes, ';'); - while (it.next()) |dir| if (dir.len != 0) module.addSystemIncludePath(.{ .cwd_relative = dir }); - if (env.get("LIB")) |libs| { - var lib_it = std.mem.splitScalar(u8, libs, ';'); - while (lib_it.next()) |dir| if (dir.len != 0) module.addLibraryPath(.{ .cwd_relative = dir }); - } - } + // C++/WinRT, which Zig's own MSVC detection leaves out. The shell sets both; LIB is read only with + // INCLUDE. + var include_dirs = std.mem.tokenizeScalar(u8, includes, ';'); + while (include_dirs.next()) |dir| module.addSystemIncludePath(.{ .cwd_relative = dir }); + var lib_dirs = std.mem.tokenizeScalar(u8, b.graph.environ_map.get("LIB") orelse "", ';'); + while (lib_dirs.next()) |dir| module.addLibraryPath(.{ .cwd_relative = dir }); } + // The static release CRT and STL (/MT), so version.dll needs no Visual C++ redistributable. inline for (.{ "libcmt", "libcpmt", "libucrt", "libvcruntime" }) |lib| module.linkSystemLibrary(lib, .{}); } else if (ctx.sysroot.macos_sdk) |sdk| { @@ -146,23 +151,31 @@ pub const Ctx = struct { } } + /// A static library from `module`, through `applyCompile`. pub fn staticLib(ctx: *const Ctx, name: []const u8, module: *Build.Module) *Build.Step.Compile { const lib = ctx.b.addLibrary(.{ .name = name, .linkage = .static, .root_module = module }); ctx.applyCompile(lib); return lib; } + /// An executable from `module`, through `applyCompile`. pub fn executable(ctx: *const Ctx, name: []const u8, module: *Build.Module) *Build.Step.Compile { const exe = ctx.b.addExecutable(.{ .name = name, .root_module = module }); ctx.applyCompile(exe); return exe; } - /// An owned copy of `list`. `Lib` fields outlive the caller's stack, so literal slices must be copied. + /// An owned copy of `list`. `Lib` fields outlive the caller's stack, and `&.{...}` of runtime values points + /// into it, so those literals must be copied. pub fn dirs(ctx: *const Ctx, list: []const Build.LazyPath) []const Build.LazyPath { return ctx.b.allocator.dupe(Build.LazyPath, list) catch @panic("OOM"); } + /// An owned copy of `list`, for `Lib.dependencies`. See `dirs`. + pub fn libs(ctx: *const Ctx, list: []const Lib) []const Lib { + return ctx.b.allocator.dupe(Lib, list) catch @panic("OOM"); + } + /// Compile `files` as one translation unit of `#include`s, so headers they share are parsed once. Only use it /// for sources that tolerate each other's macros and statics. pub fn addCxxBatch( @@ -188,33 +201,54 @@ pub const Ctx = struct { module.addCSourceFile(.{ .file = source, .flags = flags }); } - /// C flags for this target, then `extra`. + /// `addCxxBatch` over groups of four files, named `-0`, `-1` and so on. Each group shares header + /// parsing without serializing the whole module into one unit. + pub fn addCxxBatches( + ctx: *const Ctx, + module: *Build.Module, + name: []const u8, + root: Build.LazyPath, + files: []const []const u8, + flags: []const []const u8, + ) void { + var start: usize = 0; + while (start < files.len) : (start += 4) { + const batch = files[start..@min(start + 4, files.len)]; + ctx.addCxxBatch(module, ctx.b.fmt("{s}-{d}", .{ name, start / 4 }), root, batch, flags); + } + } + + /// C flags for this target, then `extra`. On Windows, -fms-runtime-lib=static selects the static CRT that + /// `createModule` links, so objects and libraries agree on /MT. pub fn cFlags(ctx: *const Ctx, extra: []const []const u8) []const []const u8 { + const allocator = ctx.b.allocator; var list: std.ArrayList([]const u8) = .empty; - if (ctx.isWindows()) list.append(ctx.b.allocator, "-fms-runtime-lib=static") catch @panic("OOM"); - list.appendSlice(ctx.b.allocator, extra) catch @panic("OOM"); + if (ctx.isWindows()) list.append(allocator, "-fms-runtime-lib=static") catch @panic("OOM"); + list.appendSlice(allocator, extra) catch @panic("OOM"); return list.items; } /// C++ flags at `std_ver` with exceptions enabled, then `extra`. pub fn cxxFlags(ctx: *const Ctx, comptime std_ver: []const u8, extra: []const []const u8) []const []const u8 { + const allocator = ctx.b.allocator; var list: std.ArrayList([]const u8) = .empty; - const base: []const []const u8 = &.{ "-std=" ++ std_ver, "-fexceptions", "-fcxx-exceptions" }; - list.appendSlice(ctx.b.allocator, base) catch @panic("OOM"); - // Zig passes -nostdinc++ when it uses the MSVC STL. Clang's MSVC driver reports it unused, and Zig turns - // that warning into an error. - if (ctx.isWindows()) list.appendSlice(ctx.b.allocator, &.{ + list.appendSlice(allocator, &.{ "-std=" ++ std_ver, "-fexceptions", "-fcxx-exceptions" }) catch @panic("OOM"); + // NOMINMAX keeps windows.h from defining min and max macros over std::min and std::max. Zig passes + // -nostdinc++ when it uses the MSVC STL; Clang's MSVC driver reports it unused, and Zig turns that warning + // into an error. + if (ctx.isWindows()) list.appendSlice(allocator, &.{ "-DNOMINMAX", "-fms-runtime-lib=static", "-Wno-unused-command-line-argument", }) catch @panic("OOM"); - list.appendSlice(ctx.b.allocator, extra) catch @panic("OOM"); + list.appendSlice(allocator, extra) catch @panic("OOM"); return list.items; } }; /// A compiled or header-only library, as its consumers see it. pub const Lib = struct { + /// null for header-only libraries and for system libraries named in `system_libs`. artifact: ?*Build.Step.Compile = null, /// Libraries this one links against. Consumers link them too, instead of this artifact linking them, so /// independent static libraries compile in parallel. @@ -295,3 +329,28 @@ pub fn globFiles( }.lessThan); return out.items; } + +/// A program the build runs on the host, from one Zig source file under zig/tools. +pub fn hostTool( + b: *Build, + name: []const u8, + source: []const u8, + optimize: std.builtin.OptimizeMode, +) *Build.Step.Compile { + return b.addExecutable(.{ + .name = name, + .root_module = b.createModule(.{ + .root_source_file = b.path(source), + .target = b.graph.host, + .optimize = optimize, + }), + }); +} + +/// The step that builds and runs the `test` blocks of a Zig source file on the host. +pub fn hostTests(b: *Build, source: []const u8) *Build.Step { + const tests = b.addTest(.{ + .root_module = b.createModule(.{ .root_source_file = b.path(source), .target = b.graph.host }), + }); + return &b.addRunArtifact(tests).step; +} diff --git a/zig/deps.zig b/zig/deps.zig index 6dd6d53c5..2b25fa688 100644 --- a/zig/deps.zig +++ b/zig/deps.zig @@ -1,5 +1,6 @@ //! Third-party C and C++ libraries other than protobuf, compiled from the pins in build.zig.zon. -//! Source lists are explicit where upstream ships platform files or tools we must not compile. +//! Source lists are explicit where upstream ships platform files or tools we must not compile. Each library +//! compiles against the same include dirs and defines it hands its consumers, plus private ones. const std = @import("std"); const Ctx = @import("ctx.zig").Ctx; const Lib = @import("ctx.zig").Lib; @@ -37,16 +38,15 @@ pub fn spdlog(ctx: *const Ctx) Lib { /// toml++ compiled once from zig/deps/tomlplusplus.cc. Consumers see only declarations. pub fn tomlplusplus(ctx: *const Ctx) Lib { const b = ctx.b; - const include = b.dependency("tomlplusplus", .{}).path("include"); - const module = ctx.createModule(true); - module.addIncludePath(include); - module.addCMacro("TOML_HEADER_ONLY", "0"); - module.addCSourceFile(.{ .file = b.path("zig/deps/tomlplusplus.cc"), .flags = ctx.cxxFlags("c++17", &.{}) }); - return .{ - .artifact = ctx.staticLib("tomlplusplus", module), - .include_dirs = ctx.dirs(&.{include}), + var lib: Lib = .{ + .include_dirs = ctx.dirs(&.{b.dependency("tomlplusplus", .{}).path("include")}), .defines = &.{.{ "TOML_HEADER_ONLY", "0" }}, }; + const module = ctx.createModule(true); + lib.includeInto(module); + module.addCSourceFile(.{ .file = b.path("zig/deps/tomlplusplus.cc"), .flags = ctx.cxxFlags("c++17", &.{}) }); + lib.artifact = ctx.staticLib("tomlplusplus", module); + return lib; } /// nlohmann/json, header-only. @@ -66,32 +66,31 @@ pub fn zydis(ctx: *const Ctx) Lib { const b = ctx.b; const core = b.dependency("zycore", .{}).path(""); const src = b.dependency("zydis", .{}).path(""); + var lib: Lib = .{ + .include_dirs = ctx.dirs(&.{ core.path(b, "include"), src.path(b, "include") }), + .defines = &.{ .{ "ZYCORE_STATIC_BUILD", "1" }, .{ "ZYDIS_STATIC_BUILD", "1" } }, + }; const module = ctx.createModule(false); - module.addIncludePath(core.path(b, "include")); - module.addIncludePath(src.path(b, "include")); + lib.includeInto(module); module.addIncludePath(src.path(b, "src")); - module.addCMacro("ZYCORE_STATIC_BUILD", "1"); - module.addCMacro("ZYDIS_STATIC_BUILD", "1"); // Native fixtures on Linux: glibc hides Zycore's recursive mutex attribute under strict C11. - if (ctx.target.result.os.tag == .linux) module.addCMacro("_GNU_SOURCE", "1"); + if (ctx.isLinux()) module.addCMacro("_GNU_SOURCE", "1"); inline for (.{ core, src }) |root| module.addCSourceFiles(.{ .root = root, .files = globFiles(b, root, "src", &.{".c"}, &.{}), .flags = ctx.cFlags(&.{"-std=c11"}), }); - return .{ - .artifact = ctx.staticLib("zydis", module), - .include_dirs = ctx.dirs(&.{ core.path(b, "include"), src.path(b, "include") }), - .defines = &.{ .{ "ZYCORE_STATIC_BUILD", "1" }, .{ "ZYDIS_STATIC_BUILD", "1" } }, - }; + lib.artifact = ctx.staticLib("zydis", module); + return lib; } /// Capstone's core and AArch64 decoder only, for SPUD's ARM64 relocator. pub fn capstone(ctx: *const Ctx) Lib { const b = ctx.b; const src = b.dependency("capstone", .{}).path(""); + var lib: Lib = .{ .include_dirs = ctx.dirs(&.{src.path(b, "include")}) }; const module = ctx.createModule(false); - module.addIncludePath(src.path(b, "include")); + lib.includeInto(module); module.addIncludePath(src); module.addCMacro("CAPSTONE_HAS_AARCH64", "1"); module.addCMacro("CAPSTONE_USE_SYS_DYN_MEM", "1"); @@ -115,10 +114,8 @@ pub fn capstone(ctx: *const Ctx) Lib { }, .flags = ctx.cFlags(&.{"-std=c11"}), }); - return .{ - .artifact = ctx.staticLib("capstone", module), - .include_dirs = ctx.dirs(&.{src.path(b, "include")}), - }; + lib.artifact = ctx.staticLib("capstone", module); + return lib; } /// The vendored SPUD detour library. third_party/spud is SPUD's unmodified include/, src/ and LICENSE; the pinned @@ -126,12 +123,17 @@ pub fn capstone(ctx: *const Ctx) Lib { pub fn spud(ctx: *const Ctx, zy: Lib, cs: Lib) Lib { const b = ctx.b; const src = b.path("third_party/spud"); + // Only the ARM64 relocator uses Capstone. Like upstream, x86-64 builds leave out Capstone and the + // SPUD_AARCH64_SUPPORT relocator. const aarch64 = ctx.isAarch64(); + var lib: Lib = .{ + .include_dirs = ctx.dirs(&.{src.path(b, "include")}), + .dependencies = ctx.libs(if (aarch64) &.{ zy, cs } else &.{zy}), + }; const module = ctx.createModule(true); - module.addIncludePath(src.path(b, "include")); + lib.includeInto(module); module.addIncludePath(src.path(b, "src")); zy.includeInto(module); - // Only the ARM64 relocator uses Capstone. Like upstream, x86-64 builds leave both out. if (aarch64) { module.addCMacro("SPUD_AARCH64_SUPPORT", "1"); cs.includeInto(module); @@ -147,11 +149,8 @@ pub fn spud(ctx: *const Ctx, zy: Lib, cs: Lib) Lib { .files = globFiles(b, src, "src", &.{".cc"}, skip), .flags = ctx.cxxFlags("c++23", &.{}), }); - return .{ - .artifact = ctx.staticLib("spud", module), - .dependencies = b.allocator.dupe(Lib, if (aarch64) &.{ zy, cs } else &.{zy}) catch @panic("OOM"), - .include_dirs = ctx.dirs(&.{src.path(b, "include")}), - }; + lib.artifact = ctx.staticLib("spud", module); + return lib; } // --- Networking ------------------------------------------------------------------------------------------------ @@ -160,8 +159,9 @@ pub fn spud(ctx: *const Ctx, zy: Lib, cs: Lib) Lib { pub fn zlib(ctx: *const Ctx) Lib { const b = ctx.b; const src = b.dependency("zlib", .{}).path(""); + var lib: Lib = .{ .include_dirs = ctx.dirs(&.{src}) }; const module = ctx.createModule(false); - module.addIncludePath(src); + lib.includeInto(module); if (!ctx.isWindows()) module.addCMacro("HAVE_UNISTD_H", "1"); module.addCSourceFiles(.{ .root = src, @@ -184,7 +184,8 @@ pub fn zlib(ctx: *const Ctx) Lib { }, .flags = ctx.cFlags(&.{"-std=c11"}), }); - return .{ .artifact = ctx.staticLib("zlib", module), .include_dirs = ctx.dirs(&.{src}) }; + lib.artifact = ctx.staticLib("zlib", module); + return lib; } /// libcurl. macOS links Apple's system copy. Windows compiles the pin with Schannel TLS and the unused protocols @@ -217,7 +218,7 @@ pub fn curl(ctx: *const Ctx, z: Lib) ?Lib { }); return .{ .artifact = ctx.staticLib("curl", module), - .dependencies = b.allocator.dupe(Lib, &.{z}) catch @panic("OOM"), + .dependencies = ctx.libs(&.{z}), .include_dirs = ctx.dirs(&.{src.path(b, "include")}), .defines = &.{.{ "CURL_STATICLIB", "1" }}, }; @@ -237,16 +238,13 @@ pub fn cpr(ctx: *const Ctx, curl_lib: Lib) Lib { \\#define CPR_VERSION_NUM 0x010e02 \\ ); - const generated = version.dirname().dirname(); + var lib: Lib = .{ .include_dirs = ctx.dirs(&.{ src.path(b, "include"), version.dirname().dirname() }) }; const module = ctx.createModule(true); - module.addIncludePath(src.path(b, "include")); - module.addIncludePath(generated); + lib.includeInto(module); curl_lib.includeInto(module); ctx.addCxxBatch(module, "cpr", src, globFiles(b, src, "cpr", &.{".cpp"}, &.{}), ctx.cxxFlags("c++17", &.{})); - return .{ - .artifact = ctx.staticLib("cpr", module), - .include_dirs = ctx.dirs(&.{ src.path(b, "include"), generated }), - }; + lib.artifact = ctx.staticLib("cpr", module); + return lib; } // --- Launcher -------------------------------------------------------------------------------------------------- @@ -256,9 +254,10 @@ pub fn rsync(ctx: *const Ctx) Lib { const b = ctx.b; const src = b.dependency("librsync", .{}).path(""); const config = b.addWriteFiles().addCopyFile(b.path("zig/deps/librsync/macos.h"), "config.h"); + var lib: Lib = .{ .include_dirs = ctx.dirs(&.{src.path(b, "src")}) }; const module = ctx.createModule(false); module.addIncludePath(config.dirname()); - module.addIncludePath(src.path(b, "src")); + lib.includeInto(module); module.addIncludePath(src.path(b, "src/blake2")); module.addCSourceFiles(.{ .root = src, @@ -292,7 +291,8 @@ pub fn rsync(ctx: *const Ctx) Lib { "src/whole.c", "src/blake2/blake2b-ref.c", }, - .flags = &.{"-std=c99"}, + .flags = ctx.cFlags(&.{"-std=c99"}), }); - return .{ .artifact = ctx.staticLib("rsync", module), .include_dirs = ctx.dirs(&.{src.path(b, "src")}) }; + lib.artifact = ctx.staticLib("rsync", module); + return lib; } diff --git a/zig/launcher.zig b/zig/launcher.zig index 42b0dd1e2..5f8aa7482 100644 --- a/zig/launcher.zig +++ b/zig/launcher.zig @@ -3,7 +3,9 @@ const std = @import("std"); const builtin = @import("builtin"); const Ctx = @import("ctx.zig").Ctx; +const macos_min = @import("ctx.zig").macos_min; const globFiles = @import("ctx.zig").globFiles; +const hostTool = @import("ctx.zig").hostTool; const deps = @import("deps.zig"); /// Register `launcher`, which installs zig-out/macOSLauncher.app. @@ -15,7 +17,7 @@ pub fn add(ctx: *const Ctx) void { return; } const sdk = ctx.sysroot.macos_sdk.?; - const swift_target = if (ctx.isAarch64()) "arm64-apple-macosx14.6" else "x86_64-apple-macosx14.6"; + const swift_target = b.fmt("{s}-apple-macosx{s}", .{ if (ctx.isAarch64()) "arm64" else "x86_64", macos_min }); // The C++ side Swift imports through module.modulemap. swiftc links the system libc++, so the bridge builds // against the SDK's libc++ headers instead of Zig's bundled copy. @@ -25,7 +27,7 @@ pub fn add(ctx: *const Ctx) void { rsync.includeInto(bridge_module); bridge_module.addCSourceFile(.{ .file = b.path("src/launcher/macos/rsync.cc"), - .flags = ctx.cxxFlags("c++17", &.{}), + .flags = ctx.cxxFlags("c++23", &.{}), }); const bridge = ctx.staticLib("launcher-bridge", bridge_module); @@ -34,7 +36,6 @@ pub fn add(ctx: *const Ctx) void { const src = b.path("src/launcher/macos"); const swift = b.addSystemCommand(&.{ "xcrun", "swiftc", "-parse-as-library" }); swift.addArgs(&.{ "-module-name", "macOSLauncher" }); - swift.addArgs(&.{ "-D", "SWIFT_PACKAGE" }); swift.addArgs(&.{ "-sdk", sdk }); swift.addArgs(&.{ "-target", swift_target }); swift.addArgs(&.{ "-Xcc", "-fmodules", "-Xcc" }); @@ -45,14 +46,7 @@ pub fn add(ctx: *const Ctx) void { swift.addArg("-o"); const object = swift.addOutputFileArg("macOSLauncher.o"); - const extractor = b.addExecutable(.{ - .name = "extract-macho-archive", - .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tools/extract_macho_archive.zig"), - .target = b.graph.host, - .optimize = .Debug, - }), - }); + const extractor = hostTool(b, "extract-macho-archive", "zig/tools/extract_macho_archive.zig", .Debug); const link = b.addSystemCommand(&.{ "xcrun", "swiftc", "-sdk", sdk, "-target", swift_target, "-g" }); link.addFileArg(object); link.addFileArg(appleArchive(b, extractor, "liblauncher-bridge.a", bridge)); @@ -86,14 +80,15 @@ fn appleArchive( fn installBundleResources(ctx: *const Ctx, step: *std.Build.Step) void { const b = ctx.b; const template = readSource(b, "src/launcher/macos/Info.plist.template"); - const plist = std.mem.replaceOwned(u8, b.allocator, template, "${VERSION}", modVersion(b)) catch @panic("OOM"); + const versioned = std.mem.replaceOwned(u8, b.allocator, template, "${VERSION}", modVersion(b)) catch @panic("OOM"); + const plist = std.mem.replaceOwned(u8, b.allocator, versioned, "${MACOS_MIN}", macos_min) catch @panic("OOM"); const info = b.addWriteFiles().add("Info.plist", plist); step.dependOn(&b.addInstallFile(info, "macOSLauncher.app/Contents/Info.plist").step); const icon = b.addInstallFile(b.path("assets/launcher.icns"), "macOSLauncher.app/Contents/Resources/launcher.icns"); step.dependOn(&icon.step); const actool = b.addSystemCommand(&.{ "xcrun", "actool", "--platform", "macosx" }); - actool.addArgs(&.{ "--minimum-deployment-target", "14.6", "--compile" }); + actool.addArgs(&.{ "--minimum-deployment-target", macos_min, "--compile" }); const compiled = actool.addOutputDirectoryArg("resources"); actool.addDirectoryArg(b.path("src/launcher/macos/Assets.xcassets")); step.dependOn(&b.addInstallDirectory(.{ @@ -115,6 +110,7 @@ fn modVersion(b: *std.Build) []const u8 { return b.fmt("{s}.{s}.{s}.{s}", .{ parts[0], parts[1], parts[2], parts[3] }); } +/// A small text file from the repository, read while the graph is built. fn readSource(b: *std.Build, path: []const u8) []const u8 { const io = b.graph.io; return std.Io.Dir.cwd().readFileAlloc(io, b.pathFromRoot(path), b.allocator, .limited(64 * 1024)) catch |err| diff --git a/zig/mod.zig b/zig/mod.zig index cc228011f..603e7c9a3 100644 --- a/zig/mod.zig +++ b/zig/mod.zig @@ -3,20 +3,23 @@ const std = @import("std"); const Ctx = @import("ctx.zig").Ctx; const Lib = @import("ctx.zig").Lib; const Options = @import("ctx.zig").Options; +const macos_min = @import("ctx.zig").macos_min; const globFiles = @import("ctx.zig").globFiles; +const hostTool = @import("ctx.zig").hostTool; const deps = @import("deps.zig"); /// Runtime bindings: metadata lookup, typed calls, strings, GC handles and detours. Also exposes Unity's /// libil2cpp headers. pub fn il2cpp(ctx: *const Ctx, spud: Lib) Lib { const b = ctx.b; - const headers = Lib{ + var lib: Lib = .{ + .dependencies = ctx.libs(&.{spud}), .include_dirs = ctx.dirs(&.{ b.path("src/libs/il2cpp/include"), b.path("third_party/libil2cpp") }), // il2cpp/api.h asks the linker for GameAssembly.lib on Windows. .lib_dirs = if (ctx.isWindows()) ctx.dirs(&.{b.path("src/libs/il2cpp")}) else &.{}, }; const module = ctx.createModule(true); - headers.includeInto(module); + lib.includeInto(module); // hook.h builds on SPUD's detours. spud.includeInto(module); module.addCSourceFiles(.{ @@ -26,20 +29,16 @@ pub fn il2cpp(ctx: *const Ctx, spud: Lib) Lib { }); const artifact = ctx.staticLib("il2cpp", module); b.step("il2cpp", "Build the IL2CPP integration static library").dependOn(&b.addInstallArtifact(artifact, .{}).step); - return .{ - .artifact = artifact, - .include_dirs = headers.include_dirs, - .lib_dirs = headers.lib_dirs, - .dependencies = ctx.b.allocator.dupe(Lib, &.{spud}) catch @panic("OOM"), - }; + lib.artifact = artifact; + return lib; } /// Unity engine bindings over il2cpp: core types, UI, input and instance tracking. Knows nothing about the game. pub fn unity(ctx: *const Ctx, runtime: Lib, spud: Lib) Lib { const b = ctx.b; - const include = ctx.dirs(&.{b.path("src/libs/unity/include")}); + var lib: Lib = .{ .include_dirs = ctx.dirs(&.{b.path("src/libs/unity/include")}) }; const module = ctx.createModule(true); - for (include) |dir| module.addIncludePath(dir); + lib.includeInto(module); runtime.includeInto(module); spud.includeInto(module); module.addCSourceFiles(.{ @@ -49,7 +48,8 @@ pub fn unity(ctx: *const Ctx, runtime: Lib, spud: Lib) Lib { }); const artifact = ctx.staticLib("unity", module); b.step("unity", "Build the Unity bindings static library").dependOn(&b.addInstallArtifact(artifact, .{}).step); - return .{ .artifact = artifact, .include_dirs = include }; + lib.artifact = artifact; + return lib; } /// The `mods` static library and the binary the game loads: version.dll on Windows, the injected dylib and its @@ -115,12 +115,11 @@ fn addModSources(ctx: *const Ctx, module: *std.Build.Module, flags: []const []co if (ctx.isMacos() and ctx.optimize == .ReleaseFast) { // Release builds on macOS spend most of their time parsing game headers. Batches of four features share // that work; the heavy sync units stay separate so they keep compiling in parallel. - const features = globFiles(b, root, "features", &.{".cc"}, &.{}); - var start: usize = 0; - while (start < features.len) : (start += 4) { - const batch = features[start..@min(start + 4, features.len)]; - ctx.addCxxBatch(module, b.fmt("features-{d}", .{start}), root, batch, flags); - } + // + // A batch is one translation unit, so names in the anonymous namespaces of its four files must not clash. + // Batches follow file order: adding, renaming or merging a feature regroups them. This configuration is + // the only one that catches a clash, so check feature changes with a macOS ReleaseFast build. + ctx.addCxxBatches(module, "features", root, globFiles(b, root, "features", &.{".cc"}, &.{}), flags); const rest = globFiles(b, root, "", &.{".cc"}, &.{"features/"}); module.addCSourceFiles(.{ .root = root, .files = rest, .flags = flags }); } else { @@ -172,15 +171,18 @@ fn addBuildIdentity(ctx: *const Ctx, module: *std.Build.Module) []const []const fn embedImages(ctx: *const Ctx, module: *std.Build.Module) void { const b = ctx.b; const images = b.addWriteFiles(); - // Header, symbol, and source image. + // Header, symbol, and source image. Built-in images resolve from the repository, so the build runs from any + // directory; a custom -Dbg_image stays relative to where the user ran it. + const custom_bg = ctx.options.bg_image; + const bg_image = if (custom_bg.len == 0) b.pathFromRoot("assets/loadingscreen.png") else custom_bg; const table = [_][3][]const u8{ - .{ "embedded_loading_image.h", "g_embeddedLoadingImage", ctx.options.bg_image }, - .{ "embedded_logo_image.h", "g_embeddedLogoImage", "assets/stfc-mod-logo.png" }, - .{ "embedded_cc_logo_image.h", "g_embeddedCCLogoImage", "assets/official-cc-logo.png" }, + .{ "embedded_loading_image.h", "g_embeddedLoadingImage", bg_image }, + .{ "embedded_logo_image.h", "g_embeddedLogoImage", b.pathFromRoot("assets/stfc-mod-logo.png") }, + .{ "embedded_cc_logo_image.h", "g_embeddedCCLogoImage", b.pathFromRoot("assets/official-cc-logo.png") }, }; + const limit: std.Io.Limit = .limited(64 * 1024 * 1024); for (table) |entry| { const header, const symbol, const path = entry; - const limit: std.Io.Limit = .limited(64 * 1024 * 1024); const data = std.Io.Dir.cwd().readFileAlloc(b.graph.io, path, b.allocator, limit) catch |err| std.debug.panic("unable to read embedded image {s}: {t}", .{ path, err }); var out: std.Io.Writer.Allocating = .init(b.allocator); @@ -222,32 +224,26 @@ fn addLoader(ctx: *const Ctx, flags: []const []const u8) void { /// `macos-universal`: run this build for both macOS architectures and lipo the dylib and loader together. /// Each architecture is a child `zig build` because one graph resolves a single -Dtarget. pub fn addUniversal(b: *std.Build, optimize: std.builtin.OptimizeMode, options: Options) void { - const lipo = b.addExecutable(.{ - .name = "lipo", - .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tools/lipo.zig"), - .target = b.graph.host, - .optimize = .ReleaseSafe, - }), - }); + const lipo = hostTool(b, "lipo", "zig/tools/lipo.zig", .ReleaseSafe); const step = b.step("macos-universal", "Build and merge ARM64 and Intel macOS dylib and loader"); var prefixes: [2]std.Build.LazyPath = undefined; inline for (.{ "aarch64", "x86_64" }, 0..) |arch, i| { const child = b.addSystemCommand(&.{ b.graph.zig_exe, "build", - "-Dtarget=" ++ arch ++ "-macos.14.6", + "-Dtarget=" ++ arch ++ "-macos." ++ macos_min, b.fmt("-Doptimize={s}", .{@tagName(optimize)}), b.fmt("-Dlto={}", .{options.lto orelse false}), b.fmt("-Dmoddbg={}", .{options.moddbg}), b.fmt("-Duse_original_bg={}", .{options.use_original_bg}), - b.fmt("-Dbg_image={s}", .{options.bg_image}), }); - child.setCwd(b.path("")); - child.has_side_effects = true; + if (options.bg_image.len != 0) child.addArg(b.fmt("-Dbg_image={s}", .{options.bg_image})); if (options.macos_sdk) |sdk| child.addArg(b.fmt("-Dmacos-sdk={s}", .{sdk})); child.addArg("--prefix"); prefixes[i] = child.addOutputDirectoryArg(arch); + child.setCwd(b.path("")); + // The child reads sources Zig cannot track from here, so it must run every time. + child.has_side_effects = true; } inline for (.{ "lib/libstfc-community-mod.dylib", "bin/stfc-community-mod-loader" }) |binary| { const merge = b.addRunArtifact(lipo); diff --git a/zig/protobuf.zig b/zig/protobuf.zig index 9955e0099..d4742e97f 100644 --- a/zig/protobuf.zig +++ b/zig/protobuf.zig @@ -5,40 +5,44 @@ const builtin = @import("builtin"); const Ctx = @import("ctx.zig").Ctx; const Lib = @import("ctx.zig").Lib; const globFiles = @import("ctx.zig").globFiles; +const hostTool = @import("ctx.zig").hostTool; +const hostTests = @import("ctx.zig").hostTests; -const abseil_sources: [101][]const u8 = @import("deps/abseil_sources.zon"); -const protobuf_sources: [26][]const u8 = @import("deps/protobuf_sources.zon"); +const abseil_sources: []const []const u8 = @import("deps/abseil_sources.zon"); +const protobuf_sources: []const []const u8 = @import("deps/protobuf_sources.zon"); /// The Abseil sources protobuf-lite reaches, not the whole library. pub fn abseil(ctx: *const Ctx) Lib { const b = ctx.b; const src = b.dependency("abseil", .{}).path(""); + var lib: Lib = .{ .include_dirs = ctx.dirs(&.{src}) }; const module = ctx.createModule(true); - module.addIncludePath(src); + lib.includeInto(module); if (ctx.isMacos()) module.linkFramework("CoreFoundation", .{}); - addRuntimeSources(ctx, module, "abseil", src, &abseil_sources); - return .{ .artifact = ctx.staticLib("abseil", module), .include_dirs = ctx.dirs(&.{src}) }; + addRuntimeSources(ctx, module, "abseil", src, abseil_sources); + lib.artifact = ctx.staticLib("abseil", module); + return lib; } /// The protobuf-lite runtime and its UTF-8 validator. pub fn protobufLite(ctx: *const Ctx, absl: Lib) Lib { const b = ctx.b; const src = b.dependency("protobuf", .{}).path(""); + // protobuf's public headers include Abseil's, so consumers get both include dirs. + var lib: Lib = .{ + .dependencies = ctx.libs(&.{absl}), + .include_dirs = ctx.dirs(&.{ src.path(b, "src"), src.path(b, "third_party/utf8_range"), absl.include_dirs[0] }), + }; const module = ctx.createModule(true); - module.addIncludePath(src.path(b, "src")); - module.addIncludePath(src.path(b, "third_party/utf8_range")); - absl.includeInto(module); - addRuntimeSources(ctx, module, "protobuf", src, &protobuf_sources); + lib.includeInto(module); + addRuntimeSources(ctx, module, "protobuf", src, protobuf_sources); module.addCSourceFiles(.{ .root = src, .files = &.{"third_party/utf8_range/utf8_range.c"}, .flags = ctx.cFlags(&.{"-std=c11"}), }); - return .{ - .artifact = ctx.staticLib("protobuf-lite", module), - .dependencies = b.allocator.dupe(Lib, &.{absl}) catch @panic("OOM"), - .include_dirs = ctx.dirs(&.{ src.path(b, "src"), src.path(b, "third_party/utf8_range"), absl.include_dirs[0] }), - }; + lib.artifact = ctx.staticLib("protobuf-lite", module); + return lib; } /// Generate and compile C++ for the game messages. null until Zig has fetched the host's protoc. @@ -63,6 +67,7 @@ pub fn generate(ctx: *const Ctx, runtime: Lib) ?Lib { const schema_dir = b.path("src/mod/game/proto"); const well_known_dir = b.dependency("protobuf", .{}).path("src"); const schemas = globFiles(b, schema_dir, "", &.{".proto"}, &.{}); + const well_known = .{ "google/protobuf/timestamp", "google/protobuf/wrappers" }; var compiled: std.ArrayList([]const u8) = .empty; // 1. Describe every schema. @@ -82,33 +87,18 @@ pub fn generate(ctx: *const Ctx, runtime: Lib) ?Lib { compiled.append(b.allocator, b.fmt("{s}.pb.cc", .{stem})) catch @panic("OOM"); } } - inline for (.{ "timestamp", "wrappers" }) |name| { - describe.addFileArg(well_known_dir.path(b, "google/protobuf/" ++ name ++ ".proto")); - compiled.append(b.allocator, "google/protobuf/" ++ name ++ ".pb.cc") catch @panic("OOM"); + inline for (well_known) |name| { + describe.addFileArg(well_known_dir.path(b, name ++ ".proto")); + compiled.append(b.allocator, name ++ ".pb.cc") catch @panic("OOM"); } // 2. Trim to the sync roots. - const selector = b.addExecutable(.{ - .name = "select-proto", - .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tools/select_proto.zig"), - .target = b.graph.host, - .optimize = .Debug, - }), - }); - const select = b.addRunArtifact(selector); + const select = b.addRunArtifact(hostTool(b, "select-proto", "zig/tools/select_proto.zig", .Debug)); select.addFileArg(descriptors); select.addFileArg(b.path("zig/deps/protobuf_roots.txt")); const selected = select.addOutputFileArg("selected.pb"); - - const selector_tests = b.addTest(.{ - .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tools/select_proto_tests.zig"), - .target = b.graph.host, - }), - }); b.step("test-codegen", "Check protobuf descriptor selection against independent protoc fixtures") - .dependOn(&b.addRunArtifact(selector_tests).step); + .dependOn(hostTests(b, "zig/tools/select_proto_tests.zig")); // 3. Emit lite C++ for every schema in the trimmed set. const emit = std.Build.Step.Run.create(b, "protoc-cpp"); @@ -116,22 +106,20 @@ pub fn generate(ctx: *const Ctx, runtime: Lib) ?Lib { emit.addPrefixedFileArg("--descriptor_set_in=", selected); const generated = emit.addPrefixedOutputDirectoryArg("--cpp_out=lite:", "proto"); emit.addArgs(schemas); - emit.addArgs(&.{ "google/protobuf/timestamp.proto", "google/protobuf/wrappers.proto" }); + inline for (well_known) |name| emit.addArg(name ++ ".proto"); + var lib: Lib = .{ .include_dirs = ctx.dirs(&.{generated}) }; const module = ctx.createModule(true); - module.addIncludePath(generated); + lib.includeInto(module); // Headers only: consumers link protobuf-lite next to these objects. runtime.includeInto(module); module.addCSourceFiles(.{ .root = generated, .files = compiled.items, .flags = ctx.cxxFlags("c++17", &.{}) }); - return .{ - .artifact = ctx.staticLib("stfc-proto", module), - .include_dirs = ctx.dirs(&.{generated}), - }; + lib.artifact = ctx.staticLib("stfc-proto", module); + return lib; } -/// On macOS, compile runtime sources in batches of four so they share header parsing without serializing the -/// library into one unit. Other targets keep one unit per file: MSVC headers and source-local CRT defines depend -/// on include order. +/// On macOS, compile runtime sources in batches of four. Other targets keep one unit per file: MSVC headers and +/// source-local CRT defines depend on include order. fn addRuntimeSources( ctx: *const Ctx, module: *std.Build.Module, @@ -140,10 +128,9 @@ fn addRuntimeSources( sources: []const []const u8, ) void { const flags = ctx.cxxFlags("c++17", &.{}); - if (!ctx.isMacos()) return module.addCSourceFiles(.{ .root = root, .files = sources, .flags = flags }); - var offset: usize = 0; - while (offset < sources.len) : (offset += 4) { - const batch = sources[offset..@min(offset + 4, sources.len)]; - ctx.addCxxBatch(module, ctx.b.fmt("{s}-{d}", .{ name, offset / 4 }), root, batch, flags); + if (ctx.isMacos()) { + ctx.addCxxBatches(module, name, root, sources, flags); + } else { + module.addCSourceFiles(.{ .root = root, .files = sources, .flags = flags }); } } diff --git a/zig/tests.zig b/zig/tests.zig index 8a3234617..da33eaf22 100644 --- a/zig/tests.zig +++ b/zig/tests.zig @@ -20,14 +20,12 @@ pub const Libs = struct { pub fn add(build_ctx: *const Ctx, libs: Libs) void { const ctx = build_ctx.withOptimize(build_ctx.options.test_optimize orelse build_ctx.optimize); const b = ctx.b; - const steps: Steps = .{ - .build = b.step("build-tests", "Compile the tests without running them"), - .il2cpp = b.step("test-il2cpp", "Build and run the IL2CPP boundary tests (native targets only)"), - .mod = b.step("test-mod", "Build and run the settings and shortcut tests (native targets only)"), - .sync = b.step("test-sync", "Run sync record and delivery regression tests without the game"), - .launcher = b.step("test-launcher", "Run launcher delta and archive tests (Linux/macOS targets)"), - .spud = b.step("test-spud", "Run the x64 trampoline relocation test (native x86_64 targets only)"), - }; + const build_tests = b.step("build-tests", "Compile the tests without running them"); + const test_il2cpp = b.step("test-il2cpp", "Build and run the IL2CPP boundary tests (native targets only)"); + const test_mod = b.step("test-mod", "Build and run the settings and shortcut tests (native targets only)"); + const test_sync = b.step("test-sync", "Run sync record and delivery regression tests without the game"); + const test_launcher = b.step("test-launcher", "Run launcher delta and archive tests (Linux/macOS targets)"); + const test_spud = b.step("test-spud", "Run the x64 trampoline relocation test (native x86_64 targets only)"); const flags = ctx.cxxFlags("c++23", &.{}); const mod_flags = ctx.cxxFlags("c++23", &.{"-Wno-deprecated-literal-operator"}); @@ -36,20 +34,24 @@ pub fn add(build_ctx: *const Ctx, libs: Libs) void { const module = ctx.createModule(true); module.addIncludePath(b.path("src/mod")); module.addCSourceFile(.{ .file = b.path("tests/action_queue.cc"), .flags = flags }); - addFixture(ctx, steps.build, steps.mod, "action-queue-tests", module); + addFixture(ctx, build_tests, test_mod, "action-queue-tests", module); } - // IL2CPP bindings and GC handles against a fake runtime API. - inline for (.{ "bindings", "gc" }) |name| { + // IL2CPP bindings and GC handles against a fake runtime API defined in each test file. + { const module = ctx.createModule(true); libs.il2cpp.linkInto(module); - module.addCSourceFile(.{ .file = b.path("tests/il2cpp_" ++ name ++ ".cc"), .flags = flags }); // The fake API must precede consumers so the linker does not pull in production API storage. - if (comptime std.mem.eql(u8, name, "bindings")) { - module.addIncludePath(b.path("src/libs/unity/include")); - module.addCSourceFile(.{ .file = b.path("src/libs/unity/src/input.cc"), .flags = flags }); - } - addFixture(ctx, steps.build, steps.il2cpp, "il2cpp-" ++ name ++ "-tests", module); + module.addCSourceFile(.{ .file = b.path("tests/il2cpp_bindings.cc"), .flags = flags }); + module.addIncludePath(b.path("src/libs/unity/include")); + module.addCSourceFile(.{ .file = b.path("src/libs/unity/src/input.cc"), .flags = flags }); + addFixture(ctx, build_tests, test_il2cpp, "il2cpp-bindings-tests", module); + } + { + const module = ctx.createModule(true); + libs.il2cpp.linkInto(module); + module.addCSourceFile(.{ .file = b.path("tests/il2cpp_gc.cc"), .flags = flags }); + addFixture(ctx, build_tests, test_il2cpp, "il2cpp-gc-tests", module); } { const module = ctx.createModule(true); @@ -57,7 +59,7 @@ pub fn add(build_ctx: *const Ctx, libs: Libs) void { libs.il2cpp.linkInto(module); libs.spud.linkInto(module); module.addCSourceFile(.{ .file = b.path("tests/unity_tracker.cc"), .flags = flags }); - addFixture(ctx, steps.build, steps.il2cpp, "unity-tracker-tests", module); + addFixture(ctx, build_tests, test_il2cpp, "unity-tracker-tests", module); } // Settings, shortcuts and reload. The fixture paths are defined on mod_logic.cc alone, so the shared settings @@ -95,7 +97,7 @@ pub fn add(build_ctx: *const Ctx, libs: Libs) void { }); module.linkFramework("Cocoa", .{}); } - addFixture(ctx, steps.build, steps.mod, "mod-logic-tests", module); + addFixture(ctx, build_tests, test_mod, "mod-logic-tests", module); } { const module = settingsModule(ctx, libs); @@ -113,7 +115,7 @@ pub fn add(build_ctx: *const Ctx, libs: Libs) void { module.addCSourceFile(.{ .file = b.path("src/mod/features/notification_audio_mac.mm"), .flags = flags }); module.linkFramework("Cocoa", .{}); } - addFixture(ctx, steps.build, steps.mod, "config-reload-tests", module); + addFixture(ctx, build_tests, test_mod, "config-reload-tests", module); } // Sync records and delivery state over the generated protobuf messages. @@ -128,18 +130,18 @@ pub fn add(build_ctx: *const Ctx, libs: Libs) void { .files = &.{ "tests/sync_logic.cc", "src/mod/sync/records.cc", "src/mod/sync/state.cc" }, .flags = mod_flags, }); - addFixture(ctx, steps.build, steps.sync, "sync-logic-tests", module); + addFixture(ctx, build_tests, test_sync, "sync-logic-tests", module); } // Launcher deltas in C++, plus the Swift archive code when the host can run swiftc. - if (ctx.target.result.os.tag == .linux or ctx.isMacos()) { + if (ctx.isLinux() or ctx.isMacos()) { const module = ctx.createModule(true); deps.rsync(ctx).linkInto(module); module.addCSourceFiles(.{ .files = &.{ "tests/launcher_rsync.cc", "src/launcher/macos/rsync.cc" }, .flags = flags, }); - addFixture(ctx, steps.build, steps.launcher, "launcher-rsync-tests", module); + addFixture(ctx, build_tests, test_launcher, "launcher-rsync-tests", module); if (builtin.os.tag == .macos and ctx.isMacos()) { const compile = b.addSystemCommand(&.{ "xcrun", "swiftc", "-parse-as-library", "-o" }); const exe = compile.addOutputFileArg("launcher-archive-tests"); @@ -148,10 +150,10 @@ pub fn add(build_ctx: *const Ctx, libs: Libs) void { const run = std.Build.Step.Run.create(b, "launcher-archive-tests"); run.addFileArg(exe); run.addDirectoryArg(b.path("tests/fixtures/launcher")); - steps.launcher.dependOn(&run.step); + test_launcher.dependOn(&run.step); } } else { - steps.launcher.dependOn(&b.addFail("The launcher rsync tests require a Linux or macOS target").step); + test_launcher.dependOn(&b.addFail("The launcher rsync tests require a Linux or macOS target").step); } // A prologue where two RIP-relative instructions expand ahead of a relative call, detoured and run. ARM64 @@ -160,7 +162,9 @@ pub fn add(build_ctx: *const Ctx, libs: Libs) void { const module = ctx.createModule(true); libs.spud.linkInto(module); module.addCSourceFile(.{ .file = b.path("tests/spud_relocation.cc"), .flags = flags }); - addFixture(ctx, steps.build, steps.spud, "spud-relocation-tests", module); + addFixture(ctx, build_tests, test_spud, "spud-relocation-tests", module); + } else { + test_spud.dependOn(&b.addFail("The SPUD relocation test requires an x86_64 target").step); } } @@ -174,21 +178,12 @@ pub fn addDependencySmoke(ctx: *const Ctx, libs: []const Lib) void { const exe = ctx.executable("dependency-smoke", module); const run = b.addRunArtifact(exe); if (b.args) |args| run.addArgs(args); - const run_step = b.step("test-deps", "Run protobuf, zlib and HTTPS backend checks; optionally pass a test URL and CA file"); - run_step.dependOn(&run.step); + b.step("test-deps", "Run protobuf, zlib and HTTPS backend checks; optionally pass a test URL and CA file") + .dependOn(&run.step); const build_step = b.step("build-dependency-test", "Cross-compile the dependency smoke executable"); build_step.dependOn(&b.addInstallArtifact(exe, .{}).step); } -const Steps = struct { - build: *std.Build.Step, - il2cpp: *std.Build.Step, - mod: *std.Build.Step, - sync: *std.Build.Step, - launcher: *std.Build.Step, - spud: *std.Build.Step, -}; - /// Mod headers, logging and TOML for the settings fixtures. il2cpp contributes headers only; settings code never /// calls into the runtime. fn settingsModule(ctx: *const Ctx, libs: Libs) *std.Build.Module { diff --git a/zig/toolchains.zig b/zig/toolchains.zig index e37a32d88..4359b7ad8 100644 --- a/zig/toolchains.zig +++ b/zig/toolchains.zig @@ -2,7 +2,10 @@ const std = @import("std"); const builtin = @import("builtin"); const Options = @import("ctx.zig").Options; +const hostTool = @import("ctx.zig").hostTool; +const hostTests = @import("ctx.zig").hostTests; +/// One entry of zig/toolchains.zon. const Pin = struct { version: []const u8, url: []const u8, @@ -24,22 +27,12 @@ pub const Sysroot = struct { /// Register `fetch-toolchains` and the fetcher's own tests. Both run on the build host. pub fn addFetchSteps(b: *std.Build) void { - const tool = b.addExecutable(.{ - .name = "fetch-toolchain", - .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tools/fetch_toolchain.zig"), - .target = b.graph.host, - .optimize = .ReleaseSafe, - }), - }); - const tool_tests = b.addTest(.{ - .root_module = b.createModule(.{ - .root_source_file = b.path("zig/tools/fetch_toolchain.zig"), - .target = b.graph.host, - }), - }); - b.step("test-toolchains", "Run toolchain archive selection tests").dependOn(&b.addRunArtifact(tool_tests).step); + const test_step = b.step("test-toolchains", "Run toolchain archive selection and Mach-O archive tests"); + test_step.dependOn(hostTests(b, "zig/tools/fetch_toolchain.zig")); + // The Mach-O archive extractor repacks the launcher's native archives; its tests run with the fetcher's. + test_step.dependOn(hostTests(b, "zig/tools/extract_macho_archive.zig")); + const tool = hostTool(b, "fetch-toolchain", "zig/tools/fetch_toolchain.zig", .ReleaseSafe); const step = b.step("fetch-toolchains", "Download, verify and extract cross-compilation sysroots"); const selected = b.option([]const u8, "toolchains", "Comma-separated sysroots: windows_msvc,macos_sdk") orelse "windows_msvc"; @@ -89,9 +82,9 @@ pub fn find(b: *std.Build, target: std.Build.ResolvedTarget, options: Options) S // Resolve the host SDK explicitly, rather than leaving it to Zig, so the other architecture's build // (x86_64 on arm64) finds frameworks too. if (builtin.os.tag == .macos) { - const sdk = std.zig.system.darwin.getSdk(b.allocator, b.graph.io, &target.result) orelse - std.debug.panic("unable to locate the macOS SDK via xcrun; install the Xcode command line tools", .{}); - return .{ .macos_sdk = sdk }; + if (std.zig.system.darwin.getSdk(b.allocator, b.graph.io, &target.result)) |sdk| + return .{ .macos_sdk = sdk }; + std.debug.panic("unable to locate the macOS SDK via xcrun; install the Xcode command line tools", .{}); } std.debug.panic( "macOS SDK not found at {s}. Run `zig build fetch-toolchains` (set {s}) or pass -Dmacos-sdk=.", @@ -102,6 +95,7 @@ pub fn find(b: *std.Build, target: std.Build.ResolvedTarget, options: Options) S } } +/// A Zig libc file for the extracted toolchain, so Zig takes the CRT and STL from it instead of searching the host. fn msvcLibcFile(b: *std.Build, root: []const u8) std.Build.LazyPath { const contents = b.fmt( \\include_dir={0s}/winsdk/include/ucrt diff --git a/zig/tools/extract_macho_archive.zig b/zig/tools/extract_macho_archive.zig index 67a00bd24..10cec440b 100644 --- a/zig/tools/extract_macho_archive.zig +++ b/zig/tools/extract_macho_archive.zig @@ -1,8 +1,10 @@ //! Extract Mach-O objects without trusting the input archive's index or member alignment. const std = @import("std"); +/// The Mach-O members of an ar archive, in order. Index members are skipped. const Objects = struct { bytes: []const u8, + /// Past the "!\n" magic. offset: usize = 8, fn init(bytes: []const u8) !Objects { @@ -10,6 +12,8 @@ const Objects = struct { return .{ .bytes = bytes }; } + // Each member has a 60-byte text header: name in bytes 0..16, decimal size in 48..58, "`\n" in 58..60. Members + // are padded to an even length. A BSD name "#1/" stores the real name in the first n bytes of the data. fn next(self: *Objects) !?[]const u8 { while (self.offset < self.bytes.len) { if (self.bytes.len - self.offset < 60) return error.TruncatedHeader; @@ -31,9 +35,11 @@ const Objects = struct { name = std.mem.trimEnd(u8, member[0..name_size], "\x00"); member = member[name_size..]; } + // BSD and GNU symbol tables and the GNU long-name table. if (std.mem.startsWith(u8, name, "__.SYMDEF") or std.mem.eql(u8, name, "/") or std.mem.eql(u8, name, "//") or std.mem.eql(u8, name, "/SYM64/")) continue; + // MH_MAGIC_64 in either byte order. if (!std.mem.startsWith(u8, member, "\xcf\xfa\xed\xfe") and !std.mem.startsWith(u8, member, "\xfe\xed\xfa\xcf")) return error.NotMachO; return member; @@ -42,7 +48,8 @@ const Objects = struct { } }; -/// Extract objects and a file list for Apple libtool. +/// Usage: extract-macho-archive . Writes the objects as 0.o, 1.o, ... and lists them in +/// objects.txt for `libtool -filelist`. pub fn main(init: std.process.Init) !void { const allocator = init.arena.allocator(); const args = try init.minimal.args.toSlice(allocator); @@ -57,10 +64,10 @@ pub fn main(init: std.process.Init) !void { var list: std.ArrayList(u8) = .empty; var count: usize = 0; while (try objects.next()) |object| : (count += 1) { - // Unique names preserve distinct objects with the same original basename. + // Numbered names keep distinct objects that share an original basename. const name = try std.fmt.allocPrint(allocator, "{d}.o", .{count}); try dest.writeFile(io, .{ .sub_path = name, .data = object }); - try list.appendSlice(allocator, try std.fmt.allocPrint(allocator, "{s}\n", .{name})); + try list.print(allocator, "{s}\n", .{name}); } if (count == 0) return error.EmptyArchive; try dest.writeFile(io, .{ .sub_path = "objects.txt", .data = list.items }); diff --git a/zig/tools/fetch_toolchain.zig b/zig/tools/fetch_toolchain.zig index 76121dd7a..ac0eebc09 100644 --- a/zig/tools/fetch_toolchain.zig +++ b/zig/tools/fetch_toolchain.zig @@ -1,10 +1,11 @@ -//! Host-only toolchain sysroot provisioning; needs no external download/extraction tools. +//! Downloads, verifies and extracts a pinned SDK archive into .cache/toolchain, with no external tools. const std = @import("std"); const Io = std.Io; const Dir = Io.Dir; const Sha256 = std.crypto.hash.sha2.Sha256; -/// Provision a verified SDK archive in the toolchain cache. +/// Arguments, in the order zig/toolchains.zig passes them: cache dir, version, manifest URL, URL environment +/// variable, expected SHA-256, local archive (may be empty), and `windows-msvc` or `macos-sdk`. pub fn main(init: std.process.Init) !void { const allocator = init.arena.allocator(); const args = try init.minimal.args.toSlice(allocator); @@ -25,17 +26,15 @@ pub fn main(init: std.process.Init) !void { try cwd.createDirPath(io, cache); const root = try std.fs.path.join(allocator, &.{ cache, kind, version }); const stamp = try std.fs.path.join(allocator, &.{ root, ".complete" }); - const archive = if (local_archive.len != 0) local_archive else try std.fmt.allocPrint(allocator, "{s}/{s}-{s}.{s}", .{ - cache, - kind, - version, - if (windows) "zip" else "tar.gz", - }); - if (cwd.access(io, archive, .{})) |_| {} else |err| { - if (err != error.FileNotFound) return err; - if (local_archive.len != 0) return err; + const extension = if (windows) "zip" else "tar.gz"; + const archive = if (local_archive.len != 0) + local_archive + else + try std.fmt.allocPrint(allocator, "{s}/{s}-{s}.{s}", .{ cache, kind, version, extension }); + if (!try exists(io, cwd, archive)) { + if (local_archive.len != 0) return error.FileNotFound; if (url.len == 0) { - std.debug.print("Set {s} or fill in zig/toolchains.zon to download the Toolchain sysroot.\n", .{url_env}); + std.debug.print("Set {s} or fill in zig/toolchains.zon to download the toolchain sysroot.\n", .{url_env}); return error.MissingToolchainUrl; } try downloadArchive(init, url, archive); @@ -47,8 +46,8 @@ pub fn main(init: std.process.Init) !void { return error.HashMismatch; } if (expected_hash.len == 0) std.debug.print("Toolchain archive SHA-256: {s}\n", .{hex}); - // Include pin metadata and the actual archive digest: replacing an unpinned - // archive or changing the manifest invalidates the completed extraction. + // Include pin metadata and the actual archive digest: replacing an unpinned archive or changing the manifest + // invalidates the completed extraction. Bump the extractor version when the extracted layout changes. const identity = try std.fmt.allocPrint(allocator, "extractor-v5\n{s}\n{s}\n{s}\n{s}\n{s}\n", .{ version, manifest_url, @@ -98,7 +97,7 @@ fn downloadArchive(init: std.process.Init, url: []const u8, archive: []const u8) var writer = file.writer(io, &buffer); var client: std.http.Client = .{ .allocator = init.gpa, .io = io }; defer client.deinit(); - std.debug.print("Downloading Toolchain sysroot...\n", .{}); + std.debug.print("Downloading toolchain sysroot...\n", .{}); const response = try client.fetch(.{ .location = .{ .url = url }, .response_writer = &writer.interface }); if (response.status != .ok) return error.DownloadFailed; try writer.end(); @@ -141,8 +140,8 @@ fn extractWindows(io: Io, path: []const u8, dest: Dir) !void { } fn extractSdk(io: Io, path: []const u8, dest: Dir) !void { - // SDK tarballs contain one top-level MacOSX.sdk directory. TAR preserves - // framework symlinks, unlike a plain ZIP extraction. + // SDK tarballs contain one top-level MacOSX.sdk directory. TAR preserves framework symlinks, unlike a plain ZIP + // extraction. const file = try Dir.cwd().openFile(io, path, .{}); defer file.close(io); var buffer: [64 * 1024]u8 = undefined; @@ -152,6 +151,8 @@ fn extractSdk(io: Io, path: []const u8, dest: Dir) !void { try std.tar.extract(io, dest, &gzip.reader, .{ .strip_components = 1 }); } +/// The header folders and x64 library folders the build uses; the archive also carries tools and other +/// architectures. fn selected(name: []const u8) bool { inline for (.{ "msvc/include/", @@ -181,6 +182,9 @@ fn validate(io: Io, dir: Dir) !void { } } +/// Make the SDK usable on a case-sensitive filesystem. MSVC and Windows SDK file names mix case, and so do the +/// `#include` lines and `/DEFAULTLIB` directives that name them. Adds symlinks: a lowercase alias for every file, +/// then every spelling an include or library directive asks for. Never changes the extracted files. fn windowsAliases(allocator: std.mem.Allocator, io: Io, root: Dir) !void { var root_buffer: [std.fs.max_path_bytes]u8 = undefined; const root_path = root_buffer[0..try root.realPath(io, &root_buffer)]; @@ -197,11 +201,10 @@ fn windowsAliases(allocator: std.mem.Allocator, io: Io, root: Dir) !void { if (std.mem.eql(u8, basename, lower)) continue; const alias = try std.fs.path.join(allocator, &.{ std.fs.path.dirname(path) orelse "", lower }); // APFS and Windows already resolve the alias on case-insensitive volumes. - if (root.access(io, alias, .{})) |_| continue else |err| if (err != error.FileNotFound) return err; + if (try exists(io, root, alias)) continue; try root.symLink(io, basename, alias, .{}); } - // Headers and static-library directives mix capitalization. Index the original - // files, then create the spellings they request without changing SDK contents. + // Lowercase relative path to the original file. var index: std.StringHashMapUnmanaged([]const u8) = .empty; defer index.deinit(allocator); for (files.items) |path| try index.put(allocator, try std.ascii.allocLowerString(allocator, path), path); @@ -213,8 +216,10 @@ fn windowsAliases(allocator: std.mem.Allocator, io: Io, root: Dir) !void { "winsdk/include/cppwinrt", "winsdk/include/ucrt", }; + // Read each file through page_allocator and free it: the arena would keep every header and library alive. for (files.items) |path| { - if (!std.mem.startsWith(u8, path, "msvc/include/") and !std.mem.startsWith(u8, path, "winsdk/include/")) continue; + const header = std.mem.startsWith(u8, path, "msvc/include/") or std.mem.startsWith(u8, path, "winsdk/include/"); + if (!header) continue; const content = try root.readFileAlloc(io, path, std.heap.page_allocator, .limited(16 * 1024 * 1024)); defer std.heap.page_allocator.free(content); var lines = std.mem.splitScalar(u8, content, '\n'); @@ -223,12 +228,14 @@ fn windowsAliases(allocator: std.mem.Allocator, io: Io, root: Dir) !void { if (!std.mem.startsWith(u8, rest, "#")) continue; rest = std.mem.trimStart(u8, rest[1..], " \t"); if (!std.mem.startsWith(u8, rest, "include")) continue; - rest = std.mem.trimStart(u8, rest[7..], " \t"); + rest = std.mem.trimStart(u8, rest["include".len..], " \t"); if (rest.len == 0 or (rest[0] != '<' and rest[0] != '"')) continue; const end = std.mem.indexOfScalarPos(u8, rest, 1, if (rest[0] == '<') '>' else '"') orelse continue; - try aliasSpelling(allocator, io, root, root_path, &index, std.fs.path.dirname(path), &include_dirs, rest[1..end]); + const local_dir = std.fs.path.dirname(path); + try aliasSpelling(allocator, io, root, root_path, &index, local_dir, &include_dirs, rest[1..end]); } } + // Static libraries embed linker directives as plain text, quoted or not. const lib_dirs = [_][]const u8{ "msvc/lib/x64", "winsdk/lib/um/x64", "winsdk/lib/ucrt/x64" }; for (files.items) |path| { if (!std.ascii.endsWithIgnoreCase(path, ".lib")) continue; @@ -247,7 +254,10 @@ fn windowsAliases(allocator: std.mem.Allocator, io: Io, root: Dir) !void { else std.mem.indexOfAny(u8, rest, " \t\r\n\x00") orelse continue; const name = rest[begin..end]; - const lib = if (std.ascii.endsWithIgnoreCase(name, ".lib")) name else try std.fmt.allocPrint(allocator, "{s}.lib", .{name}); + const lib = if (std.ascii.endsWithIgnoreCase(name, ".lib")) + name + else + try std.fmt.allocPrint(allocator, "{s}.lib", .{name}); try aliasSpelling(allocator, io, root, root_path, &index, null, &lib_dirs, lib); pos += end + @intFromBool(quoted); } @@ -255,6 +265,8 @@ fn windowsAliases(allocator: std.mem.Allocator, io: Io, root: Dir) !void { } } +/// Link `spelling` to the file it names case-insensitively, in `local_dir` first (the including file's folder) +/// and then in each of `search_dirs`. Does nothing when no file matches or the spelling already exists. fn aliasSpelling( allocator: std.mem.Allocator, io: Io, @@ -271,11 +283,14 @@ fn aliasSpelling( if (name.len == 0 or std.fs.path.isAbsolute(name)) return; var components = std.mem.splitScalar(u8, name, '/'); while (components.next()) |part| if (std.mem.eql(u8, part, "..")) return; - for (0..search_dirs.len + 1) |i| { - const dir = if (i == 0) local_dir orelse continue else search_dirs[i - 1]; + const dirs = if (local_dir) |dir| + try std.mem.concat(allocator, []const u8, &.{ &.{dir}, search_dirs }) + else + search_dirs; + for (dirs) |dir| { const alias = try std.fs.path.join(allocator, &.{ dir, name }); const source = index.get(try std.ascii.allocLowerString(allocator, alias)) orelse continue; - if (root.access(io, alias, .{})) |_| return else |err| if (err != error.FileNotFound) return err; + if (try exists(io, root, alias)) return; const parent = std.fs.path.dirname(alias).?; try root.createDirPath(io, parent); const target = try std.fs.path.relative(allocator, root_path, null, parent, source); @@ -284,6 +299,12 @@ fn aliasSpelling( } } +/// Access check for `path` under `dir`. FileNotFound reads as false; other errors propagate. +fn exists(io: Io, dir: Dir, path: []const u8) !bool { + dir.access(io, path, .{}) catch |err| return if (err == error.FileNotFound) false else err; + return true; +} + test "only compiler headers and x64 static libraries are extracted" { try std.testing.expect(selected("msvc/include/vector")); try std.testing.expect(selected("winsdk/lib/um/x64/kernel32.Lib")); @@ -298,6 +319,7 @@ test "SDK include and linker spellings resolve on case-sensitive filesystems" { defer tmp.cleanup(); var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); + const allocator = arena.allocator(); try tmp.dir.createDirPath(io, "winsdk/include/shared"); try tmp.dir.createDirPath(io, "winsdk/include/um"); try tmp.dir.createDirPath(io, "msvc/lib/x64"); @@ -314,13 +336,14 @@ test "SDK include and linker spellings resolve on case-sensitive filesystems" { }); try tmp.dir.writeFile(io, .{ .sub_path = "winsdk/lib/um/x64/advapi32.lib", .data = "advapi import" }); try tmp.dir.writeFile(io, .{ .sub_path = "winsdk/lib/um/x64/kernel32.lib", .data = "kernel import" }); - try windowsAliases(arena.allocator(), io, tmp.dir); - const driver = try tmp.dir.readFileAlloc(io, "winsdk/include/shared/DriverSpecs.h", arena.allocator(), .limited(100)); + try windowsAliases(allocator, io, tmp.dir); + const driver = try tmp.dir.readFileAlloc(io, "winsdk/include/shared/DriverSpecs.h", allocator, .limited(100)); try std.testing.expectEqualStrings("driver specs", driver); - const specs = try tmp.dir.readFileAlloc(io, "winsdk/include/shared/SpecStrings.h", arena.allocator(), .limited(100)); + const specs = try tmp.dir.readFileAlloc(io, "winsdk/include/shared/SpecStrings.h", allocator, .limited(100)); try std.testing.expectEqualStrings("spec strings", specs); try tmp.dir.access(io, "winsdk/include/um/windows.h", .{}); try tmp.dir.access(io, "winsdk/lib/um/x64/Advapi32.lib", .{}); try tmp.dir.access(io, "winsdk/lib/um/x64/Kernel32.lib", .{}); - try windowsAliases(arena.allocator(), io, tmp.dir); + // A second run over existing aliases must succeed. + try windowsAliases(allocator, io, tmp.dir); } diff --git a/zig/tools/lipo.zig b/zig/tools/lipo.zig index feaca344f..0174bc135 100644 --- a/zig/tools/lipo.zig +++ b/zig/tools/lipo.zig @@ -1,7 +1,7 @@ //! Merge two thin Mach-O binaries into a deterministic universal binary. const std = @import("std"); -/// Merge ARM64 and x86_64 Mach-O executables. +/// Usage: lipo . Both inputs must be thin 64-bit Mach-O files. pub fn main(init: std.process.Init) !void { const allocator = init.arena.allocator(); const args = try init.minimal.args.toSlice(allocator); @@ -12,15 +12,20 @@ pub fn main(init: std.process.Init) !void { try cwd.readFileAlloc(io, args[1], allocator, .limited(1024 * 1024 * 1024)), try cwd.readFileAlloc(io, args[2], allocator, .limited(1024 * 1024 * 1024)), }; + // A fat header is big-endian: magic and architecture count, then one 20-byte fat_arch record per slice + // (cputype, cpusubtype, offset, size, align as a power of two). The thin inputs are little-endian. var header: [48]u8 = @splat(0); - std.mem.writeInt(u32, header[0..4], 0xcafebabe, .big); + std.mem.writeInt(u32, header[0..4], 0xcafebabe, .big); // FAT_MAGIC std.mem.writeInt(u32, header[4..8], 2, .big); var offsets: [2]usize = undefined; var offset: usize = header.len; for (inputs, 0..) |input, i| { + // MH_MAGIC_64; mach_header_64 is 32 bytes. if (input.len < 32 or std.mem.readInt(u32, input[0..4], .little) != 0xfeedfacf) return error.ExpectedThinMachO; + // CPU_TYPE_ARM64 first, then CPU_TYPE_X86_64, matching the argument order. const cpu = std.mem.readInt(u32, input[4..8], .little); if (cpu != (if (i == 0) @as(u32, 0x0100000c) else @as(u32, 0x01000007))) return error.UnexpectedArchitecture; + // Both slices start on 16 KiB boundaries (align 2^14), the arm64 page size. x86_64 needs only 4 KiB. offset = std.mem.alignForward(usize, offset, 16384); offsets[i] = offset; const record = header[8 + i * 20 ..][0..20]; diff --git a/zig/tools/select_proto.zig b/zig/tools/select_proto.zig index f469c111d..4e8937cb9 100644 --- a/zig/tools/select_proto.zig +++ b/zig/tools/select_proto.zig @@ -5,34 +5,35 @@ const Allocator = std.mem.Allocator; /// Filter a FileDescriptorSet to the named messages and their transitive field types. /// Descriptor field numbers follow the pinned protobuf's descriptor.proto. Retained messages are copied verbatim. pub fn select(output_allocator: Allocator, input: []const u8, roots: []const []const u8) ![]const u8 { + // Everything but the returned copy lives in the arena. var arena = std.heap.ArenaAllocator.init(output_allocator); defer arena.deinit(); const allocator = arena.allocator(); + // Top-level messages in file order, and every qualified type name mapped to its top-level message. var messages: std.ArrayList(Message) = .empty; var names: std.StringHashMapUnmanaged(usize) = .empty; - defer names.deinit(allocator); - defer messages.deinit(allocator); - var files = Fields{ .bytes = input }; + var files: Fields = .{ .bytes = input }; while (try files.next()) |file| { - if (file.number != 1) continue; - const package = try stringField(file.data, 2) orelse ""; + if (file.number != 1) continue; // FileDescriptorSet.file + const package = try stringField(file.data, 2) orelse ""; // FileDescriptorProto.package const prefix = if (package.len == 0) "" else try std.fmt.allocPrint(allocator, ".{s}", .{package}); - var fields = Fields{ .bytes = file.data }; + var fields: Fields = .{ .bytes = file.data }; while (try fields.next()) |field| { switch (field.number) { + // FileDescriptorProto.message_type 4 => { const index = messages.items.len; try messages.append(allocator, .{ .bytes = field.data }); try indexNames(allocator, &names, prefix, field.data, index); }, - // Services and extensions can introduce additional message roots. These game schemas use neither. + // FileDescriptorProto.service and .extension can introduce additional message roots. These game + // schemas use neither. 6, 7 => return error.UnsupportedServiceOrExtension, else => {}, } } } var pending: std.ArrayList(usize) = .empty; - defer pending.deinit(allocator); for (roots) |root| { const qualified = try std.fmt.allocPrint(allocator, ".{s}", .{root}); const index = names.get(qualified) orelse return error.UnknownMessage; @@ -44,7 +45,9 @@ pub fn select(output_allocator: Allocator, input: []const u8, roots: []const []c message.selected = true; try addReferences(allocator, &pending, &names, message.bytes); } + // Copy the set again, dropping unselected messages. `index` walks `messages` in the same file order. var output: std.ArrayList(u8) = .empty; + var contents: std.ArrayList(u8) = .empty; files = .{ .bytes = input }; var index: usize = 0; while (try files.next()) |file| { @@ -52,16 +55,16 @@ pub fn select(output_allocator: Allocator, input: []const u8, roots: []const []c try output.appendSlice(allocator, file.encoded); continue; } - var contents: std.ArrayList(u8) = .empty; - defer contents.deinit(allocator); - var fields = Fields{ .bytes = file.data }; + contents.clearRetainingCapacity(); + var fields: Fields = .{ .bytes = file.data }; while (try fields.next()) |field| { if (field.number == 4) { const keep = messages.items[index].selected; index += 1; if (!keep) continue; } - // SourceCodeInfo paths refer to descriptor indexes that filtering changes; protoc does not need them. + // FileDescriptorProto.source_code_info paths refer to descriptor indexes that filtering changes; protoc + // does not need them. if (field.number == 9) continue; try contents.appendSlice(allocator, field.encoded); } @@ -84,13 +87,14 @@ fn indexNames( bytes: []const u8, owner: usize, ) !void { - const name = try stringField(bytes, 1) orelse return error.MissingMessageName; + const name = try stringField(bytes, 1) orelse return error.MissingMessageName; // DescriptorProto.name const qualified = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ prefix, name }); try names.put(allocator, qualified, owner); - var fields = Fields{ .bytes = bytes }; + var fields: Fields = .{ .bytes = bytes }; while (try fields.next()) |field| { - // Keep nested messages with their enclosing message, including synthetic map entries. + // DescriptorProto.nested_type stays with its enclosing message, including synthetic map entries. if (field.number == 3) try indexNames(allocator, names, qualified, field.data, owner); + // DescriptorProto.enum_type if (field.number == 4) { // An enum field may reference a nested enum in an otherwise unused enclosing message. const enum_name = try stringField(field.data, 1) orelse return error.MissingEnumName; @@ -106,9 +110,10 @@ fn addReferences( names: *const std.StringHashMapUnmanaged(usize), bytes: []const u8, ) !void { - var fields = Fields{ .bytes = bytes }; + var fields: Fields = .{ .bytes = bytes }; while (try fields.next()) |field| { switch (field.number) { + // DescriptorProto.field and .extension: FieldDescriptorProto.type_name (6) and .extendee (2). 2, 6 => { if (try stringField(field.data, 6)) |type_name| { // Enums remain in the descriptor even when no message refers to them. @@ -118,13 +123,16 @@ fn addReferences( if (names.get(extendee)) |index| try pending.append(allocator, index); } }, - 3 => try addReferences(allocator, pending, names, field.data), + 3 => try addReferences(allocator, pending, names, field.data), // DescriptorProto.nested_type else => {}, } } } +/// One protobuf field: `encoded` is the whole tag, length and payload; `data` is the payload alone. const Field = struct { number: u32, encoded: []const u8, data: []const u8 }; + +/// Reads the fields of one encoded message in order. const Fields = struct { bytes: []const u8, offset: usize = 0, @@ -165,8 +173,9 @@ const Fields = struct { } }; +/// The payload of the first field `number` in `bytes`, for string and nested-message fields. fn stringField(bytes: []const u8, number: u32) !?[]const u8 { - var fields = Fields{ .bytes = bytes }; + var fields: Fields = .{ .bytes = bytes }; while (try fields.next()) |field| { if (field.number == number) return field.data; } @@ -179,7 +188,8 @@ fn appendVarint(allocator: Allocator, bytes: *std.ArrayList(u8), value: usize) ! try bytes.append(allocator, @intCast(remaining)); } -/// Read a descriptor set and root manifest, then write the filtered set for protoc. +/// Usage: select-proto . The roots file names one message per line; blank +/// lines and lines starting with # are ignored. pub fn main(init: std.process.Init) !void { const allocator = init.arena.allocator(); const args = try init.minimal.args.toSlice(allocator); diff --git a/zig/tools/select_proto_tests.zig b/zig/tools/select_proto_tests.zig index 582f4447d..0cd07af4a 100644 --- a/zig/tools/select_proto_tests.zig +++ b/zig/tools/select_proto_tests.zig @@ -6,7 +6,7 @@ const select = @import("select_proto.zig").select; // Leaf refers to itself; Root also references Holder.Kind, retaining its enclosing message. // Both files define unused messages; leaf.proto has an unreferenced enum catalog. // The input includes SourceCodeInfo. Both sets carry an unknown top-level varint field, which must survive. -test "retain transitive, recursive, nested enum and package-less messages verbatim, including enums and unknown fields" { +test "retain transitive, recursive, nested-enum and package-less messages verbatim, with enums and unknown fields" { const output = try select(std.testing.allocator, full, &.{"fixture.Root"}); defer std.testing.allocator.free(output); try std.testing.expectEqualSlices(u8, selected, output); @@ -18,11 +18,13 @@ test "unknown roots fail rather than silently producing an empty schema" { test "reject truncated descriptors and overflowing wire lengths" { try std.testing.expectError(error.TruncatedDescriptor, select(std.testing.allocator, "\x0a\x05x", &.{})); - try std.testing.expectError(error.InvalidVarint, select(std.testing.allocator, "\x80\x80\x80\x80\x80\x80\x80\x80\x80\x02", &.{})); + const overlong = "\x80\x80\x80\x80\x80\x80\x80\x80\x80\x02"; + try std.testing.expectError(error.InvalidVarint, select(std.testing.allocator, overlong, &.{})); } test "reject services that would introduce untracked message roots" { - try std.testing.expectError(error.UnsupportedServiceOrExtension, select(std.testing.allocator, "\x0a\x02\x32\x00", &.{})); + const service = "\x0a\x02\x32\x00"; // FileDescriptorSet.file holding one empty FileDescriptorProto.service. + try std.testing.expectError(error.UnsupportedServiceOrExtension, select(std.testing.allocator, service, &.{})); } const full = From 3bf06d7c9de55b4d686ac1fcc9cbfaa721f7fcf5 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 11:46:16 -0700 Subject: [PATCH 12/13] Document the feature and binding conventions The agent guide, contributor docs and IL2CPP reference now state the shape every feature file follows, how hooks install and what the library reports, and how bindings are laid out. One-off feature guides fold into the feature list. Docs describe the shipped contracts only; no code changes. --- AGENTS.md | 41 +++- CONTRIBUTING.md | 30 ++- docs/DAILY_FACTION_BULK_CLAIM.md | 16 -- docs/FASTER_QUEUE_RECOVERY.md | 55 ----- docs/FEATURES.md | 10 +- docs/IL2CPP.md | 65 ++++-- docs/INSTALL.md | 4 +- docs/QUEUE_ADDRESS_GUARD.md | 39 ---- docs/THIN_QUEUE_PROTECTION.md | 51 ----- docs/VERSION_SUBSTITUTION.md | 6 +- .../2026-10-21-kirshara-queue-fixes.md | 189 ++++++++++++++++++ docs/testing/kirshara-macos.md | 37 ---- 12 files changed, 308 insertions(+), 235 deletions(-) delete mode 100644 docs/DAILY_FACTION_BULK_CLAIM.md delete mode 100644 docs/FASTER_QUEUE_RECOVERY.md delete mode 100644 docs/QUEUE_ADDRESS_GUARD.md delete mode 100644 docs/THIN_QUEUE_PROTECTION.md create mode 100644 docs/archive/2026-10-21-kirshara-queue-fixes.md delete mode 100644 docs/testing/kirshara-macos.md diff --git a/AGENTS.md b/AGENTS.md index 635eab4be..ed78fe8fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,26 +63,57 @@ Dependency pins and notices are listed in [THIRD_PARTY.md](docs/THIRD_PARTY.md). ## Add or change a feature -1. Extend the domain file in `src/mod/features/`, or add one named after the feature's purpose. +1. Extend the domain file in `src/mod/features/`, or add one named after the feature's purpose. Merge micro-files. 2. Keep installation private. Export a descriptor such as `const Feature kXxx{"Xxx", &Config::Patches::group, InstallXxx};`. -3. Declare the descriptor in `features/features.h` and add it to `kFeatures` in `mod.cc`. + `InstallXxx` returns whether the feature went in, usually `return hooks.Commit();`. A feature that declines for a + reason of its own logs that reason with its tag first; the library reports missing methods and failed detours. +3. Declare the descriptor in `features/features.h` and add it to `kFeatures` in `mod.cc`, both grouped by file. 4. For a new hook group, add a `[patches]` member defaulting to `true` and read it in `Config::Read`. Shortcut-only helpers need no descriptor. Frame features supply their frame function as the descriptor's fourth member and return early when disabled. +`mod.cc` logs each feature as installed, skipped or not installed, and runs frame work only for installed features. +A feature that only one platform can install is left out of `features.h` and `kFeatures` elsewhere with `#if _WIN32`. Only `[patches]` keys decide which hooks install. They are restart-only switches for conflicts and client drift. Feature settings belong in sections such as `[ui]`, `[graphics]`, or `[audio]`. Install hooks even when a feature is disabled. Each hook reads the current setting and cheaply calls `original` when off. +Every feature file has the same shape: + +- A top comment naming the settings it owns, then includes: project headers, `game/`, libraries, then std. +- One anonymous namespace holding state, helpers, hooks and, last, `Install()`. Sections use `// --- X ---`. +- Exported calls from `features.h`, then the descriptors at the end of the file. + +macOS release builds compile feature files four to a translation unit. Give file-scope names a feature-specific +name (`recovery_installed`, `InstallPinnedShips`), never a generic one like `installed`, `Enabled` or `Install`. + ## Hooks and frame work -- Use `IL2CPP_HOOK` or `IL2CPP_DETOUR` from ``. Name callbacks `Class_Method_Hook` and take `auto original` first. -- Each macro expansion owns one target. Prefer one detour per game method; chained detours depend on installation order. -- Before hooking a small wrapper or thunk, verify its native extent against SPUD's overwrite size on every supported architecture for the exact client. A build does not prove fit. +- Every installer stages its hooks in one `il2cpp::HookSet` named after its descriptor, then commits it. Use + `IL2CPP_STAGE` on a `Class::Method` lookup; `IL2CPP_STAGE_AT` is for native exports. +- Name callbacks `Class_Method_Hook`, take `auto original` first and the receiver as `_this`, typed with its binding. +- `_this` and `unity::Instances` items are never null; do not check them. Check other pointers from the game before + calling through them, since receivers must not be null. Do not cap counts or sizes the game reports. +- Use `Method` or `StaticMethod` when the hook reads value-type or enum parameters. +- A hook shared by two features goes in its own set, committed once from a function-local static that both call. +- SPUD measures each target from the image's unwind or function-start tables and refuses one too small for its patch; + the set reports it. Installers do not repeat a check that SPUD, the library or the dump checker already makes. - Match game-visible signatures from `dump.cs`. Change hidden `MethodInfo*` handling only with runtime evidence. - Keep the single `ScreenManager.Update` hook in `mod.cc`. Other hooks that read keys must call `unity::Key::Poll()` first. +The library reports missing members, signature drift and failed detours. Features do not log hook failures or +success. A set is one SPUD transaction: every hook or none, so a feature installs whole or not at all, and a hook +that runs can rely on the rest of its set. Do not pre-check fields or properties by default: a missing one is +reported and reads as zero, and the dump checker catches drift before a release. `HasField` and `HasProperty` are for +the rare hook that would take an in-game action on a value read as zero. + +Bindings contain managed exceptions, so hooks need no catch-all. Catch only around `il2cpp::InvokeBound`, which +lets them through so the caller can fall back to the game's own path. + +Feature log lines start with the descriptor name: `spdlog::info("[PinnedShips] ...")`. Log what the mod changed or +could not do, at most once for anything that repeats. + The frame tick polls input, checks for settings changes, runs installed feature frame functions, then dispatches shortcuts. Hooks run in no fixed order within a frame. Reads before polling see the previous frame's input. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c5161b76..0e7251f0e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ zig build launcher -Dtarget=aarch64-macos.14.6 ``` For Intel, replace `aarch64` with `x86_64`. -Both targets require macOS 14.6 or later. +Both targets require macOS 14.6 or later. `macos_min` in `zig/ctx.zig` holds that version for the build. The Swift launcher and app assets require a macOS host with Xcode. The C++ mod and loader can be cross-compiled with a supplied SDK. @@ -31,16 +31,19 @@ To package a universal app and disk image on macOS: ```sh scripts/package-mac-app.sh release -scripts/create-mac-dmg.sh release +scripts/create-mac-dmg.sh "zig-out/universal/release/STFC Community Mod.app" ``` -To build only the universal mod and loader: +`scripts/assemble-mac-app.sh` builds the signed bundle from per-architecture install prefixes. +The packaging script, the development helper, and CI all package through it. + +To build only the universal mod and loader, on any host with a macOS SDK: ```sh zig build macos-universal -Doptimize=ReleaseFast ``` -The development helper also supports build, run, debug, and crash-log actions: +The development helper builds one architecture and runs, debugs, or lists crash reports: ```sh scripts/mac-build-test-debug.sh build -m debug @@ -81,6 +84,7 @@ To reset build outputs, remove `.zig-cache` and `zig-out`. Keep `.cache/toolchai Windows release builds enable link-time optimization. Disable it with `-Dlto=false`. The Swift launcher compiles as one module and links after the native archives are ready. macOS `ReleaseFast` builds batch feature sources to share header parsing; sync sources compile separately. +A batch is one translation unit, so it is also the only build that catches two features with the same file-scope name. Zig 0.16 does not support Mach-O LTO, so macOS builds use ordinary optimized compilation. | Option | Effect | @@ -93,7 +97,7 @@ Zig 0.16 does not support Mach-O LTO, so macOS builds use ordinary optimized com `[patches]` switches work in every build. For library-only checks, use `zig build il2cpp`, `zig build unity`, or `zig build mods` with the required target. -Dependency build steps are `dep-spud`, `dep-protobuf`, and `dep-net`. +Dependency build steps are `dep-spud`, `dep-protobuf`, `dep-net`, and `dep-spdlog`. ## Run checks before submitting @@ -112,13 +116,13 @@ Choose the relevant native tests: | Command | Coverage | | --- | --- | | `zig build test-il2cpp` | Typed bindings, keyboard snapshots, GC handles, and Unity instance tracking | -| `zig build test-mod` | Settings parsing and reload, shortcuts, text helpers, and English example settings | +| `zig build test-mod` | Settings parsing and reload, shortcuts, text helpers, English example settings, and the action queue decisions | | `zig build test-sync` | Wire fixtures, retries, removals, target changes, session isolation, and concurrent updates | | `zig build test-spud` | x64 trampoline relocation, including RIP-relative instructions before a call | | `zig build test-launcher` | Real rsync deltas, corrupt patches, missing inputs, and output errors on Linux or macOS; 7z extraction fixtures on macOS | -These tests need no game. The boundary and mod tests need no platform SDK on Linux. -Tests must run on a matching operating system and architecture. +These tests need no game or platform SDK, so they all run on Linux; CI runs them there and on both macOS +architectures. Tests must run on a matching operating system and architecture. To compile Windows tests without running them: ```sh @@ -126,13 +130,14 @@ zig build mods build-tests -Dtarget=x86_64-windows-msvc ``` Before submitting core mod changes, also run the relevant platform build. -For macOS, use: +For feature changes on macOS, build `ReleaseFast` so feature batching checks for clashing names: ```sh -zig build mods -Dtarget=aarch64-macos.14.6 +zig build mods -Dtarget=aarch64-macos.14.6 -Doptimize=ReleaseFast ``` -`test-toolchains` checks SDK provisioning. `test-codegen` checks protobuf message selection. +`test-toolchains` checks SDK provisioning and the Mach-O archive extractor. `test-codegen` checks protobuf message +selection. `test-deps` checks dependency behavior and networking. For a local HTTPS fixture, pass its URL and CA file: @@ -202,6 +207,8 @@ python3 scripts/check-il2cpp-dump.py diff /path/to/old/dump.cs /path/to/new/dump ``` The script finds missing classes, members, and hook targets, plus static and arity changes. +It follows signature-checked hook lookups and `HasField`/`HasProperty` checks, including through a local +`using Alias = Binding::Class;`. It uses text matching. Runtime type checks and in-game validation remain necessary. ### Data sync ownership @@ -237,6 +244,7 @@ The build generates C++ for the entry-point messages in `zig/deps/protobuf_roots Add a fully qualified message name there when a new consumer needs it. Nested messages and map entries are included automatically. If its schema is not already compiled, add its `.pb.cc` file to the source selection in `zig/protobuf.zig`. The complete source schemas and their enum catalogs remain in `src/mod/game/proto/`. +Sync messages the game does not declare, such as `MyDeployedFleetsResponse`, live in `stfc.sync.proto` so regenerating the game schemas keeps them. The selector copies retained message descriptors without changing their fields, options, or wire numbers. Run `zig build test-codegen test-sync` after changing selection or schemas. diff --git a/docs/DAILY_FACTION_BULK_CLAIM.md b/docs/DAILY_FACTION_BULK_CLAIM.md deleted file mode 100644 index 29ed091c4..000000000 --- a/docs/DAILY_FACTION_BULK_CLAIM.md +++ /dev/null @@ -1,16 +0,0 @@ -# Daily faction bulk-claim configuration - -`ui.daily_bulk_claim_factions` defaults explicitly to `"federation,klingon,romulan"`. - -| Config value | Main-faction dailies eligible for bulk claim | -| --- | --- | -| Missing, empty or whitespace-only | The default: all three factions | -| `"federation,romulan"` | Federation and Romulan | -| `"none"` | None of the three | - -Other dailies remain eligible. Use `none` by itself. Settings reload when the TOML file changes. - -On upgrade, existing blank configurations keep their all-factions behavior. -The loader now resolves blank to the explicit default before populating the -runtime faction list and generated configuration values. `none` is a new explicit -opt-out; it does not share the blank/default representation. diff --git a/docs/FASTER_QUEUE_RECOVERY.md b/docs/FASTER_QUEUE_RECOVERY.md deleted file mode 100644 index 6ef8a5ed6..000000000 --- a/docs/FASTER_QUEUE_RECOVERY.md +++ /dev/null @@ -1,55 +0,0 @@ -# Faster Queue Recovery - -When a queued target disappears while a course request is outstanding, the game can remove that target before its failed response arrives. Native failure handling then attempts the removal again. Since it did not remove the front entry this time, it can leave the next target waiting for the watchdog. - -Recovery is enabled by default as part of the combined Kir'Shara fixes: - -```toml -[control] -kirshara_fixes = true -``` - -Following #313, default-enabled `[patches].actionqueuerecoveryhooks` independently controls installation. Changing that installation switch requires a restart. Installed callbacks check the current `control.kirshara_fixes` and `control.queue_enabled` preferences; changing TOML still requires reload/restart. - -The feature also respects `control.queue_enabled`. Hooks are supported on Windows x64 when the required method signatures and queue layout are compatible. Other platforms do not install these hooks; incompatible Windows layouts log an unavailable message and retain native behavior. - -The previous `faster_queue_recovery`, `thin_queue_protection`, `queue_address_guard` -and `advanced.queue.thin_queue_protection` settings no longer control these features. -Use the single `kirshara_fixes` switch; its default is true. A legacy-only configuration -logs this replacement at startup. No existing config file is rewritten automatically. - -Toggling queue entry off invalidates only the mod's pending recovery tracking. It -does not clear native target queues or stop their native processing. Forgetting the -tracking prevents an old course response from authorizing a later recovery after -the queue is toggled back on. - -## Behavior - -The adapter records the latest engagement attempt for up to eight fleets, using weak queue identities and full 64-bit target IDs. A failed, non-recall course response may request native planning only when: - -- The response belongs to the same queue and latest target/attempt, observed within 30 seconds. -- The queue was engaging on entry to the response and native processing cleared that flag. -- A different front target remains, and the failed target is absent from every inspected queue. -- The native retry decision was false. Ordinary retries are unchanged. - -The request record is consumed once. The native planner selects and validates the next target. The mod does not force ship state, clear engagement flags, change retry counts, or remove targets. If another fleet still queues the target, the native cross-fleet removal path remains responsible. - -Queue storage is bounded and validated. Unknown layouts/content are ineligible. Expired/replaced requests release their weak handles; native session cleanup clears all records. Engage, Retry and Course discard pending recovery records when they observe disabled behavior. Both Toggle Queue transitions also clear them, so an off/on toggle cannot reuse authorization from before the toggle. A native attempt returning skip/stop cancels only its own record, preserving newer reentrant attempts. - -There is no watchdog hook, frame scan, timer, background worker, or per-engagement logging. Queue inspection occurs in existing callbacks; all-queue inspection runs only for a potentially eligible failed response. Response matching is not a server-issued request ID, so delayed same-target responses remain an interoperability limitation. - -## Native integration - -One detour owns each method. Methods are resolved through their complete managed signatures. Installation validates the event and queue field layouts and the engagement result's integer representation. SPUD builds the trampolines using its existing instruction decoder. Addresses and relocation-dependent instruction bytes are not pinned to a client release. - -The hooked methods are `TryPlanPathAndEngageTarget`, `ShouldRetryFailedSetCourse`, `OnSetCourseResponseEventHandler` and `StopWatchdogAndClearAllQueues`. These checks establish binding compatibility; changes to the native retry/planner behavior still require update review and runtime testing. - -The course event is a 24-byte value type, with fleet ID at 0, success/recall at 8/9 and boxed target at 16. Metadata field offsets include the boxed object header. The native retry handler is called synchronously inside the course handler; returning true selects its existing planner branch. - -The older off-screen Kir'Shara combat-completion repair was fixed by Scopely. That workaround and its `kirshara_queue_repair` setting have already been removed; neither is a prerequisite for Faster Queue Recovery. This feature addresses only the separate unavailable-target/course-response race described above. - -## Validation - -Run `tests/run-action-queue.ps1` on Windows, or compile `tests/action_queue.cc` with a C++23 compiler and `-Isrc/mod`. Tests use the production policy and request store with fake weak handles. They cover one-shot recovery, reordered/still-present targets, cross-fleet rejection, stale attempts, replaced/collected queues, expiry, reentrant cancellation, bounded capacity and cleanup. They do not model game ABI or network scheduling. - -The prototype produced two observed handoffs with the next target attempted 1–2 ms later and successful responses within 388–532 ms. Those timings are observations, not a latency guarantee. The final adapter requires its own smoke test: normal queued combat; removal of the outstanding target; removal before the first successful course response; queue clear/rebuild; recall; and session restart. Group-wave behavior needs additional coverage when available. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e63acb1d9..328418bcb 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -14,6 +14,7 @@ Features depend on the game client. A client update can break a hook until the m | UI scale | Adjust the main interface, object viewers, and system-view ship models separately. | | System zoom | Set the maximum range, entry zoom, keyboard speed, and five presets. Save a preset or the current zoom as the default. | | Camera movement | Pan with configurable keys and set momentum falloff. Keyboard panning can be disabled. The camera can also stop following a selected target, so you can pan away from it. | +| Station interior | Zoom further out inside the station. `graphics.interior_zoom_scale` multiplies the native distance; `1.0` keeps it. | | Haven | Extend camera range while keeping road surfaces above the ground. `graphics.haven_zoom = 0.0` uses the native range. | | Fleet labels | Choose native, expanded, compact, or zoom-threshold detail separately for player and non-player fleets. | | Window and cursor | Resize the game window freely on Windows and control whether the game replaces the system cursor. | @@ -41,20 +42,23 @@ The [shortcut reference](KEYMAPPING.md) lists the defaults. | Area | Behavior | | --- | --- | | Kir'Shara queue | Start with queuing enabled, add targets, clear the queue, or toggle it. Requires ownership of the artifact. | +| Kir'Shara fixes | Keep a queue moving when its target vanishes mid-course or the watchdog prunes it, and keep it from being cleared when moving another ship briefly misreports the fleet's position. `control.kirshara_fixes = false` turns all three off. | | Instant warp | Show the confirmation, use regular warp, or jump automatically. Ship-name filters can override the choice. | | Target previews | Show cargo by target type, toggle cargo view, and close viewers with Escape. Locate and recall can remain enabled while previews are open. | | Cargo numbers | Choose decimal places for abbreviated totals. Supported Windows clients can skip the count-up animation. | -| Ship assignment | Select adjacent ships with arrow keys, double-click to assign, and pin named ships while preserving sort order. | +| Ship assignment | Select adjacent ships with arrow keys, double-click to assign, and pin named ships while preserving sort order. Shift+click a ship tile to pin or unpin it; only the `pinned_ships` line of the settings file changes. | | Officer sorting | Restore **Below Deck Ability** in roster and assignment screens. | | Ship power | Show out-of-dock power while a ship is docked. | -| Forbidden Tech | Automatically confirm level and tier upgrades when enabled. The selected item shows the ship it is equipped on, including ships without a dock. | +| Forbidden Tech | The selected item shows the ship it is equipped on, including ships without a dock. | +| Confirmations | Skip the confirmation for Forbidden Tech upgrades, the Alliance contribution max-level warning, and the U.S.S. Excelsior's Decloaking Deflector. Each is off by default. | ## Rewards and interface - Skip chest reveals, open bulk gift claims, and leave the section after the supported gift-collection flow. - Extend Alliance donations and, on Windows, supported tagged chest-purchase sliders. - Limit Daily Goals **Claim All** to selected Federation, Klingon, or Romulan goals. Its toggle can start enabled each session. -- Hide Galaxy or Veil Chat. Set supported HUD buttons to automatic, always shown, or always hidden. + `ui.daily_bulk_claim_factions` lists the factions; blank means all three, and `none` by itself excludes all of them. Other dailies stay eligible. +- Hide Galaxy or Veil Chat, and player titles under chat names. Set supported HUD buttons to automatic, always shown, or always hidden. - Open alliance side chat once per login, once the station and chat have finished loading, and set side chat's starting width. Both are off by default. - Hide **Exchange All** in the artifact-shards popup while retaining single exchanges. diff --git a/docs/IL2CPP.md b/docs/IL2CPP.md index 3385c8d2c..318ac174a 100644 --- a/docs/IL2CPP.md +++ b/docs/IL2CPP.md @@ -38,10 +38,21 @@ struct ShipView : il2cpp::Object { }; ``` -Accessor names follow managed members, without a leading `_` or `m_`. Setters use `SetX`. +Accessor names follow managed members. Fields are lowerCamel without a leading `_` or `m_` (`_count` is `count()`). +Properties and methods keep their PascalCase name. Setters use `SetX`. +A helper that converts the managed value takes the PascalCase name: `Name()` returns `std::string`, `name()` the raw +`Il2CppString*`. Nested types use `"Outer/Inner"` as the class name. Fixed-width numeric types keep the native ABI explicit. +| Managed class | Binding | +| --- | --- | +| MonoBehaviour | Derives from `unity::Behaviour`, which supplies `isActiveAndEnabled()`, `transform()` and `gameObject()`. | +| Other component | Derives from `unity::Component`. | +| Static members only | `struct X { using Class = il2cpp::Class<...>; static ... };` | +| Only ever hooked | `using X = il2cpp::Class<...>;` | +| Mirrored enum | `enum class X : int32_t` with the managed member names. Mod-owned enums use `kCamelCase`. | + ## Members and calls | Operation | API | @@ -90,6 +101,9 @@ This supports generic bases such as `Widget`, `MonoSingleton`, and `il2cpp Some game bindings use this form when their managed class has moved between client builds. They still belong in `src/mod/game/`. +`Object::HasMethod(name)` checks a method on such a runtime class without calling it. +`CallOnField<"field", T, "Method", R>()` calls a parameterless method on a value-type field in place. + `il2cpp::List` supports indexed reads and `Set`, `Add`, `RemoveAt`, and `Clear`. Its iterator reads `Count` once. Do not mutate the list during iteration. `il2cpp::Array` views a managed array; its `Set` applies reference write barriers. @@ -103,8 +117,9 @@ Resolution happens on first use. Missing calls are reported once and return valu ## Hooks -`IL2CPP_HOOK` looks up a managed method and detours its native entry point through SPUD. -Hook names follow `Class_Method_Hook`. The first parameter is `auto original`. +Every hook installs through an `il2cpp::HookSet`, one per installer. Stage each hook with `IL2CPP_STAGE(set, method, +hook)`, or `IL2CPP_STAGE_AT(set, address, hook)` for a native function such as an IL2CPP export, then call `Commit()`. +Hook names follow `Class_Method_Hook`. The first parameter is `auto original`; the receiver is `_this`. ```cpp #include @@ -114,30 +129,46 @@ void ShipView_Select_Hook(auto original, ShipView* _this, int32_t index) original(_this, index); } -void InstallShipView() +bool InstallShipView() { - IL2CPP_HOOK(ShipView::Class::Method("Select", 1), ShipView_Select_Hook); + il2cpp::HookSet hooks("ShipView"); + IL2CPP_STAGE(hooks, ShipView::Class::Method("Select", 1), ShipView_Select_Hook); + return hooks.Commit(); } ``` -The macro returns the original-call trampoline, or null on failure. -A missing method installs nothing. SPUD installation failures include the method name in the error. -`IL2CPP_DETOUR(address, hook, name)` handles native functions, including IL2CPP exports. +A set installs through one SPUD transaction: every hook goes in, or none does. A missing method or a null address +makes `Commit()` install nothing, and a failing step undoes the steps before it, so a hook never runs without the rest +of its set. `Commit()` returns whether the set went in; an installer returns it so the mod can report the feature. +SPUD stages only removable hooks, which count calls in and out. Committed hooks are detached and stay for the process. + +Every failure is reported by the library as `[IL2CPP] not installed: `, after the lookup that found a method +missing names it. Features do not log hook failures or success. +A hook that several features share goes in its own set, committed once from a function-local static. + +SPUD chains detours on one function safely, so two hooks may target the same native code. Identical methods can share +generated code, though, and a hook on one of them then also runs for the others, with their receiver. Where a feature +knows its targets can share a body, it compares their `il2cpp::DetourTarget`, the address SPUD patches, and hooks +the shared body once. | Lookup | Selects by | | --- | --- | | `Class::Method("Select")` | Name, any arity | | `Class::Method("Select", 1)` | Name and parameter count | | `Class::Method("Select", {"System.Int32"})` | Managed parameter type names | -| `Class::Method("Select")` | Complete instance signature | +| `Class::Method("Select")` | Complete instance signature, without `this` | +| `Class::StaticMethod("Select")` | Complete static signature, without running the class constructor | + +Name-based lookups fall back to a base class's method when the class itself does not declare one. +Hooking that detours the base for every subclass; check `Method::Info()->klass` when that matters. +`Class::HasField<"_name", T>()` and `Class::HasProperty<"Name", T>()` check a member without reading an object. +Reserve them for a hook that would act on a value read as zero; a missing member is already reported. -Each macro expansion owns one detour for the process. Use a separate expansion for each target. Prefer one detour per game method, then route feature work through it. -SPUD can chain detours, but their order follows installation order. -Before detouring a small wrapper or thunk, verify its native extent against SPUD's overwrite size. -Check every supported architecture for the exact client build. -A successful build does not prove the hook fits. Prefer a substantive downstream method when exact extents are unavailable. +SPUD measures each target before it patches, from the image's unwind table on Windows or its function starts on macOS, +and follows an entry that only jumps to its body. A target too small for the patch is refused, and the set reports it +with SPUD's reason. Installers do not repeat a check that SPUD, the library or the dump checker already makes. During drift repair, match the game-visible signature in `dump.cs`. Change hidden `MethodInfo*` handling only when runtime evidence points to it. @@ -148,7 +179,11 @@ Change hidden `MethodInfo*` handling only when runtime evidence points to it. | `il2cpp::New(args...)` | Allocate and run the matching constructor | | `il2cpp::Allocate()` | Allocate without a constructor, for classes whose parameterless constructor has no generated code | | `NewString`, `ToUtf8`, `Equals` | Create, convert, or compare managed strings | -| `Box` | Box a primitive | +| `ToString` | A managed string for any object | +| `Box`, `Unbox` | Box a primitive, or read one of exactly that type | +| `Nullable` | `System.Nullable` by value | +| `Is`, `As` | Test or cast a receiver against a bound class | +| `InvokeBound` | Run a single-cast delegate's method directly; managed exceptions propagate so the caller can fall back | | `NewDelegate` | Bind a delegate to a static method, with an optional native redirect | | `Guarded` | Contain Windows access violations when reading layouts that may have drifted | diff --git a/docs/INSTALL.md b/docs/INSTALL.md index f6a55147f..3b0fcee81 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -91,6 +91,7 @@ The English example tracks the current code. Translations can lag behind. Settings reload after you save the file. To reload manually, press `CTRL-SHIFT-F6`. Changes under `[patches]` require a game restart. Shortcuts that adjust settings during play do not save those changes to your TOML file. +The one exception is Shift+click pinning in the ship dock, which rewrites only the `pinned_ships` line under `[ui]`. ## Fix installation problems @@ -119,7 +120,8 @@ In Finder, press `Command-Shift-G` and enter: ``` This works even when Finder hides `Library`. -If the launcher shows game version `-1`, reinstall the game, then open the Community Mod launcher again. +If the launcher shows **Game not installed** and game version `00`, it cannot read the game's install path or version. +Reinstall the game with the Xsolla launcher, then open the Community Mod launcher again. You do not need to reinstall the mod launcher. For further help, use the [Discord server](https://discord.gg/PrpHgs7Vjs). diff --git a/docs/QUEUE_ADDRESS_GUARD.md b/docs/QUEUE_ADDRESS_GUARD.md deleted file mode 100644 index d20c21003..000000000 --- a/docs/QUEUE_ADDRESS_GUARD.md +++ /dev/null @@ -1,39 +0,0 @@ -# Kir'Shara queue address guard - -Moving or recalling another ship in a different system can temporarily make a -deployed ship's native player address point at its starbase. A subsequent native -player-state event then treats that ship's same-system queued targets as foreign -and clears its queue. This failure was also reproduced by a tester with the mod -DLL disabled. - -The default-on guard substitutes the ship's current owned deployment address only -inside the native queue address comparison during a player-state event. It requires -a validated nonempty queue belonging to that ship and slot, a local deployment -whose fleet ID matches, no recall or removal, and an active non-warping ship state. -The reported address must be a planet in another system, and the current target -must match the deployment's full galaxy/system/planet/instance address. - -The native comparison still checks every target. A missing target, a genuinely -foreign address, or unknown metadata retains native behavior. The guard writes no -fleet fields or queue contents and retains no address between events. Explicit -clearing, recalling the queued ship itself, and genuine system changes remain -native. This is enabled together with Faster Queue Recovery and Thin Queue Protection -through `control.kirshara_fixes`. - -`[control] kirshara_fixes = false` disables the combined fixes, including address correction. The separate -default-on `[patches] queueaddressguardhooks` switch controls hook installation; -changing it requires a restart. Disabling the queue also disables correction. - -The implementation is shared by Windows and macOS and contains no capture logger, -polling loop, or diagnostic hooks. It owns four native methods: the player-state -event handler, the queue address predicate, the player address getter, and the -deployment lookup. Their Windows x64 client 271 native overwrite windows were -measured against the matching installed client. Current Mac native hook fit and -gameplay validation have not been established; successful builds alone do not -establish those properties. - -Run `tests/run-action-queue.ps1` for local regression checks. Gameplay validation -must use the clean build: queue A's same-system hostiles, move/recall B in another -system, and allow A to engage its next target. Then recall A and check its queue -clears normally. The prior instrumented build passed both controls, and reproduced -the raw address fault even with fleet observation polling disabled. diff --git a/docs/THIN_QUEUE_PROTECTION.md b/docs/THIN_QUEUE_PROTECTION.md deleted file mode 100644 index a15d2a307..000000000 --- a/docs/THIN_QUEUE_PROTECTION.md +++ /dev/null @@ -1,51 +0,0 @@ -# Thin Queue Protection - -Restores the active guard from `v2.1.0-guffa.10` alongside Faster Queue Recovery. -This is separate from the retired Kir'shara combat-completion repair. - -```toml -[control] -kirshara_fixes = true -``` - -Enabled by default on Windows x64 as part of `control.kirshara_fixes`. Also respects -`control.queue_enabled`. The old separate feature settings, including -`advanced.queue.thin_queue_protection`, are retired; use `kirshara_fixes = false` -to disable the combined fixes. Default-enabled `[patches].thinqueueprotectionhooks` -controls installation independently. Installed callbacks check the current feature and queue -preferences. Changing the installation switch requires restart; TOML changes require -reload/restart. - -The native planner (`DoPlanPathAndEngageTarget`) and watchdog (`HandleStall`) run -first. If they removed an exact prefix of targets, left a nonempty unchanged -suffix, and left the same fleet idle without an active engagement, the guard -rechecks that state and asks the native `TryPlanPathAndEngageTarget` to resume. -Pending/last-target latches must be absent or refer only to removed targets. -Reordering, replacement, truncation, or a latch naming a surviving target rejects -recovery. An unchanged queue is never enough evidence to retry. - -On disposal, a fleet must be explicitly destroyed with removal reason `Destroyed` -(1). If native handling leaves that target at an inactive queue head, the guard -rechecks it and calls native `ProcessQueue(target, false)`. It does not force -immediate selection, remove arbitrary targets, or edit queue/engagement state. - -Faster Queue Recovery still owns the failed-course-response path. The restored -guard uses three different detours and calls the existing native engage entry, -so Faster Queue Recovery can observe those requests normally. The nested planner -and watchdog paths cannot replay a successfully engaged target because the outer -guard sees the resulting active engagement. Queue identity is checked again after -native calls. All three guard hooks must install before any guard action is enabled. - -Methods, field offsets/types and enum return representations are checked before -installation; incompatible layouts keep native behavior. This port currently -installs only on Windows x64. macOS runtime/ABI validation is not claimed. - -Successful actions log `[ThinQueueProtection] resume` or -`[ThinQueueProtection] process-destroyed-head`; startup logs `ready=true` or an -unavailable warning. There is no per-frame scan or background polling. - -Run `tests/run-action-queue.ps1` for both recovery-policy suites. The restored -release tests cover destroyed-head filtering, prefix removal, multiple removed -targets, latch safety, queue replacement/reordering and postcondition changes. -They do not establish game ABI or live wave behavior. Runtime verification still -requires a new build to be deployed and a wave test after restart. diff --git a/docs/VERSION_SUBSTITUTION.md b/docs/VERSION_SUBSTITUTION.md index 499d76322..ebe108896 100644 --- a/docs/VERSION_SUBSTITUTION.md +++ b/docs/VERSION_SUBSTITUTION.md @@ -3,11 +3,13 @@ After `zig build launcher`, run: ```sh -python3 scripts/verify_version_substitution.py zig-out/macOSLauncher.app/Contents/Info.plist +python3 scripts/verify-version-substitution.py zig-out/macOSLauncher.app/Contents/Info.plist ``` For a custom `--prefix`, pass the plist path under that prefix. `zig/launcher.zig` reads `VERSION_MAJOR`, `VERSION_MINOR`, `VERSION_REVISION`, and `VERSION_PATCH` from `src/mod/version.h`. -It substitutes the four-part number for `${VERSION}` in `src/launcher/macos/Info.plist.template`. +It substitutes the four-part number for `${VERSION}` in `src/launcher/macos/Info.plist.template`, +and `macos_min` from `zig/ctx.zig` for `${MACOS_MIN}`. Zig generates the plist in its cache and installs it into the app. The source template stays unchanged. +The check fails on a version mismatch or on any placeholder left in the plist. diff --git a/docs/archive/2026-10-21-kirshara-queue-fixes.md b/docs/archive/2026-10-21-kirshara-queue-fixes.md new file mode 100644 index 000000000..438b42d9e --- /dev/null +++ b/docs/archive/2026-10-21-kirshara-queue-fixes.md @@ -0,0 +1,189 @@ +# Kir'Shara queue fixes: design notes (2026-10-21) + +Archived from the per-fix guides that accompanied #287, #313 and the address guard. The live user-facing summary is in [FEATURES.md](../FEATURES.md); the code is `src/mod/features/action_queue.cc` and its tests are `tests/action_queue.cc` (`zig build test-mod`). + +Since these notes were written, the fixes moved onto typed bindings: method signatures and member types are checked when they resolve, and no field offsets are pinned. Startup no longer logs `ready=` lines; a missing member is reported by the binding library instead. #339 then enabled recovery and thin protection on macOS; its qualification notes are at the end. + +## Faster Queue Recovery + +When a queued target disappears while a course request is outstanding, the game can remove that target before its failed response arrives. Native failure handling then attempts the removal again. Since it did not remove the front entry this time, it can leave the next target waiting for the watchdog. + +Recovery is enabled by default as part of the combined Kir'Shara fixes: + +```toml +[control] +kirshara_fixes = true +``` + +Following #313, default-enabled `[patches].actionqueuerecoveryhooks` independently controls installation. Changing that installation switch requires a restart. Installed callbacks check the current `control.kirshara_fixes` and `control.queue_enabled` preferences; changing TOML still requires reload/restart. + +The feature also respects `control.queue_enabled`. Hooks are supported on Windows x64 when the required method signatures and queue layout are compatible. Other platforms do not install these hooks; incompatible Windows layouts log an unavailable message and retain native behavior. + +The previous `faster_queue_recovery`, `thin_queue_protection`, `queue_address_guard` +and `advanced.queue.thin_queue_protection` settings no longer control these features. +Use the single `kirshara_fixes` switch; its default is true. A legacy-only configuration +logs this replacement at startup. No existing config file is rewritten automatically. + +Toggling queue entry off invalidates only the mod's pending recovery tracking. It +does not clear native target queues or stop their native processing. Forgetting the +tracking prevents an old course response from authorizing a later recovery after +the queue is toggled back on. + +### Behavior + +The adapter records the latest engagement attempt for up to eight fleets, using weak queue identities and full 64-bit target IDs. A failed, non-recall course response may request native planning only when: + +- The response belongs to the same queue and latest target/attempt, observed within 30 seconds. +- The queue was engaging on entry to the response and native processing cleared that flag. +- A different front target remains, and the failed target is absent from every inspected queue. +- The native retry decision was false. Ordinary retries are unchanged. + +The request record is consumed once. The native planner selects and validates the next target. The mod does not force ship state, clear engagement flags, change retry counts, or remove targets. If another fleet still queues the target, the native cross-fleet removal path remains responsible. + +Queue storage is bounded and validated. Unknown layouts/content are ineligible. Expired/replaced requests release their weak handles; native session cleanup clears all records. Engage, Retry and Course discard pending recovery records when they observe disabled behavior. Both Toggle Queue transitions also clear them, so an off/on toggle cannot reuse authorization from before the toggle. A native attempt returning skip/stop cancels only its own record, preserving newer reentrant attempts. + +There is no watchdog hook, frame scan, timer, background worker, or per-engagement logging. Queue inspection occurs in existing callbacks; all-queue inspection runs only for a potentially eligible failed response. Response matching is not a server-issued request ID, so delayed same-target responses remain an interoperability limitation. + +### Native integration + +One detour owns each method. Methods are resolved through their complete managed signatures. Installation validates the event and queue field layouts and the engagement result's integer representation. SPUD builds the trampolines using its existing instruction decoder. Addresses and relocation-dependent instruction bytes are not pinned to a client release. + +The hooked methods are `TryPlanPathAndEngageTarget`, `ShouldRetryFailedSetCourse`, `OnSetCourseResponseEventHandler` and `StopWatchdogAndClearAllQueues`. These checks establish binding compatibility; changes to the native retry/planner behavior still require update review and runtime testing. + +The course event is a 24-byte value type, with fleet ID at 0, success/recall at 8/9 and boxed target at 16. Metadata field offsets include the boxed object header. The native retry handler is called synchronously inside the course handler; returning true selects its existing planner branch. + +The older off-screen Kir'Shara combat-completion repair was fixed by Scopely. That workaround and its `kirshara_queue_repair` setting have already been removed; neither is a prerequisite for Faster Queue Recovery. This feature addresses only the separate unavailable-target/course-response race described above. + +### Validation + +Run `zig build test-mod`.cc` with a C++23 compiler and `-Isrc/mod`. Tests use the production policy and request store with fake weak handles. They cover one-shot recovery, reordered/still-present targets, cross-fleet rejection, stale attempts, replaced/collected queues, expiry, reentrant cancellation, bounded capacity and cleanup. They do not model game ABI or network scheduling. + +The prototype produced two observed handoffs with the next target attempted 1–2 ms later and successful responses within 388–532 ms. Those timings are observations, not a latency guarantee. The final adapter requires its own smoke test: normal queued combat; removal of the outstanding target; removal before the first successful course response; queue clear/rebuild; recall; and session restart. Group-wave behavior needs additional coverage when available. + +## Thin Queue Protection + +Restores the active guard from `v2.1.0-guffa.10` alongside Faster Queue Recovery. +This is separate from the retired Kir'shara combat-completion repair. + +```toml +[control] +kirshara_fixes = true +``` + +Enabled by default on Windows x64 as part of `control.kirshara_fixes`. Also respects +`control.queue_enabled`. The old separate feature settings, including +`advanced.queue.thin_queue_protection`, are retired; use `kirshara_fixes = false` +to disable the combined fixes. Default-enabled `[patches].thinqueueprotectionhooks` +controls installation independently. Installed callbacks check the current feature and queue +preferences. Changing the installation switch requires restart; TOML changes require +reload/restart. + +The native planner (`DoPlanPathAndEngageTarget`) and watchdog (`HandleStall`) run +first. If they removed an exact prefix of targets, left a nonempty unchanged +suffix, and left the same fleet idle without an active engagement, the guard +rechecks that state and asks the native `TryPlanPathAndEngageTarget` to resume. +Pending/last-target latches must be absent or refer only to removed targets. +Reordering, replacement, truncation, or a latch naming a surviving target rejects +recovery. An unchanged queue is never enough evidence to retry. + +On disposal, a fleet must be explicitly destroyed with removal reason `Destroyed` +(1). If native handling leaves that target at an inactive queue head, the guard +rechecks it and calls native `ProcessQueue(target, false)`. It does not force +immediate selection, remove arbitrary targets, or edit queue/engagement state. + +Faster Queue Recovery still owns the failed-course-response path. The restored +guard uses three different detours and calls the existing native engage entry, +so Faster Queue Recovery can observe those requests normally. The nested planner +and watchdog paths cannot replay a successfully engaged target because the outer +guard sees the resulting active engagement. Queue identity is checked again after +native calls. All three guard hooks must install before any guard action is enabled. + +Methods, field offsets/types and enum return representations are checked before +installation; incompatible layouts keep native behavior. This port installs on +Windows x64 and, since #339, on macOS; see the macOS notes at the end. + +Successful actions log `[ThinQueueProtection] resume` or +`[ThinQueueProtection] process-destroyed-head`; startup logs `ready=true` or an +unavailable warning. There is no per-frame scan or background polling. + +Run `zig build test-mod`. The restored +release tests cover destroyed-head filtering, prefix removal, multiple removed +targets, latch safety, queue replacement/reordering and postcondition changes. +They do not establish game ABI or live wave behavior. Runtime verification still +requires a new build to be deployed and a wave test after restart. + +## Kir'Shara queue address guard + +Moving or recalling another ship in a different system can temporarily make a +deployed ship's native player address point at its starbase. A subsequent native +player-state event then treats that ship's same-system queued targets as foreign +and clears its queue. This failure was also reproduced by a tester with the mod +DLL disabled. + +The default-on guard substitutes the ship's current owned deployment address only +inside the native queue address comparison during a player-state event. It requires +a validated nonempty queue belonging to that ship and slot, a local deployment +whose fleet ID matches, no recall or removal, and an active non-warping ship state. +The reported address must be a planet in another system, and the current target +must match the deployment's full galaxy/system/planet/instance address. + +The native comparison still checks every target. A missing target, a genuinely +foreign address, or unknown metadata retains native behavior. The guard writes no +fleet fields or queue contents and retains no address between events. Explicit +clearing, recalling the queued ship itself, and genuine system changes remain +native. This is enabled together with Faster Queue Recovery and Thin Queue Protection +through `control.kirshara_fixes`. + +`[control] kirshara_fixes = false` disables the combined fixes, including address correction. The separate +default-on `[patches] queueaddressguardhooks` switch controls hook installation; +changing it requires a restart. Disabling the queue also disables correction. + +The implementation is shared by Windows and macOS and contains no capture logger, +polling loop, or diagnostic hooks. It owns four native methods: the player-state +event handler, the queue address predicate, the player address getter, and the +deployment lookup. Their Windows x64 client 271 native overwrite windows were +measured against the matching installed client. Current Mac native hook fit and +gameplay validation have not been established; successful builds alone do not +establish those properties. + +Run `zig build test-mod`. Gameplay validation +must use the clean build: queue A's same-system hostiles, move/recall B in another +system, and allow A to engage its next target. Then recall A and check its queue +clears normally. The prior instrumented build passed both controls, and reproduced +the raw address fault even with fleet observation polling disabled. + +## macOS (#339) + +Faster Queue Recovery and Thin Queue Protection use their existing implementation +on Apple Silicon and Intel. Queue Address Guard already supported macOS. The +shared `[control] kirshara_fixes` option controls the fixes on every platform; +#339 added no settings and changed no queue policy or defaults. + +Historical Mac client 199 (`1.000.52486`) inspection found the same 24-byte course +response layout: fleet at 0, success at 8, recall at 9, target at 16. Its aggregate +argument is indirect on Apple Silicon and stack-passed on Intel, matching the host +C++ ABI. The seven detour targets exceeded the architecture-specific SPUD overwrite +windows in those binaries. That historical evidence does not establish native +hook fit for a newer client. Runtime metadata checks reject incompatible managed +layouts; they do not validate native method extent. + +Current Mac build, native-fit and exact-artifact runtime results must be recorded +separately for each tested architecture and game version. + +Focused smoke test: + +1. Set `[control] queue_enabled = true` and `kirshara_fixes = true`. Leave + `[patches] actionqueuerecoveryhooks`, `thinqueueprotectionhooks` and + `queueaddressguardhooks` enabled; restart after changing hook switches. +2. Confirm the startup log reports `+ Patching` for `ActionQueueRecovery`, + `ThinQueueProtection` and `QueueAddressGuard`. Preserve any `! Not installed` + line and the binding errors logged before it. +3. Queue same-system hostiles on ship A. Move or recall ship B from another + system; A's queue should survive and continue normally. +4. Recall A itself, then separately clear its queue manually. Both should clear. +5. Let another player remove A's queued head before engagement. Check progression + to the next eligible target, then repeat with respawns and two ships. Watch for + duplicate engagements, stalled courses, unexpected clearing or crashes. +6. Compare `kirshara_fixes` off/on by editing the TOML key; the settings file is + reloaded when it changes. Record commit, artifact hash, architecture, game version, + exact steps and logs. Disable an affected hook switch and restart if needed. diff --git a/docs/testing/kirshara-macos.md b/docs/testing/kirshara-macos.md deleted file mode 100644 index 278ef78c1..000000000 --- a/docs/testing/kirshara-macos.md +++ /dev/null @@ -1,37 +0,0 @@ -# Kirshara protections on macOS - -Faster Queue Recovery and Thin Queue Protection use their existing implementation -on Apple Silicon and Intel. Queue Address Guard already supports macOS. The -shared `[control] kirshara_fixes` option controls the protections on every platform; -this change adds no settings and changes no queue policy or defaults. - -## Qualification - -Historical Mac client 199 (`1.000.52486`) inspection found the same 24-byte course -response layout: fleet at 0, success at 8, recall at 9, target at 16. Its aggregate -argument is indirect on Apple Silicon and stack-passed on Intel, matching the host -C++ ABI. The seven detour targets exceeded the architecture-specific SPUD overwrite -windows in those binaries. That historical evidence does not establish native -hook fit for a newer client. Runtime metadata checks reject incompatible managed -layouts; they do not validate native method extent. - -Current Mac build, native-fit and exact-artifact runtime results must be recorded -separately for each tested architecture and game version. - -## Focused smoke test - -1. Set `[control] queue_enabled = true` and `kirshara_fixes = true`. Leave - `[patches] actionqueuerecoveryhooks`, `thinqueueprotectionhooks` and - `queueaddressguardhooks` enabled; restart after changing hook switches. -2. Confirm startup logs report `[FasterQueueRecovery] ready=true`, - `[ThinQueueProtection] ready=true` and `[QueueAddressGuard] ready=true`. - Preserve any unavailable or hook-install failure messages. -3. Queue same-system hostiles on ship A. Move or recall ship B from another - system; A's queue should survive and continue normally. -4. Recall A itself, then separately clear its queue manually. Both should clear. -5. Let another player remove A's queued head before engagement. Check progression - to the next eligible target, then repeat with respawns and two ships. Watch for - duplicate engagements, stalled courses, unexpected clearing or crashes. -6. Compare `kirshara_fixes` off/on. Without the settings UI, edit the existing TOML - key and restart. Record commit, artifact hash, architecture, game version, - exact steps and logs. Disable an affected hook switch and restart if needed. From e87c5c366b5f825cf0d11646c01461d7390d61e3 Mon Sep 17 00:00:00 2001 From: tashcan Date: Thu, 22 Oct 2026 17:54:45 -0700 Subject: [PATCH 13/13] Build with Zig 0.17 The build graph works on Zig 0.17 as well as 0.16. One compat file holds the differences in configure behaviour, renamed options and moved fields, so dropping 0.16 later means inlining one side of it. Files the build reads while configuring are declared as configure dependencies, so a new source file still triggers reconfiguration. --- AGENTS.md | 2 +- CONTRIBUTING.md | 5 ++-- build.zig | 9 ++++-- zig/compat.zig | 69 ++++++++++++++++++++++++++++++++++++++++++++++ zig/ctx.zig | 52 ++++++++++++++++++++++++---------- zig/launcher.zig | 14 +++------- zig/mod.zig | 22 ++++++++------- zig/tests.zig | 5 ++-- zig/toolchains.zig | 24 ++++++++++------ 9 files changed, 151 insertions(+), 51 deletions(-) create mode 100644 zig/compat.zig diff --git a/AGENTS.md b/AGENTS.md index ed78fe8fd..e30c7b916 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Before changing bindings or hooks, read the [IL2CPP reference](docs/IL2CPP.md). ## Build and check -The project uses **Zig 0.16.0** and **C++23**. +The project uses **Zig 0.16 or 0.17** and **C++23**. Keep build changes working on both; `zig/compat.zig` holds the differences. Build definitions are in `build.zig` and `zig/`. Dependency URLs and hashes are pinned in `build.zig.zon`. Windows uses the MSVC ABI and static CRT. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e7251f0e..d63523b58 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contribute to STFC Community Mod Send pull requests to **`dev`**. -Use **Zig 0.16.0** and run commands from the repository root. +Use **Zig 0.16.0 or 0.17.0** and run commands from the repository root. `build.zig.zon` names 0.16.0, which version managers such as anyzig pick by default. Zig downloads dependencies and checks the hashes in `build.zig.zon`. No submodules or XMake installation are needed. ## Build for your platform @@ -85,7 +85,7 @@ Windows release builds enable link-time optimization. Disable it with `-Dlto=fal The Swift launcher compiles as one module and links after the native archives are ready. macOS `ReleaseFast` builds batch feature sources to share header parsing; sync sources compile separately. A batch is one translation unit, so it is also the only build that catches two features with the same file-scope name. -Zig 0.16 does not support Mach-O LTO, so macOS builds use ordinary optimized compilation. +Zig does not support Mach-O LTO, so macOS builds use ordinary optimized compilation. | Option | Effect | | --- | --- | @@ -272,6 +272,7 @@ Windows build-identity options have matching environment fallbacks: | `stfc_build_channel` | `STFC_BUILD_CHANNEL` | Prefix options with `-D`, as in `-Dstfc_build_channel=local`. +Zig 0.17 cannot track environment variables in its configure cache, so reading a fallback configures the build again on every run. Values accept 1 to 160 ASCII letters, digits, or `._:+/-`. The DLL version resource contains the same values. Keep CI artifact names compatible with the upstream Windows attestation and macOS release jobs. diff --git a/build.zig b/build.zig index fb46d7d5d..17dbecdd3 100644 --- a/build.zig +++ b/build.zig @@ -1,6 +1,7 @@ //! Build graph for the mod, its native test fixtures, and the macOS launcher. //! //! zig/ctx.zig per-target compile rules (Ctx), library handles (Lib), shared options +//! zig/compat.zig std.Build differences between Zig 0.16 and 0.17 //! zig/toolchains.zig pinned SDKs: fetch-toolchains and sysroot lookup //! zig/deps.zig third-party C and C++ libraries //! zig/protobuf.zig protobuf-lite, Abseil, and generated game messages @@ -10,6 +11,7 @@ //! zig/tools/ host programs the build runs const std = @import("std"); const builtin = @import("builtin"); +const compat = @import("zig/compat.zig"); const Ctx = @import("zig/ctx.zig").Ctx; const Options = @import("zig/ctx.zig").Options; const globFiles = @import("zig/ctx.zig").globFiles; @@ -21,8 +23,9 @@ const tests = @import("zig/tests.zig"); const toolchains = @import("zig/toolchains.zig"); comptime { - if (builtin.zig_version.order(.{ .major = 0, .minor = 16, .patch = 0 }) != .eq) - @compileError("This build is pinned to Zig 0.16.0"); + const version = builtin.zig_version; + if (version.major != 0 or version.minor < 16 or version.minor > 17) + @compileError("This build supports Zig 0.16 and 0.17"); } pub fn build(b: *std.Build) void { @@ -84,5 +87,5 @@ fn addFormatStep(b: *std.Build) void { inline for (.{ "src", "tests" }) |dir| for (globFiles(b, b.path(""), dir, &.{ ".cc", ".h" }, &.{})) |file| clang_format.addFileArg(b.path(file)); step.dependOn(&clang_format.step); - step.dependOn(&b.addFmt(.{ .paths = &.{ "build.zig", "build.zig.zon", "zig" } }).step); + step.dependOn(&compat.addFmt(b, &.{ "build.zig", "build.zig.zon", "zig" }).step); } diff --git a/zig/compat.zig b/zig/compat.zig new file mode 100644 index 000000000..ba6230522 --- /dev/null +++ b/zig/compat.zig @@ -0,0 +1,69 @@ +//! The std.Build differences between Zig 0.16 and 0.17. When 0.16 support ends, inline the 0.17 side and delete this. +//! +//! 0.17 runs build.zig in a separate configure process and caches the graph it produces. The cache key covers +//! `zig build` arguments and build.zig itself, so anything else the graph depends on has to be declared: files and +//! folders read while configuring through `dependOn*`, environment variables and host programs by poisoning the +//! cache for that run. 0.16 configures on every run and needs neither. +const std = @import("std"); +const builtin = @import("builtin"); +const Build = std.Build; + +const zig_017 = builtin.zig_version.minor >= 17; + +/// An absolute path under the build root. 0.17 has no `pathFromRoot` and may run the configurer with a relative root. +pub fn pathFromRoot(b: *Build, sub_path: []const u8) []const u8 { + if (!zig_017) return b.pathFromRoot(sub_path); + const sub_root = if (b.root.sub_path.len == 0) "." else b.root.sub_path; + const root = b.root.root_dir.handle.realPathFileAlloc(b.graph.io, sub_root, b.allocator) catch |err| + std.debug.panic("unable to resolve the build root: {t}", .{err}); + return b.pathJoin(&.{ root, sub_path }); +} + +/// The directory behind a source, package or cwd-relative path, for reading while configuring. 0.17 resolves +/// LazyPaths only in the make phase, so it is limited to paths that exist before the build runs. +pub fn sourcePath(b: *Build, path: Build.LazyPath) Build.Cache.Path { + if (!zig_017) return path.getPath3(b, null); + return switch (path) { + .src_path => |src| src.owner.root.join(b.allocator, src.sub_path) catch @panic("OOM"), + .dependency => |dep| dep.dependency.builder.root.join(b.allocator, dep.sub_path) catch @panic("OOM"), + .cwd_relative => |sub_path| .initCwd(sub_path), + else => std.debug.panic("{f} is not a source path", .{path}), + }; +} + +/// Reads a source or cwd-relative file while configuring. 0.17 configures again when it changes. +pub fn readFile(b: *Build, path: Build.LazyPath, limit: std.Io.Limit) []u8 { + if (zig_017) b.dependOnFileContents(path); + const file = sourcePath(b, path); + return file.root_dir.handle.readFileAlloc(b.graph.io, file.sub_path, b.allocator, limit) catch |err| + std.debug.panic("unable to read {f}: {t}", .{ file, err }); +} + +/// Declares that the graph depends on a directory's entries, read while configuring. Not recursive. +pub fn dependOnDirectory(b: *Build, path: Build.LazyPath) void { + if (zig_017) b.dependOnDirectoryContents(path); +} + +/// An environment variable read while configuring. 0.17 cannot track these, so the read turns its configure cache +/// off for this run. +pub fn getEnv(b: *Build, name: []const u8) ?[]const u8 { + poisonCache(b); + return b.graph.environ_map.get(name); +} + +/// For configure logic 0.17 cannot track, such as asking the host for its SDK. +pub fn poisonCache(b: *Build) void { + if (zig_017) b.graph.poisonCache(); +} + +/// Forwards the arguments after `zig build ... --` to `run`. +pub fn addPassthruArgs(run: *Build.Step.Run) void { + if (zig_017) return run.addPassthruArgs(); + if (run.step.owner.args) |args| run.addArgs(args); +} + +/// `zig fmt` over source paths under the build root. +pub fn addFmt(b: *Build, paths: []const []const u8) *Build.Step.Fmt { + if (zig_017) return b.addFmt(.{ .paths = b.pathList(paths) }); + return b.addFmt(.{ .paths = paths }); +} diff --git a/zig/ctx.zig b/zig/ctx.zig index caf3cf15f..51e466308 100644 --- a/zig/ctx.zig +++ b/zig/ctx.zig @@ -3,8 +3,13 @@ //! to compile against and link one library. const std = @import("std"); const Build = std.Build; +const compat = @import("compat.zig"); const toolchains = @import("toolchains.zig"); +/// Name modes as `OptimizeMode.Debug`: Zig 0.17 lowercases the fields and keeps these names only as declarations, so +/// a bare `.Debug` no longer compares. +pub const OptimizeMode = std.builtin.OptimizeMode; + /// The oldest macOS the mod, loader and launcher support. Scripts and CI pass `-Dtarget=-macos.14.6` and the /// launcher's Info.plist gets it from here; change them together. pub const macos_min = "14.6"; @@ -18,14 +23,14 @@ pub const Options = struct { /// A custom loading screen, relative to the working directory; empty for the built-in one. bg_image: []const u8, /// Fixture optimization; null follows -Doptimize. - test_optimize: ?std.builtin.OptimizeMode, + test_optimize: ?OptimizeMode, msvc_sysroot: ?[]const u8, macos_sdk: ?[]const u8, /// Declare the shared options. Call once from `build`. pub fn parse(b: *Build) Options { return .{ - .lto = b.option(bool, "lto", "Enable LTO (Windows release default; unsupported for macOS in Zig 0.16)"), + .lto = b.option(bool, "lto", "Enable LTO (Windows release default; unsupported for macOS)"), .moddbg = b.option(bool, "moddbg", "Enable diagnostic patch toggles (_MODDBG)") orelse false, .use_original_bg = b.option( bool, @@ -34,7 +39,7 @@ pub const Options = struct { ) orelse false, .bg_image = b.option([]const u8, "bg_image", "Custom loading screen image") orelse "", .test_optimize = b.option( - std.builtin.OptimizeMode, + OptimizeMode, "test-optimize", "Optimization mode for test fixtures (dependencies use -Doptimize)", ), @@ -48,7 +53,7 @@ pub const Options = struct { pub const Ctx = struct { b: *Build, target: Build.ResolvedTarget, - optimize: std.builtin.OptimizeMode, + optimize: OptimizeMode, options: Options, lto: bool, sysroot: toolchains.Sysroot, @@ -57,13 +62,13 @@ pub const Ctx = struct { pub fn init( b: *Build, target: Build.ResolvedTarget, - optimize: std.builtin.OptimizeMode, + optimize: OptimizeMode, options: Options, ) *const Ctx { const os = target.result.os.tag; - const lto = options.lto orelse (os == .windows and optimize != .Debug); + const lto = options.lto orelse (os == .windows and optimize != OptimizeMode.Debug); if (lto and os == .macos) - std.debug.panic("Zig 0.16 does not support Mach-O LTO; use -Dlto=false for macOS", .{}); + std.debug.panic("Zig does not support Mach-O LTO; use -Dlto=false for macOS", .{}); const ctx = b.allocator.create(Ctx) catch @panic("OOM"); ctx.* = .{ .b = b, @@ -77,14 +82,18 @@ pub const Ctx = struct { } /// A copy of this context compiling at `optimize`. Debug fixtures never use LTO. - pub fn withOptimize(ctx: *const Ctx, optimize: std.builtin.OptimizeMode) *const Ctx { + pub fn withOptimize(ctx: *const Ctx, optimize: OptimizeMode) *const Ctx { const copy = ctx.b.allocator.create(Ctx) catch @panic("OOM"); copy.* = ctx.*; copy.optimize = optimize; - if (optimize == .Debug) copy.lto = false; + if (optimize == OptimizeMode.Debug) copy.lto = false; return copy; } + pub fn isDebug(ctx: *const Ctx) bool { + return ctx.optimize == OptimizeMode.Debug; + } + // Target checks. The build host is `builtin.os.tag`; host-only steps such as swiftc and protoc check that. pub fn isWindows(ctx: *const Ctx) bool { return ctx.target.result.os.tag == .windows; @@ -122,13 +131,13 @@ pub const Ctx = struct { module.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ root, "winsdk/include", dir }) }); inline for (.{ "winsdk/lib/um/x64", "winsdk/lib/ucrt/x64", "msvc/lib/x64" }) |dir| module.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ root, dir }) }); - } else if (b.graph.environ_map.get("INCLUDE")) |includes| { + } else if (compat.getEnv(b, "INCLUDE")) |includes| { // No cached SDK on a Windows host: a Visual Studio developer shell supplies the paths, including // C++/WinRT, which Zig's own MSVC detection leaves out. The shell sets both; LIB is read only with // INCLUDE. var include_dirs = std.mem.tokenizeScalar(u8, includes, ';'); while (include_dirs.next()) |dir| module.addSystemIncludePath(.{ .cwd_relative = dir }); - var lib_dirs = std.mem.tokenizeScalar(u8, b.graph.environ_map.get("LIB") orelse "", ';'); + var lib_dirs = std.mem.tokenizeScalar(u8, compat.getEnv(b, "LIB") orelse "", ';'); while (lib_dirs.next()) |dir| module.addLibraryPath(.{ .cwd_relative = dir }); } // The static release CRT and STL (/MT), so version.dll needs no Visual C++ redistributable. @@ -138,6 +147,10 @@ pub const Ctx = struct { module.addSystemIncludePath(.{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/include" }) }); module.addSystemFrameworkPath(.{ .cwd_relative = b.pathJoin(&.{ sdk, "System/Library/Frameworks" }) }); module.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ sdk, "usr/lib" }) }); + // Zig 0.17's libc++ guards its float.h wrapper, so once is in (toml++ includes it before + // ), the SDK's math.h cannot include it again for INFINITY and stops compiling. This is + // the definition clang's float.h gives. + module.addCMacro("INFINITY", "__builtin_inff()"); } return module; } @@ -187,12 +200,18 @@ pub const Ctx = struct { flags: []const []const u8, ) void { const b = ctx.b; + const root_path = compat.sourcePath(b, root); var contents: std.Io.Writer.Allocating = .init(b.allocator); const generated = b.addWriteFiles(); for (files, 0..) |file, index| { // Changing an included source must also change the generated batch's cache key. _ = generated.addCopyFile(root.path(b, file), b.fmt("inputs/{d}.cc", .{index})); - const path = b.pathJoin(&.{ root.getPath(b), file }); + // Absolute, so the source's own relative includes resolve from its real directory. + const path = root_path.root_dir.handle.realPathFileAlloc( + b.graph.io, + b.pathJoin(&.{ root_path.sub_path, file }), + b.allocator, + ) catch |err| std.debug.panic("unable to resolve {f}/{s}: {t}", .{ root_path, file, err }); std.mem.replaceScalar(u8, path, '\\', '/'); const quoted = std.json.Stringify.valueAlloc(b.allocator, path, .{}) catch @panic("OOM"); contents.writer.print("#include {s}\n", .{quoted}) catch @panic("OOM"); @@ -301,16 +320,19 @@ pub fn globFiles( exclude: []const []const u8, ) []const []const u8 { const io = b.graph.io; - const root_path = root.getPath3(b, null); + const root_path = compat.sourcePath(b, root); var dir = root_path.root_dir.handle.openDir(io, b.pathJoin(&.{ root_path.sub_path, sub_path }), .{ .iterate = true, - }) catch |err| std.debug.panic("unable to open {s}/{s}: {t}", .{ root_path.sub_path, sub_path, err }); + }) catch |err| std.debug.panic("unable to open {f}/{s}: {t}", .{ root_path, sub_path, err }); defer dir.close(io); var walker = dir.walk(b.allocator) catch @panic("OOM"); defer walker.deinit(); + // A file added or removed anywhere in the tree changes the list, so every folder walked is a dependency. + compat.dependOnDirectory(b, root.path(b, sub_path)); var out: std.ArrayList([]const u8) = .empty; next: while (walker.next(io) catch |err| std.debug.panic("walk failed: {t}", .{err})) |entry| { + if (entry.kind == .directory) compat.dependOnDirectory(b, root.path(b, b.pathJoin(&.{ sub_path, entry.path }))); if (entry.kind != .file) continue; const ext = std.fs.path.extension(entry.basename); for (exts) |want| { @@ -335,7 +357,7 @@ pub fn hostTool( b: *Build, name: []const u8, source: []const u8, - optimize: std.builtin.OptimizeMode, + optimize: OptimizeMode, ) *Build.Step.Compile { return b.addExecutable(.{ .name = name, diff --git a/zig/launcher.zig b/zig/launcher.zig index 5f8aa7482..4c03bd70f 100644 --- a/zig/launcher.zig +++ b/zig/launcher.zig @@ -2,6 +2,7 @@ //! Swift needs Xcode, so this only builds on a macOS host for a macOS target. const std = @import("std"); const builtin = @import("builtin"); +const compat = @import("compat.zig"); const Ctx = @import("ctx.zig").Ctx; const macos_min = @import("ctx.zig").macos_min; const globFiles = @import("ctx.zig").globFiles; @@ -40,7 +41,7 @@ pub fn add(ctx: *const Ctx) void { swift.addArgs(&.{ "-target", swift_target }); swift.addArgs(&.{ "-Xcc", "-fmodules", "-Xcc" }); swift.addPrefixedFileArg("-fmodule-map-file=", src.path(b, "module.modulemap")); - swift.addArgs(&.{ if (ctx.optimize == .Debug) "-Onone" else "-O", "-g" }); + swift.addArgs(&.{ if (ctx.isDebug()) "-Onone" else "-O", "-g" }); swift.addArgs(&.{ "-emit-object", "-whole-module-optimization" }); for (globFiles(b, src, "", &.{".swift"}, &.{})) |file| swift.addFileArg(src.path(b, file)); swift.addArg("-o"); @@ -79,7 +80,7 @@ fn appleArchive( /// Info.plist with the mod version, the app icon, and the compiled asset catalog. fn installBundleResources(ctx: *const Ctx, step: *std.Build.Step) void { const b = ctx.b; - const template = readSource(b, "src/launcher/macos/Info.plist.template"); + const template = compat.readFile(b, b.path("src/launcher/macos/Info.plist.template"), .limited(64 * 1024)); const versioned = std.mem.replaceOwned(u8, b.allocator, template, "${VERSION}", modVersion(b)) catch @panic("OOM"); const plist = std.mem.replaceOwned(u8, b.allocator, versioned, "${MACOS_MIN}", macos_min) catch @panic("OOM"); const info = b.addWriteFiles().add("Info.plist", plist); @@ -100,7 +101,7 @@ fn installBundleResources(ctx: *const Ctx, step: *std.Build.Step) void { /// MAJOR.MINOR.REVISION.PATCH from the `#define`s in src/mod/version.h. fn modVersion(b: *std.Build) []const u8 { - const header = readSource(b, "src/mod/version.h"); + const header = compat.readFile(b, b.path("src/mod/version.h"), .limited(64 * 1024)); var parts: [4][]const u8 = undefined; inline for (.{ "VERSION_MAJOR", "VERSION_MINOR", "VERSION_REVISION", "VERSION_PATCH" }, 0..) |key, i| { const offset = std.mem.indexOf(u8, header, key) orelse @panic("missing " ++ key ++ " in src/mod/version.h"); @@ -109,10 +110,3 @@ fn modVersion(b: *std.Build) []const u8 { } return b.fmt("{s}.{s}.{s}.{s}", .{ parts[0], parts[1], parts[2], parts[3] }); } - -/// A small text file from the repository, read while the graph is built. -fn readSource(b: *std.Build, path: []const u8) []const u8 { - const io = b.graph.io; - return std.Io.Dir.cwd().readFileAlloc(io, b.pathFromRoot(path), b.allocator, .limited(64 * 1024)) catch |err| - std.debug.panic("unable to read {s}: {t}", .{ path, err }); -} diff --git a/zig/mod.zig b/zig/mod.zig index 603e7c9a3..d87c1874c 100644 --- a/zig/mod.zig +++ b/zig/mod.zig @@ -1,8 +1,10 @@ //! First-party code: the IL2CPP and Unity libraries, the mod, its platform entry points, and the macOS loader. const std = @import("std"); +const compat = @import("compat.zig"); const Ctx = @import("ctx.zig").Ctx; const Lib = @import("ctx.zig").Lib; const Options = @import("ctx.zig").Options; +const OptimizeMode = @import("ctx.zig").OptimizeMode; const macos_min = @import("ctx.zig").macos_min; const globFiles = @import("ctx.zig").globFiles; const hostTool = @import("ctx.zig").hostTool; @@ -112,7 +114,7 @@ pub fn add(ctx: *const Ctx, libs: []const Lib) void { fn addModSources(ctx: *const Ctx, module: *std.Build.Module, flags: []const []const u8) void { const b = ctx.b; const root = b.path("src/mod"); - if (ctx.isMacos() and ctx.optimize == .ReleaseFast) { + if (ctx.isMacos() and ctx.optimize == OptimizeMode.ReleaseFast) { // Release builds on macOS spend most of their time parsing game headers. Batches of four features share // that work; the heavy sync units stay separate so they keep compiling in parallel. // @@ -147,14 +149,14 @@ fn addBuildIdentity(ctx: *const Ctx, module: *std.Build.Module) []const []const }, 0..) |field, i| { const option, const env, const default = field; const value = b.option([]const u8, option, "Build identity value") orelse - b.graph.environ_map.get(env) orelse default; + compat.getEnv(b, env) orelse default; // Values land inside C and RC string literals; keep them to characters that need no escaping. if (value.len == 0 or value.len > 160) std.debug.panic("{s} must contain 1–160 characters", .{option}); for (value) |char| if (!std.ascii.isAlphanumeric(char) and std.mem.indexOfScalar(u8, "._:+/-", char) == null) std.debug.panic("{s} contains unsupported identity characters", .{option}); defines[i] = .{ env, value }; } - const mode = if (ctx.options.moddbg) "releasedbg" else if (ctx.optimize == .Debug) "debug" else "release"; + const mode = if (ctx.options.moddbg) "releasedbg" else if (ctx.isDebug()) "debug" else "release"; defines[4] = .{ "STFC_BUILD_MODE", mode }; var rc_flags: std.ArrayList([]const u8) = .empty; @@ -174,17 +176,17 @@ fn embedImages(ctx: *const Ctx, module: *std.Build.Module) void { // Header, symbol, and source image. Built-in images resolve from the repository, so the build runs from any // directory; a custom -Dbg_image stays relative to where the user ran it. const custom_bg = ctx.options.bg_image; - const bg_image = if (custom_bg.len == 0) b.pathFromRoot("assets/loadingscreen.png") else custom_bg; - const table = [_][3][]const u8{ + const bg_image: std.Build.LazyPath = + if (custom_bg.len == 0) b.path("assets/loadingscreen.png") else .{ .cwd_relative = custom_bg }; + const Image = struct { []const u8, []const u8, std.Build.LazyPath }; + const table = [_]Image{ .{ "embedded_loading_image.h", "g_embeddedLoadingImage", bg_image }, - .{ "embedded_logo_image.h", "g_embeddedLogoImage", b.pathFromRoot("assets/stfc-mod-logo.png") }, - .{ "embedded_cc_logo_image.h", "g_embeddedCCLogoImage", b.pathFromRoot("assets/official-cc-logo.png") }, + .{ "embedded_logo_image.h", "g_embeddedLogoImage", b.path("assets/stfc-mod-logo.png") }, + .{ "embedded_cc_logo_image.h", "g_embeddedCCLogoImage", b.path("assets/official-cc-logo.png") }, }; - const limit: std.Io.Limit = .limited(64 * 1024 * 1024); for (table) |entry| { const header, const symbol, const path = entry; - const data = std.Io.Dir.cwd().readFileAlloc(b.graph.io, path, b.allocator, limit) catch |err| - std.debug.panic("unable to read embedded image {s}: {t}", .{ path, err }); + const data = compat.readFile(b, path, .limited(64 * 1024 * 1024)); var out: std.Io.Writer.Allocating = .init(b.allocator); // Adjacent string literals of \x escapes. An initializer list of one expression per byte costs Clang far // more time and memory. The literal adds a null byte, so _SIZE carries the real length. diff --git a/zig/tests.zig b/zig/tests.zig index da33eaf22..cdd3d535c 100644 --- a/zig/tests.zig +++ b/zig/tests.zig @@ -1,6 +1,7 @@ //! Native test fixtures. They link the same libraries as the shipped mod, and run without the game. const std = @import("std"); const builtin = @import("builtin"); +const compat = @import("compat.zig"); const Ctx = @import("ctx.zig").Ctx; const Lib = @import("ctx.zig").Lib; const deps = @import("deps.zig"); @@ -177,7 +178,7 @@ pub fn addDependencySmoke(ctx: *const Ctx, libs: []const Lib) void { module.addCSourceFile(.{ .file = b.path("tests/dependency_smoke.cc"), .flags = ctx.cxxFlags("c++23", &.{}) }); const exe = ctx.executable("dependency-smoke", module); const run = b.addRunArtifact(exe); - if (b.args) |args| run.addArgs(args); + compat.addPassthruArgs(run); b.step("test-deps", "Run protobuf, zlib and HTTPS backend checks; optionally pass a test URL and CA file") .dependOn(&run.step); const build_step = b.step("build-dependency-test", "Cross-compile the dependency smoke executable"); @@ -213,5 +214,5 @@ fn addFixture( /// `path` from the repository root as a C string literal. fn quotedPath(b: *std.Build, path: []const u8) []const u8 { - return std.json.Stringify.valueAlloc(b.allocator, b.pathFromRoot(path), .{}) catch @panic("OOM"); + return std.json.Stringify.valueAlloc(b.allocator, compat.pathFromRoot(b, path), .{}) catch @panic("OOM"); } diff --git a/zig/toolchains.zig b/zig/toolchains.zig index 4359b7ad8..c37e2cade 100644 --- a/zig/toolchains.zig +++ b/zig/toolchains.zig @@ -1,6 +1,7 @@ //! Pinned Windows and macOS SDKs: the fetch step that provisions them and the lookup that finds a sysroot. const std = @import("std"); const builtin = @import("builtin"); +const compat = @import("compat.zig"); const Options = @import("ctx.zig").Options; const hostTool = @import("ctx.zig").hostTool; const hostTests = @import("ctx.zig").hostTests; @@ -45,7 +46,7 @@ pub fn addFetchSteps(b: *std.Build) void { const pin = if (windows) pins.windows_msvc else pins.macos_sdk; const fetch = b.addRunArtifact(tool); fetch.addArgs(&.{ - b.pathFromRoot(".cache/toolchain"), + compat.pathFromRoot(b, ".cache/toolchain"), pin.version, pin.url, pin.url_env, @@ -64,8 +65,8 @@ pub fn find(b: *std.Build, target: std.Build.ResolvedTarget, options: Options) S .windows => { if (target.result.abi != .msvc) std.debug.panic("Windows builds must use the MSVC ABI, e.g. -Dtarget=x86_64-windows-msvc", .{}); - const cached = b.pathFromRoot(b.pathJoin(&.{ ".cache/toolchain/windows-msvc", pins.windows_msvc.version })); - const root = options.msvc_sysroot orelse if (dirExists(b, cached)) cached else null; + const cached = b.pathJoin(&.{ ".cache/toolchain/windows-msvc", pins.windows_msvc.version }); + const root = options.msvc_sysroot orelse fetched(b, cached); if (root) |r| return .{ .msvc_root = r, .msvc_libc_file = msvcLibcFile(b, r) }; // A Windows host can fall back to an installed Visual Studio; see Ctx.createModule. if (builtin.os.tag == .windows) return .{}; @@ -77,11 +78,12 @@ pub fn find(b: *std.Build, target: std.Build.ResolvedTarget, options: Options) S }, .macos => { if (options.macos_sdk) |sdk| return .{ .macos_sdk = sdk }; - const cached = b.pathFromRoot(b.pathJoin(&.{ ".cache/toolchain/macos-sdk", pins.macos_sdk.version })); - if (pins.macos_sdk.version.len != 0 and dirExists(b, cached)) return .{ .macos_sdk = cached }; + const cached = b.pathJoin(&.{ ".cache/toolchain/macos-sdk", pins.macos_sdk.version }); + if (pins.macos_sdk.version.len != 0) if (fetched(b, cached)) |sdk| return .{ .macos_sdk = sdk }; // Resolve the host SDK explicitly, rather than leaving it to Zig, so the other architecture's build // (x86_64 on arm64) finds frameworks too. if (builtin.os.tag == .macos) { + compat.poisonCache(b); if (std.zig.system.darwin.getSdk(b.allocator, b.graph.io, &target.result)) |sdk| return .{ .macos_sdk = sdk }; std.debug.panic("unable to locate the macOS SDK via xcrun; install the Xcode command line tools", .{}); @@ -96,6 +98,7 @@ pub fn find(b: *std.Build, target: std.Build.ResolvedTarget, options: Options) S } /// A Zig libc file for the extracted toolchain, so Zig takes the CRT and STL from it instead of searching the host. +/// Zig 0.17 requires `darwin_sdk_dir`; 0.16 ignores keys it does not know. fn msvcLibcFile(b: *std.Build, root: []const u8) std.Build.LazyPath { const contents = b.fmt( \\include_dir={0s}/winsdk/include/ucrt @@ -104,13 +107,18 @@ fn msvcLibcFile(b: *std.Build, root: []const u8) std.Build.LazyPath { \\msvc_lib_dir={0s}/msvc/lib/x64 \\kernel32_lib_dir={0s}/winsdk/lib/um/x64 \\gcc_dir= + \\darwin_sdk_dir= \\ , .{root}); return b.addWriteFiles().add("msvc-libc.txt", contents); } -fn dirExists(b: *std.Build, path: []const u8) bool { - var dir = std.Io.Dir.cwd().openDir(b.graph.io, path, .{}) catch return false; +/// The absolute path of a fetched toolchain folder under the build root, or null if it is missing. A found one is a +/// configure dependency, so deleting it configures again; a missing one ends in a panic or a poisoned cache. +fn fetched(b: *std.Build, sub_path: []const u8) ?[]const u8 { + const path = compat.pathFromRoot(b, sub_path); + var dir = std.Io.Dir.cwd().openDir(b.graph.io, path, .{}) catch return null; dir.close(b.graph.io); - return true; + compat.dependOnDirectory(b, b.path(sub_path)); + return path; }