Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion mods/src/patches/parts/hotkeys.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1254,7 +1255,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;
}
Expand All @@ -1280,6 +1295,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;
}
Expand Down
285 changes: 285 additions & 0 deletions mods/src/patches/planned_system_warp.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
#include <il2cpp/runtime.h>
#include "planned_system_warp.h"
#include "errormsg.h"

#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <il2cpp-tabledefs.h>
#include <optional>
#include <prime/DeploymentManager.h>
#include <prime/Hub.h>
#include <prime/NavigationSectionManager.h>
#include <spdlog/spdlog.h>
#include <type_traits>

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 <typename T> 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<T, bool>) {
return scalar && scalar->type == IL2CPP_TYPE_BOOLEAN;
} else if constexpr (std::is_same_v<T, int>) {
return scalar && scalar->type == IL2CPP_TYPE_I4;
} else if constexpr (std::is_same_v<T, std::int64_t>) {
return scalar && scalar->type == IL2CPP_TYPE_I8;
} else if constexpr (std::is_same_v<T, Vector3>) {
return std::strcmp(cls->namespaze, "UnityEngine") == 0 && std::strcmp(cls->name, "Vector3") == 0;
}
return false;
}

template <typename T> std::optional<T> Unbox(Il2CppObject* object)
{
if (!object || !MatchesValueType<T>(il2cpp_class_get_type(il2cpp_object_get_class(object)))) {
return std::nullopt;
}
const auto* value = static_cast<T*>(il2cpp_object_unbox(object));
return value ? std::optional<T>(*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<Il2CppObject*>(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<PropertyInfo*>(property)) : nullptr;
if (!IsInstanceMethod(getter, 0)) {
return nullptr;
}
getter = il2cpp_object_get_virtual_method(instance, getter);
if (!IsInstanceMethod(getter, 0)) {
return nullptr;
}
Il2CppObject* result = nullptr;
return Il2CppRuntime::TryInvoke(getter, instance, nullptr, &result) ? result : nullptr;
}

template <typename T> std::optional<T> ReadValue(void* object, const char* name, bool field = false)
{ return Unbox<T>(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<bool>(method->return_type)
|| !MatchesValueType<std::int64_t>(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<bool>(result) == true;
}

bool MatchesRetainedPosition(void* navigation, void* context, void* target)
{
auto world_position = ReadValue<Vector3>(context, "Position", true);
auto galaxy_position = ReadValue<Vector3>(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<Vector3>(convert->return_type)
|| !MatchesValueType<Vector3>(convert->parameters[0]) || !MatchesValueType<int>(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<Vector3>(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<std::int64_t>(method->parameters[1])
|| !MatchesValueType<std::int64_t>(method->parameters[2]) || !MatchesValueType<bool>(method->parameters[3])) {
return false;
}
// runtime_invoke handles the platform ABI; verify the actual Nullable<Vector3> 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<bool>(has_value->type) && MatchesValueType<Vector3>(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<std::int64_t>(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<std::int64_t>(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<std::int64_t>(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<int>(course, "Type");
const auto action = ReadValue<int>(course, "TargetAction");
const auto in_use = ReadValue<bool>(course, "IsInUse");
const auto warp = ReadValue<bool>(course, "IsWarpCourse");
const auto path_result = ReadValue<int>(ReadObject(course, "PathFinderResult"), "Result");
if (type != PlannedCourse || (action != NoTargetAction && action != OrbitAction) || in_use != false || warp != true
|| ReadValue<bool>(course, "IsRecallCourse") != false || ReadValue<bool>(course, "IsWormholeCourse") != false
|| ReadValue<bool>(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<std::int64_t>(start_address, "System");
const auto start_instance = ReadValue<int>(start_address, "Instance");
if (ReadValue<int>(target_address, "NodeDepth") != SystemDepth || !start_system || *start_system <= 0
|| !start_instance || start_system != ReadValue<std::int64_t>(fleet_address, "System")
|| start_instance != ReadValue<int>(fleet_address, "Instance")) {
return false;
}
const auto position = ReadValue<Vector3>(target, "SystemPosition");
auto node_id = ReadValue<std::int64_t>(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<std::int64_t>(selected_node, "ID");
if (selected_node
&& (!selected_id || selected_id != node_id || ReadValue<int>(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<int>(fleet->CurrentState));
return true;
}
8 changes: 8 additions & 0 deletions mods/src/patches/planned_system_warp.h
Original file line number Diff line number Diff line change
@@ -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);
33 changes: 32 additions & 1 deletion mods/src/prime/NavigationInteractionUIViewController.h
Original file line number Diff line number Diff line change
@@ -1,9 +1,40 @@
#pragma once

#include "NavigationInteractionUIContext.h"
#include "ViewController.h"
#include "VisibilityController.h"
#include <il2cpp/il2cpp_helper.h>

struct NavigationInteractionUIViewController {
struct NavigationInteractionUIViewController
: ViewController<NavigationInteractionUIContext, NavigationInteractionUIViewController> {
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<Il2CppObject**>(reinterpret_cast<char*>(this) + widget_field.offset());
if (!widget) {
return false;
}
auto* visibility =
*reinterpret_cast<VisibilityController**>(reinterpret_cast<char*>(widget) + visibility_field.offset());
if (!visibility
|| (visibility->_state != VisibilityState::Visible && visibility->_state != VisibilityState::Show)) {
return false;
}
const auto* active = active_property.Get<bool>(widget);
return active && *active;
}

void OnSetCourseButtonClick()
{
static auto OnSetCourseButtonClick =
Expand Down
5 changes: 3 additions & 2 deletions mods/src/prime/ParentObjectViewerViewController.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class ParentObjectViewerViewController : public ViewController<NavigationInterac
bool __get_IsShowing()
{
static auto field = get_class_helper().GetProperty("IsShowing");
return *field.GetRaw<bool>(this);
const auto* showing = field.Get<bool>(this);
return showing && *showing;
}
};
};
Loading
Loading