Skip to content
Merged
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
27 changes: 27 additions & 0 deletions docs/IL2CPP_SHUTDOWN_VALIDATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# macOS runtime shutdown hook validation

Client: `1.000.53284` (`100053284`), inspected 14 November 2026.
GameAssembly.dylib SHA-256: `a17be3929bc7545b2537093586d971ad800fe9ef4fe15a22063936b731491108`.

Quitting reached `exit -> __cxa_finalize_ranges -> il2cpp::GcHandle::~GcHandle ->
il2cpp::gc::GCHandle::Free`, after the runtime had already torn down its handle table.
The shutdown hook disables native handle operations before runtime teardown. This is terminal
process shutdown; in-process reload guards do not disable handles.

| Slice | Export | Export extent | Runtime::Shutdown body | Body extent |
| --- | --- | --- | --- | --- |
| arm64 | `0x493b6c` | 4 bytes | `0x500230` | 620 bytes |
| x86_64 | `0x472d00` | 16 bytes | `0x4f1ee0` | 608 bytes |

Both exports are too small for SPUD's architecture-specific absolute patch (up to 32 bytes on
ARM64; 24 bytes rounded to instruction boundaries on Intel). The code validates and follows
the ARM64 `b` instruction or Intel `push rbp; mov rbp, rsp; pop rbp; jmp rel32` wrapper,
then stages the hook directly on the substantive body. No client addresses are hardcoded.
SPUD verifies the resolved target against the loaded image's function boundaries at runtime.

The ARM64 body starts with stack allocation, register saves, and an ADRP load; the Intel body
starts with register saves, stack allocation, and a RIP-relative load. Both have sufficient
extent for relocation. A changed wrapper or a rejected body causes mod startup to fail with
a diagnostic rather than leaving GC handle teardown unprotected.

This validates static hook fit for both macOS slices, not in-game shutdown behavior or Windows.
18 changes: 12 additions & 6 deletions src/libs/il2cpp/include/il2cpp/il2cpp.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

#include <algorithm>
#include <array>
#include <atomic>
#include <concepts>
#include <cstddef>
#include <cstring>
Expand Down Expand Up @@ -681,9 +682,13 @@ class GcHandle
public:
GcHandle() = default;
static GcHandle Strong(Il2CppObject* object)
{ return GcHandle(object ? il2cpp_gchandle_new(object, false) : nullptr); }
{ return GcHandle(object && !shutdown_.load() ? il2cpp_gchandle_new(object, false) : nullptr); }
static GcHandle Weak(Il2CppObject* object)
{ return GcHandle(object ? il2cpp_gchandle_new_weakref(object, false) : nullptr); }
{ return GcHandle(object && !shutdown_.load() ? il2cpp_gchandle_new_weakref(object, false) : nullptr); }
// Terminal runtime teardown, not an in-process game reload. IL2CPP reclaims its handle table itself; native
// statics may be destroyed later, when calling any handle API would access the destroyed runtime.
static void StopRuntime()
{ shutdown_.store(true); }
~GcHandle()
{ Reset(); }
GcHandle(const GcHandle&) = delete;
Expand All @@ -701,15 +706,15 @@ class GcHandle
return *this;
}
explicit operator bool() const
{ return handle_ != nullptr; }
{ return handle_ != nullptr && !shutdown_.load(); }
Il2CppObject* Get() const
{ return handle_ ? il2cpp_gchandle_get_target(handle_) : nullptr; }
{ return handle_ && !shutdown_.load() ? il2cpp_gchandle_get_target(handle_) : nullptr; }
template <typename T> T* Get() const
{ return static_cast<T*>(Get()); }
void Reset()
{
if (handle_)
il2cpp_gchandle_free(std::exchange(handle_, nullptr));
if (auto handle = std::exchange(handle_, nullptr); handle && !shutdown_.load())
il2cpp_gchandle_free(handle);
}

private:
Expand All @@ -718,6 +723,7 @@ class GcHandle
{
}
Il2CppGCHandle handle_ = nullptr;
inline static std::atomic_bool shutdown_{false};
};

// --- Internal calls ---
Expand Down
53 changes: 52 additions & 1 deletion src/libs/il2cpp/src/api.cc
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Binds the IL2CPP exports from the loaded GameAssembly image on macOS. Windows links them through GameAssembly.lib.

