From fa5fe36e71c495214f16d7080735a902cd4051f5 Mon Sep 17 00:00:00 2001 From: Guffawaffle Date: Thu, 22 Oct 2026 06:10:45 -0500 Subject: [PATCH] perf: cache Haven road material discovery --- mods/src/patches/haven_road_cache.h | 62 ++++ mods/src/patches/parts/haven_road_depth.cc | 320 ++++++++++++++++----- tests/haven_road_cache_test.cc | 168 +++++++++++ 3 files changed, 474 insertions(+), 76 deletions(-) create mode 100644 mods/src/patches/haven_road_cache.h create mode 100644 tests/haven_road_cache_test.cc diff --git a/mods/src/patches/haven_road_cache.h b/mods/src/patches/haven_road_cache.h new file mode 100644 index 00000000..5b5bae2b --- /dev/null +++ b/mods/src/patches/haven_road_cache.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include + +namespace haven_roads +{ +inline constexpr std::size_t kRendererLimit = 4096; +inline constexpr std::size_t kMaterialsPerRenderer = 16; +inline constexpr std::size_t kMaterialLimit = kRendererLimit * kMaterialsPerRenderer; + +// The backend owns native liveness/rooting. Retained owner and material entries +// are weak; a refresh may hold a temporary rooted owner without retaining it. +template class DiscoveryCache +{ +public: + using Clock = std::chrono::steady_clock; + using Weak = typename Backend::Weak; + + void Refresh(Clock::time_point now) + { + if (!Backend::GetOwner(owner_)) { + owner_ = {}; + materials_.clear(); + } + if (now < next_discovery_) + return; + next_discovery_ = now + std::chrono::seconds(5); + // A replacement can occupy the canonical path while the old wrapper stays + // alive and active. Recheck identity at discovery cadence, never per frame. + auto owner = Backend::FindOwner(); + if (!owner) { + owner_ = {}; + materials_.clear(); + return; + } + owner_ = Backend::Retain(owner); + if (!owner_) { + materials_.clear(); + return; + } + std::vector next; + if (!Backend::ScanOwner(owner, next)) { + materials_.clear(); + return; + } + if (next.size() > kMaterialLimit) + next.resize(kMaterialLimit); + materials_ = std::move(next); + } + + const std::vector& Materials() const + { return materials_; } + +private: + Weak owner_; + std::vector materials_; + Clock::time_point next_discovery_{}; +}; +} // namespace haven_roads diff --git a/mods/src/patches/parts/haven_road_depth.cc b/mods/src/patches/parts/haven_road_depth.cc index 3c573fab..d0eb398b 100644 --- a/mods/src/patches/parts/haven_road_depth.cc +++ b/mods/src/patches/parts/haven_road_depth.cc @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -8,52 +9,155 @@ #include #include #include +#include #include namespace { struct RoadDepthApi { - Il2CppClass *renderer = nullptr; + Il2CppClass* renderer = nullptr; const MethodInfo *find = nullptr, *children = nullptr, *materials = nullptr; const MethodInfo *name = nullptr, *shader = nullptr, *has = nullptr, *get = nullptr, *set = nullptr; + const MethodInfo *alive = nullptr, *active = nullptr, *instance_id = nullptr; RoadDepthApi() { - auto cls = [](const char *n) { + auto cls = [](const char* n) { return il2cpp_get_class_helper("UnityEngine.CoreModule", "UnityEngine", n).get_cls(); }; - auto *game_object = cls("GameObject"), *material = cls("Material"); + auto *game_object = cls("GameObject"), *material = cls("Material"), *object = cls("Object"); renderer = cls("Renderer"); find = method_contract::Resolve(game_object, "Find", true, "UnityEngine.GameObject", {"System.String"}); children = method_contract::Resolve(game_object, "GetComponentsInChildren", false, "UnityEngine.Component[]", {"System.Type", "System.Boolean"}); materials = method_contract::Resolve(renderer, "get_sharedMaterials", false, "UnityEngine.Material[]", {}); - name = method_contract::Resolve(cls("Object"), "get_name", false, "System.String", {}); + name = method_contract::Resolve(object, "get_name", false, "System.String", {}); shader = method_contract::Resolve(material, "get_shader", false, "UnityEngine.Shader", {}); has = method_contract::Resolve(material, "HasProperty", false, "System.Boolean", {"System.String"}); get = method_contract::Resolve(material, "GetFloat", false, "System.Single", {"System.String"}); - set = method_contract::Resolve(material, "SetFloat", false, "System.Void", {"System.String", "System.Single"}); + set = method_contract::Resolve(material, "SetFloat", false, "System.Void", {"System.String", "System.Single"}); + alive = method_contract::Resolve(object, "op_Implicit", true, "System.Boolean", {"UnityEngine.Object"}); + active = method_contract::Resolve(game_object, "get_activeInHierarchy", false, "System.Boolean", {}); + instance_id = method_contract::Resolve(object, "GetInstanceID", false, "System.Int32", {}); } bool Valid() const - { return renderer && find && children && materials && name && shader && has && get && set; } + { + return renderer && find && children && materials && name && shader && has && get && set && alive && active + && instance_id; + } }; -Il2CppObject *Invoke(const MethodInfo *method, Il2CppObject *object, void **args = nullptr) +RoadDepthApi& Api() +{ + static RoadDepthApi api; + return api; +} + +Il2CppObject* Invoke(const MethodInfo* method, Il2CppObject* object, void** args = nullptr) { - Il2CppObject *result = nullptr; + Il2CppObject* result = nullptr; return Il2CppRuntime::TryInvoke(method, object, args, &result) ? result : nullptr; } -std::string Name(RoadDepthApi &api, Il2CppObject *object) +// All roots are temporary and released on the owner-thread stack. Persistent +// discovery keeps weak handles, which do not retain scene objects or assets. +class StrongObject +{ +public: + explicit StrongObject(Il2CppObject* object = nullptr) + : handle_(object ? il2cpp_gchandle_new(object, false) : nullptr) + { + } + StrongObject(const StrongObject&) = delete; + StrongObject& operator=(const StrongObject&) = delete; + StrongObject(StrongObject&& other) noexcept + : handle_(std::exchange(other.handle_, nullptr)) + { + } + StrongObject& operator=(StrongObject&& other) noexcept + { + if (this != &other) { + if (handle_) + il2cpp_gchandle_free(handle_); + handle_ = std::exchange(other.handle_, nullptr); + } + return *this; + } + ~StrongObject() + { + if (handle_) + il2cpp_gchandle_free(handle_); + } + explicit operator bool() const + { return handle_ != nullptr; } + Il2CppObject* Get() const + { return handle_ ? il2cpp_gchandle_get_target(handle_) : nullptr; } + +private: + Il2CppGCHandle handle_ = nullptr; +}; + +bool Alive(const StrongObject& root) +{ + auto* object = root.Get(); + bool alive = false; + void* args[]{object}; + return object && Il2CppRuntime::TryBoolean(Invoke(Api().alive, nullptr, args), alive) && alive; +} + +class WeakObject +{ +public: + WeakObject() = default; + explicit WeakObject(const StrongObject& root) + : handle_(root.Get() ? il2cpp_gchandle_new_weakref(root.Get(), false) : nullptr) + { + } + WeakObject(const WeakObject&) = delete; + WeakObject& operator=(const WeakObject&) = delete; + WeakObject(WeakObject&& other) noexcept + : handle_(std::exchange(other.handle_, nullptr)) + { + } + WeakObject& operator=(WeakObject&& other) noexcept + { + if (this != &other) { + if (handle_) + il2cpp_gchandle_free(handle_); + handle_ = std::exchange(other.handle_, nullptr); + } + return *this; + } + ~WeakObject() + { + if (handle_) + il2cpp_gchandle_free(handle_); + } + explicit operator bool() const + { return handle_ != nullptr; } + StrongObject Lock() const + { + StrongObject root(handle_ ? il2cpp_gchandle_get_target(handle_) : nullptr); + if (!Alive(root)) + return StrongObject{}; + return root; + } + +private: + Il2CppGCHandle handle_ = nullptr; +}; + +std::string Name(const StrongObject& object) { - auto *value = object ? reinterpret_cast(Invoke(api.name, object)) : nullptr; - return value && value->length <= 256 ? to_string(value) : std::string{}; + StrongObject value(object ? Invoke(Api().name, object.Get()) : nullptr); + const auto* string = reinterpret_cast(value.Get()); + return string && string->length >= 0 && string->length <= 256 ? to_string(string) : std::string{}; } -bool IsRoad(const std::string &name) +bool IsRoad(const std::string& name) { - for (const char *prefix : + for (const char* prefix : {"mat_PB_3101101_asphalt_", "mat_PB_3101201_asphalt_", "mat_PB_3101301_asphalt_", "mat_PB_3101401_asphalt_", "mat_PB_3101501_asphalt_", "mat_PB_3101601_asphalt_", "mat_PB_3101701_asphalt_"}) { if (name.starts_with(prefix)) @@ -62,46 +166,118 @@ bool IsRoad(const std::string &name) return false; } -bool ReadDepth(RoadDepthApi &api, Il2CppObject *material, Il2CppString *property, float &value) +struct NativeBackend { + using Object = StrongObject; + using Weak = WeakObject; + + static Object GetOwner(const Weak& weak) + { + auto root = weak.Lock(); + bool active = false; + if (!root || !Il2CppRuntime::TryBoolean(Invoke(Api().active, root.Get()), active) || !active) + return Object{}; + return root; + } + static Weak Retain(const Object& object) + { return Weak(object); } + static Object FindOwner() + { + StrongObject name(reinterpret_cast( + il2cpp_string_new("StarbaseRoot/StarbaseManager/StarbasePlanetaryVisualsHolder"))); + if (!name) + return Object{}; + void* args[]{name.Get()}; + Object root(Invoke(Api().find, nullptr, args)); + if (!Alive(root)) + return Object{}; + return root; + } + static bool ScanOwner(const Object& owner, std::vector& roads) + { + auto& api = Api(); + StrongObject type(reinterpret_cast(il2cpp_type_get_object(il2cpp_class_get_type(api.renderer)))); + if (!type) + return false; + bool inactive = true; + void* args[]{type.Get(), &inactive}; + StrongObject renderer_root(Invoke(api.children, owner.Get(), args)); + auto* renderers = reinterpret_cast(renderer_root.Get()); + if (!renderers || renderers->max_length > haven_roads::kRendererLimit) + return false; + std::unordered_set road_ids; + for (uintptr_t i = 0; i < renderers->max_length; ++i) { + StrongObject renderer(reinterpret_cast(renderers->vector[i])); + if (!Alive(renderer)) + continue; + StrongObject material_root(Invoke(api.materials, renderer.Get())); + auto* materials = reinterpret_cast(material_root.Get()); + if (!materials || materials->max_length > haven_roads::kMaterialsPerRenderer) + continue; + for (uintptr_t j = 0; j < materials->max_length; ++j) { + StrongObject material(reinterpret_cast(materials->vector[j])); + if (!Alive(material) || !IsRoad(Name(material))) + continue; + StrongObject shader(Invoke(api.shader, material.Get())); + if (!Alive(shader) || Name(shader) != "Custom/LitMSAE") + continue; + auto* boxed_id = Invoke(api.instance_id, material.Get()); + if (!boxed_id || !method_contract::Type(il2cpp_class_get_type(boxed_id->klass), "System.Int32")) + continue; + const int id = *static_cast(il2cpp_object_unbox(boxed_id)); + if (road_ids.insert(id).second) { + if (auto weak = Retain(material); weak) + roads.push_back(std::move(weak)); + if (roads.size() == haven_roads::kMaterialLimit) + return true; + } + } + } + return true; + } +}; + +bool ReadDepth(Il2CppObject* material, Il2CppString* property, float& value) { - void *args[] = {property}; + void* args[]{property}; bool present = false; - if (!Il2CppRuntime::TryBoolean(Invoke(api.has, material, args), present) || !present) + if (!Il2CppRuntime::TryBoolean(Invoke(Api().has, material, args), present) || !present) return false; - auto *boxed = Invoke(api.get, material, args); + auto* boxed = Invoke(Api().get, material, args); if (!boxed || !method_contract::Type(il2cpp_class_get_type(boxed->klass), "System.Single")) return false; - value = *static_cast(il2cpp_object_unbox(boxed)); + value = *static_cast(il2cpp_object_unbox(boxed)); return std::isfinite(value); } } // namespace -// Called only by the qualified, enabled Haven camera path. The small bias stays -// on at every zoom level, keeping nearly coplanar road surfaces above the ground. -// All scene objects are resolved afresh; no Unity pointers or GC handles are retained. +// The existing qualified Haven camera path owns this call. Keep its no-argument, +// one-way material bias contract; discovery retains only weak owner/materials. void ApplyHavenRoadDepthBias() { using Clock = std::chrono::steady_clock; static auto next = Clock::time_point{}; - if (Clock::now() < next) + const auto now = Clock::now(); + if (now < next) return; - next = Clock::now() + std::chrono::seconds(1); - static RoadDepthApi api; - if (!api.Valid()) { + next = now + std::chrono::seconds(1); + if (!Api().Valid()) { static bool warned = false; if (!warned) { + const auto& api = Api(); if (!api.renderer) spdlog::warn("[HavenZoom] road depth unavailable: missing UnityEngine.Renderer class"); - for (const auto& [method, signature] : - std::array, 8>{{ - {api.find, "GameObject.Find(String) -> GameObject [static]"}, - {api.children, "GameObject.GetComponentsInChildren(Type, Boolean) -> Component[]"}, - {api.materials, "Renderer.get_sharedMaterials() -> Material[]"}, - {api.name, "Object.get_name() -> String"}, - {api.shader, "Material.get_shader() -> Shader"}, - {api.has, "Material.HasProperty(String) -> Boolean"}, - {api.get, "Material.GetFloat(String) -> Single"}, - {api.set, "Material.SetFloat(String, Single) -> Void"}}}) { + for (const auto& [method, signature] : std::array, 11>{ + {{api.find, "GameObject.Find(String) -> GameObject [static]"}, + {api.children, "GameObject.GetComponentsInChildren(Type, Boolean) -> Component[]"}, + {api.materials, "Renderer.get_sharedMaterials() -> Material[]"}, + {api.name, "Object.get_name() -> String"}, + {api.shader, "Material.get_shader() -> Shader"}, + {api.has, "Material.HasProperty(String) -> Boolean"}, + {api.get, "Material.GetFloat(String) -> Single"}, + {api.set, "Material.SetFloat(String, Single) -> Void"}, + {api.alive, "Object.op_Implicit(Object) -> Boolean [static]"}, + {api.active, "GameObject.get_activeInHierarchy() -> Boolean"}, + {api.instance_id, "Object.GetInstanceID() -> Int32"}}}) { if (!method) spdlog::warn("[HavenZoom] keeping native road depth: missing or incompatible UnityEngine.{}", signature); } @@ -109,54 +285,46 @@ void ApplyHavenRoadDepthBias() } return; } - void *root_args[] = {il2cpp_string_new("StarbaseRoot/StarbaseManager/StarbasePlanetaryVisualsHolder")}; - auto *root = Invoke(api.find, nullptr, root_args); - if (!root) + // Never run IL2CPP handle destructors after runtime teardown. Replacement and + // invalidation release old weak handles during normal owner-thread calls. + static auto* cache = new haven_roads::DiscoveryCache; + cache->Refresh(now); + if (cache->Materials().empty()) return; - bool include_inactive = true; - void *child_args[] = {il2cpp_type_get_object(il2cpp_class_get_type(api.renderer)), &include_inactive}; - auto *renderers = reinterpret_cast(Invoke(api.children, root, child_args)); - if (!renderers || renderers->max_length > 4096) + const std::array properties{ + StrongObject(reinterpret_cast(il2cpp_string_new("_OffsetFactor"))), + StrongObject(reinterpret_cast(il2cpp_string_new("_OffsetUnits")))}; + if (!properties[0] || !properties[1]) return; - for (uintptr_t i = 0; i < renderers->max_length; ++i) { - auto *renderer = reinterpret_cast(renderers->vector[i]); - if (!renderer) + for (const auto& weak : cache->Materials()) { + auto material = weak.Lock(); + if (!material) continue; - auto *materials = reinterpret_cast(Invoke(api.materials, renderer)); - if (!materials || materials->max_length > 16) + std::array before{}; + if (!ReadDepth(material.Get(), reinterpret_cast(properties[0].Get()), before[0]) + || !ReadDepth(material.Get(), reinterpret_cast(properties[1].Get()), before[1])) continue; - for (uintptr_t j = 0; j < materials->max_length; ++j) { - auto *material = reinterpret_cast(materials->vector[j]); - const auto name = Name(api, material); - if (!material || !IsRoad(name) || Name(api, Invoke(api.shader, material)) != "Custom/LitMSAE") + bool changed = false, verified = true; + for (unsigned k = 0; k < 2; ++k) { + float target = std::min(before[k], -1.0f); + if (target == before[k]) continue; - const std::array properties = {il2cpp_string_new("_OffsetFactor"), - il2cpp_string_new("_OffsetUnits")}; - std::array before{}; - if (!ReadDepth(api, material, properties[0], before[0]) || !ReadDepth(api, material, properties[1], before[1])) + void* args[]{properties[k].Get(), &target}; + if (!Il2CppRuntime::TryInvoke(Api().set, material.Get(), args)) { + verified = false; continue; - bool changed = false, verified = true; - for (unsigned k = 0; k < 2; ++k) { - // Preserve an already stronger native bias. - float target = std::min(before[k], -1.0f); - if (target == before[k]) - continue; - void *args[] = {properties[k], &target}; - if (!Il2CppRuntime::TryInvoke(api.set, material, args)) { - verified = false; - continue; - } - changed = true; - float after = 0.0f; - verified = ReadDepth(api, material, properties[k], after) && after == target && verified; } - if (changed) { - spdlog::debug("[HavenZoom] road depth bias {}: verified={}", name, verified); - static bool reported = false; - if (verified && !reported) { - spdlog::info("[HavenZoom] corrected road surface depth for expanded Haven zoom"); - reported = true; - } + changed = true; + float after = 0.0f; + verified = ReadDepth(material.Get(), reinterpret_cast(properties[k].Get()), after) + && after == target && verified; + } + if (changed) { + spdlog::debug("[HavenZoom] road depth bias: verified={}", verified); + static bool reported = false; + if (verified && !reported) { + spdlog::info("[HavenZoom] corrected road surface depth for expanded Haven zoom"); + reported = true; } } } diff --git a/tests/haven_road_cache_test.cc b/tests/haven_road_cache_test.cc new file mode 100644 index 00000000..e0c29a6e --- /dev/null +++ b/tests/haven_road_cache_test.cc @@ -0,0 +1,168 @@ +#include "../mods/src/patches/haven_road_cache.h" + +#include +#include +#include + +namespace +{ +void Check(bool condition) +{ + if (!condition) + std::abort(); +} + +struct Backend { + using Object = int; + struct Weak { + static inline int handles = 0; + int object = 0; + Weak() = default; + explicit Weak(int value) + : object(value) + { + if (object) + ++handles; + } + Weak(const Weak&) = delete; + Weak& operator=(const Weak&) = delete; + Weak(Weak&& other) noexcept + : object(std::exchange(other.object, 0)) + { + } + Weak& operator=(Weak&& other) noexcept + { + if (this != &other) { + if (object) + --handles; + object = std::exchange(other.object, 0); + } + return *this; + } + ~Weak() + { + if (object) + --handles; + } + explicit operator bool() const + { return object != 0; } + }; + static inline int owner = 0, finds = 0, scans = 0, scanned_owner = 0, fail_retain = 0; + static inline bool scan_ok = true; + static inline std::unordered_set live, active; + static inline std::vector materials; + static int GetOwner(const Weak& weak) + { return live.contains(weak.object) && active.contains(weak.object) ? weak.object : 0; } + static int FindOwner() + { + ++finds; + return live.contains(owner) && active.contains(owner) ? owner : 0; + } + static Weak Retain(int object) + { return Weak(object == fail_retain ? 0 : object); } + static bool ScanOwner(int owner, std::vector& out) + { + ++scans; + scanned_owner = owner; + for (int object : materials) + if (auto weak = Retain(object); weak) + out.push_back(std::move(weak)); + return scan_ok; + } +}; +} // namespace + +int main() +{ + using Cache = haven_roads::DiscoveryCache; + using namespace std::chrono_literals; + const auto t = Cache::Clock::time_point{} + 100s; + { + Cache cache; + // Missing owner and repeated same-timestamp callers cannot multiply finds. + for (int i = 0; i < 10000; ++i) + cache.Refresh(t); + Check(Backend::finds == 1 && Backend::scans == 0); + Backend::owner = 1; + Backend::live = Backend::active = {1}; + Backend::materials = {10}; + cache.Refresh(t + 4s); + Check(Backend::finds == 1 && cache.Materials().empty()); + cache.Refresh(t + 5s); + Check(Backend::finds == 2 && Backend::scans == 1 && Backend::scanned_owner == 1); + Check(cache.Materials().size() == 1 && cache.Materials()[0].object == 10); + for (int i = 0; i < 10000; ++i) + cache.Refresh(t + 6s); + Check(Backend::finds == 2 && Backend::scans == 1 && Backend::Weak::handles == 2); + + // Reacquire the canonical path even while the former owner stays active. + Backend::owner = 2; + Backend::live.insert(2); + Backend::active.insert(2); + Backend::materials = {20}; + cache.Refresh(t + 10s); + Check(Backend::finds == 3 && Backend::scans == 2 && Backend::scanned_owner == 2); + Check(cache.Materials()[0].object == 20 && Backend::Weak::handles == 2); + + // Descendant/material replacement under the same owner is rediscovered. + Backend::materials = {21}; + cache.Refresh(t + 15s); + Check(Backend::finds == 4 && Backend::scans == 3 && cache.Materials()[0].object == 21); + + // Failed scans retire stale membership and retry at the same bounded rate. + Backend::scan_ok = false; + cache.Refresh(t + 20s); + cache.Refresh(t + 21s); + Check(Backend::finds == 5 && Backend::scans == 4 && cache.Materials().empty()); + Check(Backend::Weak::handles == 1); + Backend::scan_ok = true; + Backend::fail_retain = 2; + cache.Refresh(t + 25s); + Check(Backend::finds == 6 && Backend::scans == 4 && Backend::Weak::handles == 0); + cache.Refresh(t + 26s); + Check(Backend::finds == 6 && Backend::scans == 4); + Backend::fail_retain = 0; + + // Preserve coverage above256 materials: the ceiling follows the legacy + // renderer/material scan bounds, rather than arbitrarily truncating roads. + Backend::materials.resize(1000); + std::iota(Backend::materials.begin(), Backend::materials.end(), 10000); + cache.Refresh(t + 30s); + Check(cache.Materials().size() == 1000 && Backend::Weak::handles == 1001); + Check(cache.Materials().front().object == 10000 && cache.Materials().back().object == 10999); + Backend::materials.resize(haven_roads::kMaterialLimit + 10); + std::iota(Backend::materials.begin(), Backend::materials.end(), 20000); + cache.Refresh(t + 35s); + Check(cache.Materials().size() == haven_roads::kMaterialLimit); + Check(Backend::Weak::handles == static_cast(haven_roads::kMaterialLimit + 1)); + // A failed material weak retain publishes no stale/unretained entries, and + // does not turn the next owner callback into another full discovery scan. + Backend::materials.assign(1000, 31); + Backend::fail_retain = 31; + cache.Refresh(t + 40s); + const auto scans = Backend::scans; + cache.Refresh(t + 41s); + Check(cache.Materials().empty() && Backend::Weak::handles == 1 && Backend::scans == scans); + Backend::fail_retain = 0; + Backend::materials.resize(1000); + std::iota(Backend::materials.begin(), Backend::materials.end(), 30000); + cache.Refresh(t + 45s); + Check(cache.Materials().size() == 1000 && Backend::Weak::handles == 1001); + for (int i = 1; i < 96; ++i) { + cache.Refresh(t + 45s + i * 5s); + Check(Backend::Weak::handles == 1001); + } + // Native-inactive but managed-live owner invalidates immediately, with no + // scene-wide retry before the next scheduled discovery. + Backend::active.erase(2); + cache.Refresh(t + 521s); + Check(cache.Materials().empty() && Backend::Weak::handles == 0); + const auto finds = Backend::finds; + cache.Refresh(t + 522s); + Check(Backend::finds == finds); + Backend::owner = 0; + cache.Refresh(t + 525s); + Check(Backend::finds == finds + 1 && Backend::Weak::handles == 0); + } + Check(Backend::Weak::handles == 0); +}