From 3efadedda8d731aad0775d98bb1e9102be71c1c7 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 7 Sep 2026 05:33:45 -0500 Subject: [PATCH 1/5] Fix galaxy warp shortcuts after the system popup closes --- mods/src/patches/parts/hotkeys.cc | 24 +- mods/src/patches/planned_system_warp.cc | 285 ++++++++++++++++++ mods/src/patches/planned_system_warp.h | 8 + .../NavigationInteractionUIViewController.h | 33 +- .../prime/ParentObjectViewerViewController.h | 5 +- mods/src/prime/StarNodeObjectViewerWidget.h | 6 +- 6 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 mods/src/patches/planned_system_warp.cc create mode 100644 mods/src/patches/planned_system_warp.h diff --git a/mods/src/patches/parts/hotkeys.cc b/mods/src/patches/parts/hotkeys.cc index 8428a9da0..92650b5c6 100644 --- a/mods/src/patches/parts/hotkeys.cc +++ b/mods/src/patches/parts/hotkeys.cc @@ -20,6 +20,7 @@ #include "prime/CanvasController.h" #include "prime/ChatManager.h" #include "prime/DeploymentManager.h" +#include "patches/planned_system_warp.h" #include "prime/ElementSelectorViewController.h" #include "prime/FleetBarViewController.h" #include "prime/FleetLocalViewController.h" @@ -1189,7 +1190,21 @@ void ExecuteSpaceAction(FleetBarViewController* fleet_bar) if (has_secondary) { star_node_object_viewer_widget->OnViewButtonActivation(); return; - } else if (has_primary) { + } + auto* sections = Hub::get_SectionManager(); + if (has_primary && sections && sections->CurrentSection == SectionID::Navigation_Galaxy) { + // A hidden galaxy popup can lose its destination. Use a matching planned course instead; + // never fall through to a hidden handler that substitutes the currently viewed galaxy. + auto* visibility = star_node_object_viewer_widget->_visibilityController; + auto* parent = star_node_object_viewer_widget->Parent; + if (!visibility + || (visibility->_state != VisibilityState::Visible && visibility->_state != VisibilityState::Show) + || !parent || !parent->IsShowing) { + TryRequestPlannedSystemWarp(fleet, star_node_object_viewer_widget->Context); + return; + } + } + if (has_primary) { star_node_object_viewer_widget->InitiateWarp(); return; } @@ -1215,6 +1230,13 @@ void ExecuteSpaceAction(FleetBarViewController* fleet_bar) return; } } else { + // Limit destination recovery to galaxy warps; retain the existing in-system action path. + auto* sections = Hub::get_SectionManager(); + if (sections && sections->CurrentSection == SectionID::Navigation_Galaxy + && !navigation_ui_controller->IsSetCourseVisible()) { + TryRequestPlannedSystemWarp(fleet, navigation_ui_controller->CanvasContext); + return; + } navigation_ui_controller->OnSetCourseButtonClick(); return; } diff --git a/mods/src/patches/planned_system_warp.cc b/mods/src/patches/planned_system_warp.cc new file mode 100644 index 000000000..ea8d90005 --- /dev/null +++ b/mods/src/patches/planned_system_warp.cc @@ -0,0 +1,285 @@ +#include "planned_system_warp.h" +#include "errormsg.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int PlannedCourse = 0, NoTargetAction = -1, OrbitAction = 1, ValidPath = 1; +constexpr int GalaxyDepth = 1, SystemDepth = 2, SystemTreeNode = 0; + +bool IsInstanceMethod(const MethodInfo* method, int arguments) +{ + return method && method->methodPointer && !(method->flags & METHOD_ATTRIBUTE_STATIC) + && method->parameters_count == arguments && method->return_type && !method->return_type->byref; +} + +// Check the managed representation before reading a C++ value (including enum underlying types). +template bool MatchesValueType(const Il2CppType* type) +{ + if (!type || type->byref) { + return false; + } + auto* cls = il2cpp_class_from_type(type); + if (!cls || !il2cpp_class_is_valuetype(cls)) { + return false; + } + uint32_t alignment = 0; + if (il2cpp_class_value_size(cls, &alignment) != sizeof(T)) { + return false; + } + const auto* scalar = il2cpp_class_is_enum(cls) ? il2cpp_class_enum_basetype(cls) : type; + if constexpr (std::is_same_v) { + return scalar && scalar->type == IL2CPP_TYPE_BOOLEAN; + } else if constexpr (std::is_same_v) { + return scalar && scalar->type == IL2CPP_TYPE_I4; + } else if constexpr (std::is_same_v) { + return scalar && scalar->type == IL2CPP_TYPE_I8; + } else if constexpr (std::is_same_v) { + return std::strcmp(cls->namespaze, "UnityEngine") == 0 && std::strcmp(cls->name, "Vector3") == 0; + } + return false; +} + +template std::optional Unbox(Il2CppObject* object) +{ + if (!object || !MatchesValueType(il2cpp_class_get_type(il2cpp_object_get_class(object)))) { + return std::nullopt; + } + const auto* value = static_cast(il2cpp_object_unbox(object)); + return value ? std::optional(*value) : std::nullopt; +} + +// Reads occur only on action edges. Box field values so a changed field size cannot overwrite T. +Il2CppObject* ReadMember(void* object, const char* name, bool field = false) +{ + if (!object) { + return nullptr; + } + auto* instance = static_cast(object); + auto* cls = il2cpp_object_get_class(instance); + if (field) { + auto* member = il2cpp_class_get_field_from_name(cls, name); + return member ? il2cpp_field_get_value_object(member, instance) : nullptr; + } + auto* property = il2cpp_class_get_property_from_name(cls, name); + auto* getter = property ? il2cpp_property_get_get_method(const_cast(property)) : nullptr; + if (!IsInstanceMethod(getter, 0)) { + return nullptr; + } + getter = il2cpp_object_get_virtual_method(instance, getter); + if (!IsInstanceMethod(getter, 0)) { + return nullptr; + } + Il2CppException* exception = nullptr; + auto* result = il2cpp_runtime_invoke(getter, instance, nullptr, &exception); + return exception ? nullptr : result; +} + +template std::optional ReadValue(void* object, const char* name, bool field = false) +{ return Unbox(ReadMember(object, name, field)); } + +Il2CppObject* ReadObject(void* object, const char* name, bool field = false) +{ + auto* result = ReadMember(object, name, field); + return result && !il2cpp_class_is_valuetype(il2cpp_object_get_class(result)) ? result : nullptr; +} + +bool NativeCanNavigate(void* navigation, std::int64_t fleet_id) +{ + auto* controller = ReadObject(navigation, "NavigationInteractionUIViewController"); + auto* helper = ReadObject(controller, "_fleetNavHelper", true); + auto* method = + helper ? IL2CppClassHelper(il2cpp_object_get_class(helper)).GetMethodInfo("NavigationValidateInput", 2) : nullptr; + if (!IsInstanceMethod(method, 2) || !MatchesValueType(method->return_type) + || !MatchesValueType(method->parameters[0]) || !method->parameters[1]->byref) { + return false; + } + auto* message_class = il2cpp_class_from_type(method->parameters[1]); + if (!message_class || il2cpp_class_is_valuetype(message_class) + || std::strcmp(message_class->namespaze, "Digit.Client.UI") != 0 + || std::strcmp(message_class->name, "LocaleTextContext") != 0) { + return false; + } + Il2CppObject* message = nullptr; + void* args[] = {&fleet_id, &message}; + Il2CppException* exception = nullptr; + auto* result = il2cpp_runtime_invoke(method, helper, args, &exception); + return !exception && Unbox(result) == true; +} + +bool MatchesRetainedPosition(void* navigation, void* context, void* target) +{ + auto world_position = ReadValue(context, "Position", true); + auto galaxy_position = ReadValue(target, "GalaxyPosition"); + auto* animator = ReadObject(navigation, "_transitionAnimator", true); + if (!world_position || !galaxy_position || !animator) { + return false; + } + auto helper = IL2CppClassHelper(il2cpp_object_get_class(animator)); + auto* convert = helper.GetMethodInfo("WorldToDepth", 2); + if (!IsInstanceMethod(convert, 2) || !MatchesValueType(convert->return_type) + || !MatchesValueType(convert->parameters[0]) || !MatchesValueType(convert->parameters[1])) { + return false; + } + int galaxy_depth = GalaxyDepth; + void* args[] = {&*world_position, &galaxy_depth}; + Il2CppException* exception = nullptr; + auto* result = il2cpp_runtime_invoke(convert, animator, args, &exception); + if (exception) { + return false; + } + const auto converted = Unbox(result); + if (!converted) { + return false; + } + const auto position = *converted; + // Compare in galaxy coordinates, allowing only floating-point projection roundoff. + const auto matches = [](float a, float b) { return std::isfinite(a) && std::isfinite(b) && std::abs(a - b) < 0.01f; }; + return matches(position.x, galaxy_position->x) && matches(position.y, galaxy_position->y) + && matches(position.z, galaxy_position->z); +} + +struct OptionalPosition { + bool has_value = true; + std::byte padding[3]{}; + Vector3 value{}; +}; +static_assert(sizeof(OptionalPosition) == 16 && offsetof(OptionalPosition, value) == 4); + +bool MatchesMoveSignature(const MethodInfo* method) +{ + if (!IsInstanceMethod(method, 4) || method->return_type->type != IL2CPP_TYPE_VOID || method->parameters[0]->byref + || !MatchesValueType(method->parameters[1]) + || !MatchesValueType(method->parameters[2]) || !MatchesValueType(method->parameters[3])) { + return false; + } + // runtime_invoke handles the platform ABI; verify the actual Nullable payload layout. + auto* position_class = il2cpp_class_from_type(method->parameters[0]); + if (!position_class || !il2cpp_class_is_valuetype(position_class) + || std::strcmp(position_class->namespaze, "System") != 0 + || std::strcmp(position_class->name, "Nullable`1") != 0) { + return false; + } + uint32_t alignment = 0; + auto* has_value = il2cpp_class_get_field_from_name(position_class, "hasValue"); + auto* value = il2cpp_class_get_field_from_name(position_class, "value"); + return il2cpp_class_value_size(position_class, &alignment) == sizeof(OptionalPosition) && has_value && value + && MatchesValueType(has_value->type) && MatchesValueType(value->type) + && has_value->offset == sizeof(Il2CppObject) + && value->offset == sizeof(Il2CppObject) + offsetof(OptionalPosition, value); +} +} // namespace + +bool TryRequestPlannedSystemWarp(FleetPlayerData* fleet, NavigationInteractionUIContext* context) +{ + auto* sections = Hub::get_SectionManager(); + if (!fleet || !context || !sections || sections->CurrentSection != SectionID::Navigation_Galaxy) { + return false; + } + static auto deployment_class = il2cpp_get_class_helper("Assembly-CSharp", "", "DeploymentManager"); + static auto navigation_class = + il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "NavigationManager"); + if (!deployment_class.isValidHelper() || !navigation_class.isValidHelper()) { + return false; + } + static auto* get_course = deployment_class.GetMethodInfo("GetPlannedCourse", 1); + static auto* move = navigation_class.GetMethodInfo("OnMoveFleetAction", 4); + static const bool move_signature_valid = MatchesMoveSignature(move); + if (!IsInstanceMethod(get_course, 1) || !MatchesValueType(get_course->parameters[0]) + || !move_signature_valid) { + spdlog::warn("[Hotkeys] planned system warp unavailable: native method or argument layout mismatch"); + return false; + } + auto* deployment = DeploymentManger::Instance(); + auto* navigation_section = NavigationSectionManager::Instance(); + auto* navigation = navigation_section ? navigation_section->SNavigationManager : nullptr; + if (!deployment || !navigation) { + return false; + } + + auto fleet_id = static_cast(fleet->Id); + if (!NativeCanNavigate(navigation, fleet_id)) { + return false; + } + void* course_args[] = {&fleet_id}; + Il2CppException* exception = nullptr; + auto* course = il2cpp_runtime_invoke(get_course, deployment, course_args, &exception); + if (exception || !course) { + return false; + } + // Deployed courses can have DeployedFleet without PlayerFleet. The native ID getter covers both. + const auto course_fleet_id = ReadValue(course, "FleetID"); + if (course_fleet_id != fleet_id) { + return false; + } + // A preview without a backing Course reports TargetAction::Null (-1). The native movement handler + // assigns the movement action; accept that preview as well as Orbit (1), but reject a missing read. + // Special travel keeps its own native popup flow. + const auto type = ReadValue(course, "Type"); + const auto action = ReadValue(course, "TargetAction"); + const auto in_use = ReadValue(course, "IsInUse"); + const auto warp = ReadValue(course, "IsWarpCourse"); + const auto path_result = ReadValue(ReadObject(course, "PathFinderResult"), "Result"); + if (type != PlannedCourse || (action != NoTargetAction && action != OrbitAction) || in_use != false || warp != true + || ReadValue(course, "IsRecallCourse") != false || ReadValue(course, "IsWormholeCourse") != false + || ReadValue(course, "HasToll") != false || path_result != ValidPath) { + return false; + } + auto* target = ReadObject(course, "TargetNode"); + auto* target_address = ReadObject(target, "Address"); + auto* start_address = ReadObject(ReadObject(course, "StartNode"), "Address"); + auto* fleet_address = ReadObject(fleet, "Address"); + const auto start_system = ReadValue(start_address, "System"); + const auto start_instance = ReadValue(start_address, "Instance"); + if (ReadValue(target_address, "NodeDepth") != SystemDepth || !start_system || *start_system <= 0 + || !start_instance || start_system != ReadValue(fleet_address, "System") + || start_instance != ReadValue(fleet_address, "Instance")) { + return false; + } + const auto position = ReadValue(target, "SystemPosition"); + auto node_id = ReadValue(target, "ID"); + // Dismissal can also discard TreeNode. UpdatePosition and UpdateWithCourse retain the last + // selected world position and path result, so bind the course to both before using its destination. + // Position changes before asynchronous planning finishes; matching the path alone is insufficient. + auto* context_path = ReadObject(context, "PathFindResult", true); + if (!context_path || context_path != ReadObject(course, "PathFinderResult") + || !MatchesRetainedPosition(navigation, context, target)) { + return false; + } + auto* selected_node = ReadObject(context, "TreeNode"); + const auto selected_id = ReadValue(selected_node, "ID"); + if (selected_node + && (!selected_id || selected_id != node_id || ReadValue(selected_node, "Type") != SystemTreeNode)) { + return false; + } + if (!position || !node_id || *node_id <= 0 || !std::isfinite(position->x) || !std::isfinite(position->y) + || !std::isfinite(position->z)) { + return false; + } + + // Match StarNodeObjectViewerWidget.GetPositionAndTargetID: final course SystemPosition + node ID. + // A cleared POI would otherwise pass -1 and NavigationManager substitutes the viewed galaxy node. + OptionalPosition destination{.value = *position}; + bool force_move = false; + void* args[] = {&destination, &*node_id, &fleet_id, &force_move}; + exception = nullptr; + il2cpp_runtime_invoke(move, navigation, args, &exception); + if (exception) { + spdlog::warn("[Hotkeys] planned system warp native movement handler raised an exception"); + return false; + } + spdlog::debug("[Hotkeys] requested planned system warp; node={} fleet_state={}", *node_id, + static_cast(fleet->CurrentState)); + return true; +} diff --git a/mods/src/patches/planned_system_warp.h b/mods/src/patches/planned_system_warp.h new file mode 100644 index 000000000..7bea51020 --- /dev/null +++ b/mods/src/patches/planned_system_warp.h @@ -0,0 +1,8 @@ +#pragma once + +struct FleetPlayerData; +struct NavigationInteractionUIContext; + +// Resume the selected fleet's unused ordinary system course after its popup has closed. +// The native movement handler retains ownership of confirmations and network callbacks. +bool TryRequestPlannedSystemWarp(FleetPlayerData* fleet, NavigationInteractionUIContext* context); diff --git a/mods/src/prime/NavigationInteractionUIViewController.h b/mods/src/prime/NavigationInteractionUIViewController.h index 405170ed7..284cfb222 100644 --- a/mods/src/prime/NavigationInteractionUIViewController.h +++ b/mods/src/prime/NavigationInteractionUIViewController.h @@ -1,9 +1,40 @@ #pragma once +#include "NavigationInteractionUIContext.h" +#include "ViewController.h" +#include "VisibilityController.h" #include -struct NavigationInteractionUIViewController { +struct NavigationInteractionUIViewController + : ViewController { public: + bool IsSetCourseVisible() + { + static auto widget_class = il2cpp_get_class_helper("Assembly-CSharp", "Digit.Prime.Navigation", "SetCourseWidget"); + if (!get_class_helper().isValidHelper() || !widget_class.isValidHelper()) { + return false; + } + static auto widget_field = get_class_helper().GetField("_setCourseWidget"); + static auto visibility_field = widget_class.GetField("_visibilityController"); + static auto active_property = widget_class.GetProperty("isActiveAndEnabled"); + if (!widget_field.isValidHelper() || !visibility_field.isValidHelper()) { + return false; + } + + auto* widget = *reinterpret_cast(reinterpret_cast(this) + widget_field.offset()); + if (!widget) { + return false; + } + auto* visibility = + *reinterpret_cast(reinterpret_cast(widget) + visibility_field.offset()); + if (!visibility + || (visibility->_state != VisibilityState::Visible && visibility->_state != VisibilityState::Show)) { + return false; + } + const auto* active = active_property.Get(widget); + return active && *active; + } + void OnSetCourseButtonClick() { static auto OnSetCourseButtonClick = diff --git a/mods/src/prime/ParentObjectViewerViewController.h b/mods/src/prime/ParentObjectViewerViewController.h index 11ae6a130..030985b0a 100644 --- a/mods/src/prime/ParentObjectViewerViewController.h +++ b/mods/src/prime/ParentObjectViewerViewController.h @@ -54,6 +54,7 @@ class ParentObjectViewerViewController : public ViewController(this); + const auto* showing = field.Get(this); + return showing && *showing; } -}; \ No newline at end of file +}; diff --git a/mods/src/prime/StarNodeObjectViewerWidget.h b/mods/src/prime/StarNodeObjectViewerWidget.h index 889385437..43f2ea507 100644 --- a/mods/src/prime/StarNodeObjectViewerWidget.h +++ b/mods/src/prime/StarNodeObjectViewerWidget.h @@ -2,9 +2,9 @@ #include -#include "Widget.h" +#include "ObjectViewerBaseWidget.h" -struct StarNodeObjectViewerWidget : public Widget { +struct StarNodeObjectViewerWidget : public ObjectViewerBaseWidget { public: void OnViewButtonActivation() { @@ -27,5 +27,5 @@ struct StarNodeObjectViewerWidget : public Widget; - friend struct Widget; + friend class ObjectViewerBaseWidget; }; From fd15fb53245172579e570af433c892074434d1e5 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 20:44:10 -0500 Subject: [PATCH 2/5] Add shared IL2CPP runtime boundary helpers with contract tests --- .github/workflows/ci.yaml | 12 +++ mods/src/il2cpp/il2cpp_helper.h | 6 +- mods/src/il2cpp/runtime.h | 59 +++++++++++++++ tests/il2cpp_runtime.cc | 127 ++++++++++++++++++++++++++++++++ xmake.lua | 11 +++ 5 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 mods/src/il2cpp/runtime.h create mode 100644 tests/il2cpp_runtime.cc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 61cf42826..ff6de6ff7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -189,6 +189,13 @@ jobs: shell: pwsh run: sccache --zero-stats + - name: Test IL2CPP runtime helpers + run: | + xmake build -y il2cpp-runtime-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run il2cpp-runtime-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build run: xmake build -y stfc-community-mod @@ -469,6 +476,11 @@ jobs: shell: bash run: sccache --zero-stats + - name: Test IL2CPP runtime helpers + run: | + xmake build -y il2cpp-runtime-tests + xmake run il2cpp-runtime-tests + - name: Build # -D keeps the existing XMake compiler-cache diagnostics too. run: xmake -y -D diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h index 3d4ff6e3f..19708b2cd 100644 --- a/mods/src/il2cpp/il2cpp_helper.h +++ b/mods/src/il2cpp/il2cpp_helper.h @@ -337,10 +337,10 @@ class IL2CppClassHelper inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) { auto domain = il2cpp_domain_get(); - auto assemblyT = il2cpp_domain_assembly_open(domain, assembly); - auto image = il2cpp_assembly_get_image(assemblyT); + auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; + auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; - auto cls = il2cpp_class_from_name(image, namespacez, name); + auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; return IL2CppClassHelper{cls}; } diff --git a/mods/src/il2cpp/runtime.h b/mods/src/il2cpp/runtime.h new file mode 100644 index 000000000..8617cedb1 --- /dev/null +++ b/mods/src/il2cpp/runtime.h @@ -0,0 +1,59 @@ +#pragma once + +#include "il2cpp_helper.h" +#include + +// Mechanics shared by optional runtime features. Signature/overload selection, +// argument storage and feature-specific failure policy remain with the caller. +namespace Il2CppRuntime +{ +inline Il2CppClass* Class(const char* assembly, const char* ns, const char* name) +{ return il2cpp_get_class_helper(assembly, ns, name).get_cls(); } + +inline const MethodInfo* Method(Il2CppClass* cls, const char* name, int count) +{ return IL2CppClassHelper(cls).GetMethodInfo(name, count); } + +inline bool Type(const Il2CppType* type, int expected) +{ return type && !type->byref && type->type == expected; } + +inline 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); +} + +inline 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; +} + +// The method and argument ABI must already be established by the caller. +// IL2CPP takes references directly, but value/byref arguments as addresses. +// A null target is valid for static methods. Success is independent of whether +// the return value is null (including void methods). Outputs change on success only. +inline bool TryInvoke(const MethodInfo* method, void* target, void** args, Il2CppObject** result = nullptr) +{ + if (!method) + return false; + Il2CppException* exception = nullptr; + auto* value = il2cpp_runtime_invoke(method, target, args, &exception); + if (exception) + return false; + if (result) + *result = value; + return true; +} + +inline bool TryBoolean(Il2CppObject* boxed, bool& value) +{ + if (!boxed || !boxed->klass || !Type(il2cpp_class_get_type(boxed->klass), IL2CPP_TYPE_BOOLEAN)) + return false; + auto* data = static_cast(il2cpp_object_unbox(boxed)); + if (!data) + return false; + value = *data; + return true; +} +} // namespace Il2CppRuntime diff --git a/tests/il2cpp_runtime.cc b/tests/il2cpp_runtime.cc new file mode 100644 index 000000000..1b7113340 --- /dev/null +++ b/tests/il2cpp_runtime.cc @@ -0,0 +1,127 @@ +// Production helpers with a controlled IL2CPP boundary, on Windows and macOS. +#include +#if _WIN32 +#undef IL2CPP_IMPORT +#define IL2CPP_IMPORT +#endif +#include "il2cpp/runtime.h" +#include +#include +#include + +namespace +{ +Il2CppClass klass; +Il2CppType type{}; +Il2CppObject object{}; +MethodInfo method{}; +int stage = 3, calls = 0; +bool fail = false, boxed = false, null_result = false; +void* seen_target = nullptr; +void** seen_args = nullptr; +} // namespace +#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(Il2CppDomain*, il2cpp_domain_get, ()) +{ return stage >= 0 ? reinterpret_cast(&object) : nullptr; } +END_API +API(const Il2CppAssembly*, il2cpp_domain_assembly_open, (Il2CppDomain * domain, const char*)) +{ + if (!domain) + std::abort(); + return stage >= 1 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(const Il2CppImage*, il2cpp_assembly_get_image, (const Il2CppAssembly* assembly)) +{ + if (!assembly) + std::abort(); + return stage >= 2 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage* image, const char*, const char*)) +{ + if (!image) + std::abort(); + return stage >= 3 ? &klass : nullptr; +} +END_API +API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * cls, const char*, int)) +{ + if (!cls) + std::abort(); + return &method; +} +END_API +API(const Il2CppType*, il2cpp_class_get_type, (Il2CppClass*)) +{ return &type; } +END_API +API(void*, il2cpp_object_unbox, (Il2CppObject*)) +{ return &boxed; } +END_API +API(Il2CppObject*, il2cpp_runtime_invoke, (const MethodInfo*, void* target, void** args, Il2CppException** error)) +{ + ++calls; + seen_target = target; + seen_args = args; + if (fail) + *error = reinterpret_cast(&object); + return null_result ? nullptr : &object; +} +END_API +void Require(bool condition) +{ + if (!condition) + throw std::runtime_error("runtime helper regression"); +} +int main() +{ + for (stage = -1; stage < 3; ++stage) + Require(!Il2CppRuntime::Class("Assembly", "Namespace", "Class")); + Require(Il2CppRuntime::Class("Assembly", "Namespace", "Class") == &klass); + Require(!Il2CppRuntime::Method(nullptr, "Method", 0)); + Require(Il2CppRuntime::Method(&klass, "Method", 0) == &method); + + Il2CppObject* result = &object; + Require(!Il2CppRuntime::TryInvoke(nullptr, nullptr, nullptr, &result) && calls == 0 && result == &object); + bool value = true; + void* args[] = {&value, &object, nullptr}; + Require(Il2CppRuntime::TryInvoke(&method, &object, args, &result)); + Require(seen_target == &object && seen_args == args && seen_args[0] == &value && seen_args[1] == &object); + // Static calls, null reference/void returns and exceptions remain distinct. + null_result = true; + Require(Il2CppRuntime::TryInvoke(&method, nullptr, args, &result) && !result && !seen_target); + result = &object; + fail = true; + Require(!Il2CppRuntime::TryInvoke(&method, nullptr, args, &result) && result == &object); + + object.klass = &klass; + type.type = IL2CPP_TYPE_BOOLEAN; + Require(Il2CppRuntime::TryBoolean(&object, value) && !value); + boxed = true; + Require(Il2CppRuntime::TryBoolean(&object, value) && value); + type.type = IL2CPP_TYPE_I4; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + Require(!Il2CppRuntime::TryBoolean(nullptr, value)); + type.type = IL2CPP_TYPE_CLASS; + Require(Il2CppRuntime::Reference(&type)); + type.byref = true; + Require(!Il2CppRuntime::Reference(&type)); + type.byref = false; + type.type = IL2CPP_TYPE_VOID; + method.methodPointer = reinterpret_cast(1); + method.invoker_method = reinterpret_cast(1); + method.return_type = &type; + Require(Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + method.flags = METHOD_ATTRIBUTE_STATIC; + Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + method.flags = 0; + method.has_full_generic_sharing_signature = true; + Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + std::cout << "IL2CPP runtime helper regressions passed\n"; +} diff --git a/xmake.lua b/xmake.lua index 9b25acbfb..ac08005a2 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,3 +26,14 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") + +target("il2cpp-runtime-tests") + set_kind("binary") + set_default(false) + add_files("tests/il2cpp_runtime.cc") + add_includedirs("mods/src") + add_packages("libil2cpp", "eastl") + set_exceptions("cxx") + if is_plat("windows") then + add_linkdirs("mods/src/il2cpp") + end From 1ab6141fd72f749c1fc3b5a5e3b8f88b08b88911 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 20:59:24 -0500 Subject: [PATCH 3/5] Reuse shared invocation for planned-warp virtual getters --- mods/src/patches/planned_system_warp.cc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mods/src/patches/planned_system_warp.cc b/mods/src/patches/planned_system_warp.cc index ea8d90005..7451e7dc5 100644 --- a/mods/src/patches/planned_system_warp.cc +++ b/mods/src/patches/planned_system_warp.cc @@ -1,3 +1,4 @@ +#include #include "planned_system_warp.h" #include "errormsg.h" @@ -81,9 +82,8 @@ Il2CppObject* ReadMember(void* object, const char* name, bool field = false) if (!IsInstanceMethod(getter, 0)) { return nullptr; } - Il2CppException* exception = nullptr; - auto* result = il2cpp_runtime_invoke(getter, instance, nullptr, &exception); - return exception ? nullptr : result; + Il2CppObject* result = nullptr; + return Il2CppRuntime::TryInvoke(getter, instance, nullptr, &result) ? result : nullptr; } template std::optional ReadValue(void* object, const char* name, bool field = false) From 733997cfe2cd34c9203b61cff755046ca9937643 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 22:27:13 -0500 Subject: [PATCH 4/5] Narrow shared invocation helpers around existing loading screens --- mods/src/il2cpp/il2cpp_helper.h | 6 +- mods/src/il2cpp/runtime.h | 27 ++----- .../src/patches/parts/loading_screen_common.h | 16 ++--- tests/il2cpp_runtime.cc | 70 ++++--------------- 4 files changed, 28 insertions(+), 91 deletions(-) diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h index 19708b2cd..3d4ff6e3f 100644 --- a/mods/src/il2cpp/il2cpp_helper.h +++ b/mods/src/il2cpp/il2cpp_helper.h @@ -337,10 +337,10 @@ class IL2CppClassHelper inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) { auto domain = il2cpp_domain_get(); - auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; - auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; + auto assemblyT = il2cpp_domain_assembly_open(domain, assembly); + auto image = il2cpp_assembly_get_image(assemblyT); - auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; + auto cls = il2cpp_class_from_name(image, namespacez, name); return IL2CppClassHelper{cls}; } diff --git a/mods/src/il2cpp/runtime.h b/mods/src/il2cpp/runtime.h index 8617cedb1..b3043673e 100644 --- a/mods/src/il2cpp/runtime.h +++ b/mods/src/il2cpp/runtime.h @@ -7,28 +7,6 @@ // argument storage and feature-specific failure policy remain with the caller. namespace Il2CppRuntime { -inline Il2CppClass* Class(const char* assembly, const char* ns, const char* name) -{ return il2cpp_get_class_helper(assembly, ns, name).get_cls(); } - -inline const MethodInfo* Method(Il2CppClass* cls, const char* name, int count) -{ return IL2CppClassHelper(cls).GetMethodInfo(name, count); } - -inline bool Type(const Il2CppType* type, int expected) -{ return type && !type->byref && type->type == expected; } - -inline 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); -} - -inline 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; -} - // The method and argument ABI must already be established by the caller. // IL2CPP takes references directly, but value/byref arguments as addresses. // A null target is valid for static methods. Success is independent of whether @@ -48,7 +26,10 @@ inline bool TryInvoke(const MethodInfo* method, void* target, void** args, Il2Cp inline bool TryBoolean(Il2CppObject* boxed, bool& value) { - if (!boxed || !boxed->klass || !Type(il2cpp_class_get_type(boxed->klass), IL2CPP_TYPE_BOOLEAN)) + if (!boxed || !boxed->klass) + return false; + const auto* type = il2cpp_class_get_type(boxed->klass); + if (!type || type->byref || type->type != IL2CPP_TYPE_BOOLEAN) return false; auto* data = static_cast(il2cpp_object_unbox(boxed)); if (!data) diff --git a/mods/src/patches/parts/loading_screen_common.h b/mods/src/patches/parts/loading_screen_common.h index 071b63976..4f78890c7 100644 --- a/mods/src/patches/parts/loading_screen_common.h +++ b/mods/src/patches/parts/loading_screen_common.h @@ -3,7 +3,7 @@ #include "config.h" #include "errormsg.h" -#include +#include #include #include @@ -28,9 +28,8 @@ struct FakeColor { float r, g, b, a; }; inline Il2CppObject* InvokeRuntime(const MethodInfo* method, void* target, void** args, const char* name) { if (!method) return nullptr; - Il2CppException* exception = nullptr; - Il2CppObject* result = il2cpp_runtime_invoke(method, target, args, &exception); - if (exception) { + Il2CppObject* result = nullptr; + if (!Il2CppRuntime::TryInvoke(method, target, args, &result)) { spdlog::warn("[LS] {} invocation failed", name); return nullptr; } @@ -40,9 +39,7 @@ inline Il2CppObject* InvokeRuntime(const MethodInfo* method, void* target, void* inline bool InvokeVoid(const MethodInfo* method, void* target, void** args, const char* name) { if (!method) return false; - Il2CppException* exception = nullptr; - il2cpp_runtime_invoke(method, target, args, &exception); - if (exception) { + if (!Il2CppRuntime::TryInvoke(method, target, args)) { spdlog::warn("[LS] {} invocation failed", name); return false; } @@ -52,9 +49,8 @@ inline bool InvokeVoid(const MethodInfo* method, void* target, void** args, cons inline bool InvokeBool(const MethodInfo* method, void* target, void** args, const char* name) { Il2CppObject* result = InvokeRuntime(method, target, args, name); - if (!result) return false; - void* value = il2cpp_object_unbox(result); - return value ? *reinterpret_cast(value) : false; + bool value = false; + return Il2CppRuntime::TryBoolean(result, value) && value; } inline int32_t InvokeInt32(const MethodInfo* method, void* target, int32_t fallback, const char* name) diff --git a/tests/il2cpp_runtime.cc b/tests/il2cpp_runtime.cc index 1b7113340..b098a2bbc 100644 --- a/tests/il2cpp_runtime.cc +++ b/tests/il2cpp_runtime.cc @@ -15,8 +15,8 @@ Il2CppClass klass; Il2CppType type{}; Il2CppObject object{}; MethodInfo method{}; -int stage = 3, calls = 0; -bool fail = false, boxed = false, null_result = false; +int calls = 0; +bool fail = false, boxed = false, null_result = false, null_type = false, null_unbox = false; void* seen_target = nullptr; void** seen_args = nullptr; } // namespace @@ -27,42 +27,11 @@ void** seen_args = nullptr; #define API(ret, name, params) name##_t name = +[] params->ret #define END_API ; #endif -API(Il2CppDomain*, il2cpp_domain_get, ()) -{ return stage >= 0 ? reinterpret_cast(&object) : nullptr; } -END_API -API(const Il2CppAssembly*, il2cpp_domain_assembly_open, (Il2CppDomain * domain, const char*)) -{ - if (!domain) - std::abort(); - return stage >= 1 ? reinterpret_cast(&object) : nullptr; -} -END_API -API(const Il2CppImage*, il2cpp_assembly_get_image, (const Il2CppAssembly* assembly)) -{ - if (!assembly) - std::abort(); - return stage >= 2 ? reinterpret_cast(&object) : nullptr; -} -END_API -API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage* image, const char*, const char*)) -{ - if (!image) - std::abort(); - return stage >= 3 ? &klass : nullptr; -} -END_API -API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * cls, const char*, int)) -{ - if (!cls) - std::abort(); - return &method; -} -END_API API(const Il2CppType*, il2cpp_class_get_type, (Il2CppClass*)) -{ return &type; } +{ return null_type ? nullptr : &type; } END_API API(void*, il2cpp_object_unbox, (Il2CppObject*)) -{ return &boxed; } +{ return null_unbox ? nullptr : &boxed; } END_API API(Il2CppObject*, il2cpp_runtime_invoke, (const MethodInfo*, void* target, void** args, Il2CppException** error)) { @@ -81,12 +50,6 @@ void Require(bool condition) } int main() { - for (stage = -1; stage < 3; ++stage) - Require(!Il2CppRuntime::Class("Assembly", "Namespace", "Class")); - Require(Il2CppRuntime::Class("Assembly", "Namespace", "Class") == &klass); - Require(!Il2CppRuntime::Method(nullptr, "Method", 0)); - Require(Il2CppRuntime::Method(&klass, "Method", 0) == &method); - Il2CppObject* result = &object; Require(!Il2CppRuntime::TryInvoke(nullptr, nullptr, nullptr, &result) && calls == 0 && result == &object); bool value = true; @@ -108,20 +71,17 @@ int main() type.type = IL2CPP_TYPE_I4; Require(!Il2CppRuntime::TryBoolean(&object, value) && value); Require(!Il2CppRuntime::TryBoolean(nullptr, value)); - type.type = IL2CPP_TYPE_CLASS; - Require(Il2CppRuntime::Reference(&type)); + type.type = IL2CPP_TYPE_BOOLEAN; type.byref = true; - Require(!Il2CppRuntime::Reference(&type)); - type.byref = false; - type.type = IL2CPP_TYPE_VOID; - method.methodPointer = reinterpret_cast(1); - method.invoker_method = reinterpret_cast(1); - method.return_type = &type; - Require(Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); - method.flags = METHOD_ATTRIBUTE_STATIC; - Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); - method.flags = 0; - method.has_full_generic_sharing_signature = true; - Require(!Il2CppRuntime::Instance(&method, 0, IL2CPP_TYPE_VOID)); + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + type.byref = false; + null_type = true; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + null_type = false; + null_unbox = true; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); + null_unbox = false; + object.klass = nullptr; + Require(!Il2CppRuntime::TryBoolean(&object, value) && value); std::cout << "IL2CPP runtime helper regressions passed\n"; } From d6c10b7d0d0988d4438c488cfa9354524d820106 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Mon, 21 Sep 2026 22:28:55 -0500 Subject: [PATCH 5/5] Guard existing IL2CPP class lookup when metadata is unavailable --- .github/workflows/ci.yaml | 12 ++++++ mods/src/il2cpp/il2cpp_helper.h | 6 +-- tests/il2cpp_class_lookup.cc | 71 +++++++++++++++++++++++++++++++++ xmake.lua | 11 +++++ 4 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/il2cpp_class_lookup.cc diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 61cf42826..025c4356b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -189,6 +189,13 @@ jobs: shell: pwsh run: sccache --zero-stats + - name: Test IL2CPP class lookup + run: | + xmake build -y il2cpp-class-lookup-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + xmake run il2cpp-class-lookup-tests + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build run: xmake build -y stfc-community-mod @@ -469,6 +476,11 @@ jobs: shell: bash run: sccache --zero-stats + - name: Test IL2CPP class lookup + run: | + xmake build -y il2cpp-class-lookup-tests + xmake run il2cpp-class-lookup-tests + - name: Build # -D keeps the existing XMake compiler-cache diagnostics too. run: xmake -y -D diff --git a/mods/src/il2cpp/il2cpp_helper.h b/mods/src/il2cpp/il2cpp_helper.h index 3d4ff6e3f..19708b2cd 100644 --- a/mods/src/il2cpp/il2cpp_helper.h +++ b/mods/src/il2cpp/il2cpp_helper.h @@ -337,10 +337,10 @@ class IL2CppClassHelper inline IL2CppClassHelper il2cpp_get_class_helper_impl(const char* assembly, const char* namespacez, const char* name) { auto domain = il2cpp_domain_get(); - auto assemblyT = il2cpp_domain_assembly_open(domain, assembly); - auto image = il2cpp_assembly_get_image(assemblyT); + auto assemblyT = domain ? il2cpp_domain_assembly_open(domain, assembly) : nullptr; + auto image = assemblyT ? il2cpp_assembly_get_image(assemblyT) : nullptr; - auto cls = il2cpp_class_from_name(image, namespacez, name); + auto cls = image ? il2cpp_class_from_name(image, namespacez, name) : nullptr; return IL2CppClassHelper{cls}; } diff --git a/tests/il2cpp_class_lookup.cc b/tests/il2cpp_class_lookup.cc new file mode 100644 index 000000000..0d471ea00 --- /dev/null +++ b/tests/il2cpp_class_lookup.cc @@ -0,0 +1,71 @@ +// Production helpers with a controlled IL2CPP boundary, on Windows and macOS. +#include +#if _WIN32 +#undef IL2CPP_IMPORT +#define IL2CPP_IMPORT +#endif +#include "il2cpp/il2cpp_helper.h" +#include +#include +#include + +namespace +{ +Il2CppClass klass; +Il2CppObject object{}; +MethodInfo method{}; +int stage = 3; +} // namespace +#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(Il2CppDomain*, il2cpp_domain_get, ()) +{ return stage >= 0 ? reinterpret_cast(&object) : nullptr; } +END_API +API(const Il2CppAssembly*, il2cpp_domain_assembly_open, (Il2CppDomain * domain, const char*)) +{ + if (!domain) + std::abort(); + return stage >= 1 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(const Il2CppImage*, il2cpp_assembly_get_image, (const Il2CppAssembly* assembly)) +{ + if (!assembly) + std::abort(); + return stage >= 2 ? reinterpret_cast(&object) : nullptr; +} +END_API +API(Il2CppClass*, il2cpp_class_from_name, (const Il2CppImage* image, const char*, const char*)) +{ + if (!image) + std::abort(); + return stage >= 3 ? &klass : nullptr; +} +END_API +API(const MethodInfo*, il2cpp_class_get_method_from_name, (Il2CppClass * cls, const char*, int)) +{ + if (!cls) + std::abort(); + return &method; +} +END_API +void Require(bool condition) +{ + if (!condition) + throw std::runtime_error("class lookup regression"); +} +int main() +{ + for (stage = -1; stage < 3; ++stage) + Require(!il2cpp_get_class_helper("Assembly", "Namespace", "Class").get_cls()); + Require(il2cpp_get_class_helper("Assembly", "Namespace", "Class").get_cls() == &klass); + Require(!IL2CppClassHelper(nullptr).GetMethodInfo("Method", 0)); + Require(IL2CppClassHelper(&klass).GetMethodInfo("Method", 0) == &method); + + std::cout << "IL2CPP class lookup regressions passed\n"; +} diff --git a/xmake.lua b/xmake.lua index 9b25acbfb..cfbefafff 100644 --- a/xmake.lua +++ b/xmake.lua @@ -26,3 +26,14 @@ add_rules("mode.releasedbg") includes("xmake/rules/protobuf_sccache.lua") includes("xmake/rules/cxx_sccache.lua") includes("mods") + +target("il2cpp-class-lookup-tests") + set_kind("binary") + set_default(false) + add_files("tests/il2cpp_class_lookup.cc") + add_includedirs("mods/src") + add_packages("libil2cpp", "eastl") + set_exceptions("cxx") + if is_plat("windows") then + add_linkdirs("mods/src/il2cpp") + end