#include <il2cpp/api.h>
#include <il2cpp/il2cpp.h>
#include <il2cpp/hook.h>

#include <stdexcept>

Expand All @@ -15,6 +16,45 @@ extern "C" {
}
#endif

#if __APPLE__
namespace
{
void Runtime_Shutdown_Hook(auto original)
{
il2cpp::GcHandle::StopRuntime();
il2cpp::Report("[IL2CPP] runtime shutting down; native GC handles disabled");
original();
}

void* RuntimeShutdownBody(void* entry)
{
if (!entry)
return nullptr;
const auto* code = static_cast<const uint8_t*>(entry);
#if defined(__aarch64__) || defined(__arm64__)
uint32_t instruction;
std::memcpy(&instruction, code, sizeof(instruction));
if ((instruction & 0xfc000000u) != 0x14000000u)
return nullptr;
int64_t displacement = instruction & 0x03ffffffu;
if (displacement & 0x02000000)
displacement -= 0x04000000;
return reinterpret_cast<void*>(reinterpret_cast<intptr_t>(entry) + displacement * 4);
#elif __x86_64__
// The Intel export has a balanced frame prologue before its tail jump, so it is not a lone-jump thunk.
constexpr uint8_t prefix[] = {0x55, 0x48, 0x89, 0xe5, 0x5d, 0xe9};
if (std::memcmp(code, prefix, sizeof(prefix)) != 0)
return nullptr;
int32_t displacement;
std::memcpy(&displacement, code + sizeof(prefix), sizeof(displacement));
return reinterpret_cast<void*>(reinterpret_cast<intptr_t>(entry) + 10 + displacement);
#else
return nullptr;
#endif
}
} // namespace
#endif

void il2cpp::Initialize(void* assembly)
{
#if !_WIN32
Expand Down Expand Up @@ -79,4 +119,15 @@ void il2cpp::Initialize(void* assembly)
#else
(void)assembly;
#endif
#if __APPLE__
// Hook the substantive Runtime::Shutdown body, never its undersized API wrapper. Native extents for both
// macOS slices are recorded in docs/IL2CPP_SHUTDOWN_VALIDATION.md; SPUD also validates the resolved target.
auto* shutdown = RuntimeShutdownBody(reinterpret_cast<void*>(il2cpp_shutdown));
if (!shutdown)
throw std::runtime_error("unrecognized il2cpp_shutdown wrapper; cannot protect native GC handle teardown");
il2cpp::HookSet hooks("RuntimeShutdown");
IL2CPP_STAGE_AT(hooks, shutdown, Runtime_Shutdown_Hook);
if (!hooks.Commit())
throw std::runtime_error("failed to protect native GC handles during runtime shutdown");
#endif
}
18 changes: 18 additions & 0 deletions src/libs/unity/include/unity/ui.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@

