From ec032ae11054d852052514459148914bae2d66fb Mon Sep 17 00:00:00 2001 From: netniV Date: Sat, 14 Nov 2026 13:56:39 +0000 Subject: [PATCH 1/3] Fix initial system zoom and macOS runtime shutdown Observe per-controller system entry and apply the configured zoom after native updates, including initial and cached views. Stop native GC handle operations before macOS IL2CPP teardown to keep static destructors from accessing the destroyed runtime. Add entry and shutdown regression coverage, record ARM64 and Intel hook validation, and correct the stale fleet shortcut variable that blocked builds. Validated with macOS ARM64 release build, Intel IL2CPP build, test-il2cpp, test-mod, and in-game shutdown. --- docs/IL2CPP_SHUTDOWN_VALIDATION.md | 27 +++++++++++++ src/libs/il2cpp/include/il2cpp/il2cpp.h | 18 ++++++--- src/libs/il2cpp/src/api.cc | 53 ++++++++++++++++++++++++- src/mod/features/camera.cc | 45 +++++++++++++++------ src/mod/features/fleet_actions.cc | 2 +- src/mod/features/zoom_entry.h | 26 ++++++++++++ tests/il2cpp_gc.cc | 12 ++++++ tests/zoom_entry.cc | 32 +++++++++++++++ zig/tests.zig | 7 ++++ 9 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 docs/IL2CPP_SHUTDOWN_VALIDATION.md create mode 100644 src/mod/features/zoom_entry.h create mode 100644 tests/zoom_entry.cc 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/mod/features/camera.cc b/src/mod/features/camera.cc index 1da7bc3e8..067578fea 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,26 @@ #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; + +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); + 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 +172,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 +197,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) 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..13cdfd31c --- /dev/null +++ b/src/mod/features/zoom_entry.h @@ -0,0 +1,26 @@ +#pragma once + +namespace ZoomEntry +{ +// Per-controller entry detection, including a controller first observed inside a system. +struct Gate { + bool in_system = false; + bool pending = false; + + void Observe(bool system) + { + 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/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..3978277ae --- /dev/null +++ b/tests/zoom_entry.cc @@ -0,0 +1,32 @@ +#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)); +} 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); From 2bdbaa91ce26b7534b5e9ad4c5e7f370ef185b19 Mon Sep 17 00:00:00 2001 From: netniV Date: Sat, 14 Nov 2026 14:02:43 +0000 Subject: [PATCH 2/3] Add missing reload.h --- src/mod/reload.h | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/mod/reload.h 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 From 83856bf7132583675f28cb63101b3d30ea29d039 Mon Sep 17 00:00:00 2001 From: netniV Date: Sat, 14 Nov 2026 15:18:52 +0000 Subject: [PATCH 3/3] Restore chat title spacing and zoom when returning to system view Hide the serialized title container, exclude its layout element, and rebuild the complete chat row before list measurement. Restore the original layout setting when titles are enabled again. Observe screen visibility as well as camera depth so cached system views reapply the configured default after transitions settle. Extend the entry regression tests for hidden controllers and incomplete transitions. Validated with the macOS ARM64 release build, test-il2cpp, test-mod, and in-game checks. --- src/libs/unity/include/unity/ui.h | 18 ++++++++++++ src/mod/features/camera.cc | 20 +++++++++++-- src/mod/features/chat.cc | 49 ++++++++++++++++++++++++++++++- src/mod/features/zoom_entry.h | 5 ++-- src/mod/game/chat.h | 3 ++ tests/zoom_entry.cc | 16 ++++++++++ 6 files changed, 106 insertions(+), 5 deletions(-) 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 067578fea..0bd61210a 100644 --- a/src/mod/features/camera.cc +++ b/src/mod/features/camera.cc @@ -33,13 +33,29 @@ struct ZoomEntryState { }; 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); + entry->second.gate.Observe(zoom->depth() == NodeDepth::SolarSystem, SystemZoomViewReady()); return entry->second; } @@ -439,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/zoom_entry.h b/src/mod/features/zoom_entry.h index 13cdfd31c..47b6c255d 100644 --- a/src/mod/features/zoom_entry.h +++ b/src/mod/features/zoom_entry.h @@ -2,13 +2,14 @@ namespace ZoomEntry { -// Per-controller entry detection, including a controller first observed inside a system. +// 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) + void Observe(bool system, bool view_visible = true) { + system = system && view_visible; if (system != in_system) { in_system = system; pending = system; 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/tests/zoom_entry.cc b/tests/zoom_entry.cc index 3978277ae..26d8df17c 100644 --- a/tests/zoom_entry.cc +++ b/tests/zoom_entry.cc @@ -29,4 +29,20 @@ int main() 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)); }