From 6077e3341370c23cd1fe31281cd10967551c24f2 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 21:34:40 -0500 Subject: [PATCH 01/13] Add local officer preset reordering --- example_community_patch_settings_da.toml | 5 + example_community_patch_settings_de.toml | 5 + ...munity_patch_settings_en-GB-x-cockney.toml | 5 + ...mmunity_patch_settings_en-x-minionese.toml | 5 + example_community_patch_settings_en.toml | 5 + example_community_patch_settings_es.toml | 5 + example_community_patch_settings_fr.toml | 5 + example_community_patch_settings_nl.toml | 5 + example_community_patch_settings_ru.toml | 5 + example_community_patch_settings_tlh.toml | 5 + mods/src/config.cc | 2 + mods/src/config.h | 1 + mods/src/defaultconfig.h | 1 + .../patches/parts/officer_preset_reorder.cc | 384 ++++++++++++++++++ mods/src/patches/patches.cc | 2 + 15 files changed, 440 insertions(+) create mode 100644 mods/src/patches/parts/officer_preset_reorder.cc diff --git a/example_community_patch_settings_da.toml b/example_community_patch_settings_da.toml index 4e4003d2f..a75cc138e 100644 --- a/example_community_patch_settings_da.toml +++ b/example_community_patch_settings_da.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Forhindr "Escape" i at spørge, om spillet skal afsluttes disable_escape_exit = true diff --git a/example_community_patch_settings_de.toml b/example_community_patch_settings_de.toml index 2dda59892..ef15dd229 100644 --- a/example_community_patch_settings_de.toml +++ b/example_community_patch_settings_de.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Verhindert, dass die Escape-Taste zum Beenden des Spiels auffordert disable_escape_exit = true diff --git a/example_community_patch_settings_en-GB-x-cockney.toml b/example_community_patch_settings_en-GB-x-cockney.toml index 0ccf711ca..3034cc6d2 100644 --- a/example_community_patch_settings_en-GB-x-cockney.toml +++ b/example_community_patch_settings_en-GB-x-cockney.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true diff --git a/example_community_patch_settings_en-x-minionese.toml b/example_community_patch_settings_en-x-minionese.toml index 9f1e1e3a6..0328cde23 100644 --- a/example_community_patch_settings_en-x-minionese.toml +++ b/example_community_patch_settings_en-x-minionese.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 9ffe927fe..c0433aa7e 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" from prompting to exit the game disable_escape_exit = true diff --git a/example_community_patch_settings_es.toml b/example_community_patch_settings_es.toml index b89c9a2e8..9c48e639c 100644 --- a/example_community_patch_settings_es.toml +++ b/example_community_patch_settings_es.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Impide que "Escape" pregunte si deseas salir del juego disable_escape_exit = true diff --git a/example_community_patch_settings_fr.toml b/example_community_patch_settings_fr.toml index fdbc5f79d..51f36f198 100644 --- a/example_community_patch_settings_fr.toml +++ b/example_community_patch_settings_fr.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Empêcher "échap" de proposer de quitter le jeu disable_escape_exit = true diff --git a/example_community_patch_settings_nl.toml b/example_community_patch_settings_nl.toml index b2b39b208..788a5d949 100644 --- a/example_community_patch_settings_nl.toml +++ b/example_community_patch_settings_nl.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Zorgt ervoor dat het gebruiken van "escape" het spel niet sluit disable_escape_exit = true diff --git a/example_community_patch_settings_ru.toml b/example_community_patch_settings_ru.toml index ad3db036a..61ef60edb 100644 --- a/example_community_patch_settings_ru.toml +++ b/example_community_patch_settings_ru.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Не показывать запрос выхода из игры при нажатии Escape disable_escape_exit = true diff --git a/example_community_patch_settings_tlh.toml b/example_community_patch_settings_tlh.toml index b9bce7597..69825164a 100644 --- a/example_community_patch_settings_tlh.toml +++ b/example_community_patch_settings_tlh.toml @@ -593,6 +593,11 @@ auto_confirm_discovery = true # Open the bulk gift claim flyout automatically auto_open_bulk_claim_flyout = false +# EXPERIMENTAL: Allow officer presets to be reordered in the presets screen +# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# The chosen order is stored locally and does not change Scopely's preset slots +allow_officer_preset_reordering = false + # Prevent "escape" vo' prompting Daq exit game disable_escape_exit = true diff --git a/mods/src/config.cc b/mods/src/config.cc index 95deab836..094ce8e66 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -953,6 +953,8 @@ void Config::Load() this->installAudioEventHooks = this->trace_audio_events || !this->disabled_audio_events.empty(); this->auto_open_bulk_claim_flyout = get_config_or_default(config, parsed, "ui", "auto_open_bulk_claim_flyout", DCU::auto_open_bulk_claim_flyout, write_config); + this->allow_officer_preset_reordering = get_config_or_default( + config, parsed, "ui", "allow_officer_preset_reordering", DCU::allow_officer_preset_reordering, write_config); read_daily_bulk_claim_factions(config, parsed, this->daily_bulk_claim_factions, DCU::daily_bulk_claim_factions, write_config); diff --git a/mods/src/config.h b/mods/src/config.h index 43f5c3913..2f29170a8 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -197,6 +197,7 @@ class Config final bool trace_audio_events; std::vector disabled_audio_events; bool auto_open_bulk_claim_flyout; + bool allow_officer_preset_reordering; bool auto_confirm_ft_upgrade; std::vector daily_bulk_claim_factions; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index b22cad5b8..dadaeabd2 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -219,6 +219,7 @@ namespace UI constexpr bool auto_confirm_discovery = true; constexpr bool auto_confirm_ft_upgrade = false; constexpr bool auto_open_bulk_claim_flyout = false; + constexpr bool allow_officer_preset_reordering = false; constexpr const char* daily_bulk_claim_factions = ""; constexpr bool daily_bulk_claim_toggle_default_on = false; constexpr bool disable_escape_exit = true; diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc new file mode 100644 index 000000000..fea4d8eed --- /dev/null +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -0,0 +1,384 @@ +#include "errormsg.h" +#include "file.h" +#include "patches/key.h" +#include "str_utils.h" + +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +struct OfficerPresetItemContext { + Il2CppObject object; + void* synthetic_fleet_player_data; + int32_t presentation; + bool is_save_available; + bool is_occupied; + int32_t order_id; + int64_t slot_id; + Il2CppString* preset_name; + Il2CppArray* officers; + Il2CppArray* below_deck_officers; + bool below_deck_slots_unlocked; + void* officer_presets_view_context; +}; + +using ClearAndGenerateContentsFn = void(void*, Il2CppObject*, Il2CppObject*); +using GetScrollPositionFn = float(void*); +using RestoreScrollPositionFn = void(void*, float); + +std::vector session_order; +Il2CppObject* active_controller = nullptr; +ptrdiff_t widget_context_offset = 0; +ptrdiff_t controller_scroller_offset = 0; +ptrdiff_t presets_items_offset = 0; +ClearAndGenerateContentsFn* clear_and_generate = nullptr; +GetScrollPositionFn* get_scroll_position = nullptr; +RestoreScrollPositionFn* restore_scroll_position = nullptr; + +std::filesystem::path state_path() +{ return std::filesystem::path{File::MakePath("community_patch_state.json", true)}; } + +void load_session_order() +{ + const auto path = state_path(); + std::ifstream file(path, std::ios::in | std::ios::binary); + if (!file) { + return; + } + + try { + const auto state = nlohmann::json::parse(file); + const auto order = state.find("officer_preset_order"); + if (order == state.end() || !order->is_array()) { + return; + } + + std::vector loaded_order; + loaded_order.reserve(order->size()); + for (const auto& item : *order) { + int64_t slot_id = -1; + if (item.is_string()) { + const auto& value = item.get_ref(); + const auto result = std::from_chars(value.data(), value.data() + value.size(), slot_id); + if (result.ec != std::errc{} || result.ptr != value.data() + value.size()) { + continue; + } + } else if (item.is_number_integer()) { + slot_id = item.get(); + } else { + continue; + } + + if (slot_id >= 0 && std::find(loaded_order.begin(), loaded_order.end(), slot_id) == loaded_order.end()) { + loaded_order.push_back(slot_id); + } + } + + session_order = std::move(loaded_order); + spdlog::info("[OfficerPresetReorder] loaded {} persisted slot positions", session_order.size()); + } catch (const std::exception& error) { + spdlog::warn("[OfficerPresetReorder] ignored invalid state file '{}': {}", path.string(), error.what()); + } +} + +void save_session_order() +{ + const auto path = state_path(); + nlohmann::json state = nlohmann::json::object(); + try { + std::ifstream existing(path, std::ios::in | std::ios::binary); + if (existing) { + state = nlohmann::json::parse(existing); + if (!state.is_object()) { + state = nlohmann::json::object(); + } + } + } catch (const std::exception& error) { + spdlog::warn("[OfficerPresetReorder] replacing invalid state file '{}': {}", path.string(), error.what()); + state = nlohmann::json::object(); + } + + auto order = nlohmann::json::array(); + for (const auto slot_id : session_order) { + order.push_back(std::to_string(slot_id)); + } + state["version"] = 1; + state["officer_preset_order"] = std::move(order); + + std::ofstream file(path, std::ios::out | std::ios::binary | std::ios::trunc); + if (!file) { + spdlog::warn("[OfficerPresetReorder] unable to write state file '{}'", path.string()); + return; + } + file << state.dump(2) << '\n'; +} + +static_assert(offsetof(OfficerPresetItemContext, presentation) == 0x18); +static_assert(offsetof(OfficerPresetItemContext, is_occupied) == 0x1D); +static_assert(offsetof(OfficerPresetItemContext, order_id) == 0x20); +static_assert(offsetof(OfficerPresetItemContext, slot_id) == 0x28); +static_assert(offsetof(OfficerPresetItemContext, preset_name) == 0x30); + +bool is_reorderable_preset(const OfficerPresetItemContext* context) +{ + return context != nullptr && context->slot_id >= 0 && context->order_id >= 0 && context->preset_name != nullptr + && context->officers != nullptr && reinterpret_cast(context->officers)->max_length > 0; +} + +template T* read_object_field(void* object, ptrdiff_t offset) +{ return object != nullptr ? *reinterpret_cast(reinterpret_cast(object) + offset) : nullptr; } + +void remember_slots(OfficerPresetItemContext** items, il2cpp_array_size_t size) +{ + for (il2cpp_array_size_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!is_reorderable_preset(context)) { + continue; + } + if (std::find(session_order.begin(), session_order.end(), context->slot_id) == session_order.end()) { + session_order.push_back(context->slot_id); + } + } +} + +void apply_session_order(OfficerPresetItemContext** items, il2cpp_array_size_t size) +{ + remember_slots(items, size); + if (session_order.empty()) { + return; + } + + std::vector occupied_positions; + std::vector occupied_contexts; + occupied_positions.reserve(size); + occupied_contexts.reserve(size); + for (il2cpp_array_size_t index = 0; index < size; ++index) { + if (is_reorderable_preset(items[index])) { + occupied_positions.push_back(index); + occupied_contexts.push_back(items[index]); + } + } + + std::stable_sort(occupied_contexts.begin(), occupied_contexts.end(), [](const auto* left, const auto* right) { + const auto left_order = std::find(session_order.begin(), session_order.end(), left->slot_id); + const auto right_order = std::find(session_order.begin(), session_order.end(), right->slot_id); + return left_order < right_order; + }); + + std::vector presentations; + presentations.reserve(occupied_positions.size()); + for (const auto position : occupied_positions) { + presentations.push_back(items[position]->presentation); + } + for (size_t index = 0; index < occupied_positions.size(); ++index) { + items[occupied_positions[index]] = occupied_contexts[index]; + occupied_contexts[index]->presentation = presentations[index]; + } +} + +bool try_get_preset_list(void* view_context, Il2CppObject** list, Il2CppArraySize** backing_items, int32_t* size) +{ + *list = read_object_field(view_context, presets_items_offset); + if (*list == nullptr) { + return false; + } + + auto list_helper = IL2CppClassHelper{(*list)->klass}; + auto items_field = list_helper.GetField("_items"); + auto size_field = list_helper.GetField("_size"); + if (!items_field.isValidHelper() || !size_field.isValidHelper()) { + return false; + } + + *backing_items = read_object_field(*list, items_field.offset()); + *size = *reinterpret_cast(reinterpret_cast(*list) + size_field.offset()); + return *backing_items != nullptr && *size >= 0 + && static_cast(*size) <= (*backing_items)->max_length; +} + +bool move_preset(OfficerPresetItemContext* context, int direction) +{ + if (!is_reorderable_preset(context) || context->officer_presets_view_context == nullptr + || active_controller == nullptr || clear_and_generate == nullptr) { + return false; + } + + Il2CppObject* list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (!try_get_preset_list(context->officer_presets_view_context, &list, &backing_items, &size)) { + spdlog::warn("[OfficerPresetReorder] unable to read live preset list"); + return false; + } + + auto** items = reinterpret_cast(backing_items->vector); + int32_t current_index = -1; + for (int32_t index = 0; index < size; ++index) { + if (items[index] == context) { + current_index = index; + break; + } + } + if (current_index < 0) { + return false; + } + + int32_t target_index = current_index + direction; + while (target_index >= 0 && target_index < size && !is_reorderable_preset(items[target_index])) { + target_index += direction; + } + if (target_index < 0 || target_index >= size) { + spdlog::info("[OfficerPresetReorder] slot={} is already at the {}", context->slot_id, + direction < 0 ? "top" : "bottom"); + return true; + } + + auto* target = items[target_index]; + remember_slots(items, static_cast(size)); + const auto current_order = std::find(session_order.begin(), session_order.end(), context->slot_id); + const auto target_order = std::find(session_order.begin(), session_order.end(), target->slot_id); + if (current_order != session_order.end() && target_order != session_order.end()) { + std::iter_swap(current_order, target_order); + save_session_order(); + } + + std::swap(context->presentation, target->presentation); + std::swap(items[current_index], items[target_index]); + + auto* scroller = read_object_field(active_controller, controller_scroller_offset); + if (scroller == nullptr) { + return false; + } + + const auto scroll_position = get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; + spdlog::info("[OfficerPresetReorder] moved slot={} {} across slot={} (persisted locally)", context->slot_id, + direction < 0 ? "up" : "down", target->slot_id); + clear_and_generate(scroller, active_controller, list); + if (restore_scroll_position != nullptr) { + restore_scroll_position(scroller, scroll_position); + } + return true; +} + +bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2CppArraySize** preset_contexts, + void* view_context) +{ + const bool result = original(_this, preset_contexts, view_context); + if (!result || preset_contexts == nullptr || *preset_contexts == nullptr) { + spdlog::info("[OfficerPresetReorder] preset context load returned result={} array={}", result, + preset_contexts != nullptr ? static_cast(*preset_contexts) : nullptr); + return result; + } + + auto* contexts = *preset_contexts; + auto** items = reinterpret_cast(contexts->vector); + apply_session_order(items, contexts->max_length); + spdlog::debug("[OfficerPresetReorder] applied local ordering to {} preset rows", contexts->max_length); + + return result; +} + +void OfficerPresetItemWidget_OnEditNameButtonClicked_Hook(auto original, void* _this) +{ + const bool move_up = Key::HasShift(); + const bool move_down = Key::HasCtrl(); + if (move_up != move_down) { + auto* context = read_object_field(_this, widget_context_offset); + if (move_preset(context, move_up ? -1 : 1)) { + return; + } + } + original(_this); +} + +void OfficerPresetsViewController_OnDidBindCanvasContext_Hook(auto original, Il2CppObject* _this) +{ + original(_this); + active_controller = _this; +} + +void OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook(auto original, Il2CppObject* _this) +{ + if (active_controller == _this) { + active_controller = nullptr; + } + original(_this); +} +} // namespace + +void InstallOfficerPresetReorderHooks() +{ + auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Officers", "OfficerManager"); + if (!helper.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.Prime.Officers", "OfficerManager"); + return; + } + + const auto method = helper.GetMethodInfo("TryGetPresetItemContext", 2); + if (method == nullptr || method->methodPointer == nullptr) { + ErrorMsg::MissingMethod("OfficerManager", "TryGetPresetItemContext"); + return; + } + + auto widget_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemWidget"); + auto controller_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewController"); + auto view_context_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewContext"); + auto scroller_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "SmartScrollerBase"); + if (!widget_helper.isValidHelper() || !controller_helper.isValidHelper() || !view_context_helper.isValidHelper() + || !scroller_helper.isValidHelper()) { + ErrorMsg::MissingHelper("Digit.Prime.OfficerPresets", "reorder UI surface"); + return; + } + + auto context_field = widget_helper.GetField("m_context"); + auto scroller_field = controller_helper.GetField("_smartScroller"); + auto presets_field = view_context_helper.GetField("PresetsItemsContext"); + if (!context_field.isValidHelper() || !scroller_field.isValidHelper() || !presets_field.isValidHelper()) { + ErrorMsg::MissingMethod("OfficerPresetReorder", "required field"); + return; + } + widget_context_offset = context_field.offset(); + controller_scroller_offset = scroller_field.offset(); + presets_items_offset = presets_field.offset(); + + const auto clear_method = scroller_helper.GetMethodInfo("ClearAndGenerateContents", 2); + const auto get_scroll_method = scroller_helper.GetMethodInfo("get_ScrollPosition", 0); + const auto restore_scroll_method = scroller_helper.GetMethodInfo("RestoreScrollPosition", 1); + const auto edit_method = widget_helper.GetMethodInfo("OnEditNameButtonClicked", 0); + const auto bind_method = controller_helper.GetMethodInfo("OnDidBindCanvasContext", 0); + const auto release_method = controller_helper.GetMethodInfo("OnAboutToReleaseCanvasContext", 0); + if (clear_method == nullptr || clear_method->methodPointer == nullptr || get_scroll_method == nullptr + || get_scroll_method->methodPointer == nullptr || restore_scroll_method == nullptr + || restore_scroll_method->methodPointer == nullptr || edit_method == nullptr + || edit_method->methodPointer == nullptr || bind_method == nullptr || bind_method->methodPointer == nullptr + || release_method == nullptr || release_method->methodPointer == nullptr) { + ErrorMsg::MissingMethod("OfficerPresetReorder", "required UI method"); + return; + } + clear_and_generate = reinterpret_cast(clear_method->methodPointer); + get_scroll_position = reinterpret_cast(get_scroll_method->methodPointer); + restore_scroll_position = reinterpret_cast(restore_scroll_method->methodPointer); + + load_session_order(); + + SPUD_STATIC_DETOUR(method->methodPointer, OfficerManager_TryGetPresetItemContext_Hook); + SPUD_STATIC_DETOUR(edit_method->methodPointer, OfficerPresetItemWidget_OnEditNameButtonClicked_Hook); + SPUD_STATIC_DETOUR(bind_method->methodPointer, OfficerPresetsViewController_OnDidBindCanvasContext_Hook); + SPUD_STATIC_DETOUR(release_method->methodPointer, OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook); +} diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 9579b9692..f0dbf27ef 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -48,6 +48,7 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); +void InstallOfficerPresetReorderHooks(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -151,6 +152,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.auto_confirm_ft_upgrade}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, + {"OfficerPresetReorder", {InstallOfficerPresetReorderHooks, &cfg.allow_officer_preset_reordering}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); From c2a443466848ce1fc52442c5a72d87971f31978d Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 21:42:30 -0500 Subject: [PATCH 02/13] Harden preset reorder input and state writes --- .../patches/parts/officer_preset_reorder.cc | 67 ++++++++++++++++--- 1 file changed, 57 insertions(+), 10 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index fea4d8eed..de5f1af36 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -1,6 +1,6 @@ #include "errormsg.h" #include "file.h" -#include "patches/key.h" +#include "prime/KeyCode.h" #include "str_utils.h" #include @@ -94,7 +94,28 @@ void load_session_order() } } -void save_session_order() +bool replace_state_file(const std::filesystem::path& temporary_path, const std::filesystem::path& path) +{ +#if _WIN32 + if (MoveFileExW(temporary_path.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0) { + return true; + } + spdlog::warn("[OfficerPresetReorder] unable to replace state file '{}' (Windows error {})", path.string(), + GetLastError()); +#else + std::error_code error; + std::filesystem::rename(temporary_path, path, error); + if (!error) { + return true; + } + spdlog::warn("[OfficerPresetReorder] unable to replace state file '{}': {}", path.string(), error.message()); +#endif + std::error_code cleanup_error; + std::filesystem::remove(temporary_path, cleanup_error); + return false; +} + +bool save_session_order() { const auto path = state_path(); nlohmann::json state = nlohmann::json::object(); @@ -118,12 +139,24 @@ void save_session_order() state["version"] = 1; state["officer_preset_order"] = std::move(order); - std::ofstream file(path, std::ios::out | std::ios::binary | std::ios::trunc); + auto temporary_path = path; + temporary_path += ".tmp"; + std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); if (!file) { - spdlog::warn("[OfficerPresetReorder] unable to write state file '{}'", path.string()); - return; + spdlog::warn("[OfficerPresetReorder] unable to open temporary state file '{}'", temporary_path.string()); + return false; } file << state.dump(2) << '\n'; + file.flush(); + const bool write_succeeded = file.good(); + file.close(); + if (!write_succeeded || file.fail()) { + spdlog::warn("[OfficerPresetReorder] unable to finish temporary state file '{}'", temporary_path.string()); + std::error_code cleanup_error; + std::filesystem::remove(temporary_path, cleanup_error); + return false; + } + return replace_state_file(temporary_path, path); } static_assert(offsetof(OfficerPresetItemContext, presentation) == 0x18); @@ -141,6 +174,18 @@ bool is_reorderable_preset(const OfficerPresetItemContext* context) template T* read_object_field(void* object, ptrdiff_t offset) { return object != nullptr ? *reinterpret_cast(reinterpret_cast(object) + offset) : nullptr; } +bool key_pressed(KeyCode key) +{ + static auto get_key = il2cpp_resolve_icall_typed("UnityEngine.Input::GetKeyInt(UnityEngine.KeyCode)"); + return get_key != nullptr && get_key(key); +} + +bool shift_pressed() +{ return key_pressed(KeyCode::LeftShift) || key_pressed(KeyCode::RightShift); } + +bool control_pressed() +{ return key_pressed(KeyCode::LeftControl) || key_pressed(KeyCode::RightControl); } + void remember_slots(OfficerPresetItemContext** items, il2cpp_array_size_t size) { for (il2cpp_array_size_t index = 0; index < size; ++index) { @@ -250,9 +295,10 @@ bool move_preset(OfficerPresetItemContext* context, int direction) remember_slots(items, static_cast(size)); const auto current_order = std::find(session_order.begin(), session_order.end(), context->slot_id); const auto target_order = std::find(session_order.begin(), session_order.end(), target->slot_id); + bool persisted = false; if (current_order != session_order.end() && target_order != session_order.end()) { std::iter_swap(current_order, target_order); - save_session_order(); + persisted = save_session_order(); } std::swap(context->presentation, target->presentation); @@ -264,8 +310,9 @@ bool move_preset(OfficerPresetItemContext* context, int direction) } const auto scroll_position = get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; - spdlog::info("[OfficerPresetReorder] moved slot={} {} across slot={} (persisted locally)", context->slot_id, - direction < 0 ? "up" : "down", target->slot_id); + spdlog::info("[OfficerPresetReorder] moved slot={} {} across slot={} ({})", context->slot_id, + direction < 0 ? "up" : "down", target->slot_id, + persisted ? "persisted locally" : "local persistence failed"); clear_and_generate(scroller, active_controller, list); if (restore_scroll_position != nullptr) { restore_scroll_position(scroller, scroll_position); @@ -293,8 +340,8 @@ bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2 void OfficerPresetItemWidget_OnEditNameButtonClicked_Hook(auto original, void* _this) { - const bool move_up = Key::HasShift(); - const bool move_down = Key::HasCtrl(); + const bool move_up = shift_pressed(); + const bool move_down = control_pressed(); if (move_up != move_down) { auto* context = read_object_field(_this, widget_context_offset); if (move_preset(context, move_up ? -1 : 1)) { From 87b95b56e1168938017a2cba45311cc9bfe93700 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Sun, 30 Aug 2026 22:00:10 -0500 Subject: [PATCH 03/13] Scope preset state to config profiles --- .../patches/parts/officer_preset_reorder.cc | 117 +++++++++++++++++- 1 file changed, 113 insertions(+), 4 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index de5f1af36..44edee30e 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -10,14 +10,24 @@ #include #include +#include +#include +#include #include #include #include #include #include #include +#include #include +#if !_WIN32 +#include +#include +#include +#endif + namespace { struct OfficerPresetItemContext { @@ -49,7 +59,101 @@ GetScrollPositionFn* get_scroll_position = nullptr; RestoreScrollPositionFn* restore_scroll_position = nullptr; std::filesystem::path state_path() -{ return std::filesystem::path{File::MakePath("community_patch_state.json", true)}; } +{ + if (File::hasCustomNames()) { + std::filesystem::path config_path{File::Config()}; + auto state_name = config_path.stem(); + state_name += ".state.json"; + return config_path.parent_path() / state_name; + } + return std::filesystem::path{File::MakePath("community_patch_state.json", true)}; +} + +class StateFileLock +{ +public: + explicit StateFileLock(const std::filesystem::path& path) + { + lock_path = path; + lock_path += ".lock"; + + for (int attempt = 0; attempt < 50; ++attempt) { +#if _WIN32 + handle = CreateFileW(lock_path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle != INVALID_HANDLE_VALUE) { + return; + } + const auto error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION && error != ERROR_LOCK_VIOLATION) { + break; + } +#else + descriptor = open(lock_path.c_str(), O_CREAT | O_RDWR, 0600); + if (descriptor < 0) { + break; + } + if (flock(descriptor, LOCK_EX | LOCK_NB) == 0) { + return; + } + const auto error = errno; + close(descriptor); + descriptor = -1; + if (error != EWOULDBLOCK && error != EAGAIN) { + break; + } +#endif + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + + ~StateFileLock() + { +#if _WIN32 + if (handle != INVALID_HANDLE_VALUE) { + CloseHandle(handle); + } +#else + if (descriptor >= 0) { + flock(descriptor, LOCK_UN); + close(descriptor); + } +#endif + } + + StateFileLock(const StateFileLock&) = delete; + StateFileLock& operator=(const StateFileLock&) = delete; + + bool acquired() const + { +#if _WIN32 + return handle != INVALID_HANDLE_VALUE; +#else + return descriptor >= 0; +#endif + } + +private: + std::filesystem::path lock_path; +#if _WIN32 + HANDLE handle = INVALID_HANDLE_VALUE; +#else + int descriptor = -1; +#endif +}; + +std::filesystem::path temporary_state_path(const std::filesystem::path& path) +{ + static std::atomic_uint64_t sequence{0}; + auto temporary_path = path; +#if _WIN32 + const auto process_id = static_cast(GetCurrentProcessId()); +#else + const auto process_id = static_cast(getpid()); +#endif + temporary_path += "." + std::to_string(process_id) + "." + std::to_string(++sequence) + ".tmp"; + return temporary_path; +} void load_session_order() { @@ -117,7 +221,13 @@ bool replace_state_file(const std::filesystem::path& temporary_path, const std:: bool save_session_order() { - const auto path = state_path(); + const auto path = state_path(); + StateFileLock lock(path); + if (!lock.acquired()) { + spdlog::warn("[OfficerPresetReorder] unable to lock state file '{}'", path.string()); + return false; + } + nlohmann::json state = nlohmann::json::object(); try { std::ifstream existing(path, std::ios::in | std::ios::binary); @@ -139,8 +249,7 @@ bool save_session_order() state["version"] = 1; state["officer_preset_order"] = std::move(order); - auto temporary_path = path; - temporary_path += ".tmp"; + const auto temporary_path = temporary_state_path(path); std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); if (!file) { spdlog::warn("[OfficerPresetReorder] unable to open temporary state file '{}'", temporary_path.string()); From 76910a0a0fc445a41f1545c8aa52e12b529b5c91 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 03:40:36 -0500 Subject: [PATCH 04/13] Keep officer preset identity order canonical --- .../patches/parts/officer_preset_reorder.cc | 339 +++++++++++++++--- 1 file changed, 296 insertions(+), 43 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index 44edee30e..5fd479efd 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -4,6 +4,7 @@ #include "str_utils.h" #include +#include #include #include @@ -12,8 +13,8 @@ #include #include #include -#include #include +#include #include #include #include @@ -50,8 +51,11 @@ using GetScrollPositionFn = float(void*); using RestoreScrollPositionFn = void(void*, float); std::vector session_order; +std::vector active_presentations; Il2CppObject* active_controller = nullptr; +Il2CppClass* item_context_class = nullptr; ptrdiff_t widget_context_offset = 0; +ptrdiff_t controller_context_offset = 0; ptrdiff_t controller_scroller_offset = 0; ptrdiff_t presets_items_offset = 0; ClearAndGenerateContentsFn* clear_and_generate = nullptr; @@ -221,7 +225,7 @@ bool replace_state_file(const std::filesystem::path& temporary_path, const std:: bool save_session_order() { - const auto path = state_path(); + const auto path = state_path(); StateFileLock lock(path); if (!lock.acquired()) { spdlog::warn("[OfficerPresetReorder] unable to lock state file '{}'", path.string()); @@ -249,7 +253,7 @@ bool save_session_order() state["version"] = 1; state["officer_preset_order"] = std::move(order); - const auto temporary_path = temporary_state_path(path); + const auto temporary_path = temporary_state_path(path); std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); if (!file) { spdlog::warn("[OfficerPresetReorder] unable to open temporary state file '{}'", temporary_path.string()); @@ -274,6 +278,42 @@ static_assert(offsetof(OfficerPresetItemContext, order_id) == 0x20); static_assert(offsetof(OfficerPresetItemContext, slot_id) == 0x28); static_assert(offsetof(OfficerPresetItemContext, preset_name) == 0x30); +bool validate_context_field(Il2CppClass* context_class, const char* name, ptrdiff_t expected_offset, + Il2CppTypeEnum expected_type) +{ + auto* field = il2cpp_class_get_field_from_name(context_class, name); + if (field == nullptr || field->type == nullptr) { + spdlog::error("[OfficerPresetReorder] required context field '{}' is unavailable", name); + return false; + } + if (field->offset != expected_offset || field->type->type != expected_type) { + spdlog::error("[OfficerPresetReorder] context field '{}' layout mismatch: offset=0x{:X} type={} expected " + "offset=0x{:X} type={}; feature disabled", + name, field->offset, static_cast(field->type->type), expected_offset, + static_cast(expected_type)); + return false; + } + return true; +} + +bool validate_context_layout(Il2CppClass* context_class) +{ + return context_class != nullptr + && validate_context_field(context_class, "Presentation", offsetof(OfficerPresetItemContext, presentation), + IL2CPP_TYPE_VALUETYPE) + && validate_context_field(context_class, "IsOccupied", offsetof(OfficerPresetItemContext, is_occupied), + IL2CPP_TYPE_BOOLEAN) + && validate_context_field(context_class, "OrderId", offsetof(OfficerPresetItemContext, order_id), + IL2CPP_TYPE_I4) + && validate_context_field(context_class, "SlotId", offsetof(OfficerPresetItemContext, slot_id), IL2CPP_TYPE_I8) + && validate_context_field(context_class, "PresetName", offsetof(OfficerPresetItemContext, preset_name), + IL2CPP_TYPE_STRING) + && validate_context_field(context_class, "Officers", offsetof(OfficerPresetItemContext, officers), + IL2CPP_TYPE_SZARRAY) + && validate_context_field(context_class, "_officerPresetsViewContext", + offsetof(OfficerPresetItemContext, officer_presets_view_context), IL2CPP_TYPE_CLASS); +} + bool is_reorderable_preset(const OfficerPresetItemContext* context) { return context != nullptr && context->slot_id >= 0 && context->order_id >= 0 && context->preset_name != nullptr @@ -308,21 +348,65 @@ void remember_slots(OfficerPresetItemContext** items, il2cpp_array_size_t size) } } -void apply_session_order(OfficerPresetItemContext** items, il2cpp_array_size_t size) +bool has_native_identity(const OfficerPresetItemContext* context) +{ return context != nullptr && context->slot_id >= 0 && context->order_id >= 0; } + +bool validate_unique_preset_identity(OfficerPresetItemContext** items, int32_t size) { - remember_slots(items, size); - if (session_order.empty()) { - return; + std::vector slot_ids; + std::vector order_ids; + slot_ids.reserve(size); + order_ids.reserve(size); + for (int32_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!has_native_identity(context)) { + continue; + } + if (std::find(slot_ids.begin(), slot_ids.end(), context->slot_id) != slot_ids.end() + || std::find(order_ids.begin(), order_ids.end(), context->order_id) != order_ids.end()) { + spdlog::error("[OfficerPresetReorder] duplicate preset identity detected at index {}: slot={} order={}", index, + context->slot_id, context->order_id); + return false; + } + slot_ids.push_back(context->slot_id); + order_ids.push_back(context->order_id); + } + return true; +} + +bool validate_canonical_order(OfficerPresetItemContext** items, int32_t size) +{ + if (!validate_unique_preset_identity(items, size)) { + return false; + } + for (int32_t index = 0; index < size; ++index) { + const auto* context = items[index]; + if (!has_native_identity(context)) { + continue; + } + if (context->order_id != index) { + spdlog::error("[OfficerPresetReorder] canonical preset invariant failed at index {}: slot={} order={}; " + "local ordering disabled for this view", + index, context->slot_id, context->order_id); + return false; + } } + return true; +} - std::vector occupied_positions; +std::vector make_view_order(OfficerPresetItemContext** canonical_items, int32_t size) +{ + std::vector view_items(canonical_items, canonical_items + size); + remember_slots(canonical_items, static_cast(size)); + + std::vector occupied_positions; std::vector occupied_contexts; occupied_positions.reserve(size); occupied_contexts.reserve(size); - for (il2cpp_array_size_t index = 0; index < size; ++index) { - if (is_reorderable_preset(items[index])) { + for (int32_t index = 0; index < size; ++index) { + if (is_reorderable_preset(canonical_items[index])) { occupied_positions.push_back(index); - occupied_contexts.push_back(items[index]); + occupied_contexts.push_back(canonical_items[index]); } } @@ -331,16 +415,10 @@ void apply_session_order(OfficerPresetItemContext** items, il2cpp_array_size_t s const auto right_order = std::find(session_order.begin(), session_order.end(), right->slot_id); return left_order < right_order; }); - - std::vector presentations; - presentations.reserve(occupied_positions.size()); - for (const auto position : occupied_positions) { - presentations.push_back(items[position]->presentation); - } for (size_t index = 0; index < occupied_positions.size(); ++index) { - items[occupied_positions[index]] = occupied_contexts[index]; - occupied_contexts[index]->presentation = presentations[index]; + view_items[occupied_positions[index]] = occupied_contexts[index]; } + return view_items; } bool try_get_preset_list(void* view_context, Il2CppObject** list, Il2CppArraySize** backing_items, int32_t* size) @@ -363,6 +441,76 @@ bool try_get_preset_list(void* view_context, Il2CppObject** list, Il2CppArraySiz && static_cast(*size) <= (*backing_items)->max_length; } +bool capture_native_presentations(OfficerPresetItemContext** canonical_items, int32_t size) +{ + if (!validate_canonical_order(canonical_items, size)) { + return false; + } + active_presentations.clear(); + active_presentations.reserve(size); + for (int32_t index = 0; index < size; ++index) { + active_presentations.push_back(canonical_items[index] != nullptr ? canonical_items[index]->presentation : 0); + } + return true; +} + +bool restore_native_presentations(OfficerPresetItemContext** canonical_items, int32_t size) +{ + if (!validate_canonical_order(canonical_items, size) || active_presentations.size() != static_cast(size)) { + spdlog::error("[OfficerPresetReorder] native presentation map is unavailable; local ordering disabled for this " + "view"); + return false; + } + for (int32_t index = 0; index < size; ++index) { + if (canonical_items[index] != nullptr) { + canonical_items[index]->presentation = active_presentations[index]; + } + } + return true; +} + +bool render_local_order(Il2CppObject* controller, bool preserve_scroll) +{ + if (controller == nullptr || item_context_class == nullptr || clear_and_generate == nullptr) { + return false; + } + + auto* view_context = read_object_field(controller, controller_context_offset); + auto* scroller = read_object_field(controller, controller_scroller_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (scroller == nullptr || !try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + return false; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size) || active_presentations.size() != static_cast(size)) { + return false; + } + + auto view_items = make_view_order(canonical_items, size); + auto* view_array = il2cpp_array_new(item_context_class, static_cast(size)); + if (view_array == nullptr) { + spdlog::warn("[OfficerPresetReorder] unable to allocate the scroller view array"); + return false; + } + for (int32_t index = 0; index < size; ++index) { + auto* context = view_items[index]; + if (context != nullptr) { + context->presentation = active_presentations[index]; + } + il2cpp_array_setref(view_array, index, context); + } + + const auto scroll_position = preserve_scroll && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; + clear_and_generate(scroller, controller, reinterpret_cast(view_array)); + if (preserve_scroll && restore_scroll_position != nullptr) { + restore_scroll_position(scroller, scroll_position); + } + return true; +} + bool move_preset(OfficerPresetItemContext* context, int direction) { if (!is_reorderable_preset(context) || context->officer_presets_view_context == nullptr @@ -378,10 +526,15 @@ bool move_preset(OfficerPresetItemContext* context, int direction) return false; } - auto** items = reinterpret_cast(backing_items->vector); + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size)) { + return false; + } + auto view_items = make_view_order(canonical_items, size); + int32_t current_index = -1; for (int32_t index = 0; index < size; ++index) { - if (items[index] == context) { + if (view_items[index] == context) { current_index = index; break; } @@ -391,7 +544,7 @@ bool move_preset(OfficerPresetItemContext* context, int direction) } int32_t target_index = current_index + direction; - while (target_index >= 0 && target_index < size && !is_reorderable_preset(items[target_index])) { + while (target_index >= 0 && target_index < size && !is_reorderable_preset(view_items[target_index])) { target_index += direction; } if (target_index < 0 || target_index >= size) { @@ -400,8 +553,13 @@ bool move_preset(OfficerPresetItemContext* context, int direction) return true; } - auto* target = items[target_index]; - remember_slots(items, static_cast(size)); + auto* scroller = read_object_field(active_controller, controller_scroller_offset); + if (scroller == nullptr) { + spdlog::warn("[OfficerPresetReorder] unable to access the active preset scroller"); + return false; + } + + auto* target = view_items[target_index]; const auto current_order = std::find(session_order.begin(), session_order.end(), context->slot_id); const auto target_order = std::find(session_order.begin(), session_order.end(), target->slot_id); bool persisted = false; @@ -410,23 +568,73 @@ bool move_preset(OfficerPresetItemContext* context, int direction) persisted = save_session_order(); } - std::swap(context->presentation, target->presentation); - std::swap(items[current_index], items[target_index]); - - auto* scroller = read_object_field(active_controller, controller_scroller_offset); - if (scroller == nullptr) { - return false; - } - - const auto scroll_position = get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; spdlog::info("[OfficerPresetReorder] moved slot={} {} across slot={} ({})", context->slot_id, direction < 0 ? "up" : "down", target->slot_id, persisted ? "persisted locally" : "local persistence failed"); - clear_and_generate(scroller, active_controller, list); + if (!render_local_order(active_controller, true)) { + spdlog::warn("[OfficerPresetReorder] local order changed but the scroller could not be refreshed"); + } + return true; +} + +void OfficerPresetsViewController_OnSaveSlotsSuccess_Hook(auto original, Il2CppObject* _this, + bool increase_occupied_slots_count) +{ + auto* view_context = read_object_field(_this, controller_context_offset); + auto* scroller = read_object_field(_this, controller_scroller_offset); + + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + const bool canonical_available = + scroller != nullptr && try_get_preset_list(view_context, &canonical_list, &backing_items, &size) + && validate_canonical_order(reinterpret_cast(backing_items->vector), size); + const bool can_rerender = + canonical_available && active_controller == _this + && restore_native_presentations(reinterpret_cast(backing_items->vector), size); + const auto scroll_position = can_rerender && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; + if (canonical_available) { + // Scopely's callback indexes SmartScroller._data by OrderId. Give it the canonical list for the duration of the + // callback, then rebuild our separate presentation-only view. + clear_and_generate(scroller, _this, canonical_list); + } + + original(_this, increase_occupied_slots_count); + + if (!canonical_available) { + spdlog::warn("[OfficerPresetReorder] save succeeded without an available canonical scroller source"); + return; + } + if (!can_rerender) { + spdlog::debug("[OfficerPresetReorder] save succeeded in canonical order without an active local view"); + return; + } + + if (!try_get_preset_list(read_object_field(_this, controller_context_offset), &canonical_list, &backing_items, + &size)) { + spdlog::warn("[OfficerPresetReorder] save succeeded but the canonical preset list became unavailable"); + return; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!validate_canonical_order(canonical_items, size)) { + spdlog::error("[OfficerPresetReorder] canonical preset invariant failed after save; local ordering was not " + "reapplied"); + return; + } + if (active_presentations.size() != static_cast(size)) { + if (!capture_native_presentations(canonical_items, size)) { + return; + } + } + if (!render_local_order(_this, false)) { + spdlog::warn("[OfficerPresetReorder] save reconciled safely but the local scroller order could not be restored"); + return; + } if (restore_scroll_position != nullptr) { restore_scroll_position(scroller, scroll_position); } - return true; + spdlog::info("[OfficerPresetReorder] reconciled presentation-only ordering after preset save"); } bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2CppArraySize** preset_contexts, @@ -441,8 +649,12 @@ bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2 auto* contexts = *preset_contexts; auto** items = reinterpret_cast(contexts->vector); - apply_session_order(items, contexts->max_length); - spdlog::debug("[OfficerPresetReorder] applied local ordering to {} preset rows", contexts->max_length); + if (validate_canonical_order(items, static_cast(contexts->max_length))) { + remember_slots(items, contexts->max_length); + spdlog::debug("[OfficerPresetReorder] observed {} canonical preset rows", contexts->max_length); + } else { + spdlog::error("[OfficerPresetReorder] Scopely returned non-canonical preset rows; leaving them untouched"); + } return result; } @@ -464,12 +676,41 @@ void OfficerPresetsViewController_OnDidBindCanvasContext_Hook(auto original, Il2 { original(_this); active_controller = _this; + + auto* view_context = read_object_field(_this, controller_context_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (!try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + active_presentations.clear(); + spdlog::warn("[OfficerPresetReorder] unable to capture the bound canonical preset list"); + return; + } + + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (!capture_native_presentations(canonical_items, size) || !render_local_order(_this, false)) { + active_presentations.clear(); + spdlog::warn("[OfficerPresetReorder] local ordering was disabled for the bound preset view"); + } } void OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook(auto original, Il2CppObject* _this) { if (active_controller == _this) { + auto* view_context = read_object_field(_this, controller_context_offset); + auto* scroller = read_object_field(_this, controller_scroller_offset); + Il2CppObject* canonical_list = nullptr; + Il2CppArraySize* backing_items = nullptr; + int32_t size = 0; + if (scroller != nullptr && try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + auto** canonical_items = reinterpret_cast(backing_items->vector); + if (validate_canonical_order(canonical_items, size)) { + restore_native_presentations(canonical_items, size); + clear_and_generate(scroller, _this, canonical_list); + } + } active_controller = nullptr; + active_presentations.clear(); } original(_this); } @@ -495,21 +736,30 @@ void InstallOfficerPresetReorderHooks() il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewController"); auto view_context_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewContext"); + auto item_context_helper = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemContext"); auto scroller_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "SmartScrollerBase"); if (!widget_helper.isValidHelper() || !controller_helper.isValidHelper() || !view_context_helper.isValidHelper() - || !scroller_helper.isValidHelper()) { + || !item_context_helper.isValidHelper() || !scroller_helper.isValidHelper()) { ErrorMsg::MissingHelper("Digit.Prime.OfficerPresets", "reorder UI surface"); return; } + if (!validate_context_layout(item_context_helper.get_cls())) { + return; + } + item_context_class = item_context_helper.get_cls(); - auto context_field = widget_helper.GetField("m_context"); - auto scroller_field = controller_helper.GetField("_smartScroller"); - auto presets_field = view_context_helper.GetField("PresetsItemsContext"); - if (!context_field.isValidHelper() || !scroller_field.isValidHelper() || !presets_field.isValidHelper()) { + auto context_field = widget_helper.GetField("m_context"); + auto controller_context_field = controller_helper.GetField("m_context"); + auto scroller_field = controller_helper.GetField("_smartScroller"); + auto presets_field = view_context_helper.GetField("PresetsItemsContext"); + if (!context_field.isValidHelper() || !controller_context_field.isValidHelper() || !scroller_field.isValidHelper() + || !presets_field.isValidHelper()) { ErrorMsg::MissingMethod("OfficerPresetReorder", "required field"); return; } widget_context_offset = context_field.offset(); + controller_context_offset = controller_context_field.offset(); controller_scroller_offset = scroller_field.offset(); presets_items_offset = presets_field.offset(); @@ -519,11 +769,13 @@ void InstallOfficerPresetReorderHooks() const auto edit_method = widget_helper.GetMethodInfo("OnEditNameButtonClicked", 0); const auto bind_method = controller_helper.GetMethodInfo("OnDidBindCanvasContext", 0); const auto release_method = controller_helper.GetMethodInfo("OnAboutToReleaseCanvasContext", 0); + const auto save_success_method = controller_helper.GetMethodInfo("OnSaveSlotsSuccess", 1); if (clear_method == nullptr || clear_method->methodPointer == nullptr || get_scroll_method == nullptr || get_scroll_method->methodPointer == nullptr || restore_scroll_method == nullptr || restore_scroll_method->methodPointer == nullptr || edit_method == nullptr || edit_method->methodPointer == nullptr || bind_method == nullptr || bind_method->methodPointer == nullptr - || release_method == nullptr || release_method->methodPointer == nullptr) { + || release_method == nullptr || release_method->methodPointer == nullptr || save_success_method == nullptr + || save_success_method->methodPointer == nullptr) { ErrorMsg::MissingMethod("OfficerPresetReorder", "required UI method"); return; } @@ -537,4 +789,5 @@ void InstallOfficerPresetReorderHooks() SPUD_STATIC_DETOUR(edit_method->methodPointer, OfficerPresetItemWidget_OnEditNameButtonClicked_Hook); SPUD_STATIC_DETOUR(bind_method->methodPointer, OfficerPresetsViewController_OnDidBindCanvasContext_Hook); SPUD_STATIC_DETOUR(release_method->methodPointer, OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook); + SPUD_STATIC_DETOUR(save_success_method->methodPointer, OfficerPresetsViewController_OnSaveSlotsSuccess_Hook); } From 6d7fef70dbdb22c8a81811a28e828efbb69bc2e1 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 31 Aug 2026 03:52:06 -0500 Subject: [PATCH 05/13] Fix macOS officer preset array build --- mods/src/patches/parts/officer_preset_reorder.cc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index 5fd479efd..8653a6c7d 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -4,7 +4,6 @@ #include "str_utils.h" #include -#include #include #include @@ -495,12 +494,13 @@ bool render_local_order(Il2CppObject* controller, bool preserve_scroll) spdlog::warn("[OfficerPresetReorder] unable to allocate the scroller view array"); return false; } + auto** view_slots = reinterpret_cast(reinterpret_cast(view_array)->vector); for (int32_t index = 0; index < size; ++index) { auto* context = view_items[index]; if (context != nullptr) { context->presentation = active_presentations[index]; } - il2cpp_array_setref(view_array, index, context); + il2cpp_gc_wbarrier_set_field(reinterpret_cast(view_array), &view_slots[index], context); } const auto scroll_position = preserve_scroll && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; From 4e71efaa34b21fe9d7a0e2bcce29a1989f81de2f Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 04:43:16 -0500 Subject: [PATCH 06/13] Add shared mod state store --- mods/src/mod_state.cc | 225 ++++++++++++++++++++++++++++++++++++++++++ mods/src/mod_state.h | 18 ++++ 2 files changed, 243 insertions(+) create mode 100644 mods/src/mod_state.cc create mode 100644 mods/src/mod_state.h diff --git a/mods/src/mod_state.cc b/mods/src/mod_state.cc new file mode 100644 index 000000000..515dd6e4f --- /dev/null +++ b/mods/src/mod_state.cc @@ -0,0 +1,225 @@ +#include "mod_state.h" + +#include "file.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#if _WIN32 +#include +#else +#include +#include +#include +#endif + +namespace +{ +std::filesystem::path state_path() +{ + if (File::hasCustomNames()) { + std::filesystem::path config_path{File::Config()}; + auto state_name = config_path.stem(); + state_name += ".state.json"; + return config_path.parent_path() / state_name; + } + return std::filesystem::path{File::MakePath("community_patch_state.json", true)}; +} + +class StateFileLock +{ +public: + explicit StateFileLock(const std::filesystem::path& path, int maximum_attempts) + { + lock_path = path; + lock_path += ".lock"; + + for (int attempt = 0; attempt < maximum_attempts; ++attempt) { +#if _WIN32 + handle = CreateFileW(lock_path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle != INVALID_HANDLE_VALUE) { + return; + } + const auto error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION && error != ERROR_LOCK_VIOLATION) { + break; + } +#else + descriptor = open(lock_path.c_str(), O_CREAT | O_RDWR, 0600); + if (descriptor < 0) { + break; + } + if (flock(descriptor, LOCK_EX | LOCK_NB) == 0) { + return; + } + const auto error = errno; + close(descriptor); + descriptor = -1; + if (error != EWOULDBLOCK && error != EAGAIN) { + break; + } +#endif + if (attempt + 1 < maximum_attempts) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + } + + ~StateFileLock() + { +#if _WIN32 + if (handle != INVALID_HANDLE_VALUE) { + CloseHandle(handle); + } +#else + if (descriptor >= 0) { + flock(descriptor, LOCK_UN); + close(descriptor); + } +#endif + } + + StateFileLock(const StateFileLock&) = delete; + StateFileLock& operator=(const StateFileLock&) = delete; + + bool acquired() const + { +#if _WIN32 + return handle != INVALID_HANDLE_VALUE; +#else + return descriptor >= 0; +#endif + } + +private: + std::filesystem::path lock_path; +#if _WIN32 + HANDLE handle = INVALID_HANDLE_VALUE; +#else + int descriptor = -1; +#endif +}; + +std::filesystem::path temporary_state_path(const std::filesystem::path& path) +{ + static std::atomic_uint64_t sequence{0}; + auto temporary_path = path; +#if _WIN32 + const auto process_id = static_cast(GetCurrentProcessId()); +#else + const auto process_id = static_cast(getpid()); +#endif + temporary_path += "." + std::to_string(process_id) + "." + std::to_string(++sequence) + ".tmp"; + return temporary_path; +} + +bool replace_state_file(const std::filesystem::path& temporary_path, const std::filesystem::path& path) +{ +#if _WIN32 + if (MoveFileExW(temporary_path.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0) { + return true; + } + spdlog::warn("[ModState] unable to replace state file '{}' (Windows error {})", path.string(), GetLastError()); +#else + std::error_code error; + std::filesystem::rename(temporary_path, path, error); + if (!error) { + return true; + } + spdlog::warn("[ModState] unable to replace state file '{}': {}", path.string(), error.message()); +#endif + std::error_code cleanup_error; + std::filesystem::remove(temporary_path, cleanup_error); + return false; +} +} // namespace + +namespace mod_state +{ +std::optional Read() +{ + const auto path = state_path(); + std::ifstream file(path, std::ios::in | std::ios::binary); + if (!file) { + return std::nullopt; + } + + try { + auto state = nlohmann::json::parse(file); + if (!state.is_object()) { + spdlog::warn("[ModState] ignored non-object state file '{}'", path.string()); + return std::nullopt; + } + return state; + } catch (const std::exception& error) { + spdlog::warn("[ModState] ignored invalid state file '{}': {}", path.string(), error.what()); + return std::nullopt; + } +} + +static bool update_state(const std::function& update, int lock_attempts) +{ + const auto path = state_path(); + StateFileLock lock(path, lock_attempts); + if (!lock.acquired()) { + spdlog::warn("[ModState] unable to lock state file '{}'", path.string()); + return false; + } + + nlohmann::json state = nlohmann::json::object(); + try { + std::ifstream existing(path, std::ios::in | std::ios::binary); + if (existing) { + state = nlohmann::json::parse(existing); + if (!state.is_object()) { + state = nlohmann::json::object(); + } + } + } catch (const std::exception& error) { + spdlog::warn("[ModState] replacing invalid state file '{}': {}", path.string(), error.what()); + state = nlohmann::json::object(); + } + + try { + state["version"] = 1; + update(state); + } catch (const std::exception& error) { + spdlog::warn("[ModState] state update failed: {}", error.what()); + return false; + } + + const auto temporary_path = temporary_state_path(path); + std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); + if (!file) { + spdlog::warn("[ModState] unable to open temporary state file '{}'", temporary_path.string()); + return false; + } + file << state.dump(2) << '\n'; + file.flush(); + const bool write_succeeded = file.good(); + file.close(); + if (!write_succeeded || file.fail()) { + spdlog::warn("[ModState] unable to finish temporary state file '{}'", temporary_path.string()); + std::error_code cleanup_error; + std::filesystem::remove(temporary_path, cleanup_error); + return false; + } + return replace_state_file(temporary_path, path); +} + +bool Update(const std::function& update) +{ return update_state(update, 50); } + +bool TryUpdate(const std::function& update) +{ return update_state(update, 1); } +} // namespace mod_state diff --git a/mods/src/mod_state.h b/mods/src/mod_state.h new file mode 100644 index 000000000..61bb41000 --- /dev/null +++ b/mods/src/mod_state.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include +#include + +namespace mod_state +{ +// Reads the latest complete state snapshot. Missing, malformed, and non-object files return no value. +std::optional Read(); + +// Performs a serialized read-modify-write transaction, retrying briefly when another writer holds the state lock. +bool Update(const std::function& update); + +// Performs the same transaction only when the state lock is immediately available. +bool TryUpdate(const std::function& update); +} // namespace mod_state From c96d5cf67d8d454adcc854ab5f8c6aa3a78adda3 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 04:48:48 -0500 Subject: [PATCH 07/13] Contain state serialization failures --- mods/src/mod_state.cc | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mods/src/mod_state.cc b/mods/src/mod_state.cc index 515dd6e4f..9cf4267f7 100644 --- a/mods/src/mod_state.cc +++ b/mods/src/mod_state.cc @@ -198,13 +198,21 @@ static bool update_state(const std::function& update, int return false; } + std::string serialized_state; + try { + serialized_state = state.dump(2); + } catch (const std::exception& error) { + spdlog::warn("[ModState] state serialization failed: {}", error.what()); + return false; + } + const auto temporary_path = temporary_state_path(path); std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); if (!file) { spdlog::warn("[ModState] unable to open temporary state file '{}'", temporary_path.string()); return false; } - file << state.dump(2) << '\n'; + file << serialized_state << '\n'; file.flush(); const bool write_succeeded = file.good(); file.close(); From d8edd482b5ed89ba8c405a5efe83dfe64500e40b Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 07:33:13 -0500 Subject: [PATCH 08/13] Use shared mod state for officer preset order --- .../patches/parts/officer_preset_reorder.cc | 186 +----------------- 1 file changed, 7 insertions(+), 179 deletions(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index 8653a6c7d..5f51daf9f 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -1,5 +1,5 @@ #include "errormsg.h" -#include "file.h" +#include "mod_state.h" #include "prime/KeyCode.h" #include "str_utils.h" @@ -10,24 +10,12 @@ #include #include -#include -#include #include -#include #include #include -#include -#include #include -#include #include -#if !_WIN32 -#include -#include -#include -#endif - namespace { struct OfficerPresetItemContext { @@ -61,115 +49,16 @@ ClearAndGenerateContentsFn* clear_and_generate = nullptr; GetScrollPositionFn* get_scroll_position = nullptr; RestoreScrollPositionFn* restore_scroll_position = nullptr; -std::filesystem::path state_path() -{ - if (File::hasCustomNames()) { - std::filesystem::path config_path{File::Config()}; - auto state_name = config_path.stem(); - state_name += ".state.json"; - return config_path.parent_path() / state_name; - } - return std::filesystem::path{File::MakePath("community_patch_state.json", true)}; -} - -class StateFileLock -{ -public: - explicit StateFileLock(const std::filesystem::path& path) - { - lock_path = path; - lock_path += ".lock"; - - for (int attempt = 0; attempt < 50; ++attempt) { -#if _WIN32 - handle = CreateFileW(lock_path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_ALWAYS, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (handle != INVALID_HANDLE_VALUE) { - return; - } - const auto error = GetLastError(); - if (error != ERROR_SHARING_VIOLATION && error != ERROR_LOCK_VIOLATION) { - break; - } -#else - descriptor = open(lock_path.c_str(), O_CREAT | O_RDWR, 0600); - if (descriptor < 0) { - break; - } - if (flock(descriptor, LOCK_EX | LOCK_NB) == 0) { - return; - } - const auto error = errno; - close(descriptor); - descriptor = -1; - if (error != EWOULDBLOCK && error != EAGAIN) { - break; - } -#endif - std::this_thread::sleep_for(std::chrono::milliseconds(10)); - } - } - - ~StateFileLock() - { -#if _WIN32 - if (handle != INVALID_HANDLE_VALUE) { - CloseHandle(handle); - } -#else - if (descriptor >= 0) { - flock(descriptor, LOCK_UN); - close(descriptor); - } -#endif - } - - StateFileLock(const StateFileLock&) = delete; - StateFileLock& operator=(const StateFileLock&) = delete; - - bool acquired() const - { -#if _WIN32 - return handle != INVALID_HANDLE_VALUE; -#else - return descriptor >= 0; -#endif - } - -private: - std::filesystem::path lock_path; -#if _WIN32 - HANDLE handle = INVALID_HANDLE_VALUE; -#else - int descriptor = -1; -#endif -}; - -std::filesystem::path temporary_state_path(const std::filesystem::path& path) -{ - static std::atomic_uint64_t sequence{0}; - auto temporary_path = path; -#if _WIN32 - const auto process_id = static_cast(GetCurrentProcessId()); -#else - const auto process_id = static_cast(getpid()); -#endif - temporary_path += "." + std::to_string(process_id) + "." + std::to_string(++sequence) + ".tmp"; - return temporary_path; -} - void load_session_order() { - const auto path = state_path(); - std::ifstream file(path, std::ios::in | std::ios::binary); - if (!file) { + const auto state = mod_state::Read(); + if (!state) { return; } try { - const auto state = nlohmann::json::parse(file); - const auto order = state.find("officer_preset_order"); - if (order == state.end() || !order->is_array()) { + const auto order = state->find("officer_preset_order"); + if (order == state->end() || !order->is_array()) { return; } @@ -197,78 +86,17 @@ void load_session_order() session_order = std::move(loaded_order); spdlog::info("[OfficerPresetReorder] loaded {} persisted slot positions", session_order.size()); } catch (const std::exception& error) { - spdlog::warn("[OfficerPresetReorder] ignored invalid state file '{}': {}", path.string(), error.what()); - } -} - -bool replace_state_file(const std::filesystem::path& temporary_path, const std::filesystem::path& path) -{ -#if _WIN32 - if (MoveFileExW(temporary_path.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0) { - return true; - } - spdlog::warn("[OfficerPresetReorder] unable to replace state file '{}' (Windows error {})", path.string(), - GetLastError()); -#else - std::error_code error; - std::filesystem::rename(temporary_path, path, error); - if (!error) { - return true; + spdlog::warn("[OfficerPresetReorder] ignored invalid persisted order: {}", error.what()); } - spdlog::warn("[OfficerPresetReorder] unable to replace state file '{}': {}", path.string(), error.message()); -#endif - std::error_code cleanup_error; - std::filesystem::remove(temporary_path, cleanup_error); - return false; } bool save_session_order() { - const auto path = state_path(); - StateFileLock lock(path); - if (!lock.acquired()) { - spdlog::warn("[OfficerPresetReorder] unable to lock state file '{}'", path.string()); - return false; - } - - nlohmann::json state = nlohmann::json::object(); - try { - std::ifstream existing(path, std::ios::in | std::ios::binary); - if (existing) { - state = nlohmann::json::parse(existing); - if (!state.is_object()) { - state = nlohmann::json::object(); - } - } - } catch (const std::exception& error) { - spdlog::warn("[OfficerPresetReorder] replacing invalid state file '{}': {}", path.string(), error.what()); - state = nlohmann::json::object(); - } - auto order = nlohmann::json::array(); for (const auto slot_id : session_order) { order.push_back(std::to_string(slot_id)); } - state["version"] = 1; - state["officer_preset_order"] = std::move(order); - - const auto temporary_path = temporary_state_path(path); - std::ofstream file(temporary_path, std::ios::out | std::ios::binary | std::ios::trunc); - if (!file) { - spdlog::warn("[OfficerPresetReorder] unable to open temporary state file '{}'", temporary_path.string()); - return false; - } - file << state.dump(2) << '\n'; - file.flush(); - const bool write_succeeded = file.good(); - file.close(); - if (!write_succeeded || file.fail()) { - spdlog::warn("[OfficerPresetReorder] unable to finish temporary state file '{}'", temporary_path.string()); - std::error_code cleanup_error; - std::filesystem::remove(temporary_path, cleanup_error); - return false; - } - return replace_state_file(temporary_path, path); + return mod_state::Update([&order](nlohmann::json& state) { state["officer_preset_order"] = std::move(order); }); } static_assert(offsetof(OfficerPresetItemContext, presentation) == 0x18); From 55f83dd729920c19059753373ff5adb6f4a371d9 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 07:41:20 -0500 Subject: [PATCH 09/13] Reject unsupported mod state versions --- mods/src/mod_state.cc | 10 +++++++++- mods/src/mod_state.h | 5 +++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/mods/src/mod_state.cc b/mods/src/mod_state.cc index 9cf4267f7..a45d4ced8 100644 --- a/mods/src/mod_state.cc +++ b/mods/src/mod_state.cc @@ -190,8 +190,16 @@ static bool update_state(const std::function& update, int state = nlohmann::json::object(); } + const auto version = state.find("version"); + if (version != state.end() && (!version->is_number_integer() || *version != 1)) { + spdlog::warn("[ModState] refusing to update unsupported state version in '{}'", path.string()); + return false; + } + try { - state["version"] = 1; + if (version == state.end()) { + state["version"] = 1; + } update(state); } catch (const std::exception& error) { spdlog::warn("[ModState] state update failed: {}", error.what()); diff --git a/mods/src/mod_state.h b/mods/src/mod_state.h index 61bb41000..4e7692721 100644 --- a/mods/src/mod_state.h +++ b/mods/src/mod_state.h @@ -11,8 +11,9 @@ namespace mod_state std::optional Read(); // Performs a serialized read-modify-write transaction, retrying briefly when another writer holds the state lock. -bool Update(const std::function& update); +// Unsupported schema versions are left unchanged and return false. +bool Update(const std::function& update); // Performs the same transaction only when the state lock is immediately available. -bool TryUpdate(const std::function& update); +bool TryUpdate(const std::function& update); } // namespace mod_state From 59f48e2ff70b79d005c97f94e153a76653a5a755 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 07:45:52 -0500 Subject: [PATCH 10/13] Enforce mod state version on reads and updates --- mods/src/mod_state.cc | 26 ++++++++++++++++++++------ mods/src/mod_state.h | 3 ++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/mods/src/mod_state.cc b/mods/src/mod_state.cc index a45d4ced8..b13fe741b 100644 --- a/mods/src/mod_state.cc +++ b/mods/src/mod_state.cc @@ -142,6 +142,17 @@ bool replace_state_file(const std::filesystem::path& temporary_path, const std:: std::filesystem::remove(temporary_path, cleanup_error); return false; } + +constexpr int state_version = 1; + +bool has_supported_state_version(const nlohmann::json& state, bool allow_missing) +{ + if (!state.is_object()) { + return false; + } + const auto version = state.find("version"); + return version == state.end() ? allow_missing : version->is_number_integer() && *version == state_version; +} } // namespace namespace mod_state @@ -156,8 +167,8 @@ std::optional Read() try { auto state = nlohmann::json::parse(file); - if (!state.is_object()) { - spdlog::warn("[ModState] ignored non-object state file '{}'", path.string()); + if (!has_supported_state_version(state, true)) { + spdlog::warn("[ModState] ignored state file with unsupported shape or version '{}'", path.string()); return std::nullopt; } return state; @@ -190,17 +201,20 @@ static bool update_state(const std::function& update, int state = nlohmann::json::object(); } - const auto version = state.find("version"); - if (version != state.end() && (!version->is_number_integer() || *version != 1)) { + if (!has_supported_state_version(state, true)) { spdlog::warn("[ModState] refusing to update unsupported state version in '{}'", path.string()); return false; } try { - if (version == state.end()) { - state["version"] = 1; + if (!state.contains("version")) { + state["version"] = state_version; } update(state); + if (!has_supported_state_version(state, false)) { + spdlog::warn("[ModState] state update changed the reserved version field"); + return false; + } } catch (const std::exception& error) { spdlog::warn("[ModState] state update failed: {}", error.what()); return false; diff --git a/mods/src/mod_state.h b/mods/src/mod_state.h index 4e7692721..25f39334b 100644 --- a/mods/src/mod_state.h +++ b/mods/src/mod_state.h @@ -7,7 +7,8 @@ namespace mod_state { -// Reads the latest complete state snapshot. Missing, malformed, and non-object files return no value. +// Reads the latest supported complete snapshot. Missing, malformed, non-object, and future-version files return no +// value. std::optional Read(); // Performs a serialized read-modify-write transaction, retrying briefly when another writer holds the state lock. From 73f62851525845dc3fe72e1036f5d6fe09ccac99 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Fri, 4 Sep 2026 07:49:35 -0500 Subject: [PATCH 11/13] Fail closed after preset view bind errors --- mods/src/patches/parts/officer_preset_reorder.cc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index 5f51daf9f..3228c2fed 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -510,6 +510,7 @@ void OfficerPresetsViewController_OnDidBindCanvasContext_Hook(auto original, Il2 Il2CppArraySize* backing_items = nullptr; int32_t size = 0; if (!try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { + active_controller = nullptr; active_presentations.clear(); spdlog::warn("[OfficerPresetReorder] unable to capture the bound canonical preset list"); return; @@ -517,6 +518,7 @@ void OfficerPresetsViewController_OnDidBindCanvasContext_Hook(auto original, Il2 auto** canonical_items = reinterpret_cast(backing_items->vector); if (!capture_native_presentations(canonical_items, size) || !render_local_order(_this, false)) { + active_controller = nullptr; active_presentations.clear(); spdlog::warn("[OfficerPresetReorder] local ordering was disabled for the bound preset view"); } From d3b8aa23e929948c9104c98a7d9f2dc04f8b62bd Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 22:47:24 -0500 Subject: [PATCH 12/13] Separate preset installation and restore canonical save sources --- example_community_patch_settings_en.toml | 4 +- mods/src/config.cc | 2 + mods/src/config.h | 1 + mods/src/defaultconfig.h | 1 + mods/src/il2cpp/method_contract.h | 44 ++ .../patches/parts/officer_preset_reorder.cc | 615 +++++++++++------- mods/src/patches/patches.cc | 2 +- 7 files changed, 425 insertions(+), 244 deletions(-) create mode 100644 mods/src/il2cpp/method_contract.h diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index d0ad5e561..cad958ef5 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -263,6 +263,8 @@ loadingtiphooks = true doubleclickassignshiphooks = true forbiddentechconfirmhooks = true audioeventhooks = true +# Install local preset ordering hooks; the feature remains opt-in under [ui] +officerpresetreorderhooks = true freeresizehooks = true game_version = true giftsbulkclaimhooks = true @@ -656,7 +658,7 @@ auto_confirm_discovery = true auto_open_bulk_claim_flyout = false # EXPERIMENTAL: Allow officer presets to be reordered in the presets screen -# Shift-click a preset's edit-name button to move it up; Ctrl-click to move it down +# Shift-click a preset's edit-name button to move it up; Ctrl-click (Command on Mac) moves it down # The chosen order is stored locally and does not change Scopely's preset slots allow_officer_preset_reordering = false diff --git a/mods/src/config.cc b/mods/src/config.cc index 7127aaf9f..527d11a42 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -925,6 +925,8 @@ void Config::Load() get_config_or_default(config, parsed, "patches", "forbiddentechconfirmhooks", DCP::forbiddentechconfirmhooks, write_config); this->installAudioEventHooks = get_config_or_default(config, parsed, "patches", "audioeventhooks", DCP::audioeventhooks, write_config); + this->installOfficerPresetReorderHooks = get_config_or_default( + config, parsed, "patches", "officerpresetreorderhooks", DCP::officerpresetreorderhooks, write_config); this->installInstantCargoCounterHooks = get_config_or_default(config, parsed, "patches", "instantcargocounterhooks", DCP::instantcargocounterhooks, write_config); this->installCargoFormatHooks = diff --git a/mods/src/config.h b/mods/src/config.h index e78e4df67..b8ae09064 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -279,6 +279,7 @@ class Config final bool installForbiddenTechConfirmationHooks; bool installInstantWarpConfirmationHooks; bool installAudioEventHooks; + bool installOfficerPresetReorderHooks; std::string config_settings_url; std::string config_assets_url_override; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 25a17327c..de0e89bd7 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -94,6 +94,7 @@ namespace Patches constexpr bool doubleclickassignshiphooks = true; constexpr bool forbiddentechconfirmhooks = true; constexpr bool audioeventhooks = true; + constexpr bool officerpresetreorderhooks = true; constexpr bool instantcargocounterhooks = true; constexpr bool cargoformathooks = true; // on by default: cargo number precision override constexpr bool officersorthooks = true; // restore Below Deck Ability sort option diff --git a/mods/src/il2cpp/method_contract.h b/mods/src/il2cpp/method_contract.h new file mode 100644 index 000000000..d7d1ccdc5 --- /dev/null +++ b/mods/src/il2cpp/method_contract.h @@ -0,0 +1,44 @@ +#pragma once + +#include "il2cpp-functions.h" +#include +#include +#include + +namespace method_contract +{ +inline bool Type(const Il2CppType* type, const char* name) +{ + if (!type || type->byref) return false; + auto* actual = il2cpp_type_get_name(type); + const bool matches = actual && std::strcmp(actual, name) == 0; + il2cpp_free(actual); + return matches; +} + +// Resolve the entire managed signature, including static/instance dispatch. +// A renamed, ambiguous or generic method is not a compatible callback. +inline const MethodInfo* Resolve(Il2CppClass* cls, const char* name, bool is_static, + const char* result, std::initializer_list parameters) +{ + if (!cls) return nullptr; + const MethodInfo* found = nullptr; + void* iterator = nullptr; + while (auto* method = il2cpp_class_get_methods(cls, &iterator)) { + if (std::strcmp(method->name, name) != 0 || !method->methodPointer || method->is_generic || method->is_inflated + || bool(method->flags & METHOD_ATTRIBUTE_STATIC) != is_static + || method->parameters_count != parameters.size() || !Type(method->return_type, result)) continue; + bool matches = true; + unsigned i = 0; + for (auto* parameter : parameters) + matches = Type(method->parameters[i++], parameter) && matches; + if (!matches) continue; + if (found) return nullptr; + found = method; + } + return found; +} + +inline void* Pointer(const MethodInfo* method) +{ return method ? reinterpret_cast(method->methodPointer) : nullptr; } +} // namespace method_contract diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index 3228c2fed..0229848f1 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -1,9 +1,12 @@ #include "errormsg.h" +#include "config.h" #include "mod_state.h" #include "prime/KeyCode.h" #include "str_utils.h" #include +#include +#include #include #include @@ -14,6 +17,7 @@ #include #include #include +#include #include namespace @@ -33,21 +37,83 @@ struct OfficerPresetItemContext { void* officer_presets_view_context; }; -using ClearAndGenerateContentsFn = void(void*, Il2CppObject*, Il2CppObject*); -using GetScrollPositionFn = float(void*); -using RestoreScrollPositionFn = void(void*, float); - -std::vector session_order; -std::vector active_presentations; -Il2CppObject* active_controller = nullptr; -Il2CppClass* item_context_class = nullptr; -ptrdiff_t widget_context_offset = 0; -ptrdiff_t controller_context_offset = 0; -ptrdiff_t controller_scroller_offset = 0; -ptrdiff_t presets_items_offset = 0; -ClearAndGenerateContentsFn* clear_and_generate = nullptr; -GetScrollPositionFn* get_scroll_position = nullptr; -RestoreScrollPositionFn* restore_scroll_position = nullptr; +std::vector session_order; +std::vector active_presentations; +Il2CppObject* active_controller = nullptr; +Il2CppObject* active_view_context = nullptr; +Il2CppClass* item_context_class = nullptr; +Il2CppClass* view_context_class = nullptr; +Il2CppClass* scroller_class = nullptr; +FieldInfo* widget_context_field = nullptr; +FieldInfo* controller_context_field = nullptr; +FieldInfo* controller_scroller_field = nullptr; +FieldInfo* presets_items_field = nullptr; +FieldInfo* scroller_data_field = nullptr; +FieldInfo* scroller_held_data_field = nullptr; +const MethodInfo* clear_and_generate = nullptr; +const MethodInfo* get_scroll_position = nullptr; +const MethodInfo* restore_scroll_position = nullptr; +bool hooks_ready = false; +bool custom_presentations = false; +bool view_failed = false; +bool processing = false; +bool order_loaded = false; +uint64_t view_epoch = 0; + +bool enabled() { return hooks_ready && Config::Get().allow_officer_preset_reordering; } + +struct ProcessingScope { + bool previous = processing; + ProcessingScope() { processing = true; } + ~ProcessingScope() { processing = previous; } +}; + +bool instance_field(FieldInfo* field) +{ + return field && field->type && !field->type->byref && field->offset >= 0 + && !(field->type->attrs & FIELD_ATTRIBUTE_STATIC); +} + +FieldInfo* reference_field(Il2CppClass* owner, const char* name, Il2CppClass* expected = nullptr) +{ + auto* field = owner ? il2cpp_class_get_field_from_name(owner, name) : nullptr; + auto* cls = field && field->type ? il2cpp_class_from_type(field->type) : nullptr; + return instance_field(field) && cls && !il2cpp_class_is_valuetype(cls) + && (!expected || il2cpp_class_is_assignable_from(expected, cls)) ? field : nullptr; +} + +Il2CppObject* read_reference(void* object, FieldInfo* field) +{ + Il2CppObject* value = nullptr; + if (!object || !instance_field(field)) return nullptr; + auto* instance = static_cast(object); + if (!instance->klass || !il2cpp_class_is_assignable_from(field->parent, instance->klass)) return nullptr; + il2cpp_field_get_value(instance, field, &value); + auto* expected = il2cpp_class_from_type(field->type); + return value && value->klass && expected && il2cpp_class_is_assignable_from(expected, value->klass) ? value : nullptr; +} + +bool write_reference(Il2CppObject* object, FieldInfo* field, Il2CppObject* value) +{ + auto* expected = field && field->type ? il2cpp_class_from_type(field->type) : nullptr; + if (!object || !object->klass || !instance_field(field) || !expected + || !il2cpp_class_is_assignable_from(field->parent, object->klass) + || (value && (!value->klass || !il2cpp_class_is_assignable_from(expected, value->klass)))) return false; + il2cpp_field_set_value_object(object, field, value); + Il2CppObject* observed = nullptr; + il2cpp_field_get_value(object, field, &observed); + return observed == value; +} + +void clear_active_view() +{ + ++view_epoch; + active_controller = nullptr; + active_view_context = nullptr; + active_presentations.clear(); + custom_presentations = false; + view_failed = false; +} void load_session_order() { @@ -90,6 +156,14 @@ void load_session_order() } } +void ensure_order_loaded() +{ + if (!order_loaded) { + load_session_order(); + order_loaded = true; + } +} + bool save_session_order() { auto order = nlohmann::json::array(); @@ -109,7 +183,7 @@ bool validate_context_field(Il2CppClass* context_class, const char* name, ptrdif Il2CppTypeEnum expected_type) { auto* field = il2cpp_class_get_field_from_name(context_class, name); - if (field == nullptr || field->type == nullptr) { + if (!instance_field(field)) { spdlog::error("[OfficerPresetReorder] required context field '{}' is unavailable", name); return false; } @@ -125,7 +199,17 @@ bool validate_context_field(Il2CppClass* context_class, const char* name, ptrdif bool validate_context_layout(Il2CppClass* context_class) { - return context_class != nullptr + auto* presentation = context_class ? il2cpp_class_get_field_from_name(context_class, "Presentation") : nullptr; + auto* enum_class = presentation && presentation->type ? il2cpp_class_from_type(presentation->type) : nullptr; + const auto* underlying = enum_class && il2cpp_class_is_enum(enum_class) ? il2cpp_class_enum_basetype(enum_class) : nullptr; + auto* owner_field = context_class ? reference_field(context_class, "_officerPresetsViewContext", view_context_class) : nullptr; + auto* officers_field = context_class ? il2cpp_class_get_field_from_name(context_class, "Officers") : nullptr; + auto* officers_array = officers_field && officers_field->type ? il2cpp_class_from_type(officers_field->type) : nullptr; + return context_class != nullptr && enum_class && enum_class->declaringType == context_class + && std::strcmp(enum_class->name, "PresentationType") == 0 + && underlying && !underlying->byref && underlying->type == IL2CPP_TYPE_I4 + && owner_field && officers_array && officers_array->element_class + && !il2cpp_class_is_valuetype(officers_array->element_class) && validate_context_field(context_class, "Presentation", offsetof(OfficerPresetItemContext, presentation), IL2CPP_TYPE_VALUETYPE) && validate_context_field(context_class, "IsOccupied", offsetof(OfficerPresetItemContext, is_occupied), @@ -143,13 +227,10 @@ bool validate_context_layout(Il2CppClass* context_class) bool is_reorderable_preset(const OfficerPresetItemContext* context) { - return context != nullptr && context->slot_id >= 0 && context->order_id >= 0 && context->preset_name != nullptr + return context != nullptr && context->object.klass == item_context_class && context->slot_id >= 0 && context->order_id >= 0 && context->preset_name != nullptr && context->officers != nullptr && reinterpret_cast(context->officers)->max_length > 0; } -template T* read_object_field(void* object, ptrdiff_t offset) -{ return object != nullptr ? *reinterpret_cast(reinterpret_cast(object) + offset) : nullptr; } - bool key_pressed(KeyCode key) { static auto get_key = il2cpp_resolve_icall_typed("UnityEngine.Input::GetKeyInt(UnityEngine.KeyCode)"); @@ -160,7 +241,13 @@ bool shift_pressed() { return key_pressed(KeyCode::LeftShift) || key_pressed(KeyCode::RightShift); } bool control_pressed() -{ return key_pressed(KeyCode::LeftControl) || key_pressed(KeyCode::RightControl); } +{ +#ifdef __APPLE__ + return key_pressed(KeyCode::LeftCommand) || key_pressed(KeyCode::RightCommand); +#else + return key_pressed(KeyCode::LeftControl) || key_pressed(KeyCode::RightControl); +#endif +} void remember_slots(OfficerPresetItemContext** items, il2cpp_array_size_t size) { @@ -176,7 +263,7 @@ void remember_slots(OfficerPresetItemContext** items, il2cpp_array_size_t size) } bool has_native_identity(const OfficerPresetItemContext* context) -{ return context != nullptr && context->slot_id >= 0 && context->order_id >= 0; } +{ return context != nullptr && context->object.klass == item_context_class && context->slot_id >= 0 && context->order_id >= 0; } bool validate_unique_preset_identity(OfficerPresetItemContext** items, int32_t size) { @@ -186,6 +273,7 @@ bool validate_unique_preset_identity(OfficerPresetItemContext** items, int32_t s order_ids.reserve(size); for (int32_t index = 0; index < size; ++index) { const auto* context = items[index]; + if (context && context->object.klass != item_context_class) return false; if (!has_native_identity(context)) { continue; } @@ -250,21 +338,18 @@ std::vector make_view_order(OfficerPresetItemContext* bool try_get_preset_list(void* view_context, Il2CppObject** list, Il2CppArraySize** backing_items, int32_t* size) { - *list = read_object_field(view_context, presets_items_offset); - if (*list == nullptr) { - return false; - } - - auto list_helper = IL2CppClassHelper{(*list)->klass}; - auto items_field = list_helper.GetField("_items"); - auto size_field = list_helper.GetField("_size"); - if (!items_field.isValidHelper() || !size_field.isValidHelper()) { - return false; - } - - *backing_items = read_object_field(*list, items_field.offset()); - *size = *reinterpret_cast(reinterpret_cast(*list) + size_field.offset()); - return *backing_items != nullptr && *size >= 0 + *list = read_reference(view_context, presets_items_field); + if (!*list || !(*list)->klass) return false; + auto* items_field = reference_field((*list)->klass, "_items"); + auto* size_field = il2cpp_class_get_field_from_name((*list)->klass, "_size"); + auto* array_class = items_field ? il2cpp_class_from_type(items_field->type) : nullptr; + if (!array_class || items_field->type->type != IL2CPP_TYPE_SZARRAY + || array_class->element_class != item_context_class || !instance_field(size_field) + || size_field->type->type != IL2CPP_TYPE_I4) return false; + *backing_items = reinterpret_cast(read_reference(*list, items_field)); + il2cpp_field_get_value(*list, size_field, size); + return *backing_items && reinterpret_cast(*backing_items)->klass == array_class && *size >= 0 + && (*backing_items)->max_length <= static_cast(std::numeric_limits::max()) && static_cast(*size) <= (*backing_items)->max_length; } @@ -296,52 +381,110 @@ bool restore_native_presentations(OfficerPresetItemContext** canonical_items, in return true; } -bool render_local_order(Il2CppObject* controller, bool preserve_scroll) +bool read_scroll(Il2CppObject* scroller, float& position) { - if (controller == nullptr || item_context_class == nullptr || clear_and_generate == nullptr) { - return false; - } + Il2CppObject* boxed = nullptr; + if (!Il2CppRuntime::TryInvoke(get_scroll_position, scroller, nullptr, &boxed) + || !boxed || !boxed->klass || il2cpp_class_get_type(boxed->klass)->type != IL2CPP_TYPE_R4) return false; + auto* value = static_cast(il2cpp_object_unbox(boxed)); + if (!value) return false; + position = *value; + return true; +} - auto* view_context = read_object_field(controller, controller_context_offset); - auto* scroller = read_object_field(controller, controller_scroller_offset); - Il2CppObject* canonical_list = nullptr; - Il2CppArraySize* backing_items = nullptr; - int32_t size = 0; - if (scroller == nullptr || !try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { +bool restore_scroll(Il2CppObject* scroller, float position) +{ + void* args[]{&position}; + return Il2CppRuntime::TryInvoke(restore_scroll_position, scroller, args); +} + +// This runs for an owned custom view even after the preference becomes false. +// A successful ClearAndGenerateContents can defer or omit AssignContextData. +// Its invocation status therefore cannot establish the canonical save source. +bool restore_canonical_view(Il2CppObject* controller, bool regenerate) +{ + if (active_controller != controller || !custom_presentations) return true; + auto* view_context = read_reference(controller, controller_context_field); + auto* scroller = read_reference(controller, controller_scroller_field); + Il2CppObject* list = nullptr; + Il2CppArraySize* backing = nullptr; + int32_t size = 0; + if (view_context != active_view_context || !scroller + || !try_get_preset_list(view_context, &list, &backing, &size) + || !restore_native_presentations(reinterpret_cast(backing->vector), size)) { + view_failed = true; return false; } - - auto** canonical_items = reinterpret_cast(backing_items->vector); - if (!validate_canonical_order(canonical_items, size) || active_presentations.size() != static_cast(size)) { + bool regenerated = true; + if (regenerate) { + ProcessingScope scope; + void* args[]{controller, list}; + regenerated = Il2CppRuntime::TryInvoke(clear_and_generate, scroller, args); + } + // Reference writes use the reflected API and its GC barrier, never raw offsets. + const bool source_restored = write_reference(scroller, scroller_data_field, list); + Il2CppObject* held = nullptr; + il2cpp_field_get_value(scroller, scroller_held_data_field, &held); + const bool pending_restored = !held || write_reference(scroller, scroller_held_data_field, list); + if (source_restored && pending_restored) custom_presentations = false; + if (!regenerated || !source_restored || !pending_restored) { + view_failed = true; + spdlog::warn("[OfficerPresetReorder] canonical restoration incomplete; custom ordering suspended"); return false; } + return true; +} - auto view_items = make_view_order(canonical_items, size); +bool render_local_order(Il2CppObject* controller, bool preserve_scroll) +{ + if (!enabled() || processing || view_failed || active_controller != controller) return false; + auto* view_context = read_reference(controller, controller_context_field); + auto* scroller = read_reference(controller, controller_scroller_field); + Il2CppObject* list = nullptr; + Il2CppArraySize* backing = nullptr; + int32_t size = 0; + if (view_context != active_view_context || !scroller + || !try_get_preset_list(view_context, &list, &backing, &size)) return false; + auto** canonical_items = reinterpret_cast(backing->vector); + if (!validate_canonical_order(canonical_items, size) || active_presentations.size() != static_cast(size)) return false; + auto view_items = make_view_order(canonical_items, size); auto* view_array = il2cpp_array_new(item_context_class, static_cast(size)); - if (view_array == nullptr) { - spdlog::warn("[OfficerPresetReorder] unable to allocate the scroller view array"); + if (!view_array) return false; + float scroll = 0.0f; + const bool have_scroll = preserve_scroll && read_scroll(scroller, scroll); + const auto epoch = view_epoch; + custom_presentations = true; // retain the restoration snapshot before the first mutation + auto** slots = reinterpret_cast(reinterpret_cast(view_array)->vector); + for (int32_t i = 0; i < size; ++i) { + auto* context = view_items[i]; + if (context) context->presentation = active_presentations[i]; + il2cpp_gc_wbarrier_set_field(reinterpret_cast(view_array), &slots[i], context); + } + bool generated = false; + { + ProcessingScope scope; + void* args[]{controller, view_array}; + generated = Il2CppRuntime::TryInvoke(clear_and_generate, scroller, args); + } + if (epoch != view_epoch || active_controller != controller || !custom_presentations) return false; + // A normal return without installing this source is not a completed custom render. + if (!generated || read_reference(scroller, scroller_data_field) != reinterpret_cast(view_array)) { + view_failed = true; + restore_canonical_view(controller, false); return false; } - auto** view_slots = reinterpret_cast(reinterpret_cast(view_array)->vector); - for (int32_t index = 0; index < size; ++index) { - auto* context = view_items[index]; - if (context != nullptr) { - context->presentation = active_presentations[index]; - } - il2cpp_gc_wbarrier_set_field(reinterpret_cast(view_array), &view_slots[index], context); - } - - const auto scroll_position = preserve_scroll && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; - clear_and_generate(scroller, controller, reinterpret_cast(view_array)); - if (preserve_scroll && restore_scroll_position != nullptr) { - restore_scroll_position(scroller, scroll_position); + if (have_scroll && !restore_scroll(scroller, scroll)) { + view_failed = true; + restore_canonical_view(controller, true); + return false; } return true; } bool move_preset(OfficerPresetItemContext* context, int direction) { - if (!is_reorderable_preset(context) || context->officer_presets_view_context == nullptr + if (!enabled() || processing || view_failed || !is_reorderable_preset(context) + || context->officer_presets_view_context != active_view_context || active_controller == nullptr || clear_and_generate == nullptr) { return false; } @@ -381,7 +524,7 @@ bool move_preset(OfficerPresetItemContext* context, int direction) return true; } - auto* scroller = read_object_field(active_controller, controller_scroller_offset); + auto* scroller = read_reference(active_controller, controller_scroller_field); if (scroller == nullptr) { spdlog::warn("[OfficerPresetReorder] unable to access the active preset scroller"); return false; @@ -408,216 +551,204 @@ bool move_preset(OfficerPresetItemContext* context, int direction) void OfficerPresetsViewController_OnSaveSlotsSuccess_Hook(auto original, Il2CppObject* _this, bool increase_occupied_slots_count) { - auto* view_context = read_object_field(_this, controller_context_offset); - auto* scroller = read_object_field(_this, controller_scroller_offset); - - Il2CppObject* canonical_list = nullptr; - Il2CppArraySize* backing_items = nullptr; - int32_t size = 0; - const bool canonical_available = - scroller != nullptr && try_get_preset_list(view_context, &canonical_list, &backing_items, &size) - && validate_canonical_order(reinterpret_cast(backing_items->vector), size); - const bool can_rerender = - canonical_available && active_controller == _this - && restore_native_presentations(reinterpret_cast(backing_items->vector), size); - const auto scroll_position = can_rerender && get_scroll_position != nullptr ? get_scroll_position(scroller) : 0.0f; - if (canonical_available) { - // Scopely's callback indexes SmartScroller._data by OrderId. Give it the canonical list for the duration of the - // callback, then rebuild our separate presentation-only view. - clear_and_generate(scroller, _this, canonical_list); - } - - original(_this, increase_occupied_slots_count); - - if (!canonical_available) { - spdlog::warn("[OfficerPresetReorder] save succeeded without an available canonical scroller source"); - return; - } - if (!can_rerender) { - spdlog::debug("[OfficerPresetReorder] save succeeded in canonical order without an active local view"); - return; - } - - if (!try_get_preset_list(read_object_field(_this, controller_context_offset), &canonical_list, &backing_items, - &size)) { - spdlog::warn("[OfficerPresetReorder] save succeeded but the canonical preset list became unavailable"); - return; - } - - auto** canonical_items = reinterpret_cast(backing_items->vector); - if (!validate_canonical_order(canonical_items, size)) { - spdlog::error("[OfficerPresetReorder] canonical preset invariant failed after save; local ordering was not " - "reapplied"); - return; - } - if (active_presentations.size() != static_cast(size)) { - if (!capture_native_presentations(canonical_items, size)) { - return; + const auto epoch = view_epoch; + auto* context = active_view_context; + const bool owned = active_controller == _this && custom_presentations; + bool restored = false; + float scroll = 0.0f; + bool have_scroll = false; + try { + if (owned) { + auto* scroller = read_reference(_this, controller_scroller_field); + have_scroll = scroller && read_scroll(scroller, scroll); + restored = restore_canonical_view(_this, !processing); } + } catch (...) { + view_failed = true; + // Keep the snapshot; a later release can still restore it. } - if (!render_local_order(_this, false)) { - spdlog::warn("[OfficerPresetReorder] save reconciled safely but the local scroller order could not be restored"); - return; - } - if (restore_scroll_position != nullptr) { - restore_scroll_position(scroller, scroll_position); + original(_this, increase_occupied_slots_count); + if (!owned || !restored || !enabled() || processing || view_failed || epoch != view_epoch + || active_controller != _this || read_reference(_this, controller_context_field) != context) return; + try { + Il2CppObject* list = nullptr; + Il2CppArraySize* backing = nullptr; + int32_t size = 0; + if (!try_get_preset_list(context, &list, &backing, &size) + || !capture_native_presentations(reinterpret_cast(backing->vector), size) + || !render_local_order(_this, false)) return; + if (have_scroll) restore_scroll(read_reference(_this, controller_scroller_field), scroll); + } catch (...) { + view_failed = true; + restore_canonical_view(_this, false); } - spdlog::info("[OfficerPresetReorder] reconciled presentation-only ordering after preset save"); } bool OfficerManager_TryGetPresetItemContext_Hook(auto original, void* _this, Il2CppArraySize** preset_contexts, void* view_context) { const bool result = original(_this, preset_contexts, view_context); - if (!result || preset_contexts == nullptr || *preset_contexts == nullptr) { - spdlog::info("[OfficerPresetReorder] preset context load returned result={} array={}", result, - preset_contexts != nullptr ? static_cast(*preset_contexts) : nullptr); - return result; - } - - auto* contexts = *preset_contexts; - auto** items = reinterpret_cast(contexts->vector); - if (validate_canonical_order(items, static_cast(contexts->max_length))) { - remember_slots(items, contexts->max_length); - spdlog::debug("[OfficerPresetReorder] observed {} canonical preset rows", contexts->max_length); - } else { - spdlog::error("[OfficerPresetReorder] Scopely returned non-canonical preset rows; leaving them untouched"); + if (!enabled() || processing || !result || !preset_contexts || !*preset_contexts) return result; + try { + auto* contexts = *preset_contexts; + if (!reinterpret_cast(contexts)->klass || reinterpret_cast(contexts)->klass->element_class != item_context_class + || contexts->max_length > static_cast(std::numeric_limits::max())) return result; + auto** items = reinterpret_cast(contexts->vector); + if (validate_canonical_order(items, static_cast(contexts->max_length))) { + ensure_order_loaded(); + remember_slots(items, contexts->max_length); + } + } catch (...) { + spdlog::warn("[OfficerPresetReorder] ignored a failed order observation"); } - return result; } void OfficerPresetItemWidget_OnEditNameButtonClicked_Hook(auto original, void* _this) { - const bool move_up = shift_pressed(); - const bool move_down = control_pressed(); - if (move_up != move_down) { - auto* context = read_object_field(_this, widget_context_offset); - if (move_preset(context, move_up ? -1 : 1)) { - return; + if (!enabled() || processing || view_failed) { + if (hooks_ready && active_controller && custom_presentations && !processing) { + try { restore_canonical_view(active_controller, true); } catch (...) { view_failed = true; } + } + original(_this); + return; + } + try { + const bool up = shift_pressed(); + const bool down = control_pressed(); + if (up != down) { + auto* context = reinterpret_cast(read_reference(_this, widget_context_field)); + if (move_preset(context, up ? -1 : 1)) return; } + } catch (...) { + view_failed = true; + if (active_controller && custom_presentations) restore_canonical_view(active_controller, false); } original(_this); } void OfficerPresetsViewController_OnDidBindCanvasContext_Hook(auto original, Il2CppObject* _this) { - original(_this); - active_controller = _this; - - auto* view_context = read_object_field(_this, controller_context_offset); - Il2CppObject* canonical_list = nullptr; - Il2CppArraySize* backing_items = nullptr; - int32_t size = 0; - if (!try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { - active_controller = nullptr; - active_presentations.clear(); - spdlog::warn("[OfficerPresetReorder] unable to capture the bound canonical preset list"); - return; + if (hooks_ready && active_controller && custom_presentations) { + try { restore_canonical_view(active_controller, false); } catch (...) { view_failed = true; } } - - auto** canonical_items = reinterpret_cast(backing_items->vector); - if (!capture_native_presentations(canonical_items, size) || !render_local_order(_this, false)) { - active_controller = nullptr; - active_presentations.clear(); - spdlog::warn("[OfficerPresetReorder] local ordering was disabled for the bound preset view"); + original(_this); + clear_active_view(); + if (!enabled() || processing) return; + try { + active_controller = _this; + active_view_context = read_reference(_this, controller_context_field); + Il2CppObject* list = nullptr; + Il2CppArraySize* backing = nullptr; + int32_t size = 0; + ensure_order_loaded(); + if (!try_get_preset_list(active_view_context, &list, &backing, &size) + || !capture_native_presentations(reinterpret_cast(backing->vector), size) + || !render_local_order(_this, false)) view_failed = true; + } catch (...) { + view_failed = true; + restore_canonical_view(_this, false); } } void OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook(auto original, Il2CppObject* _this) { - if (active_controller == _this) { - auto* view_context = read_object_field(_this, controller_context_offset); - auto* scroller = read_object_field(_this, controller_scroller_offset); - Il2CppObject* canonical_list = nullptr; - Il2CppArraySize* backing_items = nullptr; - int32_t size = 0; - if (scroller != nullptr && try_get_preset_list(view_context, &canonical_list, &backing_items, &size)) { - auto** canonical_items = reinterpret_cast(backing_items->vector); - if (validate_canonical_order(canonical_items, size)) { - restore_native_presentations(canonical_items, size); - clear_and_generate(scroller, _this, canonical_list); - } - } - active_controller = nullptr; - active_presentations.clear(); + const bool owned = active_controller == _this; + const auto epoch = view_epoch; + if (hooks_ready && owned && custom_presentations) { + try { restore_canonical_view(_this, !processing); } catch (...) { view_failed = true; } } original(_this); + // Teardown is definitive; never clear a different view bound by the native callback. + if (owned && epoch == view_epoch && active_controller == _this) clear_active_view(); } -} // namespace -void InstallOfficerPresetReorderHooks() +const MethodInfo* resolve(Il2CppClass* cls, const char* name, const char* result, + std::initializer_list parameters) { - auto helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Officers", "OfficerManager"); - if (!helper.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.Officers", "OfficerManager"); - return; - } - - const auto method = helper.GetMethodInfo("TryGetPresetItemContext", 2); - if (method == nullptr || method->methodPointer == nullptr) { - ErrorMsg::MissingMethod("OfficerManager", "TryGetPresetItemContext"); - return; - } + auto* method = method_contract::Resolve(cls, name, false, result, parameters); + return method && !method->has_full_generic_sharing_signature ? method : nullptr; +} - auto widget_helper = - il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemWidget"); - auto controller_helper = - il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewController"); - auto view_context_helper = - il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewContext"); - auto item_context_helper = - il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemContext"); - auto scroller_helper = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "SmartScrollerBase"); - if (!widget_helper.isValidHelper() || !controller_helper.isValidHelper() || !view_context_helper.isValidHelper() - || !item_context_helper.isValidHelper() || !scroller_helper.isValidHelper()) { - ErrorMsg::MissingHelper("Digit.Prime.OfficerPresets", "reorder UI surface"); - return; - } - if (!validate_context_layout(item_context_helper.get_cls())) { - return; - } - item_context_class = item_context_helper.get_cls(); +const MethodInfo* resolve_manager(Il2CppClass* cls) +{ + const MethodInfo* found = nullptr; + void* iterator = nullptr; + while (auto* method = il2cpp_class_get_methods(cls, &iterator)) { + if (std::strcmp(method->name, "TryGetPresetItemContext") != 0 || !method->methodPointer + || method->is_generic || method->is_inflated || method->has_full_generic_sharing_signature + || (method->flags & METHOD_ATTRIBUTE_STATIC) || method->parameters_count != 2 + || !method_contract::Type(method->return_type, "System.Boolean") || !method->parameters + || !method->parameters[0] || !method->parameters[0]->byref + || method->parameters[0]->type != IL2CPP_TYPE_SZARRAY + || !method_contract::Type(method->parameters[1], "Digit.Prime.OfficerPresets.OfficerPresetsViewContext")) continue; + auto* array_class = il2cpp_class_from_type(method->parameters[0]); + if (!array_class || array_class->element_class != item_context_class) continue; + if (found) return nullptr; + found = method; + } + return found; +} +} // namespace - auto context_field = widget_helper.GetField("m_context"); - auto controller_context_field = controller_helper.GetField("m_context"); - auto scroller_field = controller_helper.GetField("_smartScroller"); - auto presets_field = view_context_helper.GetField("PresetsItemsContext"); - if (!context_field.isValidHelper() || !controller_context_field.isValidHelper() || !scroller_field.isValidHelper() - || !presets_field.isValidHelper()) { - ErrorMsg::MissingMethod("OfficerPresetReorder", "required field"); +void InstallOfficerPresetReorderHooks() +{ + auto manager = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Officers", "OfficerManager"); + auto widget = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemWidget"); + auto controller = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewController"); + auto view = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetsViewContext"); + auto item = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.OfficerPresets", "OfficerPresetItemContext"); + auto scroller = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "SmartScrollerBase"); + auto list_interface = il2cpp_get_class_helper("mscorlib", "System.Collections", "IList"); + auto provider = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Client.UI", "IDataContextProvider"); + if (!manager.isValidHelper() || !widget.isValidHelper() || !controller.isValidHelper() + || !view.isValidHelper() || !item.isValidHelper() || !scroller.isValidHelper() + || !list_interface.isValidHelper() || !provider.isValidHelper()) { + ErrorMsg::MissingHelper("OfficerPresetReorder", "required UI surface"); return; } - widget_context_offset = context_field.offset(); - controller_context_offset = controller_context_field.offset(); - controller_scroller_offset = scroller_field.offset(); - presets_items_offset = presets_field.offset(); - - const auto clear_method = scroller_helper.GetMethodInfo("ClearAndGenerateContents", 2); - const auto get_scroll_method = scroller_helper.GetMethodInfo("get_ScrollPosition", 0); - const auto restore_scroll_method = scroller_helper.GetMethodInfo("RestoreScrollPosition", 1); - const auto edit_method = widget_helper.GetMethodInfo("OnEditNameButtonClicked", 0); - const auto bind_method = controller_helper.GetMethodInfo("OnDidBindCanvasContext", 0); - const auto release_method = controller_helper.GetMethodInfo("OnAboutToReleaseCanvasContext", 0); - const auto save_success_method = controller_helper.GetMethodInfo("OnSaveSlotsSuccess", 1); - if (clear_method == nullptr || clear_method->methodPointer == nullptr || get_scroll_method == nullptr - || get_scroll_method->methodPointer == nullptr || restore_scroll_method == nullptr - || restore_scroll_method->methodPointer == nullptr || edit_method == nullptr - || edit_method->methodPointer == nullptr || bind_method == nullptr || bind_method->methodPointer == nullptr - || release_method == nullptr || release_method->methodPointer == nullptr || save_success_method == nullptr - || save_success_method->methodPointer == nullptr) { - ErrorMsg::MissingMethod("OfficerPresetReorder", "required UI method"); +#ifdef __APPLE__ + if (!il2cpp_field_set_value_object || !il2cpp_field_get_value || !il2cpp_class_enum_basetype) return; +#endif + item_context_class = item.get_cls(); + view_context_class = view.get_cls(); + scroller_class = scroller.get_cls(); + if (!validate_context_layout(item_context_class)) return; + widget_context_field = reference_field(widget.get_cls(), "m_context", item_context_class); + controller_context_field = reference_field(controller.get_cls(), "m_context", view_context_class); + controller_scroller_field = reference_field(controller.get_cls(), "_smartScroller", scroller_class); + presets_items_field = reference_field(view_context_class, "PresetsItemsContext", list_interface.get_cls()); + scroller_data_field = reference_field(scroller_class, "_data", list_interface.get_cls()); + scroller_held_data_field = reference_field(scroller_class, "_dataHeldWhilstInitializing", list_interface.get_cls()); + if (!widget_context_field || !controller_context_field || !controller_scroller_field || !presets_items_field + || !scroller_data_field || !scroller_held_data_field + || il2cpp_class_from_type(scroller_data_field->type) != list_interface.get_cls() + || il2cpp_class_from_type(scroller_held_data_field->type) != list_interface.get_cls() + || !il2cpp_class_is_assignable_from(provider.get_cls(), controller.get_cls())) return; + + clear_and_generate = resolve(scroller_class, "ClearAndGenerateContents", "System.Void", + {"Digit.Client.UI.IDataContextProvider", "System.Collections.IList"}); + get_scroll_position = resolve(scroller_class, "get_ScrollPosition", "System.Single", {}); + restore_scroll_position = resolve(scroller_class, "RestoreScrollPosition", "System.Void", {"System.Single"}); + const auto* method = resolve_manager(manager.get_cls()); + const auto* edit = resolve(widget.get_cls(), "OnEditNameButtonClicked", "System.Void", {}); + const auto* bind = resolve(controller.get_cls(), "OnDidBindCanvasContext", "System.Void", {}); + const auto* release = resolve(controller.get_cls(), "OnAboutToReleaseCanvasContext", "System.Void", {}); + const auto* save = resolve(controller.get_cls(), "OnSaveSlotsSuccess", "System.Void", {"System.Boolean"}); + if (!clear_and_generate || !get_scroll_position || !restore_scroll_position + || !method || !edit || !bind || !release || !save) { + ErrorMsg::MissingMethod("OfficerPresetReorder", "full signature"); return; } - clear_and_generate = reinterpret_cast(clear_method->methodPointer); - get_scroll_position = reinterpret_cast(get_scroll_method->methodPointer); - restore_scroll_position = reinterpret_cast(restore_scroll_method->methodPointer); - - load_session_order(); - - SPUD_STATIC_DETOUR(method->methodPointer, OfficerManager_TryGetPresetItemContext_Hook); - SPUD_STATIC_DETOUR(edit_method->methodPointer, OfficerPresetItemWidget_OnEditNameButtonClicked_Hook); - SPUD_STATIC_DETOUR(bind_method->methodPointer, OfficerPresetsViewController_OnDidBindCanvasContext_Hook); - SPUD_STATIC_DETOUR(release_method->methodPointer, OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook); - SPUD_STATIC_DETOUR(save_success_method->methodPointer, OfficerPresetsViewController_OnSaveSlotsSuccess_Hook); + const MethodInfo* targets[]{method, edit, bind, release, save}; + for (size_t i = 0; i < std::size(targets); ++i) + for (size_t j = 0; j < i; ++j) + if (targets[i]->methodPointer == targets[j]->methodPointer) return; + const bool manager_ok = SPUD_STATIC_DETOUR(method->methodPointer, OfficerManager_TryGetPresetItemContext_Hook) != nullptr; + const bool edit_ok = SPUD_STATIC_DETOUR(edit->methodPointer, OfficerPresetItemWidget_OnEditNameButtonClicked_Hook) != nullptr; + const bool bind_ok = SPUD_STATIC_DETOUR(bind->methodPointer, OfficerPresetsViewController_OnDidBindCanvasContext_Hook) != nullptr; + const bool release_ok = SPUD_STATIC_DETOUR(release->methodPointer, OfficerPresetsViewController_OnAboutToReleaseCanvasContext_Hook) != nullptr; + const bool save_ok = SPUD_STATIC_DETOUR(save->methodPointer, OfficerPresetsViewController_OnSaveSlotsSuccess_Hook) != nullptr; + hooks_ready = manager_ok && edit_ok && bind_ok && release_ok && save_ok; + if (!hooks_ready) spdlog::warn("[OfficerPresetReorder] incomplete hook family; callbacks retain native behavior"); } diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index 07b9ba989..d49949de4 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -153,7 +153,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.installForbiddenTechConfirmationHooks}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, - {"OfficerPresetReorder", {InstallOfficerPresetReorderHooks, &cfg.allow_officer_preset_reordering}}, + {"OfficerPresetReorder", {InstallOfficerPresetReorderHooks, &cfg.installOfficerPresetReorderHooks}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); From 251eac6e28d9575be2bb3d9e7c426cf3fd282008 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 1 Oct 2026 23:04:19 -0500 Subject: [PATCH 13/13] Preserve Mac Ctrl-click alongside native Command input --- mods/src/patches/parts/officer_preset_reorder.cc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mods/src/patches/parts/officer_preset_reorder.cc b/mods/src/patches/parts/officer_preset_reorder.cc index 0229848f1..e073dadfd 100644 --- a/mods/src/patches/parts/officer_preset_reorder.cc +++ b/mods/src/patches/parts/officer_preset_reorder.cc @@ -243,7 +243,8 @@ bool shift_pressed() bool control_pressed() { #ifdef __APPLE__ - return key_pressed(KeyCode::LeftCommand) || key_pressed(KeyCode::RightCommand); + return key_pressed(KeyCode::LeftCommand) || key_pressed(KeyCode::RightCommand) + || key_pressed(KeyCode::LeftControl) || key_pressed(KeyCode::RightControl); #else return key_pressed(KeyCode::LeftControl) || key_pressed(KeyCode::RightControl); #endif