namespace unity
{
struct LayoutElement : Behaviour {
using Class = il2cpp::Class<"UnityEngine.UI", "UnityEngine.UI", "LayoutElement">;

bool ignoreLayout()
{ return Get<"ignoreLayout", bool>(); }
void SetIgnoreLayout(bool value)
{ Set<"ignoreLayout">(value); }
};

struct LayoutRebuilder {
using Class = il2cpp::Class<"UnityEngine.UI", "UnityEngine.UI", "LayoutRebuilder">;

static void ForceRebuildLayoutImmediate(RectTransform* rect)
{ Class::Call<"ForceRebuildLayoutImmediate">(rect); }
static void MarkLayoutForRebuild(RectTransform* rect)
{ Class::Call<"MarkLayoutForRebuild">(rect); }
};

struct CanvasScaler : Behaviour {
using Class = il2cpp::Class<"UnityEngine.UI", "UnityEngine.UI", "CanvasScaler">;

Expand Down
63 changes: 50 additions & 13 deletions src/mod/features/camera.cc
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include "config.h"
#include "features/features.h"
#include "features/zoom_entry.h"
#include "shortcuts.h"

#include "game/app.h"
Expand All @@ -21,14 +22,42 @@
#include <algorithm>
#include <cmath>
#include <unordered_map>
#include <utility>

namespace
{
// --- Zoom ---

// Entering a system jumps to default_system_zoom on the next camera update.
bool zoom_to_default = false;
struct ZoomEntryState {
il2cpp::GcHandle controller;
ZoomEntry::Gate gate;
};
std::unordered_map<NavigationZoom*, ZoomEntryState> zoom_entries;

bool SystemZoomViewReady()
{
auto* sections = Hub::Sections();
return sections && sections->CurrentSection() == SectionID::Navigation_System
&& !sections->IsSectionChangeInProgress();
}

void ZoomFrame()
{
// Hidden controllers need not receive Update or change depth. Remember leaving from the shared frame tick,
// so returning to their cached system view schedules the default even when their depth stays SolarSystem.
if (!SystemZoomViewReady())
for (auto& [controller, entry] : zoom_entries)
entry.gate.Observe(false);
}

ZoomEntryState& ObserveZoomDepth(NavigationZoom* zoom)
{
std::erase_if(zoom_entries, [](const auto& item) { return !item.second.controller.Get(); });
auto [entry, inserted] = zoom_entries.try_emplace(zoom);
if (inserted)
entry->second.controller = il2cpp::GcHandle::Weak(zoom);
entry->second.gate.Observe(zoom->depth() == NodeDepth::SolarSystem, SystemZoomViewReady());
return entry->second;
}

// Presets and default_system_zoom are in zoom units: the camera distance's place in [minimum, maximum] mapped onto
// [0, graphics.zoom], so they keep their meaning when the game moves the range. ZoomAtMouse(absolute) is the inverse.
Expand Down Expand Up @@ -159,17 +188,23 @@ void NavigationZoom_Update_Hook(auto original, NavigationZoom* _this)
FleetLabelsFollowDepth(_this, _this->depth());
KeepZoomRange(_this);

// The jump on entering a system is not keyboard input, so it happens even while a text field has focus.
bool run_game_update = true;
if (std::exchange(zoom_to_default, false))
ZoomAtMouse(_this, Config::Get().graphics.default_system_zoom, true);
else if (!unity::Key::IsInputFocused())
run_game_update = KeyboardZoom(_this);
if (!run_game_update)
// Initial and cached views can enter through SetDepth without SetViewParameters on macOS. Observe the actual
// depth in the existing update hook, including the first update of a controller already in a system.
const bool pending_default = ObserveZoomDepth(_this).gate.pending;
if (!pending_default && !unity::Key::IsInputFocused() && !KeyboardZoom(_this))
return;

original(_this);
KeepZoomRange(_this);
// Native entry work can reset the distance and range in Update. Apply after it, using the same range that
// presets and the reset shortcut see once the system is ready. Keep the request while its camera is not ready.
auto& entry = ObserveZoomDepth(_this);
if (entry.gate.Consume(_this->sceneCamera() && _this->maximum() > _this->minimum())) {
const auto target = Config::Get().graphics.default_system_zoom;
ZoomAtMouse(_this, target, true);
spdlog::info("[Zoom] system entry default applied: requested={} actual={} distance={} range=[{}, {}]", target,
CurrentZoom(_this), _this->Distance(), _this->minimum(), _this->maximum());
}
FleetLabelsAfterZoom(_this);
}

Expand All @@ -178,12 +213,14 @@ template <typename Original, typename... Args>
void EnterDepth(Original original, NavigationZoom* zoom, NodeDepth depth, float radius, Args... args)
{
FleetLabelsFollowDepth(zoom, depth);
if (depth != NodeDepth::SolarSystem)
if (depth != NodeDepth::SolarSystem) {
ObserveZoomDepth(zoom).gate.pending = false;
return original(zoom, args...);
}
ApplyZoomRange(zoom, radius);
original(zoom, args...);
ApplyZoomRange(zoom, 0.0f);
zoom_to_default = true;
ObserveZoomDepth(zoom).gate.pending = true;
}

void NavigationZoom_SetViewParameters_Hook(auto original, NavigationZoom* _this, float radius, NodeDepth depth)
Expand Down Expand Up @@ -418,6 +455,6 @@ bool InstallInteriorZoom()
}
} // namespace

