diff --git a/docs/IL2CPP_SHUTDOWN_VALIDATION.md b/docs/IL2CPP_SHUTDOWN_VALIDATION.md new file mode 100644 index 000000000..be2ab6aeb --- /dev/null +++ b/docs/IL2CPP_SHUTDOWN_VALIDATION.md @@ -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. diff --git a/src/libs/il2cpp/include/il2cpp/il2cpp.h b/src/libs/il2cpp/include/il2cpp/il2cpp.h index 72ea2c53d..474f7c016 100644 --- a/src/libs/il2cpp/include/il2cpp/il2cpp.h +++ b/src/libs/il2cpp/include/il2cpp/il2cpp.h @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -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; @@ -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 T* Get() const { return static_cast(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: @@ -718,6 +723,7 @@ class GcHandle { } Il2CppGCHandle handle_ = nullptr; + inline static std::atomic_bool shutdown_{false}; }; // --- Internal calls --- diff --git a/src/libs/il2cpp/src/api.cc b/src/libs/il2cpp/src/api.cc index af82e1267..fddbc6448 100644 --- a/src/libs/il2cpp/src/api.cc +++ b/src/libs/il2cpp/src/api.cc @@ -1,6 +1,7 @@ // Binds the IL2CPP exports from the loaded GameAssembly image on macOS. Windows links them through GameAssembly.lib. -#include +#include +#include #include @@ -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(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(reinterpret_cast(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(reinterpret_cast(entry) + 10 + displacement); +#else + return nullptr; +#endif +} +} // namespace +#endif + void il2cpp::Initialize(void* assembly) { #if !_WIN32 @@ -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(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 } diff --git a/src/libs/unity/include/unity/ui.h b/src/libs/unity/include/unity/ui.h index a8527a111..575df624e 100644 --- a/src/libs/unity/include/unity/ui.h +++ b/src/libs/unity/include/unity/ui.h @@ -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">; diff --git a/src/mod/features/camera.cc b/src/mod/features/camera.cc index 1da7bc3e8..0bd61210a 100644 --- a/src/mod/features/camera.cc +++ b/src/mod/features/camera.cc @@ -5,6 +5,7 @@ #include "config.h" #include "features/features.h" +#include "features/zoom_entry.h" #include "shortcuts.h" #include "game/app.h" @@ -21,14 +22,42 @@ #include #include #include -#include 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 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. @@ -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); } @@ -178,12 +213,14 @@ template 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) @@ -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}; diff --git a/src/mod/features/chat.cc b/src/mod/features/chat.cc index aaad84290..ddd3ba68e 100644 --- a/src/mod/features/chat.cc +++ b/src/mod/features/chat.cc @@ -17,8 +17,10 @@ #include #include #include +#include #include +#include #include namespace @@ -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 chat_title_layouts; + +void RestoreChatTitleLayout(UserProfileWidget* profile) +{ + auto* title = profile ? profile->playerTitleWidget() : nullptr; + auto* container = title ? title->container() : nullptr; + auto* element = container ? container->GetComponent() : 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()) + auto* row = transform ? transform->FindInParents() : 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()) { + 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() : 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); @@ -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()); original(_this); if (Config::Get().ui.show_player_title) return; diff --git a/src/mod/features/fleet_actions.cc b/src/mod/features/fleet_actions.cc index 9882dd478..f1710adac 100644 --- a/src/mod/features/fleet_actions.cc +++ b/src/mod/features/fleet_actions.cc @@ -462,7 +462,7 @@ bool SelectShipShortcuts() const auto index = static_cast(ship - kShips.begin()); if (SelectManagedDock(index)) { - last_index = -1; + fleet_last_index = -1; return true; } diff --git a/src/mod/features/zoom_entry.h b/src/mod/features/zoom_entry.h new file mode 100644 index 000000000..47b6c255d --- /dev/null +++ b/src/mod/features/zoom_entry.h @@ -0,0 +1,27 @@ +#pragma once + +namespace ZoomEntry +{ +// Per-controller entry detection, including a controller first observed inside a system or reused after hiding. +struct Gate { + bool in_system = false; + bool pending = false; + + void Observe(bool system, bool view_visible = true) + { + system = system && view_visible; + if (system != in_system) { + in_system = system; + pending = system; + } + } + + bool Consume(bool ready) + { + if (!in_system || !pending || !ready) + return false; + pending = false; + return true; + } +}; +} // namespace ZoomEntry diff --git a/src/mod/game/chat.h b/src/mod/game/chat.h index 7d752a204..0ec2e2f8e 100644 --- a/src/mod/game/chat.h +++ b/src/mod/game/chat.h @@ -89,6 +89,9 @@ struct FullScreenChatViewController : il2cpp::Object { struct PlayerTitleWidget : unity::Component { using Class = il2cpp::Class<"Assembly-CSharp", "Digit.Prime.PlayerAvatars", "PlayerTitleWidget">; + + unity::GameObject* container() + { return Field<"_container", unity::GameObject*>(); } }; // A player's name, avatar and title, on chat rows and other screens. diff --git a/src/mod/reload.h b/src/mod/reload.h new file mode 100644 index 000000000..3544f5a13 --- /dev/null +++ b/src/mod/reload.h @@ -0,0 +1,33 @@ +#pragma once + +// Game-free readiness gate for in-process reloads. Every interruption restarts the settling period. +namespace reload +{ +struct Gate { + bool suspended = false; + double ready_since = -1; + + void Begin() + { + suspended = true; + ready_since = -1; + } + + // True only on the frame that resumes mod work. + bool Tick(bool ready, double now) + { + if (!suspended) + return false; + if (!ready) { + ready_since = -1; + return false; + } + if (ready_since < 0) + ready_since = now; + if (now - ready_since < 3.0) + return false; + suspended = false; + return true; + } +}; +} // namespace reload diff --git a/tests/il2cpp_gc.cc b/tests/il2cpp_gc.cc index a4a65a40e..653baac53 100644 --- a/tests/il2cpp_gc.cc +++ b/tests/il2cpp_gc.cc @@ -79,5 +79,17 @@ int main() Require(root && !root.Get()); } Require(frees == 3 && targets.empty()); + { + auto root = il2cpp::GcHandle::Strong(&object); + auto weak_root = il2cpp::GcHandle::Weak(&other); + il2cpp::GcHandle::StopRuntime(); + targets.clear(); // The runtime destroys its own handle table before native static destructors run. + Require(!root && !weak_root && !root.Get() && !weak_root.Get()); + Require(!il2cpp::GcHandle::Strong(&object) && !il2cpp::GcHandle::Weak(&object) && targets.empty()); + root.Reset(); + Require(frees == 3); + // weak_root's destructor must not call the now-invalid handle APIs either. + } + Require(frees == 3); std::cout << "IL2CPP GC ownership tests passed\n"; } diff --git a/tests/zoom_entry.cc b/tests/zoom_entry.cc new file mode 100644 index 000000000..26d8df17c --- /dev/null +++ b/tests/zoom_entry.cc @@ -0,0 +1,48 @@ +#include "features/zoom_entry.h" + +#include + +int main() +{ + ZoomEntry::Gate system, galaxy; + // First view needs no SetViewParameters notification to schedule the default. + system.Observe(true); + galaxy.Observe(false); + assert(!galaxy.Consume(true)); + assert(!system.Consume(false)); // Camera/range not ready yet. + assert(system.Consume(true)); + system.Observe(true); + assert(!system.Consume(true)); // Leave manual zoom alone on subsequent frames. + + // Re-entering a cached controller through depth alone schedules another default. + system.Observe(false); + system.Observe(true); + assert(system.Consume(true)); + + // Leaving before readiness cancels the request. + system.pending = true; + system.Observe(false); + assert(!system.Consume(true)); + + // An explicit entry notification can request a reset within the same depth. + system.Observe(true); + assert(system.Consume(true)); + system.pending = true; + assert(system.Consume(true)); + + // The cached controller can remain at system depth while another screen is visible. A shared-frame sample + // records that interruption even if the controller receives no Update until the player comes back. + system.Observe(true, false); + assert(!system.Consume(true)); + system.Observe(true, true); + assert(system.Consume(true)); + system.Observe(true, true); + assert(!system.Consume(true)); + + // Native transitions can still change the camera; do not consume a default until the view has settled. + system.Observe(true, false); + system.pending = true; // SetViewParameters ran before section completion. + assert(!system.Consume(true)); + system.Observe(true, true); + assert(system.Consume(true)); +} diff --git a/zig/tests.zig b/zig/tests.zig index cdd3d535c..1f07b0140 100644 --- a/zig/tests.zig +++ b/zig/tests.zig @@ -38,6 +38,13 @@ pub fn add(build_ctx: *const Ctx, libs: Libs) void { addFixture(ctx, build_tests, test_mod, "action-queue-tests", module); } + { + const module = ctx.createModule(true); + module.addIncludePath(b.path("src/mod")); + module.addCSourceFile(.{ .file = b.path("tests/zoom_entry.cc"), .flags = flags }); + addFixture(ctx, build_tests, test_mod, "zoom-entry-tests", module); + } + // IL2CPP bindings and GC handles against a fake runtime API defined in each test file. { const module = ctx.createModule(true);