From 5e87a04fa49bb48fbc3352f9bb0d764652daf31e Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 28 Oct 2026 18:35:57 -0500 Subject: [PATCH 1/3] Fix Alliance Games Store research actions and purchase refresh --- example_community_patch_settings_en.toml | 2 + mods/src/config.cc | 2 + mods/src/config.h | 3 + mods/src/defaultconfig.h | 1 + mods/src/patches/alliance_store_bindings.h | 416 ++++++++++++++++ mods/src/patches/parts/alliance_store.cc | 533 +++++++++++++++++++++ mods/src/patches/parts/hotkeys.cc | 3 +- mods/src/patches/patches.cc | 2 + tests/alliance_store_runtime.cc | 528 ++++++++++++++++++++ 9 files changed, 1489 insertions(+), 1 deletion(-) create mode 100644 mods/src/patches/alliance_store_bindings.h create mode 100644 mods/src/patches/parts/alliance_store.cc create mode 100644 tests/alliance_store_runtime.cc diff --git a/example_community_patch_settings_en.toml b/example_community_patch_settings_en.toml index cecaa0a32..4ee0a013f 100644 --- a/example_community_patch_settings_en.toml +++ b/example_community_patch_settings_en.toml @@ -301,6 +301,8 @@ giftsbulkclaimhooks = true # Enable Haven help-history ordering hooks. havenhistoryhooks = true +# Restore native Research actions, currencies and visible rows in Alliance Games Store. +alliancegamesstorehooks = true # Enable Haven zoom hooks. havenzoomhooks = true diff --git a/mods/src/config.cc b/mods/src/config.cc index 77953091e..ab6c25cc1 100644 --- a/mods/src/config.cc +++ b/mods/src/config.cc @@ -1037,6 +1037,8 @@ void Config::Load() get_config_or_default(config, parsed, "patches", "pinnedshiphooks", DCP::pinnedshiphooks, write_config); this->installPinnedShipClickToggle = get_config_or_default(config, parsed, "patches", "pinnedshipclicktoggle", DCP::pinnedshipclicktoggle, write_config); + this->installAllianceGamesStoreHooks = get_config_or_default( + config, parsed, "patches", "alliancegamesstorehooks", DCP::alliancegamesstorehooks, write_config); this->installHavenHistoryHooks = get_config_or_default(config, parsed, "patches", "havenhistoryhooks", DCP::havenhistoryhooks, write_config); spdlog::debug(""); diff --git a/mods/src/config.h b/mods/src/config.h index 89576773d..21f694bff 100644 --- a/mods/src/config.h +++ b/mods/src/config.h @@ -355,4 +355,7 @@ class Config final // Haven help-history list ordering bool installHavenHistoryHooks; + + // Native Alliance Games store research, currency and purchase refresh + bool installAllianceGamesStoreHooks; }; diff --git a/mods/src/defaultconfig.h b/mods/src/defaultconfig.h index e75ec3921..c1753abf6 100644 --- a/mods/src/defaultconfig.h +++ b/mods/src/defaultconfig.h @@ -119,6 +119,7 @@ namespace Patches constexpr bool pinnedshipclicktoggle = true; // SHIFT+click a fleet dock ship tile to pin/unpin it constexpr bool havenhistoryhooks = true; // reverse Haven help history into newest-first order constexpr bool revealconfirmhooks = true; // auto-confirm the Excelsior Decloaking Deflector popup when enabled in [ui] + constexpr bool alliancegamesstorehooks = true; // repair Alliance Games store research/currency/refresh } // namespace Patches namespace Shortcuts diff --git a/mods/src/patches/alliance_store_bindings.h b/mods/src/patches/alliance_store_bindings.h new file mode 100644 index 000000000..57fa3c4b0 --- /dev/null +++ b/mods/src/patches/alliance_store_bindings.h @@ -0,0 +1,416 @@ +#pragma once +// Alliance Games-only checked runtime operations. No replacement binding engine. +#include +#include +#include +#include +#include +#include +#include + +namespace alliance_store_game +{ +using Object = Il2CppObject; +template struct Result { + bool succeeded = false; + T value{}; + explicit operator bool() const + { return succeeded; } +}; +template <> struct Result { + bool succeeded = false; + explicit operator bool() const + { return succeeded; } +}; +class Handle +{ +public: + Handle() = default; + Handle(const Handle&) = delete; + Handle& operator=(const Handle&) = delete; + Handle(Handle&& other) noexcept + : handle_(std::exchange(other.handle_, nullptr)) + { + } + Handle& operator=(Handle&& other) noexcept + { + if (this != &other) { + Reset(); + handle_ = std::exchange(other.handle_, nullptr); + } + return *this; + } + ~Handle() + { Reset(); } + static Handle Strong(Object* value) + { return Make(value, false); } + static Handle Weak(Object* value) + { return Make(value, true); } + Object* Get() const + { return handle_ ? il2cpp_gchandle_get_target(handle_) : nullptr; } + template T* Get() const + { return reinterpret_cast(Get()); } + explicit operator bool() const + { return handle_ != nullptr; } + void Reset() + { + if (handle_) + il2cpp_gchandle_free(std::exchange(handle_, nullptr)); + } + +private: + static Handle Make(Object* value, bool weak) + { + Handle result; + if (value) + result.handle_ = weak ? il2cpp_gchandle_new_weakref(value, false) : il2cpp_gchandle_new(value, false); + if (result.Get() != value) + result.Reset(); + return result; + } + Il2CppGCHandle handle_ = nullptr; +}; +struct Roots { + std::vector values; + bool healthy = true; + Object* Keep(Object* value) + { + if (!value) + return nullptr; + auto root = Handle::Strong(value); + if (!root || root.Get() != value) { + healthy = false; + return nullptr; + } + values.push_back(std::move(root)); + return value; + } +}; +inline Il2CppClass* Class(const char* space, const char* name, const char* assembly = "Assembly-CSharp") +{ return il2cpp_get_class_helper(assembly, space, name).get_cls(); } +inline Il2CppClass* TooltipClass() +{ return Class("Digit.Prime.Inventories", "InventoryTooltipTriggerWidget"); } +inline Il2CppClass* TriggerClass() +{ return Class("Digit.Client.Tooltip", "TooltipTrigger"); } +inline Il2CppClass* ActionClass() +{ return Class("Digit.Client.Tooltip", "TooltipActionTrigger"); } +inline bool Reference(const Il2CppType* type) +{ + auto* cls = type && !type->byref ? il2cpp_class_from_type(type) : nullptr; + return cls && !il2cpp_class_is_valuetype(cls) + && (type->type == IL2CPP_TYPE_CLASS || type->type == IL2CPP_TYPE_OBJECT || type->type == IL2CPP_TYPE_STRING + || type->type == IL2CPP_TYPE_GENERICINST || type->type == IL2CPP_TYPE_SZARRAY + || type->type == IL2CPP_TYPE_ARRAY); +} +struct Vector2 { + float x, y; +}; +struct OverridableBool { + bool serialized, current, modified; +}; +static_assert(sizeof(OverridableBool) == 3); +template bool ValueType(const Il2CppType* type) +{ + if (!type || type->byref) + return false; + if constexpr (std::is_same_v) + return type->type == IL2CPP_TYPE_VOID; + else if constexpr (std::is_pointer_v) + return Reference(type); + else if constexpr (std::is_same_v) + return type->type == IL2CPP_TYPE_BOOLEAN; + else if constexpr (std::is_same_v) + return type->type == IL2CPP_TYPE_I4; + else if constexpr (std::is_same_v) + return type->type == IL2CPP_TYPE_I8; + else if constexpr (std::is_same_v) + return type->type == IL2CPP_TYPE_R4; + else { + const char* name = + std::is_same_v ? "UnityEngine.Vector2" : "Digit.Client.Serialization.OverridableBool"; + uint32_t alignment = 0; + auto* cls = il2cpp_class_from_type(type); + return method_contract::Type(type, name) && cls && il2cpp_class_is_valuetype(cls) + && il2cpp_class_value_size(cls, &alignment) == sizeof(T); + } +} +struct Argument { + void* value; + const char* scalar = nullptr; +}; +inline Argument Ref(Object* value) +{ return {value}; } +template Argument Scalar(T& value, const char* name) +{ return {&value, name}; } +// Inherited/closed List and Widget accessors retain their concrete runtime metadata. +template bool Signature(const MethodInfo* method, bool is_static, std::initializer_list args) +{ + if (!method || method->is_generic || !method->invoker_method + || bool(method->flags & METHOD_ATTRIBUTE_STATIC) != is_static || method->parameters_count != args.size() + || !ValueType(method->return_type)) + return false; + size_t index = 0; + for (const auto& arg : args) { + const auto* type = method->parameters ? method->parameters[index++] : nullptr; + if (arg.scalar) { + if (!method_contract::Type(type, arg.scalar)) + return false; + } else { + auto* value = static_cast(arg.value); + if (!Reference(type) || (value && !il2cpp_class_is_assignable_from(il2cpp_class_from_type(type), value->klass))) + return false; + } + } + return true; +} +template +Result Call(Object* object, const char* name, std::initializer_list args = {}, + Il2CppClass* static_owner = nullptr) +{ + const MethodInfo* selected = nullptr; + for (auto* cls = object ? object->klass : static_owner; cls && !selected; cls = il2cpp_class_get_parent(cls)) { + void* iterator = nullptr; + while (auto* method = il2cpp_class_get_methods(cls, &iterator)) { + if (std::strcmp(method->name, name) || !Signature(method, !object, args)) + continue; + if (selected) + return {}; + selected = method; + } + } + if (!selected) + return {}; + if (object) + selected = il2cpp_object_get_virtual_method(object, selected); + if (!Signature(selected, !object, args)) + return {}; + std::vector parameters; + for (const auto& arg : args) + parameters.push_back(arg.value); + Object* result = nullptr; + if (!Il2CppRuntime::TryInvoke(selected, object, parameters.empty() ? nullptr : parameters.data(), &result)) + return {}; + if constexpr (std::is_same_v) + return {true}; + else if constexpr (std::is_pointer_v) { + if (result && !il2cpp_class_is_assignable_from(il2cpp_class_from_type(selected->return_type), result->klass)) + return {}; + return {true, reinterpret_cast(result)}; + } else { + auto box = Handle::Strong(result); + if (!box || !box.Get() || !ValueType(il2cpp_class_get_type(box.Get()->klass))) + return {}; + auto* data = il2cpp_object_unbox(box.Get()); + if (!data) + return {}; + T value; + std::memcpy(&value, data, sizeof(T)); + return {true, value}; + } +} +inline Result Alive(Object* object) +{ + return object ? Call(nullptr, "op_Implicit", {Ref(object)}, + Class("UnityEngine", "Object", "UnityEngine.CoreModule")) + : Result{true, false}; +} +template struct Field { + FieldInfo* field = nullptr; + bool Resolve(Il2CppClass* cls, const char* name, const char* expected = nullptr) + { + field = nullptr; + for (; cls && !field; cls = il2cpp_class_get_parent(cls)) + field = il2cpp_class_get_field_from_name(cls, name); + if (!field || (il2cpp_field_get_flags(field) & FIELD_ATTRIBUTE_STATIC) || field->offset < sizeof(Object) + || !field->parent || static_cast(field->offset) + sizeof(T) > il2cpp_class_instance_size(field->parent)) + return false; + if constexpr (std::is_same_v) { + auto* type = il2cpp_class_from_type(field->type); + if (!ValueType(field->type) + && !(type && il2cpp_class_is_enum(type) && ValueType(il2cpp_class_enum_basetype(type)))) + return false; + } else if (!ValueType(field->type)) + return false; + return !expected || method_contract::Type(field->type, expected); + } + T Read(Object* object) const + { + T value{}; + if (field && object && il2cpp_class_is_assignable_from(field->parent, object->klass)) + il2cpp_field_get_value(object, field, &value); + return value; + } + bool Write(Object* object, T value) const + { + if (!field || !object || !il2cpp_class_is_assignable_from(field->parent, object->klass)) + return false; + if constexpr (std::is_pointer_v) { + if (value && !il2cpp_class_is_assignable_from(il2cpp_class_from_type(field->type), value->klass)) + return false; + il2cpp_field_set_value(object, field, value); // Reference itself; runtime write barrier. + } else + il2cpp_field_set_value(object, field, &value); + const auto observed = Read(object); + return std::memcmp(&observed, &value, sizeof(T)) == 0; + } +}; +struct Fields { + Field trigger, action, search, locale, settings, header, description, target, description_override, loader; + Field scroller, spawned, bundle_tooltip, bundle_owner, spawning, initializing; + Field show_header, show_description; + Field override_locale, interactable, regenerating; + Field anchor; + Field size, offset; + bool Resolve() + { + auto* widget = TooltipClass(); + auto* trigger_type = TriggerClass(); + auto* shop = Class("Digit.Prime.Shop", "ShopListScrollerViewController"); + auto* smart = Class("Digit.Client.UI", "SmartScrollerBase"); + auto* bundle = Class("Digit.Prime.Shop", "BundleDataWidget"); + bool ok = true; + ok &= trigger.Resolve(widget, "_tooltipTrigger", "Digit.Client.Tooltip.TooltipTrigger"); + ok &= action.Resolve(widget, "_tooltipActionTrigger", "Digit.Client.Tooltip.TooltipActionTrigger"); + ok &= search.Resolve(widget, "_searchActionLocaleSetting", "Digit.Prime.Actions.ActionLocaleSetting"); + ok &= locale.Resolve(widget, "_actionLocaleSetting", "Digit.Prime.Actions.ActionLocaleSetting"); + ok &= settings.Resolve(widget, "_settings", "Digit.Prime.Actions.ActionLocaleSettingsList"); + ok &= show_header.Resolve(trigger_type, "_showHeader"); + ok &= show_description.Resolve(trigger_type, "_showDescription"); + ok &= header.Resolve(trigger_type, "_header", "Digit.Client.UI.LocaleTextContext"); + ok &= description.Resolve(trigger_type, "_description", "Digit.Client.UI.LocaleTextContext"); + ok &= override_locale.Resolve(trigger_type, "_overrideLocale"); + ok &= anchor.Resolve(trigger_type, "_anchorOverride"); + ok &= size.Resolve(trigger_type, "_sizeOverride"); + ok &= offset.Resolve(trigger_type, "_positionOverrideOffset"); + ok &= target.Resolve(trigger_type, "_positionOverrideTarget", "UnityEngine.Transform"); + ok &= interactable.Resolve(trigger_type, "_isInteractable"); + ok &= description_override.Resolve(trigger_type, "k__BackingField", "System.String"); + ok &= loader.Resolve(trigger_type, "_loader", "Digit.Client.Tooltip.TooltipLoadAndShow"); + ok &= scroller.Resolve(shop, "_smartScroller"); + ok &= spawned.Resolve(smart, "_spawnedItems"); + ok &= bundle_tooltip.Resolve(bundle, "_invTooltip", "Digit.Prime.Inventories.InventoryTooltipTriggerWidget"); + ok &= bundle_owner.Resolve(bundle, "_scrollerController", "Digit.Prime.Shop.ShopListScrollerViewController"); + ok &= regenerating.Resolve(smart, "_regeneratingContent"); + ok &= spawning.Resolve(smart, "_spawnerCoroutine"); + ok &= initializing.Resolve(smart, "_dataHeldWhilstInitializing"); + return ok; + } +}; +inline Fields& Members() +{ + static Fields fields; + return fields; +} +inline bool IsStore(Object* object) +{ + auto* type = Class("Digit.Prime.Alliances.Games", "AllianceGamesStoreScrollerViewController"); + return object && type && il2cpp_class_is_assignable_from(type, object->klass); +} +inline Object* TypeObject(Il2CppClass* type) +{ return type ? reinterpret_cast(il2cpp_type_get_object(il2cpp_class_get_type(type))) : nullptr; } +inline Result Eligible(Object* item) +{ + Roots roots; + if (!roots.Keep(item)) + return {}; + const auto resource = Call(item, "get_ResourceSpec"); + auto* spec = resource ? roots.Keep(resource.value) : nullptr; + const auto id = Call(spec, "get_Id"); + if (!id || id.value <= 0) + return {}; + const auto observed = Call(nullptr, "get_ResearchManager", {}, Class("Digit.Client.Core", "Hub")); + auto* manager = observed ? roots.Keep(observed.value) : nullptr; + auto resource_id = id.value; + int32_t tree = 0; + return manager ? Call( + manager, "IsResourceRequiredByVisibleResearches", + {Scalar(resource_id, "System.Int64"), Scalar(tree, "Digit.PrimeServer.Models.ResearchTreeType")}) + : Result{}; +} +inline bool Enabled(Object* trigger, bool value) +{ + static const auto* method = method_contract::Resolve(Class("UnityEngine", "Behaviour", "UnityEngine.CoreModule"), + "set_enabled", false, "System.Void", {"System.Boolean"}); + void* args[]{&value}; + return trigger && Il2CppRuntime::TryInvoke(method, trigger, args); +} +inline Result NormalizedPosition(Object* smart) +{ return Call(smart, "get_NormalizedPosition"); } +inline bool NotifyPosition(Object* smart, Vector2 value) +{ return Call(smart, "OnValueChangedCallback", {Scalar(value, "UnityEngine.Vector2")}).succeeded; } +inline Result CurrencyCount(Object* context, Roots& roots) +{ + const auto observed = Call(context, "get_StoreCurrency"); + auto* currencies = observed ? roots.Keep(observed.value) : nullptr; + const auto soft = Call(currencies, "get_SoftResources"); + return soft && roots.Keep(soft.value) ? Call(soft.value, "get_Count") : Result{}; +} +inline Object* Component(Object* source, const char* name, Il2CppClass* type, Roots& roots, bool children = false) +{ + auto* reflected = roots.Keep(TypeObject(type)); + if (!reflected) + return nullptr; + bool inactive = true; + const auto found = children ? Call(source, name, {Ref(reflected), Scalar(inactive, "System.Boolean")}) + : Call(source, name, {Ref(reflected)}); + return found ? roots.Keep(found.value) : nullptr; +} +inline Object* ActionComponent(Object* widget, bool create, Roots& roots) +{ + const auto observed = Call(widget, "get_gameObject"); + auto* object = observed ? roots.Keep(observed.value) : nullptr; + if (!object) + return nullptr; + auto* reflected = roots.Keep(TypeObject(ActionClass())); + if (!reflected) + return nullptr; + const auto found = Call(object, "GetComponent", {Ref(reflected)}); + if (!found) + return nullptr; + if (found.value) + return roots.Keep(found.value); + if (!create) + return nullptr; + const auto added = Call(object, "AddComponent", {Ref(reflected)}); + return added ? roots.Keep(added.value) : nullptr; +} +inline Object* PlainComponent(Object* widget, Roots& roots) +{ + auto* value = Component(widget, "GetComponent", TriggerClass(), roots); + return value && value->klass == TriggerClass() ? value : nullptr; +} +inline Object* Template(Roots& roots) +{ + auto* type = roots.Keep(TypeObject(TooltipClass())); + const auto found = type ? Call(nullptr, "FindObjectsOfTypeAll", {Ref(type)}, + Class("UnityEngine", "Resources", "UnityEngine.CoreModule")) + : Result{}; + auto* array = found ? roots.Keep(found.value) : nullptr; + const auto* metadata = array ? il2cpp_class_get_type(array->klass) : nullptr; + if (!metadata || metadata->type != IL2CPP_TYPE_SZARRAY || !array->klass->element_class + || il2cpp_class_is_valuetype(array->klass->element_class)) + return nullptr; + auto* items = reinterpret_cast(array); + for (il2cpp_array_size_t index = 0; index < items->max_length; ++index) { + auto* widget = reinterpret_cast(items->vector)[index]; + if (widget && il2cpp_class_is_assignable_from(TooltipClass(), widget->klass) && Members().action.Read(widget) + && Members().search.Read(widget)) + return roots.Keep(widget); + } + return nullptr; +} +inline bool CopyAppearance(Object* plain, Object* action, Roots& roots) +{ + auto& f = Members(); + auto copy = [&](const Field& field) { + const auto value = field.Read(plain); + if constexpr (std::is_pointer_v) + if (value && !roots.Keep(value)) + return false; + return field.Write(action, value); + }; + return copy(f.show_header) && copy(f.header) && copy(f.show_description) && copy(f.description) + && copy(f.override_locale) && copy(f.anchor) && copy(f.size) && copy(f.target) && copy(f.offset) + && copy(f.interactable) && copy(f.description_override) && copy(f.loader); +} +} // namespace alliance_store_game diff --git a/mods/src/patches/parts/alliance_store.cc b/mods/src/patches/parts/alliance_store.cc new file mode 100644 index 000000000..295b9c407 --- /dev/null +++ b/mods/src/patches/parts/alliance_store.cc @@ -0,0 +1,533 @@ +// Alliance Games store: native material research tooltips and currency refresh +// after bundle readiness. One feature owner, no claim-trace or file probe. +#include "patches/alliance_store_bindings.h" +#include "patches/native_hook_extent.h" +#include "patches/screen_update_hook.h" + +#include +#if _WIN32 +#include +#endif +#include +#include + +#include +#include +#include + +namespace +{ +namespace store_game = alliance_store_game; +using Clock = std::chrono::steady_clock; +using StoreInventoryTooltip = Il2CppObject; +using StoreTooltipTrigger = Il2CppObject; +using StoreTooltipAction = Il2CppObject; +using StoreActionLocale = Il2CppObject; +using StoreActionLocales = Il2CppObject; +using AllianceStoreScroller = Il2CppObject; +using InventoryItem = Il2CppObject; +using AllianceStoreContext = Il2CppObject; +using ShopListScrollerViewController = Il2CppObject; +using BundleDataWidget = Il2CppObject; +using Bundle = Il2CppObject; +bool alliance_store_active = false; +std::thread::id alliance_store_thread; +bool StoreThread() +{ return alliance_store_active && std::this_thread::get_id() == alliance_store_thread; } + +struct AllianceStoreWork { + store_game::Handle source; + uint64_t epoch = 0; + bool pending = false, currency = false, refreshing_currency = false, layout = false; + Clock::time_point due{}; +} alliance_store_work; + +enum class ResearchUpdate { Unchanged, Upgraded, Restored, Unavailable, Failed }; + +bool SameStoreItem(StoreInventoryTooltip* tooltip, InventoryItem* item) +{ + const auto alive = store_game::Alive(tooltip); + if (!alive || !alive.value) + return false; + const auto current = store_game::Call(tooltip, "get_Context"); + return current && current.value == item; +} + +// Weak component identity survives pooled reuse without retaining a scene. Only +// the original serialized assets stay rooted while our replacement is bound. +struct OwnedStoreTooltip { + store_game::Handle widget, plain, action, store; + store_game::Handle search, locale, settings; + bool bound = false, plain_enabled = true, retiring = false; + uint64_t generation = 0; +}; +std::vector> alliance_store_tooltips; + +std::shared_ptr OwnedTooltip(StoreInventoryTooltip* tooltip) +{ + for (auto& entry : alliance_store_tooltips) + if (entry->widget.Get() == tooltip) + return entry; + return nullptr; +} + +bool RestoreStoreTooltip(OwnedStoreTooltip& entry) +{ + if (!entry.bound) + return true; + if (entry.retiring) + return false; + ++entry.generation; // Any in-flight setup loses publication authority. + entry.retiring = true; + struct ClearRetiring { + bool& value; + ~ClearRetiring() + { value = false; } + } clear{entry.retiring}; + store_game::Roots roots; + auto* widget = roots.Keep(entry.widget.Get()); + auto* plain = roots.Keep(entry.plain.Get()); + auto* action = roots.Keep(entry.action.Get()); + const auto alive = store_game::Alive(widget); + if (!roots.healthy || !widget || !plain || !action || !alive) + return false; + if (!alive.value) { + entry.bound = false; + entry.search.Reset(); + entry.locale.Reset(); + entry.settings.Reset(); + return true; + } + auto& f = store_game::Members(); + if ((f.trigger.Read(widget) != plain && f.trigger.Read(widget) != action) + || (f.action.Read(widget) && f.action.Read(widget) != action)) { + // A new native owner replaced our arrangement. Disable only our component; + // never overwrite the replacement owner's fields or enabled state. + (void)store_game::Enabled(action, false); + entry.bound = false; + entry.search.Reset(); + entry.locale.Reset(); + entry.settings.Reset(); + return true; + } + bool ok = store_game::Enabled(action, false); + ok &= f.trigger.Write(widget, plain); + ok &= f.action.Write(widget, nullptr); + ok &= f.search.Write(widget, entry.search.Get()); + ok &= f.locale.Write(widget, entry.locale.Get()); + ok &= f.settings.Write(widget, entry.settings.Get()); + ok &= store_game::Call(widget, "UpdateTooltipDescription").succeeded; + ok &= store_game::Enabled(plain, entry.plain_enabled); + if (ok) { + entry.bound = false; + entry.search.Reset(); + entry.locale.Reset(); + entry.settings.Reset(); + } + return ok; +} + +ResearchUpdate ReconcileStoreTooltip(StoreInventoryTooltip* tooltip, StoreInventoryTooltip*& donor, + store_game::Roots& pass_roots) +{ + store_game::Roots roots; + if (!roots.Keep(tooltip)) + return ResearchUpdate::Unavailable; + const auto epoch = alliance_store_work.epoch; + auto* store = roots.Keep(alliance_store_work.source.Get()); + const auto alive = store_game::Alive(tooltip); + const auto observed = alive && alive.value ? store_game::Call(tooltip, "get_Context") + : store_game::Result{}; + auto* item = observed ? roots.Keep(observed.value) : nullptr; + auto same_item = [&] { + if (!store || alliance_store_work.epoch != epoch || alliance_store_work.source.Get() != store) + return false; + const auto live_store = store_game::Alive(store); + return live_store && live_store.value && SameStoreItem(tooltip, item) && alliance_store_work.epoch == epoch + && alliance_store_work.source.Get() == store; + }; + auto* plain = item ? store_game::PlainComponent(tooltip, roots) : nullptr; + if (!plain) + return ResearchUpdate::Unchanged; + auto& f = store_game::Members(); + auto* old_action = roots.Keep(f.action.Read(tooltip)); + auto* old_trigger = roots.Keep(f.trigger.Read(tooltip)); + auto owned = OwnedTooltip(tooltip); + // Existing native action components and unrelated arrangements remain native. + if (!roots.healthy || (old_action && (!owned || owned->action.Get() != old_action)) + || (old_action ? old_trigger != old_action : old_trigger != plain)) + return ResearchUpdate::Unchanged; + const auto eligible = store_game::Eligible(item); + if (!eligible || !same_item()) + return ResearchUpdate::Unavailable; + if (!eligible.value) + return owned && owned->bound ? (RestoreStoreTooltip(*owned) ? ResearchUpdate::Restored : ResearchUpdate::Failed) + : ResearchUpdate::Unchanged; + if (!donor) + donor = store_game::Template(pass_roots); + if (!donor) + return ResearchUpdate::Unavailable; + auto* search = roots.Keep(f.search.Read(donor)); + auto* locale = roots.Keep(f.locale.Read(donor)); + auto* settings = roots.Keep(f.settings.Read(donor)); + auto* loader = roots.Keep(f.loader.Read(plain)); + const auto plain_enabled = store_game::Call(plain, "get_enabled"); + if (!roots.healthy || !search || !loader || !plain_enabled || !same_item()) + return ResearchUpdate::Unavailable; + auto* action = store_game::ActionComponent(tooltip, false, roots); + // Reuse only the disabled component this feature previously added. + if (action && (!owned || owned->action.Get() != action)) + return ResearchUpdate::Unchanged; + if (!action) + action = store_game::ActionComponent(tooltip, true, roots); + if (!action) + return ResearchUpdate::Unavailable; + if (!owned) { + auto entry = std::make_shared(); + entry->widget = store_game::Handle::Weak(tooltip); + entry->plain = store_game::Handle::Weak(plain); + entry->action = store_game::Handle::Weak(action); + if (!entry->widget || !entry->plain || !entry->action) { + store_game::Enabled(action, false); + return ResearchUpdate::Unavailable; + } + alliance_store_tooltips.push_back(std::move(entry)); + owned = alliance_store_tooltips.back(); + } + const bool previously_bound = owned->bound; + if (!owned->bound) { + auto retain = [](T* value, store_game::Handle& handle) { + handle = store_game::Handle::Strong(value); + return !value || (handle && handle.Get() == value); + }; + if (!retain(f.search.Read(tooltip), owned->search) || !retain(f.locale.Read(tooltip), owned->locale) + || !retain(f.settings.Read(tooltip), owned->settings)) { + owned->search.Reset(); + owned->locale.Reset(); + owned->settings.Reset(); + store_game::Enabled(action, false); + return ResearchUpdate::Unavailable; + } + owned->store = store_game::Handle::Weak(store); + if (!owned->store || owned->store.Get() != store) { + owned->search.Reset(); + owned->locale.Reset(); + owned->settings.Reset(); + store_game::Enabled(action, false); + return ResearchUpdate::Unavailable; + } + owned->plain_enabled = plain_enabled.value; + owned->bound = true; + } + const auto generation = ++owned->generation; + auto current = [&] { return same_item() && owned->bound && !owned->retiring && owned->generation == generation; }; + struct Rollback { + std::shared_ptr entry; + bool committed = false; + ~Rollback() + { + if (!committed) + (void)RestoreStoreTooltip(*entry); + } + } rollback{owned}; + + if (!store_game::Enabled(action, false) || !store_game::CopyAppearance(plain, action, roots) || !current() + || !f.search.Write(tooltip, search) || !f.locale.Write(tooltip, locale) || !f.settings.Write(tooltip, settings) + || !f.trigger.Write(tooltip, action) || !f.action.Write(tooltip, action) + || !store_game::Call(tooltip, "UpdateTooltipDescription") || !current() + || !store_game::Call(tooltip, "SetupActionTooltipTrigger") || !current() + || !store_game::Enabled(plain, false) || !store_game::Enabled(action, true) || !current()) + return ResearchUpdate::Failed; + const auto enabled = store_game::Call(action, "get_enabled"); + if (!current() || !enabled || !enabled.value || f.trigger.Read(tooltip) != action || f.action.Read(tooltip) != action) + return ResearchUpdate::Failed; + rollback.committed = true; + return previously_bound ? ResearchUpdate::Unchanged : ResearchUpdate::Upgraded; +} + +void QueueStore(AllianceStoreScroller* source, bool currency) +{ + if (!store_game::IsStore(source)) + return; + auto& work = alliance_store_work; + if (work.source.Get() != source) { + work.source = store_game::Handle::Weak(source); + ++work.epoch; + } + if (!work.source) + return; + // Debounce through separate-frame native card spawning. + work.due = Clock::now() + std::chrono::milliseconds(250); + work.pending = true; + work.currency |= currency && !work.refreshing_currency; + work.layout |= currency && !work.refreshing_currency; +} + +void AllianceStoreScroller_OnDidBindCanvasContext_Hook(auto original, AllianceStoreScroller* self) +{ + if (!StoreThread()) { + original(self); + return; + } + original(self); + std::erase_if(alliance_store_tooltips, [](auto& entry) { return !entry->widget.Get(); }); + QueueStore(self, true); +} + +void AllianceStoreScroller_OnAboutToReleaseCanvasContext_Hook(auto original, AllianceStoreScroller* self) +{ + if (!StoreThread()) { + original(self); + return; + } + auto& work = alliance_store_work; + if (work.source.Get() == self) { + work.source.Reset(); + work.pending = work.currency = work.layout = false; + ++work.epoch; + } + // Native restoration can reenter bind/pruning. Keep stable entry custody and + // invalidate the source before the first such callback. + const auto entries = alliance_store_tooltips; + for (auto& entry : entries) + if (entry->store.Get() == self) + (void)RestoreStoreTooltip(*entry); + original(self); +} + +void ShopListScrollerViewController_RegenerateSortedBundles_Hook(auto original, ShopListScrollerViewController* self, + bool instant) +{ + if (!StoreThread()) { + original(self, instant); + return; + } + original(self, instant); + if (store_game::IsStore(self) && !alliance_store_work.refreshing_currency) + QueueStore(reinterpret_cast(self), true); +} + +void BundleDataWidget_OnAboutToReleaseContext_Hook(auto original, BundleDataWidget* self) +{ + if (!StoreThread()) { + original(self); + return; + } + auto* tooltip = store_game::Members().bundle_tooltip.Read(self); + if (auto entry = OwnedTooltip(tooltip)) + (void)RestoreStoreTooltip(*entry); + original(self); +} + +void QueueStoreCard(BundleDataWidget* self) +{ + // Native OnDidBindContext sets this AFTER SetupTooltip. Never use the + // RefreshBundle argument or a pre-bind owner as current-card identity. + auto* owner = store_game::Members().bundle_owner.Read(self); + if (owner && alliance_store_work.source.Get() == owner) + QueueStore(reinterpret_cast(owner), false); +} +void BundleDataWidget_RefreshBundle_Hook(auto original, BundleDataWidget* self, Bundle* bundle) +{ + if (!StoreThread()) { + original(self, bundle); + return; + } + original(self, bundle); + QueueStoreCard(self); +} +void BundleDataWidget_OnDidBindContext_Hook(auto original, BundleDataWidget* self) +{ + if (!StoreThread()) { + original(self); + return; + } + original(self); + QueueStoreCard(self); +} + +void AllianceStoreFrame() +{ + if (!StoreThread()) + return; + auto& work = alliance_store_work; + if (!work.pending || Clock::now() < work.due) + return; + const bool currency = std::exchange(work.currency, false); + const bool layout = std::exchange(work.layout, false); + work.pending = false; + const auto epoch = work.epoch; + store_game::Roots roots; + auto* source = roots.Keep(work.source.Get()); + const auto alive = store_game::Alive(source); + const auto observed = source && alive && alive.value + ? store_game::Call(source, "get_AllianceGamesContext") + : store_game::Result{}; + auto* context = observed ? roots.Keep(observed.value) : nullptr; + const auto active = context ? store_game::Call(context, "get_IsShopViewActive") : store_game::Result{}; + if (!context || !active || !active.value) + return; + if (work.epoch != epoch || work.source.Get() != source) + return; + auto& fields = store_game::Members(); + auto* smart = roots.Keep(fields.scroller.Read(source)); + if (smart && (fields.regenerating.Read(smart) || fields.spawning.Read(smart) || fields.initializing.Read(smart))) { + work.pending = true; + work.currency |= currency; + work.layout |= layout; + work.due = Clock::now() + std::chrono::milliseconds(250); + return; + } + bool position_notified = false; + if (layout && smart) { + const auto position = store_game::NormalizedPosition(smart); + const auto live_smart = store_game::Alive(smart); + if (position && live_smart && live_smart.value && work.epoch == epoch && work.source.Get() == source + && fields.scroller.Read(source) == smart) { + position_notified = store_game::NotifyPosition(smart, position.value); + } + } + if (work.epoch != epoch || work.source.Get() != source || fields.scroller.Read(source) != smart) + return; + auto* pool = smart ? roots.Keep(fields.spawned.Read(smart)) : nullptr; + const auto count = pool ? store_game::Call(pool, "get_Count") : store_game::Result{}; + if (!count || count.value < 0) + return; + int upgraded = 0, restored = 0, unavailable = 0, failed = 0; + StoreInventoryTooltip* donor = nullptr; + auto* tooltip_type = roots.Keep(store_game::TypeObject(store_game::TooltipClass())); + if (!tooltip_type) + return; + for (int32_t index = 0; index < count.value; ++index) { + if (work.epoch != epoch || work.source.Get() != source) + return; + store_game::Roots card_roots; + const auto entry = store_game::Call(pool, "get_Item", {store_game::Scalar(index, "System.Int32")}); + auto* rect = entry ? card_roots.Keep(entry.value) : nullptr; + const auto live = store_game::Alive(rect); + if (!rect || !live || !live.value) + continue; + const auto tooltip = store_game::Result{ + true, store_game::Component(rect, "GetComponentInChildren", store_game::TooltipClass(), card_roots, true)}; + switch (tooltip && tooltip.value ? ReconcileStoreTooltip(tooltip.value, donor, roots) : ResearchUpdate::Unchanged) { + case ResearchUpdate::Upgraded: + ++upgraded; + break; + case ResearchUpdate::Restored: + ++restored; + break; + case ResearchUpdate::Unavailable: + ++unavailable; + break; + case ResearchUpdate::Failed: + ++failed; + break; + default: + break; + } + } + bool refreshed = false; + store_game::Result currency_before, currency_after; + // Native bundle-derived discovery, not a fixed list of currency IDs. An + // empty/loading store waits for its real data/regeneration event. + const auto fetching = store_game::Call(context, "get_IsFetchingBundlesInProgress"); + const auto bundles = store_game::Call(context, "get_BundlesCount"); + if (currency && work.epoch == epoch && work.source.Get() == source && fetching && !fetching.value && bundles + && bundles.value > 0) { + work.refreshing_currency = true; + struct Reset { + bool& value; + ~Reset() + { value = false; } + } reset{work.refreshing_currency}; + currency_before = store_game::CurrencyCount(context, roots); + const auto current = store_game::Call(source, "get_AllianceGamesContext"); + const auto live_source = store_game::Alive(source); + if (current && current.value == context && live_source && live_source.value && work.epoch == epoch + && work.source.Get() == source) { + refreshed = store_game::Call(context, "UpdateStoreCurrency").succeeded; + currency_after = store_game::CurrencyCount(context, roots); + } + } + if (layout) + spdlog::info("[AllianceGamesStore] settled layout notification={} (position unchanged)", position_notified); + const auto visible = store_game::Call(smart, "get_VisibleItemsCount"); + const auto position = store_game::Call(smart, "get_ScrollPosition"); + spdlog::info("[AllianceGamesStore] pool={} upgraded={} restored={} unavailable={} failed={} currency_refresh={} " + "currencies={}->{} visible={} position={}", + count.value, upgraded, restored, unavailable, failed, refreshed, + currency_before ? std::to_string(currency_before.value) : "unavailable", + currency_after ? std::to_string(currency_after.value) : "unavailable", + visible ? std::to_string(visible.value) : "unavailable", + position ? std::to_string(position.value) : "unavailable"); +} + +bool StoreHookFits(const MethodInfo* method) +{ + if (!method || !method->methodPointer) + return false; + auto* entry = reinterpret_cast(method->methodPointer); +#if _WIN32 && defined(_M_X64) + DWORD64 base = 0; + auto* function = RtlLookupFunctionEntry(reinterpret_cast(entry), &base, nullptr); + return function && base + function->BeginAddress == reinterpret_cast(entry) + && function->EndAddress > function->BeginAddress + && spud::has_detour_prologue(entry, function->EndAddress - function->BeginAddress); +#elif __APPLE__ + return native_hooks::MacHookFits(entry); +#else + return false; +#endif +} +} // namespace + +void InstallAllianceGamesStoreHooks() +{ + static bool attempted = false; + if (std::exchange(attempted, true)) + return; + auto* store = store_game::Class("Digit.Prime.Alliances.Games", "AllianceGamesStoreScrollerViewController"); + auto* shop = store_game::Class("Digit.Prime.Shop", "ShopListScrollerViewController"); + auto* card = store_game::Class("Digit.Prime.Shop", "BundleDataWidget"); + const std::array targets{ + method_contract::Resolve(store, "OnDidBindCanvasContext", false, "System.Void", {}), + method_contract::Resolve(store, "OnAboutToReleaseCanvasContext", false, "System.Void", {}), + method_contract::Resolve(shop, "RegenerateSortedBundles", false, "System.Void", {"System.Boolean"}), + method_contract::Resolve(card, "RefreshBundle", false, "System.Void", {"Digit.PrimePlatform.Content.Bundle"}), + method_contract::Resolve(card, "OnAboutToReleaseContext", false, "System.Void", {}), + method_contract::Resolve(card, "OnDidBindContext", false, "System.Void", {})}; + if (!store_game::Members().Resolve()) { + spdlog::warn("[AllianceGamesStore] unavailable: native field contract changed"); + return; + } + for (size_t i = 0; i < targets.size(); ++i) { + if (!StoreHookFits(targets[i])) { + spdlog::warn("[AllianceGamesStore] unavailable: hook signature/extent {}", i); + return; + } + for (size_t j = 0; j < i; ++j) + if (targets[i]->methodPointer == targets[j]->methodPointer) { + spdlog::warn("[AllianceGamesStore] unavailable: shared hook target"); + return; + } + } + if (!install_screen_manager_update_hook() || !register_screen_manager_update_callback(AllianceStoreFrame)) { + spdlog::warn("[AllianceGamesStore] unavailable: shared frame delivery"); + return; + } + // Old SPUD has no hook transaction/rollback API. Preflight every target first, + // attempt once, and leave all installed callbacks native-only unless complete. + const bool complete = + SPUD_STATIC_DETOUR(method_contract::Pointer(targets[0]), AllianceStoreScroller_OnDidBindCanvasContext_Hook) + && SPUD_STATIC_DETOUR(method_contract::Pointer(targets[1]), + AllianceStoreScroller_OnAboutToReleaseCanvasContext_Hook) + && SPUD_STATIC_DETOUR(method_contract::Pointer(targets[2]), + ShopListScrollerViewController_RegenerateSortedBundles_Hook) + && SPUD_STATIC_DETOUR(method_contract::Pointer(targets[3]), BundleDataWidget_RefreshBundle_Hook) + && SPUD_STATIC_DETOUR(method_contract::Pointer(targets[4]), BundleDataWidget_OnAboutToReleaseContext_Hook) + && SPUD_STATIC_DETOUR(method_contract::Pointer(targets[5]), BundleDataWidget_OnDidBindContext_Hook); + alliance_store_thread = std::this_thread::get_id(); + alliance_store_active = complete; + if (!complete) + spdlog::warn("[AllianceGamesStore] unavailable: incomplete detour installation; adapters inactive"); +} diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index 8592d89f6..16013768a 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -1718,7 +1718,8 @@ bool install_screen_manager_update_hook() if (!helper.isValidHelper()) { ErrorMsg::MissingHelper("UI", "ScreenManager"); } else if (auto update = helper.GetMethod("Update"); update) { - SPUD_STATIC_DETOUR(update, ScreenManager_Update_Hook); + if (!SPUD_STATIC_DETOUR(update, ScreenManager_Update_Hook)) + return false; installed = true; return true; } else { diff --git a/mods/src/patches/patches.cc b/mods/src/patches/patches.cc index be74d9dbc..7245542e6 100644 --- a/mods/src/patches/patches.cc +++ b/mods/src/patches/patches.cc @@ -61,6 +61,7 @@ void InstallActionQueueRecovery(); void InstallThinQueueProtection(); void InstallQueueAddressGuard(); void InstallHavenHistoryHooks(); +void InstallAllianceGamesStoreHooks(); __int64 il2cpp_init_hook(auto original, const char* domain_name) { @@ -186,6 +187,7 @@ __int64 il2cpp_init_hook(auto original, const char* domain_name) {"QueueAddressGuard", {InstallQueueAddressGuard, &cfg.installQueueAddressGuardHooks}}, {"ThinQueueProtection", {InstallThinQueueProtection, &cfg.installThinQueueProtectionHooks}}, {"HavenHistory", {InstallHavenHistoryHooks, &cfg.installHavenHistoryHooks}}, + {"AllianceGamesStore", {InstallAllianceGamesStoreHooks, &cfg.installAllianceGamesStoreHooks}}, }; printf("il2cpp_init_hook(%s)\n", domain_name); diff --git a/tests/alliance_store_runtime.cc b/tests/alliance_store_runtime.cc new file mode 100644 index 000000000..6cfab0ce7 --- /dev/null +++ b/tests/alliance_store_runtime.cc @@ -0,0 +1,528 @@ +// Exercise the production Alliance Games adapter against a controlled IL2CPP boundary. +// This fixture makes no Unity/rendering or native-detour claim. +#include +#if _WIN32 +#undef IL2CPP_IMPORT +#define IL2CPP_IMPORT +#endif +#include "patches/alliance_store_bindings.h" +#include +#include +#include +#include +#include + +namespace +{ +using namespace alliance_store_game; + +void Require(bool condition, const char* message) +{ + if (!condition) + throw std::runtime_error(message); +} + +Il2CppClass reference_class{}, child_class{}, foreign_class{}, boolean_class{}, int32_class{}, int64_class{}; +Il2CppClass void_class{}, vector_class{}, foreign_vector_class{}, anchor_class{}; +struct Instance { + Object object{}; + alignas(8) unsigned char storage[32]{}; +} receiver; +struct Box { + Object object{}; + alignas(8) unsigned char data[16]{}; +} box; +Object argument{}, foreign_object{}; +MethodInfo method{}, override_method{}; +const MethodInfo* methods[2]{}; +const Il2CppType* parameters[3]{}; +FieldInfo field{}; + +Object* returned = nullptr; +bool managed_exception = false, null_unbox = false; +int invoke_calls = 0, virtual_calls = 0, field_reads = 0, field_writes = 0; +const MethodInfo* seen_method = nullptr; +void* seen_target = nullptr; +std::array seen_arguments{}; +int64_t seen_int64 = 0; +bool seen_boolean = false; +Vector2 seen_vector{}; +void* seen_field_argument = nullptr; +bool drop_field_write = false, use_override = false; + +struct HandleSlot { + Object* target = nullptr; + bool live = false, weak = false; +}; +std::array handles{}; +HandleSlot* last_handle = nullptr; +bool fail_handle = false, mismatch_handle = false; +int handle_creations = 0, handle_frees = 0, weak_creations = 0; + +Il2CppGCHandle NewHandle(Object* value, bool weak) +{ + if (fail_handle) + return nullptr; + for (auto& handle : handles) { + if (handle.live) + continue; + handle = {mismatch_handle ? &foreign_object : value, true, weak}; + last_handle = &handle; + ++handle_creations; + weak_creations += weak; + return &handle; + } + throw std::runtime_error("fixture handle capacity exhausted"); +} + +void InitClass(Il2CppClass& cls, const char* space, const char* name, Il2CppTypeEnum type, bool value_type = false, + uint32_t size = 0) +{ + cls.namespaze = space; + cls.name = name; + cls.byval_arg.type = type; + cls.byval_arg.valuetype = value_type; + cls.byval_arg.data.dummy = &cls; + cls.element_size = size; + cls.instance_size = sizeof(Instance); +} + +void InitMethod(const char* name, Il2CppClass& result, std::initializer_list args = {}) +{ + method = {}; + method.name = name; + method.klass = &reference_class; + method.return_type = &result.byval_arg; + method.parameters_count = static_cast(args.size()); + size_t index = 0; + for (auto* type : args) + parameters[index++] = type; + method.parameters = parameters; + // The adapter checks this invoker; the mock runtime owns invocation itself. + method.invoker_method = reinterpret_cast(1); + methods[0] = &method; + reference_class.methods = methods; + reference_class.method_count = 1; + receiver.object.klass = &reference_class; + returned = nullptr; + managed_exception = use_override = null_unbox = false; + seen_arguments = {}; +} + +template void ReturnBox(Il2CppClass& cls, T value) +{ + static_assert(sizeof(T) <= sizeof(box.data)); + box.object.klass = &cls; + std::memcpy(box.data, &value, sizeof(value)); + returned = &box.object; +} + +void InitField(Il2CppClass& type) +{ + field = {}; + field.name = "value"; + field.type = &type.byval_arg; + field.parent = &reference_class; + field.offset = offsetof(Instance, storage); + reference_class.fields = &field; + reference_class.field_count = 1; + drop_field_write = false; + receiver.object.klass = &reference_class; + std::memset(receiver.storage, 0, sizeof(receiver.storage)); +} + +size_t FieldSize(FieldInfo* value) +{ + auto* cls = static_cast(value->type->data.dummy); + return cls->byval_arg.valuetype ? cls->element_size : sizeof(Object*); +} +} // namespace + +// Windows imports API functions; macOS resolves API function pointers. +#if _WIN32 +#define API(ret, name, params) extern "C" ret name params +#define END_API +#else +#define API(ret, name, params) name##_t name = +[] params->ret +#define END_API ; +#endif +API(Il2CppClass*, il2cpp_class_from_type, (const Il2CppType* type)) +{ return type ? static_cast(type->data.dummy) : nullptr; } +END_API +API(const Il2CppType*, il2cpp_class_get_type, (Il2CppClass * cls)) +{ return cls ? &cls->byval_arg : nullptr; } +END_API +API(bool, il2cpp_class_is_valuetype, (const Il2CppClass* cls)) +{ return cls && cls->byval_arg.valuetype; } +END_API +API(bool, il2cpp_class_is_enum, (const Il2CppClass* cls)) +{ return cls && cls->enumtype; } +END_API +API(const Il2CppType*, il2cpp_class_enum_basetype, (Il2CppClass * cls)) +{ return cls == &anchor_class ? &int32_class.byval_arg : nullptr; } +END_API +API(int32_t, il2cpp_class_value_size, (Il2CppClass * cls, uint32_t* alignment)) +{ + *alignment = 4; + return static_cast(cls->element_size); +} +END_API +API(int32_t, il2cpp_class_instance_size, (Il2CppClass * cls)) +{ return static_cast(cls->instance_size); } +END_API +API(Il2CppClass*, il2cpp_class_get_parent, (Il2CppClass * cls)) +{ return cls ? cls->parent : nullptr; } +END_API +API(bool, il2cpp_class_is_assignable_from, (Il2CppClass * expected, Il2CppClass* actual)) +{ + for (; actual; actual = actual->parent) + if (actual == expected) + return true; + return false; +} +END_API +API(const MethodInfo*, il2cpp_class_get_methods, (Il2CppClass * cls, void** iterator)) +{ + const auto index = reinterpret_cast(*iterator); + if (index >= cls->method_count) + return nullptr; + *iterator = reinterpret_cast(index + 1); + return cls->methods[index]; +} +END_API +API(const MethodInfo*, il2cpp_object_get_virtual_method, (Object*, const MethodInfo* selected)) +{ + ++virtual_calls; + return use_override && selected == &method ? &override_method : selected; +} +END_API +API(char*, il2cpp_type_get_name, (const Il2CppType* type)) +{ + auto* cls = type ? static_cast(type->data.dummy) : nullptr; + if (!cls) + return nullptr; + const auto name = std::string(cls->namespaze) + "." + cls->name; + auto* copy = static_cast(std::malloc(name.size() + 1)); + Require(copy != nullptr, "fixture type-name allocation"); + std::memcpy(copy, name.c_str(), name.size() + 1); + return copy; +} +END_API +API(void, il2cpp_free, (void* value)) +{ std::free(value); } +END_API +API(void*, il2cpp_object_unbox, (Object * object)) +{ return null_unbox ? nullptr : reinterpret_cast(object)->data; } +END_API +API(Object*, il2cpp_runtime_invoke, (const MethodInfo* selected, void* target, void** args, Il2CppException** error)) +{ + ++invoke_calls; + seen_method = selected; + seen_target = target; + seen_arguments = {}; + for (size_t i = 0; args && i < selected->parameters_count && i < seen_arguments.size(); ++i) { + seen_arguments[i] = args[i]; + if (selected->parameters[i] == &int64_class.byval_arg) + std::memcpy(&seen_int64, args[i], sizeof(seen_int64)); + else if (selected->parameters[i] == &boolean_class.byval_arg) + std::memcpy(&seen_boolean, args[i], sizeof(seen_boolean)); + else if (selected->parameters[i] == &vector_class.byval_arg) + std::memcpy(&seen_vector, args[i], sizeof(seen_vector)); + } + if (managed_exception) + *error = reinterpret_cast(&foreign_object); + return returned; +} +END_API +API(FieldInfo*, il2cpp_class_get_field_from_name, (Il2CppClass * cls, const char* name)) +{ + for (size_t i = 0; i < cls->field_count; ++i) + if (std::strcmp(cls->fields[i].name, name) == 0) + return &cls->fields[i]; + return nullptr; +} +END_API +API(int, il2cpp_field_get_flags, (FieldInfo * value)) +{ return value->type->attrs; } +END_API +API(void, il2cpp_field_get_value, (Object * object, FieldInfo* value, void* output)) +{ + ++field_reads; + std::memcpy(output, reinterpret_cast(object) + value->offset, FieldSize(value)); +} +END_API +API(void, il2cpp_field_set_value, (Object * object, FieldInfo* value, void* input)) +{ + ++field_writes; + seen_field_argument = input; + if (drop_field_write) + return; + auto* destination = reinterpret_cast(object) + value->offset; + if (static_cast(value->type->data.dummy)->byval_arg.valuetype) + std::memcpy(destination, input, FieldSize(value)); + else + std::memcpy(destination, &input, sizeof(Object*)); // Reference itself, not its address. +} +END_API +API(Il2CppGCHandle, il2cpp_gchandle_new, (Object * object, bool pinned)) +{ + Require(!pinned, "adapter must not pin its short-lived roots"); + return NewHandle(object, false); +} +END_API +API(Il2CppGCHandle, il2cpp_gchandle_new_weakref, (Object * object, bool resurrection)) +{ + Require(!resurrection, "weak Unity identities must not track resurrection"); + return NewHandle(object, true); +} +END_API +API(Object*, il2cpp_gchandle_get_target, (Il2CppGCHandle value)) +{ + auto* handle = static_cast(value); + return handle && handle->live ? handle->target : nullptr; +} +END_API +API(void, il2cpp_gchandle_free, (Il2CppGCHandle value)) +{ + auto* handle = static_cast(value); + Require(handle && handle->live, "handle freed twice"); + handle->live = false; + ++handle_frees; +} +END_API + +namespace +{ +void CallCases() +{ + InitMethod("get_Reference", reference_class); + const auto null_reference = Call(&receiver.object, "get_Reference"); + Require(null_reference && !null_reference.value, "successful null reference is known"); + const auto before_missing = invoke_calls; + Require(!Call(&receiver.object, "missing") && invoke_calls == before_missing, + "missing method must not invoke"); + managed_exception = true; + Require(!Call(&receiver.object, "get_Reference"), "managed exception is unknown, not known null"); + managed_exception = false; + returned = &foreign_object; + Require(!Call(&receiver.object, "get_Reference"), "foreign reference result must be rejected"); + + InitMethod("get_Id", int64_class); + ReturnBox(int64_class, int64_t{0}); + const auto zero = Call(&receiver.object, "get_Id"); + Require(zero && zero.value == 0, "valid Int64 zero must remain known"); + ReturnBox(int64_class, int64_t{0x100000007}); + Require(Call(&receiver.object, "get_Id").value == 0x100000007, "Int64 must preserve high bits"); + method.return_type = &int32_class.byval_arg; + const auto before_width = invoke_calls; + Require(!Call(&receiver.object, "get_Id") && invoke_calls == before_width, + "Int32 metadata is not an Int64 getter"); + method.return_type = &int64_class.byval_arg; + ReturnBox(int32_class, int32_t{0}); + Require(!Call(&receiver.object, "get_Id"), "Int32 box is not known Int64 zero"); + returned = nullptr; + Require(!Call(&receiver.object, "get_Id"), "null scalar result is unknown"); + ReturnBox(int64_class, int64_t{0}); + fail_handle = true; + Require(!Call(&receiver.object, "get_Id"), "failed box custody is unknown, not zero"); + fail_handle = false; + null_unbox = true; + Require(!Call(&receiver.object, "get_Id"), "missing unbox data is unknown"); + + InitMethod("get_Eligible", boolean_class); + ReturnBox(boolean_class, false); + const auto known_false = Call(&receiver.object, "get_Eligible"); + Require(known_false && !known_false.value, "valid false must remain known"); + managed_exception = true; + Require(!Call(&receiver.object, "get_Eligible"), "failed eligibility is unknown, not false"); + + InitMethod("get_NormalizedPosition", vector_class); + ReturnBox(vector_class, Vector2{0.25f, 0.75f}); + const auto position = NormalizedPosition(&receiver.object); + Require(position && position.value.x == 0.25f && position.value.y == 0.75f, "exact Vector2 return"); + ReturnBox(foreign_vector_class, Vector2{0.25f, 0.75f}); + Require(!NormalizedPosition(&receiver.object), "same-sized foreign value is not Vector2"); + method.return_type = &foreign_vector_class.byval_arg; + const auto before_foreign = invoke_calls; + Require(!NormalizedPosition(&receiver.object) && invoke_calls == before_foreign, "foreign Vector2 signature"); +} + +void SignatureAndArgumentsCases() +{ + InitMethod("Apply", void_class, {&int64_class.byval_arg, &reference_class.byval_arg, &boolean_class.byval_arg}); + int64_t id = 0x100000009; + bool enabled = false; + Require(Call(&receiver.object, "Apply", + {Scalar(id, "System.Int64"), Ref(&argument), Scalar(enabled, "System.Boolean")}) + .succeeded, + "void success with scalar and reference arguments"); + Require(seen_target == &receiver.object && seen_arguments[0] == &id && seen_int64 == id + && seen_arguments[1] == &argument && seen_arguments[2] == &enabled && !seen_boolean, + "scalar addresses and direct references must retain their distinct ABI"); + const auto before_invalid = invoke_calls; + Require(!Call(&receiver.object, "Apply", + {Scalar(id, "System.Int32"), Ref(&argument), Scalar(enabled, "System.Boolean")}), + "wrong scalar signature"); + Require(!Call(&receiver.object, "Apply", + {Scalar(id, "System.Int64"), Ref(&foreign_object), Scalar(enabled, "System.Boolean")}), + "foreign reference argument"); + Require(invoke_calls == before_invalid, "invalid argument metadata must not invoke"); + Require(Call(&receiver.object, "Apply", + {Scalar(id, "System.Int64"), Ref(nullptr), Scalar(enabled, "System.Boolean")}) + .succeeded, + "null reference argument is valid"); + + InitMethod("get_Reference", reference_class); + Require(Signature(&method, false, {}), "valid instance signature"); + method.is_generic = true; + Require(!Signature(&method, false, {}), "open generic method must be rejected"); + method.is_generic = false; + method.is_inflated = true; + Require(Call(&receiver.object, "get_Reference").succeeded, "closed accessor metadata remains usable"); + method.invoker_method = nullptr; + Require(!Signature(&method, false, {}), "missing invoker must be rejected"); + method.invoker_method = reinterpret_cast(1); + MethodInfo duplicate = method; + methods[1] = &duplicate; + reference_class.method_count = 2; + const auto before_ambiguous = invoke_calls; + Require(!Call(&receiver.object, "get_Reference") && invoke_calls == before_ambiguous, + "ambiguous compatible overloads must not invoke"); + reference_class.method_count = 1; + method.flags = METHOD_ATTRIBUTE_STATIC; + Require(Call(nullptr, "get_Reference", {}, &reference_class).succeeded && !seen_target, + "static null reference success"); + Require(!Call(&receiver.object, "get_Reference"), "static method is not an instance callback"); + + InitMethod("OnValueChangedCallback", void_class, {&vector_class.byval_arg}); + receiver.object.klass = &child_class; // Method is inherited from the base scroller. + override_method = method; + override_method.klass = &child_class; + use_override = true; + const auto before_virtual = virtual_calls; + Require(NotifyPosition(&receiver.object, {0.125f, 0.875f}), "virtual position notification success"); + Require(virtual_calls == before_virtual + 1 && seen_method == &override_method && seen_target == &receiver.object + && seen_vector.x == 0.125f && seen_vector.y == 0.875f, + "notification must use the selected override and copied Vector2 argument"); + override_method.return_type = &boolean_class.byval_arg; + const auto before_bad_override = invoke_calls; + Require(!NotifyPosition(&receiver.object, {}) && invoke_calls == before_bad_override, + "virtual override signature must be requalified"); +} + +void FieldCases() +{ + InitField(boolean_class); + Field flag; + Require(flag.Resolve(&child_class, "value"), "inherited boolean field binding"); + Require(flag.Write(&receiver.object, true) && flag.Write(&receiver.object, false) && !flag.Read(&receiver.object), + "boolean field preserves false and verifies writeback"); + drop_field_write = true; + Require(!flag.Write(&receiver.object, true), "failed scalar writeback must be visible"); + drop_field_write = false; + const auto before_foreign = field_writes; + Require(!flag.Write(&foreign_object, true) && field_writes == before_foreign, "foreign field owner rejected"); + Require(!flag.Resolve(&reference_class, "missing"), "rebinding missing field must clear prior binding"); + + InitField(anchor_class); + Field anchor; + Require(anchor.Resolve(&reference_class, "value", "Digit.Client.Tooltip.TooltipAnchor"), "Int32 enum field"); + Require(anchor.Write(&receiver.object, -1) && anchor.Read(&receiver.object) == -1, "Undefined anchor is valid"); + Require(anchor.Write(&receiver.object, 0) && anchor.Read(&receiver.object) == 0, "zero anchor is valid"); + + InitField(reference_class); + Field reference; + Require(reference.Resolve(&reference_class, "value", "Tests.Reference"), "reference field binding"); + Require(reference.Write(&receiver.object, &argument) && seen_field_argument == &argument + && reference.Read(&receiver.object) == &argument, + "reference write takes the object itself"); + const auto before_bad_reference = field_writes; + Require(!reference.Write(&receiver.object, &foreign_object) && field_writes == before_bad_reference, + "foreign reference field value rejected"); + Require(reference.Write(&receiver.object, nullptr) && !reference.Read(&receiver.object), "null reference write"); + + InitField(vector_class); + Field vector; + Require(vector.Resolve(&reference_class, "value") && vector.Write(&receiver.object, {0.5f, 0.75f}), + "Vector2 field uses value storage"); + const auto observed = vector.Read(&receiver.object); + Require(observed.x == 0.5f && observed.y == 0.75f, "Vector2 field readback"); + InitField(foreign_vector_class); + Require(!vector.Resolve(&reference_class, "value"), "same-sized foreign field is not Vector2"); + InitField(boolean_class); + boolean_class.byval_arg.attrs = FIELD_ATTRIBUTE_STATIC; + Require(!flag.Resolve(&reference_class, "value"), "static field rejected"); + boolean_class.byval_arg.attrs = 0; + field.offset = sizeof(Instance); + Require(!flag.Resolve(&reference_class, "value"), "field beyond object extent rejected"); + field.offset = 0; + Require(!flag.Resolve(&reference_class, "value"), "field overlapping object header rejected"); +} + +void HandleCases() +{ + const auto before = handle_frees; + { + auto first = Handle::Strong(&argument); + Require(first && first.Get() == &argument, "strong handle custody"); + auto moved = std::move(first); + Require(!first && moved.Get() == &argument, "move transfers handle custody"); + auto replacement = Handle::Strong(&receiver.object); + replacement = std::move(moved); + Require(!moved && replacement.Get() == &argument && handle_frees == before + 1, + "move assignment releases old custody exactly once"); + replacement.Reset(); + replacement.Reset(); + } + Require(handle_frees == before + 2, "reset/destruction do not double free"); + { + const auto weak_before = weak_creations; + auto weak = Handle::Weak(&argument); + Require(weak && weak_creations == weak_before + 1, "weak Unity identity handle"); + last_handle->target = nullptr; // Simulated collection of a weak target. + Require(weak && !weak.Get(), "collected weak target remains distinct from allocation failure"); + } + fail_handle = true; + Require(!Handle::Strong(&argument), "failed strong allocation"); + { + Roots roots; + Require(!roots.Keep(nullptr) && roots.healthy, "null reference requires no custody"); + Require(!roots.Keep(&argument) && !roots.healthy, "failed root marks operation unhealthy"); + } + fail_handle = false; + mismatch_handle = true; + const auto before_mismatch = handle_frees; + Require(!Handle::Strong(&argument) && handle_frees == before_mismatch + 1, + "wrong handle target releases acquired handle"); + mismatch_handle = false; + { + Roots roots; + Require(roots.Keep(&argument) == &argument && roots.Keep(&receiver.object) == &receiver.object && roots.healthy, + "operation roots keep successful custody"); + } + Require(handle_creations == handle_frees, "all acquired handles must be released"); +} +} // namespace + +int main() +{ + InitClass(reference_class, "Tests", "Reference", IL2CPP_TYPE_CLASS); + InitClass(child_class, "Tests", "Child", IL2CPP_TYPE_CLASS); + child_class.parent = &reference_class; + InitClass(foreign_class, "Tests", "Foreign", IL2CPP_TYPE_CLASS); + InitClass(boolean_class, "System", "Boolean", IL2CPP_TYPE_BOOLEAN, true, sizeof(bool)); + InitClass(int32_class, "System", "Int32", IL2CPP_TYPE_I4, true, sizeof(int32_t)); + InitClass(int64_class, "System", "Int64", IL2CPP_TYPE_I8, true, sizeof(int64_t)); + InitClass(void_class, "System", "Void", IL2CPP_TYPE_VOID, true); + InitClass(vector_class, "UnityEngine", "Vector2", IL2CPP_TYPE_VALUETYPE, true, sizeof(Vector2)); + InitClass(foreign_vector_class, "Tests", "ForeignVector", IL2CPP_TYPE_VALUETYPE, true, sizeof(Vector2)); + InitClass(anchor_class, "Digit.Client.Tooltip", "TooltipAnchor", IL2CPP_TYPE_VALUETYPE, true, sizeof(int32_t)); + anchor_class.enumtype = true; + argument.klass = &child_class; + foreign_object.klass = &foreign_class; + CallCases(); + SignatureAndArgumentsCases(); + FieldCases(); + HandleCases(); + std::cout << "Alliance Games runtime adapter regressions passed\n"; +} From 8dac56b6bea30d5c0047c1926e2c1cc3bbbdafd5 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 28 Oct 2026 21:36:19 -0500 Subject: [PATCH 2/3] Use portable JSON parsing for fleet sync numbers --- mods/src/patches/parts/sync.cc | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/mods/src/patches/parts/sync.cc b/mods/src/patches/parts/sync.cc index 6f64bdd2c..68483eea6 100644 --- a/mods/src/patches/parts/sync.cc +++ b/mods/src/patches/parts/sync.cc @@ -2483,7 +2483,11 @@ namespace deployed_fleets char buffer[32]; double widened = value; if (const auto [end, ec] = std::to_chars(std::begin(buffer), std::end(buffer), value); ec == std::errc{}) { - std::from_chars(std::begin(buffer), end, widened); + // Floating-point from_chars requires macOS 26; use the existing JSON parser on supported clients. + auto parsed = nlohmann::json::parse(std::begin(buffer), end, nullptr, false); + if (parsed.is_number()) { + return parsed; + } } return widened; } From 622144c29358ea703cb8a058e83855b2bb26edfd Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Wed, 28 Oct 2026 22:25:50 -0500 Subject: [PATCH 3/3] Retry Store CI after artifact service failure