const Feature kZoom{"Zoom", &Config::Patches::zoomhooks, InstallZoom};
const Feature kZoom{"Zoom", &Config::Patches::zoomhooks, InstallZoom, ZoomFrame};
const Feature kPan{"Pan", &Config::Patches::panhooks, InstallPan};
const Feature kInteriorZoom{"InteriorZoom", &Config::Patches::interiorzoomhooks, InstallInteriorZoom};
49 changes: 48 additions & 1 deletion src/mod/features/chat.cc
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
#include <spdlog/spdlog.h>
#include <unity/core.h>
#include <unity/tracker.h>
#include <unity/ui.h>

#include <chrono>
#include <unordered_map>
#include <utility>

namespace
Expand Down Expand Up @@ -191,19 +193,61 @@ void ChatPreviewController_OnRegionalMessageReceived_Hook(auto original, ChatPre
//
// Only chat rows lose their title: the same profile widget appears on other screens.

struct ChatTitleLayout {
il2cpp::GcHandle element;
bool original_ignore = false;
};
std::unordered_map<unity::LayoutElement*, ChatTitleLayout> chat_title_layouts;

void RestoreChatTitleLayout(UserProfileWidget* profile)
{
auto* title = profile ? profile->playerTitleWidget() : nullptr;
auto* container = title ? title->container() : nullptr;
auto* element = container ? container->GetComponent<unity::LayoutElement>() : nullptr;
const auto saved = chat_title_layouts.find(element);
if (saved == chat_title_layouts.end())
return;
if (saved->second.element.Get() == element)
element->SetIgnoreLayout(saved->second.original_ignore);
chat_title_layouts.erase(saved);
}

void HideChatTitle(UserProfileWidget* profile)
{
auto* title = profile ? profile->playerTitleWidget() : nullptr;
auto* transform = title ? profile->transform() : nullptr;
if (!transform || !transform->FindInParents<ChatMessageWidget>())
auto* row = transform ? transform->FindInParents<ChatMessageWidget>() : nullptr;
if (!row) {
RestoreChatTitleLayout(profile);
return;
}
if (auto* object = title->gameObject())
object->SetActive(false);
// The serialized container reserves height even when its title child is hidden. A nested native bind may
// reactivate it, so also exclude its LayoutElement until titles are enabled again.
if (auto* container = title->container()) {
if (auto* element = container->GetComponent<unity::LayoutElement>()) {
std::erase_if(chat_title_layouts, [](const auto& item) { return !item.second.element.Get(); });
if (!chat_title_layouts.contains(element))
chat_title_layouts.emplace(element, ChatTitleLayout{il2cpp::GcHandle::Weak(element), element->ignoreLayout()});
element->SetIgnoreLayout(true);
}
container->SetActive(false);
}
// The virtualized list measures the complete message row, not just the inner profile. Update its cached size
// immediately and schedule the normal layout pass as well.
auto* object = row->gameObject();
if (auto* rect = object ? object->GetComponent<unity::RectTransform>() : nullptr) {
unity::LayoutRebuilder::ForceRebuildLayoutImmediate(rect);
unity::LayoutRebuilder::MarkLayoutForRebuild(rect);
}
}

// The game shows the title again on every update, so it is hidden after each one.
void UserProfileWidget_UpdatePlayerTitle_Hook(auto original, UserProfileWidget* _this)
{
if (Config::Get().ui.show_player_title)
RestoreChatTitleLayout(_this);
original(_this);
if (!Config::Get().ui.show_player_title)
HideChatTitle(_this);
Expand All @@ -212,6 +256,9 @@ void UserProfileWidget_UpdatePlayerTitle_Hook(auto original, UserProfileWidget*
// SetWidgetData and OnDidBindContext both bind a title from the title widget's side; one hook serves both.
void PlayerTitleWidget_Bind_Hook(auto original, PlayerTitleWidget* _this)
{
if (Config::Get().ui.show_player_title)
if (auto* transform = _this->transform())
RestoreChatTitleLayout(transform->FindInParents<UserProfileWidget>());
original(_this);
if (Config::Get().ui.show_player_title)
return;
Expand Down
2 changes: 1 addition & 1 deletion src/mod/features/fleet_actions.cc
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ bool SelectShipShortcuts()
const auto index = static_cast<int32_t>(ship - kShips.begin());

if (SelectManagedDock(index)) {
last_index = -1;
fleet_last_index = -1;
return true;
}

Expand Down
Loading
Loading