diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6f1268bc1..90ba05175 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -206,6 +206,10 @@ jobs: - name: Build run: xmake build -y stfc-community-mod + - name: Test confirmation setting contracts + shell: pwsh + run: ./tests/run-confirmation-settings.ps1 + - name: Test x64 trampoline relocation shell: pwsh run: | @@ -521,6 +525,10 @@ jobs: shell: bash run: sccache --show-stats + - name: Test confirmation setting contracts + shell: bash + run: bash tests/run-confirmation-settings.sh + - name: Report Swift module cache shell: bash run: | diff --git a/docs/MOD_SETTINGS_FOUNDATION.md b/docs/MOD_SETTINGS_FOUNDATION.md new file mode 100644 index 000000000..be0147672 --- /dev/null +++ b/docs/MOD_SETTINGS_FOUNDATION.md @@ -0,0 +1,111 @@ +# Boolean settings foundation and native FC control + +The controller and Fleet Commander preference adapter back a Windows x64 native +confirmation-page control. Mod-owned TOML +persistence and the Community Mod category are separate work. + +`settings/boolean_settings.h` is independent of Unity and storage. Definitions have +a stable ID, readable label, read callback, and immediate-write callback. Registry +IDs are unique and registration freezes on first lookup. All operations belong to +the constructing thread; there is no polling, background work or disk I/O. + +Consumers must keep unknown state separate from a boolean. `ReadResult` carries +availability, an optional positive value, and an adapter generation. A snapshot +also carries controller identity, revision and lifecycle epoch. Passing a stale +or foreign snapshot rejects the request. `RenderScope` suppresses user-write +handling around native binding/refresh callbacks. Reentrant application is Busy. + +Writes re-read before applying, skip already-satisfied values and verify readback. +Failed or uncertain writes never trigger an automatic reverse write. The returned +snapshot contains a fresh authoritative read when available; `Unverified` must +not be rendered as successful application. `AppliedVerified` is local verification, +not a claim of cloud durability. + +## Fleet Commander adapter + +`FleetCommanderConfirmationSetting()` provides the native UI setting. ON means show confirmation. Reads use the existing +PersistentPrefsManager's `GetBool(key, false, false)`: the final false prevents +insertion of a missing preference while preserving the game's default. Writes use +the native FC setter. The adapter never instantiates managers, invokes abilities, +forces cloud saves, or enumerates other preferences. + +Metadata is resolved lazily and checked before access. Two weak handles detect +replacement of the preference manager or saved-data object without retaining +account data. Unavailability invalidates the observed generation. One process-wide +root holds the constant, non-sensitive preference key; no work runs while idle. + +The UI calls `InvalidateFleetCommanderConfirmationSession()` before the native +preference manager's RegisterEvents (initialization/reload), session-start handler, +and cloud-load entry. It immediately invalidates live view snapshots. These are +substantive functions; neither the tiny OnApplicationReload wrapper nor the +LifecycleUpdatedEventHandler save-timer path is hooked. Exact-client account +transition validation is still required; object identity alone is insufficient. + +The prototype recovery shortcut has been removed. Use the native settings row; +legacy `enable_fc_ability_confirmation` entries are no longer consumed. + +## Standalone controller tests + +Windows (clang++ with the installed C++ toolchain): + +```powershell +clang++ -std=c++23 -Wall -Wextra -Werror -I mods/src tests/boolean_settings_test.cc -o boolean_settings_test.exe +./boolean_settings_test.exe +``` + +On Unix, use the equivalent compiler invocation with `-pthread`. Keep executables +outside tracked source. These tests cover the pure state machine, not native ABI, +account lifecycle wiring, frame timings, or cloud persistence. + +Before extending the UI, measure the baseline and candidate with the same scene, +FPS cap and diagnostics: no scheduled closed-menu work or per-frame allocations; +initial target <=1 ms added normal bind/refresh work at p95, <=2 ms per normal +operation. These are proposed UI acceptance budgets, not measured P1 results. + +## Native UI adapter (P2 candidate) + +The first registered control is `[MOD] Confirm Fleet Commander abilities`, under +the existing confirmation category. ON means show confirmations; OFF means skip. +The adapter is independent of mod hotkeys and does not install a global localization +hook. It overrides TextLocalizer after native binding and clears its own overrides +on release/rebind, using weak ownership records rather than matching visible text. + +`BooleanView` retains the displayed snapshot. Rendering suppresses writes; stale +clicks conflict; an uncertain apply remains unresolved until a subsequent bind. +Rejected writes with known readback retain that value and show a retry message. +Unknown values suppress both native switch/state visual nodes while retaining the +label. The prefab must prove that those nodes are descendants of the row and do +not contain the label; otherwise that UI is unsupported. Exact visual validation +of this behavior remains a release gate. + +Eight weak view records bound bookkeeping. Native contexts own rows/delegates; +there are no strong roots retaining historical settings pages. Native release +clears records, with dead-record reclamation on binding as a fallback. A successful +write refreshes other live framework views. No polling or file work is scheduled. + +Each callback registration owns a permanent MethodInfo copy with replaced direct, +virtual and runtime-invoker pointers. Matching native schema supplies reflection +metadata only; the donor MethodInfo remains untouched. Closed delegates must point +to that owned descriptor. The native setter delegate is deliberately inert: +only a live widget's explicit change handler can submit its displayed snapshot. +Reflection and refresh callbacks cannot authorize writes. This does not claim a +general managed-method registration API. + +All seven hooks resolve managed signatures and require distinct target addresses. +Hooks remain inert until installation completes. The native control supports +Windows x64 and macOS; other platforms omit it. + +Additional standalone tests: + +```powershell +clang++ -std=c++23 -Wall -Wextra -Werror -I mods/src tests/boolean_view_test.cc -o boolean_view_test.exe +./boolean_view_test.exe +clang++ -std=c++23 -Wall -Wextra -Werror -Wno-unused-parameter -I mods/src -I third_party/libil2cpp tests/native_boolean_callback_test.cc -o native_boolean_callback_test.exe +./native_boolean_callback_test.exe +``` + +These cover view failure transitions and owned native callback invocation pointers. +They do not establish delegate construction, DynamicInvoke, Unity pooling, unknown +prefab presentation, account transitions, cloud durability or frame-time budgets +on a running game. Those require the exact candidate artifact, not the earlier +play prototype's successful tests. diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index 8dcc031a6..b94fb04d2 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 native Mod Settings hooks; disable only for compatibility troubleshooting. +nativesettingshooks = true freeresizehooks = true game_version = true giftsbulkclaimhooks = true diff --git a/mods/src/config.cc b/mods/src/config.cc index b69377fbb..c8570edd6 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1046,6 +1046,9 @@ void Config::Load() this->auto_confirm_instant_warp = get_auto_confirm_instant_warp(config, parsed, DCU::auto_confirm_instant_warp, write_config); this->installInstantWarpConfirmationHooks = true; + // Internal installation switch; UI availability is checked by the native adapter. + this->installModConfirmationSettings = + get_config_or_default(config, parsed, "patches", "nativesettingshooks", DCP::nativesettingshooks, write_config); read_instant_warp_filter(config, parsed, "instant_warp_auto_jump", this->instant_warp_auto_jump, this->instant_warp_auto_jump_all, DCU::instant_warp_auto_jump, write_config); read_instant_warp_filter(config, parsed, "instant_warp_auto_warp", this->instant_warp_auto_warp, diff --git a/mods/src/config.h b/mods/src/config.h index 2ab320ce8..0b33f8e7d 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -278,6 +278,7 @@ class Config final bool installForbiddenTechConfirmationHooks; bool installInstantWarpConfirmationHooks; bool installAudioEventHooks; + bool installModConfirmationSettings; std::string config_settings_url; std::string config_assets_url_override; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index 10764b99f..dc68066be 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 nativesettingshooks = 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/patches/parts/fc_confirmation_reset.cc b/mods/src/patches/parts/fc_confirmation_reset.cc new file mode 100644 index 000000000..3eae8a7e5 --- /dev/null +++ b/mods/src/patches/parts/fc_confirmation_reset.cc @@ -0,0 +1,165 @@ +#include "fc_confirmation_reset.h" +#include "settings/boolean_settings.h" +#include +#include + +namespace +{ +using namespace mod_settings; +constexpr const char* PreferenceKey = "options/hide_fcaa_use_confirmation"; + +bool ReferenceField(const FieldInfo* field) +{ + if (!field || field->type->byref) + return false; + const auto type = field->type->type; + return type == IL2CPP_TYPE_CLASS || type == IL2CPP_TYPE_GENERICINST || type == IL2CPP_TYPE_OBJECT; +} +Il2CppObject* ExistingSingleton(IL2CppClassHelper& helper) +{ + auto parent = helper.GetParent("MonoSingleton`1"); + auto* cls = parent.get_cls(); + auto* instanceField = cls ? il2cpp_class_get_field_from_name(cls, "s_instance") : nullptr; + auto* initializedField = cls ? il2cpp_class_get_field_from_name(cls, "s_initialized") : nullptr; + if (!ReferenceField(instanceField) || !(instanceField->type->attrs & FIELD_ATTRIBUTE_STATIC) || !initializedField + || initializedField->type->type != IL2CPP_TYPE_BOOLEAN + || !(initializedField->type->attrs & FIELD_ATTRIBUTE_STATIC)) + return nullptr; + bool initialized = false; + il2cpp_field_static_get_value(initializedField, &initialized); + if (!initialized) + return nullptr; + Il2CppObject* instance = nullptr; + il2cpp_field_static_get_value(instanceField, &instance); + return instance && il2cpp_class_is_assignable_from(helper.get_cls(), instance->klass) ? instance : nullptr; +} +bool BoolField(const FieldInfo* field) +{ + return field && field->type->type == IL2CPP_TYPE_BOOLEAN && !field->type->byref + && !(field->type->attrs & FIELD_ATTRIBUTE_STATIC); +} +bool InstanceMethod(const MethodInfo* method, int count, int result) +{ + return method && method->parameters_count == count && method->return_type->type == result + && !method->return_type->byref && !(method->flags & METHOD_ATTRIBUTE_STATIC); +} + +struct Binding { + IL2CppClassHelper prefs = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.PersistentPrefs", "PersistentPrefsManager"); + IL2CppClassHelper fc = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.FleetCommander", "FleetCommanderManager"); + const MethodInfo* get = prefs.GetMethodInfo("GetBool", 3); + const MethodInfo* set = fc.GetMethodInfo("set_HideFleetCommanderAbilityUseConfirmationHidden", 1); + FieldInfo* loaded = + prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_isCloudFileLoaded") : nullptr; + FieldInfo* loading = prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_isLoading") : nullptr; + FieldInfo* saved = prefs.get_cls() ? il2cpp_class_get_field_from_name(prefs.get_cls(), "_savedData") : nullptr; + // Two bounded weak roots track storage replacement without retaining account data. + // Cleared on invalidation while IL2CPP is live, not in a process-exit destructor. + Il2CppGCHandle owner = nullptr, data = nullptr, key = nullptr; + std::uint64_t generation = 1; + bool supported() const + { + return BoolField(loaded) && BoolField(loading) && ReferenceField(saved) + && !(saved->type->attrs & FIELD_ATTRIBUTE_STATIC) && InstanceMethod(get, 3, IL2CPP_TYPE_BOOLEAN) + && get->parameters[0]->type == IL2CPP_TYPE_STRING && !get->parameters[0]->byref + && get->parameters[1]->type == IL2CPP_TYPE_BOOLEAN && !get->parameters[1]->byref + && get->parameters[2]->type == IL2CPP_TYPE_BOOLEAN && !get->parameters[2]->byref + && InstanceMethod(set, 1, IL2CPP_TYPE_VOID) && set->parameters[0]->type == IL2CPP_TYPE_BOOLEAN + && !set->parameters[0]->byref; + } + void invalidate() + { + if (owner) + il2cpp_gchandle_free(owner); + if (data) + il2cpp_gchandle_free(data); + owner = data = nullptr; + ++generation; + } + Il2CppObject* ready() + { + auto* p = ExistingSingleton(prefs); + bool isLoaded = false, isLoading = true; + Il2CppObject* state = nullptr; + if (p) { + il2cpp_field_get_value(p, loaded, &isLoaded); + il2cpp_field_get_value(p, loading, &isLoading); + il2cpp_field_get_value(p, saved, &state); + } + if (!p || !state || !isLoaded || isLoading) { + if (owner || data) + invalidate(); + return nullptr; + } + if (!owner || !data || il2cpp_gchandle_get_target(owner) != p || il2cpp_gchandle_get_target(data) != state) { + invalidate(); + owner = il2cpp_gchandle_new_weakref(p, false); + data = il2cpp_gchandle_new_weakref(state, false); + if (!owner || !data) { + invalidate(); + return nullptr; + } + } + return p; + } +}; +Binding& Backend() +{ + static Binding binding; + return binding; +} + +ReadResult ReadConfirmation() +{ + auto& b = Backend(); + if (!b.supported()) + return {Availability::Unsupported}; + auto* prefs = b.ready(); + if (!prefs) + return {}; + if (!b.key) + b.key = il2cpp_gchandle_new(reinterpret_cast(il2cpp_string_new(PreferenceKey)), false); + if (!b.key) + return {}; + // FC's native property uses GetBool(key,false,true). Display reads must not insert + // a missing preference: preserve its default but explicitly pass shouldAddKey=false. + bool defaultHidden = false, addKey = false; + void* args[] = {il2cpp_gchandle_get_target(b.key), &defaultHidden, &addKey}; + Il2CppException* exception = nullptr; + auto* value = il2cpp_runtime_invoke(b.get, prefs, args, &exception); + if (exception || !value) + return {}; + return ReadResult::Known(!*static_cast(il2cpp_object_unbox(value)), b.generation); +} +ApplyResult WriteConfirmation(bool enabled, std::uint64_t expectedGeneration) +{ + auto& b = Backend(); + if (!b.supported() || !b.ready() || b.generation != expectedGeneration) + return ApplyResult::Rejected; + auto* manager = ExistingSingleton(b.fc); + if (!manager) + return ApplyResult::Rejected; + bool hidden = !enabled; + void* args[] = {&hidden}; + Il2CppException* exception = nullptr; + il2cpp_runtime_invoke(b.set, manager, args, &exception); + return exception ? ApplyResult::Unverified : ApplyResult::Applied; +} +} // namespace + +mod_settings::BooleanSetting& FleetCommanderConfirmationSetting() +{ + static mod_settings::BooleanSetting setting({"community_mod.fc_ability_confirmation", + "[MOD] Confirm Fleet Commander abilities", ReadConfirmation, + WriteConfirmation}); + return setting; +} + +void InvalidateFleetCommanderConfirmationSession() +{ + // The native UI calls this before preference-session boundaries. + FleetCommanderConfirmationSetting().InvalidateSession(); + Backend().invalidate(); +} diff --git a/mods/src/patches/parts/fc_confirmation_reset.h b/mods/src/patches/parts/fc_confirmation_reset.h new file mode 100644 index 000000000..237e204a3 --- /dev/null +++ b/mods/src/patches/parts/fc_confirmation_reset.h @@ -0,0 +1,9 @@ +#pragma once + +namespace mod_settings +{ +class BooleanSetting; +} +// Game-thread preference adapter for the native confirmation settings UI. +mod_settings::BooleanSetting& FleetCommanderConfirmationSetting(); +void InvalidateFleetCommanderConfirmationSession(); diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index fb3129d63..cd499ce98 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -766,6 +766,7 @@ void ScreenManager_Update_Hook(auto original, ScreenManager* _this) if (!is_in_chat) { if (!Key::IsInputFocused()) { + if (MapKey::IsDown(GameFunction::SelectCurrent)) { auto fleet_bar = ObjectFinder::Get(); if (fleet_bar) { diff --git a/mods/src/patches/parts/mod_confirmation_settings.cc b/mods/src/patches/parts/mod_confirmation_settings.cc new file mode 100644 index 000000000..3db918011 --- /dev/null +++ b/mods/src/patches/parts/mod_confirmation_settings.cc @@ -0,0 +1,675 @@ +#include "fc_confirmation_reset.h" +#include "settings/boolean_view.h" + +#if (defined(_WIN32) && defined(_M_X64)) || defined(__APPLE__) +#include "settings/native_boolean_callback.h" +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace mod_settings; +constexpr const char* CategoryKey = "game_settings_category_7"; +constexpr std::size_t ViewLimit = 8; +bool active = false; +bool installing = false; +bool warned = false; +std::thread::id uiThread; +NativeCallback getter; +NativeCallback setter; +NativeCallback query; + +void Warn() +{ + if (!warned) { + warned = true; + spdlog::warn("[ModSettings] Native confirmation UI unavailable; no mod control added"); + } +} + +struct Root { + Il2CppGCHandle handle = nullptr; + explicit Root(Il2CppObject* object, bool weak = false) + { + if (object) + handle = weak ? il2cpp_gchandle_new_weakref(object, false) : il2cpp_gchandle_new(object, false); + if (object && !handle) + throw std::runtime_error("settings root"); + } + ~Root() + { + if (handle) + il2cpp_gchandle_free(handle); + } + Root(const Root&) = delete; + Il2CppObject* get() const + { return handle ? il2cpp_gchandle_get_target(handle) : nullptr; } +}; + +bool Type(const Il2CppType* type, int expected) +{ return type && !type->byref && type->type == expected; } + +bool Reference(const Il2CppType* type) +{ + return Type(type, IL2CPP_TYPE_CLASS) || Type(type, IL2CPP_TYPE_GENERICINST) || Type(type, IL2CPP_TYPE_OBJECT) + || Type(type, IL2CPP_TYPE_STRING); +} + +bool Instance(const MethodInfo* method, int count, int result) +{ + return method && method->methodPointer && method->invoker_method && !(method->flags & METHOD_ATTRIBUTE_STATIC) + && method->parameters_count == count && Type(method->return_type, result) + && !method->has_full_generic_sharing_signature; +} +FieldInfo* Field(Il2CppClass* cls, const char* name) +{ + auto* field = cls ? il2cpp_class_get_field_from_name(cls, name) : nullptr; + if (!field || !Reference(field->type) || (field->type->attrs & FIELD_ATTRIBUTE_STATIC)) + throw std::runtime_error("settings reference field"); + return field; +} +Il2CppObject* ReadField(Il2CppObject* object, FieldInfo* field) +{ + Il2CppObject* value = nullptr; + if (object) + il2cpp_field_get_value(object, field, &value); + return value; +} +Il2CppObject* Invoke(const MethodInfo* method, Il2CppObject* object, void** args = nullptr) +{ + if (!method || !object) + throw std::runtime_error("settings invocation"); + Il2CppObject* result = nullptr; + if (!Il2CppRuntime::TryInvoke(method, object, args, &result)) + throw std::runtime_error("settings managed exception"); + return result; +} +// Bounded discovery helpers used only while opening a page or binding a row. +Il2CppObject* Call(Il2CppObject* object, const char* name, int count = 0, void** args = nullptr) +{ return Invoke(object ? IL2CppClassHelper(object->klass).GetMethodInfo(name, count) : nullptr, object, args); } +bool Boolean(Il2CppObject* boxed) +{ + bool value = false; + if (!Il2CppRuntime::TryBoolean(boxed, value)) + throw std::runtime_error("settings boolean result"); + return value; +} +bool Equals(Il2CppObject* value, const char* ascii) +{ + if (!value || !Type(il2cpp_class_get_type(value->klass), IL2CPP_TYPE_STRING)) + return false; + auto* text = reinterpret_cast(value); + const auto length = std::strlen(ascii); + if (il2cpp_string_length(text) != length) + return false; + auto* chars = il2cpp_string_chars(text); + for (std::size_t i = 0; i < length; ++i) + if (chars[i] != static_cast(ascii[i])) + return false; + return true; +} + +struct Metadata { + IL2CppClassHelper director = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "SettingsSectionDirector"); + IL2CppClassHelper widget = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "ToggleOptionWidget"); + IL2CppClassHelper context = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "SettingsContext"); + IL2CppClassHelper row = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.GameSettings", "ToggleOptionContext"); + IL2CppClassHelper prefs = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.PersistentPrefs", "PersistentPrefsManager"); + const MethodInfo* addGeneral = director.GetMethodInfo("AddGeneralSettings", 1); + const MethodInfo* addToggle = context.GetMethodInfo("AddToggle", 4); + const MethodInfo* refresh = widget.GetMethodInfo("SetWidgetData", 0); + const MethodInfo* changed = widget.GetMethodInfo("OnToggleValueChanged", 1); + const MethodInfo* release = widget.GetMethodInfo("OnAboutToReleaseContext", 0); + const MethodInfo* reload = prefs.GetMethodInfo("RegisterEvents", 0); + const MethodInfo* session = prefs.GetMethodInfo("GameSessionStartedEventHandler", 0); + const MethodInfo* load = prefs.GetMethodInfo("LoadPersistentPrefsFromCloud", 0); + const MethodInfo* getContext = widget.GetMethodInfo("get_Context", 0); + const MethodInfo* querySetter = row.GetMethodInfo("set_QueryOptionState", 1); + FieldInfo* queryField = Field(row.get_cls(), "k__BackingField"); + FieldInfo* labelField = Field(widget.get_cls(), "_label"); + FieldInfo* toggleField = Field(widget.get_cls(), "_toggle"); + FieldInfo* stateField = Field(widget.get_cls(), "_toggleStateAnimator"); +}; +Metadata& Meta() +{ + static Metadata metadata; + return metadata; +} + +// Fixed weak records: no page, context, delegate, or account is retained by UI +// bookkeeping. Records are released on native unbind and reclaimed on next bind +// if Unity destroys a widget without sending that notification. +struct View { + Il2CppGCHandle widget = nullptr, context = nullptr, label = nullptr; + std::array indicators{}; + std::array activeBefore{}; + bool overridden = false; + bool hidden = false; + bool rendering = false; + bool binding = false; + bool requesting = false; + bool clearing = false; + bool preserveNextRefresh = false; + BooleanView state{FleetCommanderConfirmationSetting()}; +}; +std::array& Views() +{ + static std::array views; + return views; +} +Il2CppObject* Target(Il2CppGCHandle handle) +{ return handle ? il2cpp_gchandle_get_target(handle) : nullptr; } +void Free(Il2CppGCHandle& handle) +{ + if (handle) + il2cpp_gchandle_free(handle); + handle = nullptr; +} +void SetActive(Il2CppObject* object, bool value) +{ + void* args[] = {&value}; + Call(object, "SetActive", 1, args); +} +void Restore(View& view) +{ + if (view.overridden) { + if (auto* label = Target(view.label)) + Call(label, "ClearTextOverride"); + view.overridden = false; + } + if (view.hidden) { + for (std::size_t i = 0; i < view.indicators.size(); ++i) + if (auto* object = Target(view.indicators[i])) + SetActive(object, view.activeBefore[i]); + view.hidden = false; + } +} +void Clear(View& view) +{ + if (view.clearing) + return; + struct Scope { + View& view; + explicit Scope(View& view) : view(view) { view.clearing = true; } + ~Scope() { view.clearing = false; } + } scope(view); + try { + Restore(view); + } catch (...) { + Warn(); + } + Free(view.widget); + Free(view.context); + Free(view.label); + for (auto& handle : view.indicators) + Free(handle); + view.state.Unbind(); + view.overridden = view.hidden = view.preserveNextRefresh = false; +} +View* Find(Il2CppObject* widget) +{ + for (auto& view : Views()) + if (Target(view.widget) == widget) + return &view; + return nullptr; +} +bool Owned(Il2CppObject* context) +{ + if (!context || context->klass != Meta().row.get_cls()) + return false; + auto* callback = reinterpret_cast(ReadField(context, Meta().queryField)); + return callback && callback->method == query.method() && callback->method_ptr == query.method()->methodPointer; +} +bool ChildOf(Il2CppObject* transform, Il2CppObject* parent) +{ + void* args[] = {parent}; + return Boolean(Call(transform, "IsChildOf", 1, args)); +} +View& Track(Il2CppObject* widget, Il2CppObject* context) +{ + for (auto& view : Views()) { + if (Target(view.widget) || view.rendering || view.binding || view.requesting || view.clearing) + continue; + Clear(view); + Root label(ReadField(widget, Meta().labelField)); + Root widgetTransform(Call(widget, "get_transform")); + Root labelTransform(Call(label.get(), "get_transform")); + std::array indicators{ReadField(widget, Meta().toggleField), + ReadField(widget, Meta().stateField)}; + Root first(Call(indicators[0], "get_gameObject")); + Root second(Call(indicators[1], "get_gameObject")); + indicators = {first.get(), second.get()}; + // Never hide the row or an ancestor of its label to suppress unknown ON/OFF. + // An unsupported prefab is rejected before any override is applied. + for (auto* indicator : indicators) { + Root transform(Call(indicator, "get_transform")); + if (transform.get() == widgetTransform.get() || !ChildOf(transform.get(), widgetTransform.get()) + || ChildOf(labelTransform.get(), transform.get())) + throw std::runtime_error("settings indicator hierarchy"); + } + try { + auto weak = [](Il2CppObject* object) { + auto handle = il2cpp_gchandle_new_weakref(object, false); + if (!handle) + throw std::runtime_error("settings weak root"); + return handle; + }; + view.widget = weak(widget); + view.context = weak(context); + view.label = weak(label.get()); + for (std::size_t i = 0; i < indicators.size(); ++i) + view.indicators[i] = weak(indicators[i]); + } catch (...) { + Clear(view); + throw; + } + return view; + } + throw std::runtime_error("settings view capacity"); +} + +View* renderingView = nullptr; +bool GetEnabled(Il2CppObject*, const MethodInfo*) +{ + // Native bool signatures cannot express unknown. Only the owned render scope + // consumes this placeholder; its indicators are suppressed when value is empty. + return renderingView ? renderingView->state.value().value_or(false) : false; +} +void SetEnabled(Il2CppObject*, bool, const MethodInfo*) +{ + // Deliberately inert. Only OnToggleValueChanged with a live view snapshot can + // authorize a write; rendering and reflection cannot mutate game preferences. +} +int QueryState(Il2CppObject*, const MethodInfo*) +{ return 0; } + +Il2CppObject* MakeDelegate(Il2CppClass* cls, Il2CppObject* director, const MethodInfo* method) +{ + const auto* ctor = cls ? il2cpp_class_get_method_from_name(cls, ".ctor", 2) : nullptr; + if (!Instance(ctor, 2, IL2CPP_TYPE_VOID) || !Reference(ctor->parameters[0]) + || !Type(ctor->parameters[1], IL2CPP_TYPE_I) || !method) + throw std::runtime_error("settings delegate constructor"); + const auto* invoke = il2cpp_class_get_method_from_name(cls, "Invoke", method->parameters_count); + auto* parent = il2cpp_class_get_parent(cls); + if (!parent || std::strcmp(il2cpp_class_get_name(parent), "MulticastDelegate") != 0 + || std::strcmp(il2cpp_class_get_namespace(parent), "System") != 0 || !invoke || invoke->return_type->byref + || (invoke->flags & METHOD_ATTRIBUTE_STATIC) + || il2cpp_class_from_type(invoke->return_type) != il2cpp_class_from_type(method->return_type) + || !il2cpp_class_is_assignable_from(method->klass, director->klass)) + throw std::runtime_error("settings delegate signature"); + for (int i = 0; i < method->parameters_count; ++i) + if (invoke->parameters[i]->byref + || il2cpp_class_from_type(invoke->parameters[i]) != il2cpp_class_from_type(method->parameters[i])) + throw std::runtime_error("settings delegate parameter"); + Root object(il2cpp_object_new(cls)); + void* args[] = {director, &method}; + Invoke(ctor, object.get(), args); + auto* delegate = reinterpret_cast(object.get()); + if (delegate->target != director || delegate->invoke_impl_this != director || delegate->method != method) + throw std::runtime_error("settings closed delegate"); + delegate->method_ptr = method->methodPointer; + delegate->invoke_impl = method->methodPointer; + return object.get(); +} + +int Count(Il2CppObject* list) +{ + Root value(Call(list, "get_Count")); + if (!value.get() || !Type(il2cpp_class_get_type(value.get()->klass), IL2CPP_TYPE_I4)) + throw std::runtime_error("settings child count"); + const int count = *static_cast(il2cpp_object_unbox(value.get())); + if (count < 0 || count > 128) + throw std::runtime_error("settings child bound"); + return count; +} +Il2CppObject* Item(Il2CppObject* list, int index) +{ + void* args[] = {&index}; + return Call(list, "get_Item", 1, args); +} +bool HasLabel(Il2CppObject* row, const char* id) +{ + Root label(Call(row, "get_LabelContext")); + return label.get() && Equals(Call(label.get(), "get_Identifier"), id); +} +Il2CppObject* Category(Il2CppObject* container, int depth, int& remaining) +{ + if (!container || depth > 3 || --remaining < 0) + return nullptr; + if (HasLabel(container, CategoryKey)) + return container; + if (!il2cpp_class_get_method_from_name(container->klass, "get_Children", 0)) + return nullptr; + Root children(Call(container, "get_Children")); + for (int i = 0, count = Count(children.get()); i < count && remaining > 0; ++i) + if (auto* found = Category(Item(children.get(), i), depth + 1, remaining)) + return found; + return nullptr; +} +void AddRow(Il2CppObject* director, Il2CppObject* context) +{ + auto& setting = FleetCommanderConfirmationSetting(); + if (setting.Observe().state.availability == Availability::Unsupported) + return; + Root root(Call(context, "get_RootOption")); + int remaining = 128; + Root category(Category(root.get(), 0, remaining)); + if (!category.get()) + throw std::runtime_error("settings confirmation category"); + Root children(Call(category.get(), "get_Children")); + const int before = Count(children.get()); + for (int i = 0; i < before; ++i) + if (Owned(Item(children.get(), i)) || HasLabel(Item(children.get(), i), setting.id().c_str())) + return; + if (before == 128) + throw std::runtime_error("settings category full"); + auto& m = Meta(); + Root get(MakeDelegate(il2cpp_class_from_type(m.addToggle->parameters[2]), director, getter.method())); + Root set(MakeDelegate(il2cpp_class_from_type(m.addToggle->parameters[3]), director, setter.method())); + Root state(MakeDelegate(il2cpp_class_from_type(m.querySetter->parameters[0]), director, query.method())); + Root label(reinterpret_cast(il2cpp_string_new(setting.id().c_str()))); + void* args[] = {category.get(), label.get(), get.get(), set.get()}; + Invoke(m.addToggle, context, args); + if (Count(children.get()) != before + 1) + throw std::runtime_error("settings row insertion"); + Root row(Item(children.get(), before)); + try { + if (row.get()->klass != m.row.get_cls() || !HasLabel(row.get(), setting.id().c_str())) + throw std::runtime_error("settings row identity"); + void* stateArgs[] = {state.get()}; + Invoke(m.querySetter, row.get(), stateArgs); + } catch (...) { + void* removeArgs[] = {row.get()}; + Call(category.get(), "RemoveChild", 1, removeArgs); + throw; + } +} + +void Render(View& view, auto original, Il2CppObject* widget) +{ + if (view.rendering) + return; + Root boundContext(Target(view.context)); + struct Scope { + View& view; + View* previous; + BooleanSetting::RenderScope suppress; + Scope(View& view) + : view(view) + , previous(renderingView) + , suppress(view.state.setting()) + { + view.rendering = true; + renderingView = &view; + } + ~Scope() + { + renderingView = previous; + view.rendering = false; + } + } scope(view); + Restore(view); + original(widget); + if (Target(view.widget) != widget || Target(view.context) != boundContext.get()) + return; + Root label(Target(view.label)); + std::string text = view.state.setting().label(); + // The native row has limited label width: "Change not applied; try again" was + // visibly truncated after "; tr" alongside the FC label. Keep these suffixes + // short; recheck the full label at supported UI scales when changing wording. + if (!view.state.value()) + text += " — Reopen to retry"; + else if (view.state.failed()) + text += " — Retry"; + Root message(reinterpret_cast(il2cpp_string_new(text.c_str()))); + void* args[] = {message.get()}; + Call(label.get(), "OverrideLocalizedText", 1, args); + view.overridden = true; + if (!view.state.value()) { + // Capture all native values first (the two components may share a node). + for (std::size_t i = 0; i < view.indicators.size(); ++i) + view.activeBefore[i] = Boolean(Call(Target(view.indicators[i]), "get_activeSelf")); + view.hidden = true; + for (auto handle : view.indicators) + SetActive(Target(handle), false); + } +} +void HideUnsupported(Il2CppObject* widget) +{ + try { + Root object(Call(widget, "get_gameObject")); + SetActive(object.get(), false); + } catch (...) { + } + Warn(); +} +bool OnThread() +{ return active && std::this_thread::get_id() == uiThread; } + +void AddGeneralHook(auto original, Il2CppObject* director, Il2CppObject* context) +{ + original(director, context); + if (!OnThread()) + return; + try { + AddRow(director, context); + } catch (...) { + Warn(); + } +} +void RefreshHook(auto original, Il2CppObject* widget) +{ + if (!OnThread()) { + original(widget); + return; + } + bool owned = false; + try { + Root context(Invoke(Meta().getContext, widget)); + owned = Owned(context.get()); + auto* view = Find(widget); + if (view && (view->rendering || view->binding || view->clearing)) + return; + if (view && Target(view->context) != context.get()) { + Clear(*view); + view = nullptr; + } + if (!owned) { + if (view) + Clear(*view); + } else { + if (!view) + view = &Track(widget, context.get()); + struct BindingScope { + View& view; + explicit BindingScope(View& view) : view(view) { view.binding = true; } + ~BindingScope() { view.binding = false; } + } binding(*view); + if (!view->preserveNextRefresh) + view->state.Bind(); + if (Target(view->widget) != widget || Target(view->context) != context.get()) { + view->state.Unbind(); + return; + } + view->preserveNextRefresh = false; + Render(*view, original, widget); + return; + } + } catch (...) { + if (owned) { + HideUnsupported(widget); + return; + } + Warn(); + } + original(widget); +} +void RefreshViews() +{ + if (!OnThread()) + return; + for (auto& view : Views()) { + if (view.requesting || view.rendering || view.binding || view.clearing) + continue; + Root widget(Target(view.widget)); + if (!widget.get()) + continue; + try { + Root context(Invoke(Meta().getContext, widget.get())); + if (Target(view.context) != context.get() || !Owned(context.get())) + continue; + Invoke(Meta().refresh, widget.get()); + } catch (...) { + HideUnsupported(widget.get()); + } + } +} +void ChangedHook(auto original, Il2CppObject* widget, bool desired) +{ + if (!OnThread()) { + original(widget, desired); + return; + } + bool owned = false; + try { + Root context(Invoke(Meta().getContext, widget)); + owned = Owned(context.get()); + if (owned) { + auto* view = Find(widget); + if (!view || view->rendering || view->binding || view->requesting || view->clearing + || Target(view->context) != context.get()) + return; + struct RequestScope { + View& view; + explicit RequestScope(View* view) : view(*view) + { view->requesting = true; } + ~RequestScope() + { view.requesting = false; } + } requestScope(view); + auto result = view->state.Request(desired); + if (Target(view->widget) != widget || Target(view->context) != context.get()) + return; + if (result.outcome == Outcome::Suppressed || result.outcome == Outcome::Busy) + return; + // Refresh through the hook once, preserving the write result. A fresh Bind + // here would erase an unverified outcome merely because a later read works. + view->preserveNextRefresh = true; + Invoke(Meta().refresh, widget); + return; + } + } catch (...) { + if (owned) { + HideUnsupported(widget); + return; + } + Warn(); + } + original(widget, desired); +} +void ReleaseHook(auto original, Il2CppObject* widget) +{ + if (OnThread()) { + try { + if (auto* view = Find(widget)) + Clear(*view); + } catch (...) { + Warn(); + } + } + original(widget); +} +void Invalidate() +{ + InvalidateFleetCommanderConfirmationSession(); + for (auto& view : Views()) { + view.state.Invalidate(); + // Re-rendering during a native account transition can read the old account. + // Hide the indicators immediately; next explicit bind may establish readiness. + if (auto* widget = Target(view.widget)) { + try { + Render(view, [](Il2CppObject*) {}, widget); + } catch (...) { + HideUnsupported(widget); + } + } + } +} +void SessionBoundary(auto original, Il2CppObject* owner) +{ + if (OnThread()) { + try { + Invalidate(); + } catch (...) { + Warn(); + } + } + original(owner); +} +void ReloadHook(auto original, Il2CppObject* owner) +{ SessionBoundary(original, owner); } +void SessionHook(auto original, Il2CppObject* owner) +{ SessionBoundary(original, owner); } +void LoadHook(auto original, Il2CppObject* owner) +{ SessionBoundary(original, owner); } +} // namespace + +void InstallModConfirmationSettings() +{ + if (active || installing) + return; + installing = true; + try { + auto& m = Meta(); + const std::array hooks{m.addGeneral, m.refresh, m.changed, m.release, m.reload, m.session, m.load}; + for (std::size_t i = 0; i < hooks.size(); ++i) { + if (!Instance(hooks[i], i == 0 || i == 2 ? 1 : 0, IL2CPP_TYPE_VOID)) + throw std::runtime_error("settings hook metadata"); + for (std::size_t j = 0; j < i; ++j) + if (hooks[i]->methodPointer == hooks[j]->methodPointer) + throw std::runtime_error("settings shared hook"); + } + const auto* getSchema = m.director.GetMethodInfo("IsBorgCubeCuttingBeamConfirmationOn", 0); + const auto* setSchema = m.director.GetMethodInfo("ToggleBorgCubeCuttingBeamConfirmation", 1); + const auto* querySchema = m.director.GetMethodInfo("QueryShouldShowGenericPcSetting", 0); + if (!Instance(getSchema, 0, IL2CPP_TYPE_BOOLEAN) || !Instance(setSchema, 1, IL2CPP_TYPE_VOID) + || !Type(setSchema->parameters[0], IL2CPP_TYPE_BOOLEAN) || !querySchema + || !il2cpp_class_is_enum(il2cpp_class_from_type(querySchema->return_type)) + || !Type(il2cpp_class_enum_basetype(il2cpp_class_from_type(querySchema->return_type)), IL2CPP_TYPE_I4) + || !Instance(querySchema, 0, IL2CPP_TYPE_VALUETYPE) || !Instance(m.addToggle, 4, IL2CPP_TYPE_VOID) + || !Type(m.addToggle->parameters[1], IL2CPP_TYPE_STRING) || !Reference(m.addToggle->parameters[0]) + || !Reference(m.addToggle->parameters[2]) || !Reference(m.addToggle->parameters[3]) + || !Type(m.changed->parameters[0], IL2CPP_TYPE_BOOLEAN) || !Reference(m.addGeneral->parameters[0]) + || !m.getContext || !Reference(m.getContext->return_type) || !Instance(m.querySetter, 1, IL2CPP_TYPE_VOID) + || !Reference(m.querySetter->parameters[0]) || !getter.Initialize(getSchema, GetEnabled) + || !setter.Initialize(setSchema, SetEnabled) || !query.Initialize(querySchema, QueryState)) + throw std::runtime_error("settings callback schema"); + uiThread = std::this_thread::get_id(); + if (!FleetCommanderConfirmationSetting().SetChangeObserver(RefreshViews)) + throw std::runtime_error("settings observer ownership"); + // A rejected target need not throw. Keep any installed hooks on their native + // path until the complete adapter is ready; do not retry a partial install. + if (!SPUD_STATIC_DETOUR(m.refresh->methodPointer, RefreshHook) + || !SPUD_STATIC_DETOUR(m.changed->methodPointer, ChangedHook) + || !SPUD_STATIC_DETOUR(m.release->methodPointer, ReleaseHook) + || !SPUD_STATIC_DETOUR(m.reload->methodPointer, ReloadHook) + || !SPUD_STATIC_DETOUR(m.session->methodPointer, SessionHook) + || !SPUD_STATIC_DETOUR(m.load->methodPointer, LoadHook) + || !SPUD_STATIC_DETOUR(m.addGeneral->methodPointer, AddGeneralHook)) + throw std::runtime_error("settings hook installation"); + active = true; + spdlog::info("[ModSettings] Native FC confirmation adapter installed"); + } catch (...) { + Warn(); + } +} +#else +void InstallModConfirmationSettings() {} +#endif diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index d4e50a9d9..55d2e9f37 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -49,6 +49,7 @@ void InstallDoubleClickAssignShipHooks(); void InstallInstantWarpConfirmationHooks(); void InstallForbiddenTechConfirmationHooks(); void InstallAudioEventHooks(); +void InstallModConfirmationSettings(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -152,6 +153,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"InstantWarpConfirm", {InstallInstantWarpConfirmationHooks, &cfg.installInstantWarpConfirmationHooks}}, {"ForbiddenTechConfirm", {InstallForbiddenTechConfirmationHooks, &cfg.installForbiddenTechConfirmationHooks}}, {"AudioEvents", {InstallAudioEventHooks, &cfg.installAudioEventHooks}}, + {"ModConfirmationSettings", {InstallModConfirmationSettings, &cfg.installModConfirmationSettings}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); diff --git a/mods/src/settings/boolean_settings.h b/mods/src/settings/boolean_settings.h new file mode 100644 index 000000000..693135b83 --- /dev/null +++ b/mods/src/settings/boolean_settings.h @@ -0,0 +1,57 @@ +#pragma once +#include "value_settings.h" + +namespace mod_settings +{ +using ReadResult = ValueReadResult; +using Definition = ValueDefinition; +using Snapshot = ValueSnapshot; +using WriteResult = ValueWriteResult; +class BooleanSetting : public ValueSetting +{ +public: + using ValueSetting::ValueSetting; +}; + +enum class Registration { Added, Duplicate, Invalid, Frozen }; +class BooleanRegistry +{ +public: + Registration Register(Definition definition) + { + CheckThread(); + if (frozen_) + return Registration::Frozen; + if (definition.id.empty() || definition.label.empty() || !definition.read || !definition.write) + return Registration::Invalid; + if (settings_.contains(definition.id)) + return Registration::Duplicate; + auto id = definition.id; + settings_.try_emplace(std::move(id), std::move(definition)); + return Registration::Added; + } + BooleanSetting* Find(const std::string& id) + { + CheckThread(); + frozen_ = true; + auto it = settings_.find(id); + return it == settings_.end() ? nullptr : &it->second; + } + void InvalidateSession() + { + CheckThread(); + for (auto& [id, setting] : settings_) + setting.InvalidateSession(); + } + +private: + void CheckThread() const + { + if (std::this_thread::get_id() != thread_) + throw std::logic_error("registry thread mismatch"); + } + const std::thread::id thread_ = std::this_thread::get_id(); + bool frozen_ = false; + std::map settings_; +}; +} // namespace mod_settings diff --git a/mods/src/settings/boolean_view.h b/mods/src/settings/boolean_view.h new file mode 100644 index 000000000..9ace78ea8 --- /dev/null +++ b/mods/src/settings/boolean_view.h @@ -0,0 +1,7 @@ +#pragma once +#include "boolean_settings.h" +#include "value_view.h" +namespace mod_settings +{ +using BooleanView = ValueView; +} diff --git a/mods/src/settings/native_boolean_callback.h b/mods/src/settings/native_boolean_callback.h new file mode 100644 index 000000000..ab899d286 --- /dev/null +++ b/mods/src/settings/native_boolean_callback.h @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace mod_settings +{ +// Process-lifetime schema, owned by the registration, not a settings page. The +// donor supplies only matching reflection metadata; every executable path is +// replaced. Never modify a MethodInfo owned by IL2CPP. +template class NativeCallback +{ +public: + using Function = Result (*)(Il2CppObject*, Args..., const MethodInfo*); + NativeCallback() = default; + NativeCallback(const NativeCallback&) = delete; + NativeCallback& operator=(const NativeCallback&) = delete; + bool Initialize(const MethodInfo* schema, Function callback) + { + if (initialized_ || !schema || !callback || schema->is_generic || schema->is_inflated + || schema->has_full_generic_sharing_signature || (schema->flags & METHOD_ATTRIBUTE_STATIC) + || (schema->flags & METHOD_ATTRIBUTE_VIRTUAL) || schema->parameters_count != sizeof...(Args)) + return false; + if (!schema->return_type || schema->return_type->byref) + return false; + if constexpr (std::is_void_v) { + if (schema->return_type->type != IL2CPP_TYPE_VOID) + return false; + } else if constexpr (std::is_same_v) { + if (schema->return_type->type != IL2CPP_TYPE_BOOLEAN) + return false; + } else if constexpr (std::is_same_v) { + if (schema->return_type->type != IL2CPP_TYPE_R4) + return false; + } else if constexpr (std::is_same_v) { + if (schema->return_type->type != IL2CPP_TYPE_STRING) + return false; + } else { + static_assert(std::is_same_v, "Only boolean, void, Single and Int32 callbacks are supported"); + // Enum users additionally validate the Int32 backing type in the adapter. + if (schema->return_type->type != IL2CPP_TYPE_VALUETYPE && schema->return_type->type != IL2CPP_TYPE_I4) + return false; + } + static_assert(((std::is_same_v || std::is_same_v || std::is_same_v) && ...), + "Only boolean, Single and Int32 callback arguments are supported"); + constexpr std::array types{(std::is_same_v ? IL2CPP_TYPE_BOOLEAN + : std::is_same_v ? IL2CPP_TYPE_R4 + : IL2CPP_TYPE_I4)...}; + for (std::size_t i = 0; i < sizeof...(Args); ++i) + if (!schema->parameters || !schema->parameters[i] || schema->parameters[i]->byref + || schema->parameters[i]->type != types[i]) + return false; + method_ = *schema; + method_.methodPointer = reinterpret_cast(callback); + method_.virtualMethodPointer = method_.methodPointer; + method_.invoker_method = Invoke; + initialized_ = true; + return true; + } + const MethodInfo* method() const + { return initialized_ ? &method_ : nullptr; } + +private: + template + static void Call(const MethodInfo* method, void* object, void** args, void* result, std::index_sequence) + { + auto callback = reinterpret_cast(method->methodPointer); + if constexpr (std::is_void_v) + callback(static_cast(object), *static_cast(args[I])..., method); + else + *static_cast(result) = + callback(static_cast(object), *static_cast(args[I])..., method); + } + static void Invoke(Il2CppMethodPointer, const MethodInfo* method, void* object, void** args, void* result) + { Call(method, object, args, result, std::index_sequence_for{}); } + MethodInfo method_{}; + bool initialized_ = false; +}; +} // namespace mod_settings diff --git a/mods/src/settings/value_settings.h b/mods/src/settings/value_settings.h new file mode 100644 index 000000000..b4b9f89cd --- /dev/null +++ b/mods/src/settings/value_settings.h @@ -0,0 +1,215 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mod_settings +{ +enum class Availability { Known, Unavailable, Unsupported }; +enum class UnavailableReason { Retry, OutsideRange, InvalidValue }; +template struct ValueReadResult { + Availability availability = Availability::Unavailable; + std::optional value; + std::uint64_t generation = 0; + UnavailableReason reason = UnavailableReason::Retry; + static ValueReadResult Known(T value, std::uint64_t generation) + { return {Availability::Known, value, generation}; } + bool known() const + { return availability == Availability::Known && value.has_value() && generation != 0; } +}; +enum class ApplyResult { Applied, Rejected, Unverified }; +enum class Outcome { AppliedVerified, Unchanged, Suppressed, Busy, Conflict, Rejected, Unverified }; +template struct ValueDefinition { + std::string id; + std::string label; + std::function()> read; + // Adapter must revalidate its own generation immediately before mutation. + std::function write; +}; +template struct ValueSnapshot { + ValueReadResult state; + std::uint64_t revision = 0; + std::uint64_t epoch = 0; + std::uint64_t owner = 0; +}; +template struct ValueWriteResult { + Outcome outcome; + ValueSnapshot snapshot; +}; + +// All calls, including scope destruction, belong to the constructing UI thread. +// No timers, background work, locks, storage, or IL2CPP dependencies. +template class ValueSetting +{ +public: + using ReadResult = ValueReadResult; + using Definition = ValueDefinition; + using Snapshot = ValueSnapshot; + using WriteResult = ValueWriteResult; + explicit ValueSetting(Definition definition) + : definition_(std::move(definition)) + { + } + ValueSetting(const ValueSetting&) = delete; + ValueSetting& operator=(const ValueSetting&) = delete; + const std::string& id() const + { return definition_.id; } + const std::string& label() const + { return definition_.label; } + + // One process-lifetime presentation adapter. Notifications are synchronous, + // bounded and run under the write reentry guard; rendering cannot write back. + bool SetChangeObserver(void (*observer)()) + { + CheckThread(); + if (change_observer_ && change_observer_ != observer) + return false; + change_observer_ = observer; + return true; + } + + class RenderScope + { + public: + explicit RenderScope(ValueSetting& setting) + : setting_(setting) + { + setting_.CheckThread(); + ++setting_.render_depth_; + } + ~RenderScope() + { --setting_.render_depth_; } + RenderScope(const RenderScope&) = delete; + RenderScope& operator=(const RenderScope&) = delete; + + private: + ValueSetting& setting_; + }; + + Snapshot Observe() + { + CheckThread(); + if (observing_) + return {{}, current_.revision, epoch_, identity_}; + struct Observing { + bool& flag; + Observing(bool& flag) + : flag(flag) + { flag = true; } + ~Observing() + { flag = false; } + } guard(observing_); + const auto read_epoch = epoch_; + ReadResult next; + try { + next = definition_.read(); + } catch (...) { + next = {}; + } + if (read_epoch != epoch_) + next = {}; + if (!next.known()) { + next.value.reset(); + next.generation = 0; + if (next.availability == Availability::Known) + next.availability = Availability::Unavailable; + } + if (next.availability != current_.state.availability || next.value != current_.state.value + || next.generation != current_.state.generation || next.reason != current_.state.reason) + ++current_.revision; + current_.state = next; + current_.epoch = epoch_; + current_.owner = identity_; + return current_; + } + + void InvalidateSession() + { + CheckThread(); + ++epoch_; + current_ = {{}, current_.revision + 1, epoch_, identity_}; + } + + WriteResult SetFromUser(T desired, const Snapshot& observed) + { + CheckThread(); + if (render_depth_) + return {Outcome::Suppressed, current_}; + if (applying_) + return {Outcome::Busy, current_}; + // Cover read callbacks as well as the setter: neither can reenter application. + struct Applying { + bool& flag; + Applying(bool& flag) + : flag(flag) + { flag = true; } + ~Applying() + { flag = false; } + } guard(applying_); + const auto before = Observe(); + if (!before.state.known()) + return {Outcome::Rejected, before}; + if (observed.owner != identity_ || !observed.state.known() || observed.epoch != before.epoch + || observed.revision != before.revision || observed.state.generation != before.state.generation + || observed.state.value != before.state.value) + return {Outcome::Conflict, before}; + if (*before.state.value == desired) { + NotifyChanged(); + if (epoch_ != before.epoch) + return {Outcome::Unverified, current_}; + return {Outcome::Unchanged, before}; + } + ApplyResult applied = ApplyResult::Unverified; + try { + applied = definition_.write(desired, before.state.generation); + } catch (...) { + } + if (epoch_ != before.epoch) + return {Outcome::Unverified, current_}; + const auto after = Observe(); + if (!after.state.known() || after.state.generation != before.state.generation) + return {Outcome::Unverified, after}; + if (applied == ApplyResult::Applied && *after.state.value == desired) { + NotifyChanged(); + if (epoch_ != before.epoch) + return {Outcome::Unverified, current_}; + return {Outcome::AppliedVerified, after}; + } + // Preserve the authoritative readback even when the adapter reports failure. + return {applied == ApplyResult::Rejected ? Outcome::Rejected : Outcome::Unverified, after}; + } + +private: + void NotifyChanged() + { + // Presentation failure does not undo or misreport an authoritative write. + try { + if (change_observer_) + change_observer_(); + } catch (...) { + } + } + void CheckThread() const + { + if (std::this_thread::get_id() != thread_) + throw std::logic_error("setting thread mismatch"); + } + Definition definition_; + inline static std::atomic next_identity_{1}; + const std::uint64_t identity_ = next_identity_.fetch_add(1); + const std::thread::id thread_ = std::this_thread::get_id(); + Snapshot current_; + std::uint64_t epoch_ = 1; + unsigned render_depth_ = 0; + bool applying_ = false; + bool observing_ = false; + void (*change_observer_)() = nullptr; +}; + +} // namespace mod_settings diff --git a/mods/src/settings/value_view.h b/mods/src/settings/value_view.h new file mode 100644 index 000000000..c9148e0fc --- /dev/null +++ b/mods/src/settings/value_view.h @@ -0,0 +1,69 @@ +#pragma once + +#include "value_settings.h" + +namespace mod_settings +{ +// One instance per live view, never a persisted copy of the preference. Native +// adapters own rendering and invalidate this view before its context is released. +template class ValueView +{ +public: + using Snapshot = ValueSnapshot; + using WriteResult = ValueWriteResult; + explicit ValueView(ValueSetting& setting) + : setting_(setting) + { + } + + void Bind() + { + snapshot_ = setting_.Observe(); + unresolved_ = !snapshot_.state.known(); + failed_ = false; + bound_ = true; + } + void Unbind() + { + snapshot_ = {}; + bound_ = false; + unresolved_ = true; + failed_ = false; + } + void Invalidate() + { + snapshot_ = {}; + unresolved_ = true; + failed_ = false; + } + WriteResult Request(T desired) + { + if (!editable()) + return {Outcome::Rejected, snapshot_}; + auto result = setting_.SetFromUser(desired, snapshot_); + if (result.outcome == Outcome::Suppressed || result.outcome == Outcome::Busy) + return result; + snapshot_ = result.snapshot; + failed_ = result.outcome != Outcome::AppliedVerified && result.outcome != Outcome::Unchanged; + unresolved_ = !snapshot_.state.known() || result.outcome == Outcome::Unverified; + return result; + } + bool editable() const + { return bound_ && !unresolved_ && snapshot_.state.known(); } + bool failed() const + { return failed_; } + UnavailableReason unavailableReason() const + { return snapshot_.state.reason; } + std::optional value() const + { return editable() ? snapshot_.state.value : std::nullopt; } + ValueSetting& setting() const + { return setting_; } + +private: + ValueSetting& setting_; + Snapshot snapshot_; + bool bound_ = false; + bool unresolved_ = true; + bool failed_ = false; +}; +} // namespace mod_settings diff --git a/tests/boolean_settings_test.cc b/tests/boolean_settings_test.cc new file mode 100644 index 000000000..d3c533409 --- /dev/null +++ b/tests/boolean_settings_test.cc @@ -0,0 +1,194 @@ +#include "settings/boolean_settings.h" +#include +#include + +using namespace mod_settings; +#define CHECK(x) \ + do { \ + if (!(x)) { \ + std::cerr << "FAILED line " << __LINE__ << ": " << #x << '\n'; \ + std::exit(1); \ + } \ + } while (false) + +struct Fake { + bool value = false, available = true, reject = false, fail_readback = false, throw_write = false; + unsigned reads = 0, writes = 0; + std::uint64_t generation = 1; + std::function on_read, on_write; + Definition definition(std::string id = "test.confirm") + { + return {std::move(id), "[MOD] Confirm test", + [this] { + ++reads; + if (on_read) + on_read(); + return available ? ReadResult::Known(value, generation) : ReadResult{}; + }, + [this](bool desired, std::uint64_t expected) { + ++writes; + CHECK(expected == generation); + if (on_write) + on_write(); + if (throw_write) + throw std::runtime_error("write"); + if (reject) + return ApplyResult::Rejected; + value = desired; + if (fail_readback) + available = false; + return ApplyResult::Applied; + }}; + } +}; + +int main() +{ + { + Fake f; + BooleanRegistry registry; + CHECK(registry.Register(f.definition()) == Registration::Added); + CHECK(registry.Register(f.definition()) == Registration::Duplicate); + CHECK(registry.Register({}) == Registration::Invalid); + CHECK(f.reads == 0 && f.writes == 0); // Construction schedules no work. + auto* s = registry.Find("test.confirm"); + CHECK(s); + CHECK(registry.Register(f.definition("late")) == Registration::Frozen); + CHECK(!registry.Find("missing")); + auto initial = s->Observe(); + CHECK(initial.state.known() && !*initial.state.value); + CHECK(s->SetFromUser(false, initial).outcome == Outcome::Unchanged); + CHECK(f.writes == 0); + auto result = s->SetFromUser(true, initial); + CHECK(result.outcome == Outcome::AppliedVerified && result.snapshot.state.value == true && f.writes == 1); + CHECK(s->SetFromUser(false, initial).outcome == Outcome::Conflict); + CHECK(f.writes == 1); + CHECK(s->SetFromUser(false, result.snapshot).outcome == Outcome::AppliedVerified); + CHECK(f.writes == 2); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + { + BooleanSetting::RenderScope outer(s); + BooleanSetting::RenderScope inner(s); + s.Observe(); + CHECK(s.SetFromUser(true, before).outcome == Outcome::Suppressed); + } + CHECK(f.writes == 0); + CHECK(s.SetFromUser(true, before).outcome == Outcome::AppliedVerified); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.on_write = [&] { CHECK(s.SetFromUser(true, before).outcome == Outcome::Busy); }; + CHECK(s.SetFromUser(true, before).outcome == Outcome::AppliedVerified); + CHECK(f.writes == 1); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.on_read = [&] { CHECK(s.SetFromUser(true, before).outcome == Outcome::Busy); }; + CHECK(s.SetFromUser(true, before).outcome == Outcome::AppliedVerified); + CHECK(f.writes == 1); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.reject = true; + auto result = s.SetFromUser(true, before); + CHECK(result.outcome == Outcome::Rejected && result.snapshot.state.value == false); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.fail_readback = true; + auto result = s.SetFromUser(true, before); + CHECK(result.outcome == Outcome::Unverified && !result.snapshot.state.value && f.value); + CHECK(f.writes == 1); // Never attempt an unverified reverse write. + f.available = true; + CHECK(s.Observe().state.value == true); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.throw_write = true; + CHECK(s.SetFromUser(true, before).outcome == Outcome::Unverified); + f.throw_write = false; + CHECK(s.SetFromUser(true, s.Observe()).outcome == Outcome::AppliedVerified); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.available = false; + CHECK(!s.Observe().state.value); + CHECK(s.SetFromUser(true, before).outcome == Outcome::Rejected); + CHECK(f.writes == 0); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + ++f.generation; + CHECK(s.SetFromUser(true, before).outcome == Outcome::Conflict); + CHECK(f.writes == 0); + before = s.Observe(); + s.InvalidateSession(); + CHECK(s.SetFromUser(true, before).outcome == Outcome::Conflict); + CHECK(f.writes == 0); + } + { + Fake f; + BooleanSetting s(f.definition()); + auto before = s.Observe(); + f.on_write = [&] { s.InvalidateSession(); }; + CHECK(s.SetFromUser(true, before).outcome == Outcome::Unverified); + } + { + Fake f; + BooleanSetting s(f.definition()); + f.on_read = [&] { s.InvalidateSession(); }; + CHECK(!s.Observe().state.known()); + } + { + Fake f; + BooleanSetting s(f.definition()), other(f.definition("other")); + CHECK(s.SetFromUser(true, other.Observe()).outcome == Outcome::Conflict); + CHECK(f.writes == 0); + } + { + Fake f; + BooleanSetting s(f.definition()); + f.on_read = [&] { CHECK(!s.Observe().state.known()); }; + CHECK(s.Observe().state.known()); + } + { + Definition malformed{"invalid", "invalid", [] { return ReadResult{Availability::Known, true, 0}; }, + [](bool, std::uint64_t) { return ApplyResult::Applied; }}; + BooleanSetting s(std::move(malformed)); + CHECK(!s.Observe().state.value); + } + { + Fake f; + BooleanSetting s(f.definition()); + bool rejected = false; + std::thread other([&] { + try { + s.Observe(); + } catch (const std::logic_error&) { + rejected = true; + } + }); + other.join(); + CHECK(rejected && f.reads == 0); + } + std::cout + << "PASS boolean settings: registration, readback, failure, refresh, reentry, generation and thread contracts\n"; +} diff --git a/tests/boolean_view_test.cc b/tests/boolean_view_test.cc new file mode 100644 index 000000000..a20df4f9d --- /dev/null +++ b/tests/boolean_view_test.cc @@ -0,0 +1,84 @@ +#include "settings/boolean_view.h" +#include +#include + +using namespace mod_settings; +namespace +{ +BooleanView* observedView = nullptr; +BooleanSetting* observedSetting = nullptr; +int notifications = 0; +void Refresh() +{ + ++notifications; + observedView->Bind(); + assert(observedSetting->SetFromUser(false, observedSetting->Observe()).outcome == Outcome::Busy); +} +void OtherObserver() {} +} // namespace +int main() +{ + bool available = true, value = true; + int writes = 0; + ApplyResult apply = ApplyResult::Applied; + BooleanSetting setting({"test.confirmation", "Confirm test", + [&] { return available ? ReadResult::Known(value, 1) : ReadResult{}; }, + [&](bool desired, std::uint64_t) { + ++writes; + if (apply != ApplyResult::Rejected) + value = desired; + return apply; + }}); + BooleanView first(setting), second(setting); + assert(!first.value() && first.Request(false).outcome == Outcome::Rejected); + first.Bind(); + second.Bind(); + { + BooleanSetting::RenderScope render(setting); + assert(first.Request(false).outcome == Outcome::Suppressed); + assert(first.value() == true && !first.failed() && writes == 0); + } + assert(first.Request(false).outcome == Outcome::AppliedVerified); + assert(first.value() == false && writes == 1); + assert(second.Request(false).outcome == Outcome::Conflict); + assert(second.value() == false && second.failed() && writes == 1); + second.Bind(); + assert(!second.failed()); + apply = ApplyResult::Rejected; + assert(first.Request(true).outcome == Outcome::Rejected); + assert(first.value() == false && first.failed()); + apply = ApplyResult::Unverified; + assert(first.Request(true).outcome == Outcome::Unverified); + assert(!first.value() && !first.editable() && first.failed()); + auto count = writes; + assert(first.Request(false).outcome == Outcome::Rejected && writes == count); + first.Bind(); + assert(first.value() == true); + setting.InvalidateSession(); + first.Invalidate(); + second.Invalidate(); + assert(!first.value() && first.Request(false).outcome == Outcome::Rejected); + available = false; + first.Bind(); + assert(!first.value()); + available = true; + first.Bind(); + assert(first.value() == true); + first.Unbind(); + assert(!first.value() && first.Request(false).outcome == Outcome::Rejected); + assert(writes == count); + apply = ApplyResult::Applied; + value = false; + first.Bind(); + observedView = &first; + observedSetting = &setting; + assert(setting.SetChangeObserver(Refresh)); + assert(setting.SetChangeObserver(Refresh)); + assert(!setting.SetChangeObserver(OtherObserver)); + // Recovery shortcut uses the shared setting directly, outside BooleanView. + assert(setting.SetFromUser(true, setting.Observe()).outcome == Outcome::AppliedVerified); + assert(first.value() == true && notifications == 1 && writes == count + 1); + assert(setting.SetFromUser(true, setting.Observe()).outcome == Outcome::Unchanged); + assert(first.value() == true && notifications == 2 && writes == count + 1); + std::cout << "PASS boolean view: render, stale views, unavailable, uncertain writes and teardown\n"; +} diff --git a/tests/native_boolean_callback_test.cc b/tests/native_boolean_callback_test.cc new file mode 100644 index 000000000..ca622486f --- /dev/null +++ b/tests/native_boolean_callback_test.cc @@ -0,0 +1,62 @@ +#include "settings/native_boolean_callback.h" +#include +#include + +namespace +{ +int donorCalls = 0, calls = 0; +bool Donor(Il2CppObject*, const MethodInfo*) +{ + ++donorCalls; + return false; +} +bool Getter(Il2CppObject*, const MethodInfo*) +{ + ++calls; + return true; +} +void Setter(Il2CppObject*, bool value, const MethodInfo*) +{ calls += value ? 10 : 20; } +} // namespace +int main() +{ + Il2CppType boolType{}, voidType{}; + boolType.type = IL2CPP_TYPE_BOOLEAN; + voidType.type = IL2CPP_TYPE_VOID; + MethodInfo schema{}; + schema.return_type = &boolType; + schema.methodPointer = reinterpret_cast(Donor); + schema.virtualMethodPointer = schema.methodPointer; + mod_settings::NativeCallback get; + assert(get.Initialize(&schema, Getter)); + assert(!get.Initialize(&schema, Getter)); + assert(schema.methodPointer == reinterpret_cast(Donor)); + auto* owned = get.method(); + auto direct = reinterpret_cast(owned->methodPointer); + assert(direct(nullptr, owned)); + auto virt = reinterpret_cast(owned->virtualMethodPointer); + assert(virt(nullptr, owned)); + bool value = false; + owned->invoker_method(schema.methodPointer, owned, nullptr, nullptr, &value); + assert(value && calls == 3 && donorCalls == 0); + schema.return_type = &voidType; + const Il2CppType* parameters[] = {&boolType}; + schema.parameters_count = 1; + schema.parameters = parameters; + mod_settings::NativeCallback set; + assert(set.Initialize(&schema, Setter)); + void* args[] = {&value}; + owned = set.method(); + owned->invoker_method(nullptr, owned, nullptr, args, nullptr); + assert(calls == 13 && donorCalls == 0); + mod_settings::NativeCallback invalid; + assert(!invalid.Initialize(&schema, Getter)); + schema.parameters_count = 0; + schema.return_type = &boolType; + schema.flags = METHOD_ATTRIBUTE_VIRTUAL; + assert(!invalid.Initialize(&schema, Getter)); + schema.flags = 0; + schema.is_inflated = true; + assert(!invalid.Initialize(&schema, Getter)); + std::cout << "PASS owned callback: direct, virtual and runtime invoker paths; donor remains untouched\n"; +} diff --git a/tests/run-confirmation-settings.ps1 b/tests/run-confirmation-settings.ps1 new file mode 100644 index 000000000..eaa0c7d31 --- /dev/null +++ b/tests/run-confirmation-settings.ps1 @@ -0,0 +1,17 @@ +[CmdletBinding()] +param() +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot +Push-Location $repoRoot +try { + New-Item -ItemType Directory -Force build/settings-test | Out-Null + foreach ($fixture in @('boolean_settings', 'boolean_view', 'native_boolean_callback')) { + & clang++ --driver-mode=cl /std:c++latest /EHsc /MT /Imods/src /Ithird_party/libil2cpp ` + "tests/${fixture}_test.cc" "/Febuild/settings-test/$fixture.exe" /Fobuild/settings-test/ + if ($LASTEXITCODE -ne 0) { throw "Settings fixture compilation failed: $fixture" } + & "./build/settings-test/$fixture.exe" + if ($LASTEXITCODE -ne 0) { throw "Settings fixture failed: $fixture" } + } +} finally { + Pop-Location +} diff --git a/tests/run-confirmation-settings.sh b/tests/run-confirmation-settings.sh new file mode 100644 index 000000000..f22f7c042 --- /dev/null +++ b/tests/run-confirmation-settings.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p build/settings-test +for fixture in boolean_settings boolean_view native_boolean_callback; do + clang++ -std=c++23 -pthread -I mods/src -I third_party/libil2cpp \ + "tests/${fixture}_test.cc" -o "build/settings-test/$fixture" + "build/settings-test/$fixture" +done