diff --git a/Extension/Music/Test/local_music_shelf_lifetime_tests.cpp b/Extension/Music/Test/local_music_shelf_lifetime_tests.cpp new file mode 100644 index 0000000..e479567 --- /dev/null +++ b/Extension/Music/Test/local_music_shelf_lifetime_tests.cpp @@ -0,0 +1,84 @@ +#include "Extension/Music/local_music_shelf_lifetime.h" +#include +#include +#include +#include + +namespace md = dingosdk::multiplayer::menu_data; +struct Ref { std::uint64_t record, handle; bool operator==(const Ref&) const = default; }; +struct Model { std::uint64_t handle, type; }; +void check(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +void shelf_transition(unsigned leaving, unsigned active) { + md::MenuLifetime lifetime; + md::OwnedMenuModels models; + lifetime.before_transition(13, [] {}); + const std::vector native{{11, 100}, {22, 200}, {33, 300}}; + auto refs = native; + refs.insert(refs.begin() + 1, Ref{22, 401}); // Tagged anchor, same record as native Liked. + models.track({400, 1}); models.track({500, 2}); + bool child_bound = true, assets_live = true; + unsigned cleanups{}; + std::vector destroyed; + const auto cleanup = [&] { + ++cleanups; + check(lifetime.blocked(), "Block shelf creation and polling before detach callbacks"); + models.release([&] { + refs = dingosdk::profile_runtime::music_shelf_detached(std::span{refs}, 400); + child_bound = false; + }, [&](Model model) { + check(assets_live, "Destroy widget-bearing rows before unloading their assets"); + check(refs == native && !child_bound, "Detach shelf and child bindings before destruction"); + destroyed.push_back(model.handle); + }); + }; + lifetime.before_transition(leaving, cleanup); + assets_live = false; + for (unsigned next : {leaving, 3U, 4U, 5U, 12U}) { + lifetime.before_transition(next, cleanup); + check(lifetime.blocked(), "Loading ticks must not recreate or republish the shelf"); + } + check(cleanups == 1 && models.empty() && destroyed == std::vector{500, 400}, + "Release both roots exactly once, including the two-row tile list"); + assets_live = true; + lifetime.before_transition(active, cleanup); + check(!lifetime.blocked(), "Allow rebuilding in the next active world"); + models.track({600, 1}); + lifetime.before_transition(24, cleanup); + assets_live = false; + lifetime.before_transition(24, cleanup); + lifetime.before_transition(active, cleanup); + check(lifetime.blocked() && models.empty() && destroyed.back() == 600 && cleanups == 2, + "Shutdown must release a rebuilt shelf and permanently suppress recreation"); +} + +void partial_generation() { + md::OwnedMenuModels models; + models.track({400, 1}); // Anchor created, tile-template preparation failed. + unsigned destroys{}; + try { models.release([] { throw std::runtime_error("detach failed"); }, [&](Model) { ++destroys; }); } + catch (const std::runtime_error&) {} + check(!models.empty() && !destroys, "Keep failed cleanup ownership for a retry"); + models.release([] {}, [&](Model) { ++destroys; }); + check(models.empty() && destroys == 1, "Release partial builds as well as published shelves"); + models.track({400, 1}); + models.manager_replaced(); + models.release([] {}, [](Model) { throw std::runtime_error("expired manager dereferenced"); }); +} + +int main() { + try { + for (unsigned leaving : {14U, 22U, 3U}) shelf_transition(leaving, leaving == 22 ? 21 : 13); + partial_generation(); + const std::array native{{{1, 100}}}; + check(dingosdk::profile_runtime::music_shelf_detached(std::span{native}, 400) == + std::vector{native.begin(), native.end()}, "Expired/missing shelf removal preserves native entries"); + std::puts("Music shelf lifetime tests passed."); + return 0; + } catch (const std::exception& error) { + std::fprintf(stderr, "%s\n", error.what()); + return 1; + } +} diff --git a/Extension/Music/local_music_shelf.cpp b/Extension/Music/local_music_shelf.cpp index e731c51..a3254e2 100644 --- a/Extension/Music/local_music_shelf.cpp +++ b/Extension/Music/local_music_shelf.cpp @@ -1,4 +1,5 @@ #include "Extension/Music/local_music_shelf.h" +#include "Extension/Music/local_music_shelf_lifetime.h" #include "Extension/Music/local_music_assets.h" #include "Engine/Core/Log/logging.h" #include "Engine/Game/Abi/native_data.h" @@ -27,11 +28,50 @@ std::string music_diag_hex(std::uint64_t value, int width) { namespace md = dingosdk::multiplayer::menu_data; struct ModsShelf { std::uintptr_t manager{}; - md::Value anchor{}, tiles{}, featured_anchor{}; + md::Value anchor{}, tiles{}, featured_anchor{}, list{}; ULONGLONG next_poll{}; std::vector published_rows; }; ModsShelf mods_shelf; +md::MenuLifetime music_shelf_lifetime; +md::OwnedMenuModels music_shelf_models; +bool music_shelf_cleaning{}; + +void music_release_shelf(std::uintptr_t base) { + if (music_shelf_models.empty()) { mods_shelf = {}; return; } + // The provider can disappear before this callback. Use the common UI model + // manager, as native-menu cleanup does, rather than requiring a live provider. + std::uintptr_t ui{}, manager{}; + md::require(read(base + addr::engine::ui_manager, ui) && ui && + read(ui + 0x140, manager) && manager, "Music shelf cleanup manager is unavailable."); + if (manager != mods_shelf.manager) { + music_shelf_models.manager_replaced(); + mods_shelf = {}; + return; + } + const bool was_cleaning = music_shelf_cleaning; + music_shelf_cleaning = true; + struct Restore { bool previous; ~Restore() { music_shelf_cleaning = previous; } } restore{was_cleaning}; + game::ModelWriteLock lock(manager); + const md::Context context{base, manager}; + music_shelf_models.release([&] { + if (mods_shelf.list.handle && context.type_of(mods_shelf.list.handle) == mods_shelf.list.type) { + const auto items = context.field(mods_shelf.list, 0x67223da7U); + unsigned count{}, stride{}; + const auto bytes = context.array(items, 64, count, stride); + md::require(stride == sizeof(md::Ref), "Music shelf cleanup list differs."); + std::vector refs(count); + if (!bytes.empty()) std::memcpy(refs.data(), bytes.data(), bytes.size()); + const auto retained = music_shelf_detached(std::span{refs}, mods_shelf.anchor.handle); + if (retained.size() != refs.size()) context.array(items, retained); + } + // Unbind the child before reverse-creation-order destruction. Both roots + // must release their widget references while the outgoing assets live. + if (mods_shelf.anchor.handle && context.type_of(mods_shelf.anchor.handle) == mods_shelf.anchor.type) + context.set(context.path(mods_shelf.anchor, {0x214d4984U, 0x25e4d6c8U}), md::Ref{}); + }, [&](md::Value value) { context.destroy(value); }); + mods_shelf = {}; +} // Read inline typed fields without creating child handles during authored construction. std::uintptr_t music_inline_path(const md::Context& context, std::uintptr_t data, std::uintptr_t type, std::initializer_list hashes) { @@ -89,7 +129,8 @@ void music_sync_mods(const md::Context& context) { dingosdk::Json{{"event", "music_mods_shelf_fill"}, {"count", mods}, {"featured", count}}.dump().c_str()); } void music_poll_mods(std::uintptr_t base) { - if (!mods_shelf.manager || GetTickCount64() < mods_shelf.next_poll) return; + if (music_shelf_lifetime.blocked() || music_shelf_cleaning || + !mods_shelf.manager || GetTickCount64() < mods_shelf.next_poll) return; mods_shelf.next_poll = GetTickCount64() + 250; std::uintptr_t provider{}, manager{}; if (!read(base + addr::local_music::ui_manager, provider) || !provider || @@ -138,7 +179,14 @@ std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, st std::memcpy(&record_ref, bytes.data() + stride, 8); std::memcpy(&source_vm, bytes.data() + stride + 8, 8); const auto source_type = shelf.type_of(source_vm & ~std::uint64_t{1}); + // A screen can be recreated in the same manager without changing maps. + // Detach and release the old generation, rather than dropping its handles. + music_release_shelf(base); + mods_shelf.manager = model; + mods_shelf.list = root.model; auto new_vm = shelf.create(md::Schema{0x0e3be640U, 192}, (GetTickCount64() << 12) | 0x4dU); + mods_shelf.anchor = new_vm; + music_shelf_models.track(new_vm); md::Value new_tiles{}; try { const md::Value source_anchor{source_vm, source_type}; @@ -153,14 +201,15 @@ std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, st tile_data = md::read(tile_ref.record + 0x20); md::require(tile_data != 0, "Music tile template is not available."); new_tiles = shelf.create(md::Schema{0x74b1ea1dU, 392}, (GetTickCount64() << 12) | 0x4eU); + mods_shelf.tiles = new_tiles; + music_shelf_models.track(new_tiles); shelf.copy(new_tiles, tile_data); shelf.text(shelf.path(new_tiles, {0xfaec2d05U, 0xcb79482dU, 0x4d8e01b9U}), "Mods"); // An independent list retains each native row's actions and artwork on publication. shelf.array(shelf.field(new_tiles, 0x67223da7U), std::span{}, 0); shelf.set(shelf.path(new_vm, {0x214d4984U, 0x25e4d6c8U}), md::Ref{0, new_tiles.handle}); } catch (...) { - if (new_tiles.handle) shelf.destroy(new_tiles); - shelf.destroy(new_vm); + music_release_shelf(base); throw; } std::vector element(stride); @@ -169,12 +218,7 @@ std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, st auto merged = bytes; merged.insert(merged.begin(), element.begin(), element.end()); shelf.array(items, merged, count + 1); - const auto previous = mods_shelf; - mods_shelf = {model, new_vm, new_tiles, md::Value{target, shelf.type_of(target)}, 0}; - // Release our previous generation after the new shelf has been published. - if (previous.manager == model) { - try { shelf.destroy(previous.anchor); shelf.destroy(previous.tiles); } catch (const std::exception&) {} - } + mods_shelf.featured_anchor = md::Value{target, shelf.type_of(target)}; try { music_sync_mods(shelf); } catch (const std::exception&) {} unsigned after = 0, after_stride = 0; shelf.array(items, 64, after, after_stride); @@ -196,6 +240,7 @@ std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mo std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record) { const auto handle = music_model_construct_original(manager, mode, id, type, flag, record); PreserveError preserve; + if (music_shelf_lifetime.blocked() || music_shelf_cleaning) return handle; try { if (!handle || !type || md::read(md::read(type)) != 0x48455d84U) return handle; @@ -229,7 +274,7 @@ std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mo for (const auto& playlist : mod_music_playlists()) if (!playlist.songs.empty()) { has_mod_playlists = true; break; } if (!has_mod_playlists) { - mods_shelf = {}; + music_release_shelf(local_runtime().base); dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"music_mods_shelf_skipped\"}"); return handle; } @@ -240,6 +285,19 @@ std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mo return handle; } +void music_shelf_before_level_transition(unsigned next) noexcept { + try { + music_shelf_lifetime.before_transition(next, [&] { + music_release_shelf(local_runtime().base); + dingosdk::logging::event(dingosdk::logging::Channel::music, + "{\"event\":\"music_mods_shelf_released\"}"); + }); + } catch (...) { + try { dingosdk::logging::event(dingosdk::logging::Channel::music, + "{\"event\":\"music_mods_shelf_cleanup_failed\"}"); } catch (...) {} + } +} + void update_music_shelf() { auto& runtime = local_runtime(); if (!runtime.active.load(std::memory_order_acquire)) return; diff --git a/Extension/Music/local_music_shelf.h b/Extension/Music/local_music_shelf.h index 3bb8075..e129d8c 100644 --- a/Extension/Music/local_music_shelf.h +++ b/Extension/Music/local_music_shelf.h @@ -12,4 +12,5 @@ extern MusicModelConstruct music_model_construct_original; std::uint64_t music_model_construct_hook(std::uintptr_t manager, std::uint8_t mode, std::uint64_t id, std::uintptr_t type, bool flag, std::uintptr_t record); void update_music_shelf(); +void music_shelf_before_level_transition(unsigned next) noexcept; } // namespace dingosdk::profile_runtime diff --git a/Extension/Music/local_music_shelf_lifetime.h b/Extension/Music/local_music_shelf_lifetime.h new file mode 100644 index 0000000..335312f --- /dev/null +++ b/Extension/Music/local_music_shelf_lifetime.h @@ -0,0 +1,18 @@ +#pragma once +#include "Extension/UI/NativeMenu/native_menu_lifetime.h" +#include +#include +#include + +namespace dingosdk::profile_runtime { +// Remove only our independently owned anchor. Preserve native records, handles, +// and order, including when another shelf was inserted ahead of ours. +template +std::vector music_shelf_detached(std::span refs, std::uint64_t anchor) { + std::vector retained; + retained.reserve(refs.size()); + for (const auto& ref : refs) + if ((ref.handle & ~std::uint64_t{1}) != anchor) retained.push_back(ref); + return retained; +} +} diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index 17599a8..b6c5318 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -340,6 +340,7 @@ void local_profile_before_level_transition(unsigned next) noexcept { auto& s = local_runtime(); if (!s.active.load(std::memory_order_acquire)) return; std::lock_guard lock(s.native_mutex); + music_shelf_before_level_transition(next); news_runtime().pending.before_transition(next); object_runtime().pending.before_transition(next); auto& placements = placements_runtime(); diff --git a/cmake/Apps.cmake b/cmake/Apps.cmake index 7c77a21..c6b7312 100644 --- a/cmake/Apps.cmake +++ b/cmake/Apps.cmake @@ -52,6 +52,10 @@ if(DINGOSDK_BUILD_LAUNCHER_TESTS AND WIN32) target_include_directories(dingosdk_music_safety_tests PRIVATE "${PROJECT_SOURCE_DIR}") dingosdk_configure_target(dingosdk_music_safety_tests) add_test(NAME music_safety COMMAND dingosdk_music_safety_tests) + add_executable(dingosdk_music_shelf_lifetime_tests Extension/Music/Test/local_music_shelf_lifetime_tests.cpp) + target_include_directories(dingosdk_music_shelf_lifetime_tests PRIVATE "${PROJECT_SOURCE_DIR}") + dingosdk_configure_target(dingosdk_music_shelf_lifetime_tests) + add_test(NAME music_shelf_lifetime COMMAND dingosdk_music_shelf_lifetime_tests) add_executable(dingosdk_music_artwork_tests Extension/Music/Test/music_artwork_tests.cpp Extension/Music/music_artwork.cpp) target_link_libraries(dingosdk_music_artwork_tests PRIVATE dingosdk_mods ws2_32 winhttp) dingosdk_configure_target(dingosdk_music_artwork_tests) diff --git a/contrib/native-music-ui.md b/contrib/native-music-ui.md index d5a6908..c6ab55e 100644 --- a/contrib/native-music-ui.md +++ b/contrib/native-music-ui.md @@ -273,6 +273,17 @@ when replacing its tracked generation in the same manager. Reopening and switchi screens must be tested: a valid handle from one observation is not proof of its validity after scene teardown. +The Mods shelf participates in the existing pre-level-transition callback. Before +leaving a level/sublevel or shutting down, it removes its anchor from the native +shelf list, clears the anchor's tile binding, and destroys both owned roots while +their widget assets are still loaded. Construction and row polling are suspended +during loading and resume when the next world becomes active. Recreating the +screen also releases the previous generation, including partially built roots. +This addresses the observed stale widget-reference crash at `0x143DC5EAA` during +map unloading. The installed Release build was confirmed in game to resolve the +reported shelf-creation/map-switch reproduction; the lifetime tests separately +cover ownership and transition ordering. + ## Inspecting a screen yourself ReSkate's console exposes the live model dumper: