diff --git a/Extension/Music/local_music_ui.cpp b/Extension/Music/local_music_ui.cpp index b249242d..2c4fa180 100644 --- a/Extension/Music/local_music_ui.cpp +++ b/Extension/Music/local_music_ui.cpp @@ -10,8 +10,182 @@ #include "Engine/Game/Build/20260929/local_music.h" #include "Engine/Game/World/location_travel.h" #include "Extension/Profile/runtime_internal.h" +#include "Extension/UI/NativeMenu/native_menu_data.h" +#include +#include namespace dingosdk::profile_runtime { +namespace { +// Format model handles for shelf publication logs. +std::string music_diag_hex(std::uint64_t value, int width) { + static constexpr char digits[] = "0123456789abcdef"; + std::string out(static_cast(width), '0'); + for (int i = width - 1; i >= 0; --i, value >>= 4) out[static_cast(i)] = digits[value & 15]; + return out; +} +namespace md = dingosdk::multiplayer::menu_data; +struct ModsShelf { + std::uintptr_t manager{}; + md::Value anchor{}, tiles{}, featured_anchor{}; + ULONGLONG next_poll{}; + std::vector published_rows; +}; +ModsShelf 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) { + for (auto hash : hashes) { + const auto field = context.member(type, hash); + data += field.offset; + type = field.type; + } + return data; +} +md::Ref music_tile_ref(const md::Context& context, md::Value anchor) { + return md::read(music_inline_path(context, context.address(anchor), anchor.type, + {0x214d4984U, 0x25e4d6c8U})); +} +void music_sync_mods(const md::Context& context) { + if (mods_shelf.manager != context.manager || !mods_shelf.tiles.handle) return; + const auto ref = music_tile_ref(context, mods_shelf.featured_anchor); + const auto handle = ref.handle & ~std::uint64_t{1}; + if (!handle) return; + const md::Value featured{handle, context.type_of(handle)}; + md::require(featured.type == mods_shelf.tiles.type, "Featured tile schema differs."); + const auto source_items = context.field(featured, 0x67223da7U); + unsigned count{}, stride{}; + const auto source = context.array(source_items, 1024, count, stride); + const auto row_type = md::read(md::read(source_items.type) + 0x30); + md::require(stride == 1200 && md::read(md::read(row_type)) == 0xdeb20e0fU, + "Music tile row schema differs."); + std::vector filtered; + unsigned mods{}; + for (unsigned i = 0; i < count; ++i) { + const auto at = reinterpret_cast(source.data() + std::size_t{i} * stride); + const auto tile = md::read(music_inline_path(context, at, row_type, + {0x214d4984U, 0x0fb0d794U, 0xa704272aU, 0xc52416efU, 0x0abf7c31U})); + const auto tile_handle = tile.handle & ~std::uint64_t{1}; + if (!tile_handle) continue; + const md::Value tile_model{tile_handle, context.type_of(tile_handle)}; + const auto playlist_handle = md::read(music_inline_path(context, + context.address(tile_model), tile_model.type, {0xeb4f9b4cU})) & ~std::uint64_t{1}; + if (!playlist_handle) continue; + const md::Value playlist{playlist_handle, context.type_of(playlist_handle)}; + const auto id = context.text(context.field(playlist, 0xa5b84b8aU), 2048); + if (!id.starts_with("mod:")) continue; + filtered.insert(filtered.end(), source.begin() + std::size_t{i} * stride, + source.begin() + std::size_t{i + 1} * stride); + ++mods; + } + const auto destination = context.field(mods_shelf.tiles, 0x67223da7U); + unsigned previous{}, previous_stride{}; + context.array(destination, 1024, previous, previous_stride); + md::require(previous_stride == stride, "Mods tile row schema differs."); + if (previous == mods && mods_shelf.published_rows == filtered) return; + context.array(destination, filtered, mods); + mods_shelf.published_rows = std::move(filtered); + dingosdk::logging::event(dingosdk::logging::Channel::music, + 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; + mods_shelf.next_poll = GetTickCount64() + 250; + std::uintptr_t provider{}, manager{}; + if (!read(base + addr::local_music::ui_manager, provider) || !provider || + !read(provider + 0x80, manager) || manager != mods_shelf.manager) return; + try { + game::ModelWriteLock lock(mods_shelf.manager); + music_sync_mods(md::Context{base, mods_shelf.manager}); + } catch (const std::exception& error) { + static std::string last_error; + if (last_error != error.what()) { + last_error = error.what(); + dingosdk::logging::event(dingosdk::logging::Channel::music, + dingosdk::Json{{"event", "music_mods_shelf_waiting"}, {"reason", last_error}}.dump().c_str()); + } + } +} +// Publish the independent Mods shelf before native widgets bind. +std::string music_shelf_append_one(std::uintptr_t base, std::uintptr_t model, std::uint64_t exact_list = 0) { + namespace md = dingosdk::multiplayer::menu_data; + game::ModelWriteLock lock(model); + md::Context shelf{base, model}; + std::string report = "shelf_append"; + unsigned candidates = 0; + const auto roots = exact_list + ? std::vector{{md::Value{exact_list, shelf.type_of(exact_list)}, 0}} + : shelf.roots({0x48455d84U}); + for (const auto& root : roots) { + try { + const auto items = shelf.field(root.model, 0x67223da7U); + unsigned count = 0, stride = 0; + const auto bytes = shelf.array(items, 64, count, stride); + if (stride != 16 || count < 1 || count > 8) continue; + const auto first = shelf.element(items, 0); + const auto element_schema = first.type ? md::read(md::read(first.type)) : 0U; + if (element_schema != 0x62088281U) continue; + std::uint64_t target = 0; + std::memcpy(&target, bytes.data() + 8, sizeof(target)); + std::uint32_t target_schema = 0; + if (target) { try { const auto type = shelf.type_of(target & ~std::uint64_t{1}); target_schema = type ? md::read(md::read(type)) : 0U; } catch (const std::exception&) {} } + ++candidates; + if (target_schema != 0x0e3be640U) continue; + report += " list=0x" + music_diag_hex(root.model.handle, 16) + " count=" + std::to_string(count); + if (count >= 4) { report += " already"; break; } + if (count < 2) { report += " too_few"; break; } + std::uint64_t record_ref = 0, source_vm = 0; + 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}); + auto new_vm = shelf.create(md::Schema{0x0e3be640U, 192}, (GetTickCount64() << 12) | 0x4dU); + md::Value new_tiles{}; + try { + const md::Value source_anchor{source_vm, source_type}; + shelf.copy(new_vm, shelf.address(source_anchor)); + const auto tile_ref = music_tile_ref(shelf, source_anchor); + auto tile_handle = tile_ref.handle & ~std::uint64_t{1}; + const auto tile_type = shelf.type(md::Schema{0x74b1ea1dU, 392}); + std::uintptr_t tile_data{}; + if (tile_handle && shelf.type_of(tile_handle) == tile_type) + tile_data = shelf.address(md::Value{tile_handle, tile_type}); + else if (tile_ref.record && md::read(tile_ref.record + 0x18) == tile_type) + 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); + 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); + throw; + } + std::vector element(stride); + std::memcpy(element.data(), &record_ref, 8); + std::memcpy(element.data() + 8, &new_vm.handle, 8); + 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&) {} + } + try { music_sync_mods(shelf); } catch (const std::exception&) {} + unsigned after = 0, after_stride = 0; + shelf.array(items, 64, after, after_stride); + report += " new=0x" + music_diag_hex(new_vm.handle, 16) + " appended=" + std::to_string(count + 1) + + " after=" + std::to_string(after); + break; + } catch (const std::exception& error) { report += " error=" + std::string(error.what()); continue; } + } + report += " candidates=" + std::to_string(candidates); + return report; +} +} // Runtime-only music UI hydration: read_music_catalog copies actual registered // MusicGraphAsset metadata/TagRefs. No generated catalog, guessed memberships, @@ -29,6 +203,40 @@ void music_release_weak(std::uintptr_t owner) noexcept { MusicUiRuntime& music_ui_runtime() { static auto* r = new MusicUiRuntime; return *r; } +// CONFIRMED: the authored loader calls the shared constructor at RVA 0x1912670. +// The return path exposes the shelf list before its native widgets bind. +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) { + const auto handle = music_model_construct_original(manager, mode, id, type, flag, record); + PreserveError preserve; + try { + if (!handle || !type || md::read(md::read(type)) != 0x48455d84U) + return handle; + game::ModelWriteLock lock(manager); + md::Context context{local_runtime().base, manager}; + const auto items = context.field(md::Value{handle, type}, 0x67223da7U); + unsigned count{}, stride{}; + const auto bytes = context.array(items, 1024, count, stride); + if (count != 3 || stride != 16) return handle; + constexpr const char* records[] = { + "MusicPlaylistManager_Base_ContentResources/Featured_AnchoredContent_VM", + "MusicPlaylistManager_Base_ContentResources/Liked_AnchoredContent_VM", + "MusicPlaylistManager_Base_ContentResources/NewlyDiscovered_AnchoredContent_VM"}; + for (unsigned item = 0; item < 3; ++item) { + std::uintptr_t item_record{}; + std::memcpy(&item_record, bytes.data() + item * stride, sizeof(item_record)); + // Named record header +0x28 is also used by the native UI dumper. + if (!item_record || md::string(md::read(item_record + 0x28), 256) != records[item]) + return handle; + } + const auto report = music_shelf_append_one(local_runtime().base, manager, handle); + dingosdk::logging::event(dingosdk::logging::Channel::music, + dingosdk::Json{{"event", "music_shelf_append"}, {"report", report}}.dump().c_str()); + } catch (...) {} // A shelf failure must not interrupt the native constructor. + return handle; +} + bool initialize_music_functions(std::uintptr_t base) { namespace music = addr::local_music; for (const auto& fp : music::music_ui_contracts) { @@ -212,6 +420,7 @@ void update_music_catalog() { auto& s = local_runtime(); auto& ui = music_ui_runtime(); if (!s.active.load(std::memory_order_acquire) || ui.updating || cosmetic_runtime().update_thread != GetCurrentThreadId()) return; PreserveError preserve; std::lock_guard lock(s.native_mutex); + music_poll_mods(s.base); if (!ui.pending) return; if (ui.pending->thread != GetCurrentThreadId() || !music_ui_current(*ui.pending)) { ui.pending.reset(); return; } const auto now = GetTickCount64(); if (now < ui.pending->next_poll) return; @@ -287,4 +496,4 @@ void update_music_catalog() { dingosdk::logging::event(dingosdk::logging::Channel::music, dingosdk::Json{{"event", "native_music_ui_complete"}, {"playlists", playlist_count}, {"songs", song_count}, {"source", "runtime_music_assets"}}.dump().c_str()); } catch (...) { retry = false; dingosdk::logging::event(dingosdk::logging::Channel::music, "{\"event\":\"native_music_ui_publish_failed\"}"); } } -} \ No newline at end of file +} diff --git a/Extension/Music/local_music_ui.h b/Extension/Music/local_music_ui.h index f5fa19ed..57062f58 100644 --- a/Extension/Music/local_music_ui.h +++ b/Extension/Music/local_music_ui.h @@ -82,4 +82,14 @@ void music_ui_initialize_hook(std::uint64_t all, std::uint64_t hidden, std::uint const std::uint32_t* handle, const void* callback); void update_music_catalog(); + +// Shared authored-model constructor, ABI verified at Skate.exe +0x1912670 and +0x191868f. +inline constexpr game::build::Fingerprint music_model_construct_contract{0x1912670, + {0x4c,0x89,0x44,0x24,0x18,0x55,0x56,0x41,0x55,0x41,0x56,0x48,0x8d,0x6c,0x24,0xd1, + 0x48,0x81,0xec,0xf8,0x00,0x00,0x00,0x4d,0x8b,0xf1,0x44,0x0f,0xb6,0xea,0x48,0x8b}}; +using MusicModelConstruct = std::uint64_t (*)(std::uintptr_t, std::uint8_t, std::uint64_t, + std::uintptr_t, bool, std::uintptr_t); +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); } diff --git a/Extension/Profile/local_profile_runtime.cpp b/Extension/Profile/local_profile_runtime.cpp index e7596edd..637acf02 100644 --- a/Extension/Profile/local_profile_runtime.cpp +++ b/Extension/Profile/local_profile_runtime.cpp @@ -523,8 +523,15 @@ bool initialize_local_profile(std::uintptr_t base, bool authored_offline, hook(news_list_contract, &news_list_hook, news_runtime().functions.list); hook(news_subscribe_contract, &news_subscribe_hook, news_runtime().functions.subscribe); hook(object_subscribe_contract, &object_categories_hook, object_runtime().functions.subscribe); - if (music_ready) + if (music_ready) { hook(music_ui_initialize_contract, &music_ui_initialize_hook, music_ui_runtime().functions.initialize); + std::array construct_bytes{}; + if (read(base + music_model_construct_contract.rva, construct_bytes) && + construct_bytes == music_model_construct_contract.bytes) + hook(music_model_construct_contract, &music_model_construct_hook, music_model_construct_original); + else dingosdk::logging::event(dingosdk::logging::Channel::music, + "{\"event\":\"music_model_construct_contract_mismatch\"}"); + } hook(buildkit_text_exists_contract, &buildkit_text_exists, buildkit_text_functions().exists); hook(buildkit_text_translate_contract, &buildkit_text_translate, buildkit_text_functions().translate); hook(buildkit_grabber_settings_contract, &buildkit_grabber_settings_hook, buildkit_limits_runtime().grabber_settings); diff --git a/contrib/native-music-ui.md b/contrib/native-music-ui.md new file mode 100644 index 00000000..8510c067 --- /dev/null +++ b/contrib/native-music-ui.md @@ -0,0 +1,310 @@ +# Modding the native Music Playlist Manager + +This guide explains how ReSkate adds a **Mods** shelf above Featured in skate.'s +existing Music Playlist Manager. It is a worked example of changing native UI +through its data models, while reusing the game's layout, playlist tiles and +selection behavior. + +The implementation lives in [local_music_ui.cpp](../Extension/Music/local_music_ui.cpp). +These are C++ runtime changes. A music mod can supply playlists through the +existing music-mod pipeline; adding a new native shelf requires runtime code +such as this hook. There is no general declarative UI-mod API established here. + +## Evidence and version boundary + +The loader addresses below are **CONFIRMED by disassembly** for Skate.exe SHA256 +`fbce74d5e28ef525dbba2cb4adbebc13405bdbd88f31bc940bca45e4ae88b8f9`. +Addresses use preferred image base `0x140000000`; runtime code uses the loaded +base plus an RVA. Schema sizes and field paths were checked against authored +type metadata and live UI dumps on that build. + +The independent Mods shelf and its position above Featured were **confirmed by +the user's in-game observation** on the personal development build. That is +distinct from compiling the isolated upstream-based PR branch. It does not +establish compatibility with a different executable or every native screen. + +## Three layers to keep separate + +**Authored assets describe the screen.** The music content-resource asset contains +named records for its shelves. Type-info assets describe the structures and their +fields. These records are templates and references, rather than live widgets. + +**Runtime models hold the current values.** A model manager instantiates typed +values and assigns handles. The music provider publishes playlist/song data into +its contexts. The native UI also has presentation models: lists, labels, +presenters and tile rows. A playlist model and its visible tile are different +objects. + +**Widgets consume presentation models.** A presenter binds a model to an authored +widget. Updating a value and creating a new visible widget are therefore separate +events. For this screen, adding a shelf after binding changed the array count +without producing the fourth rendered shelf. Inserting it before binding worked. +That observation is specific to the shelf list; do not assume all native lists +have identical behavior. + +```mermaid +flowchart TD + A[Authored music content records] --> B[Shared model constructor] + B --> C[Shelf linear list] + C --> D[Anchored presenter model] + D --> E[Content presenter / DataModelReference] + E --> F[TileListViewModel] + F --> G[Label and inline tile rows] + G --> H[Native widgets] + I[Music provider and playlist contexts] --> J[Featured presentation rows] + J --> K[Filter playlist IDs beginning with mod:] + K --> L[Independent Mods tile list] + L --> H +``` + +## Finding the right model + +A schema hash identifies a **type**, not a particular screen instance. Many +screens use `linear_list`, and several lists can contain three anchored +presenters. Matching only a schema and count was insufficient. + +The music hook first checks for a `linear_list`, then requires three 16-byte +entries with these exact authored record names, in this order: + +```text +MusicPlaylistManager_Base_ContentResources/Featured_AnchoredContent_VM +MusicPlaylistManager_Base_ContentResources/Liked_AnchoredContent_VM +MusicPlaylistManager_Base_ContentResources/NewlyDiscovered_AnchoredContent_VM +``` + +Record identity is available earlier than every nested runtime binding. An early +attempt to identify the list by traversing its rendered titles did not complete. +Checking the authored names allowed the constructor-time insertion to fire. +The precise cause of that failed title traversal was not established. + +### Types encountered in the shelf + +| Type or role | Schema hash | Size on the tested build | +| --- | --- | ---: | +| PageViewModel | `0x5844be65` | 1968 bytes | +| Shelf `linear_list` | `0x48455d84` | 464 bytes | +| DataModelReference | `0x62088281` | 16 bytes | +| AnchoredContentPresenterViewModel | `0x0e3be640` | 192 bytes | +| ContentPresenter | `0x395d20a4` | 24 bytes | +| TileListViewModel | `0x74b1ea1d` | 392 bytes | +| Inline playlist tile row | `0xdeb20e0f` | 1200 bytes | +| Playlist data model | `0x63ddd270` | Resolve its fields through metadata | + +The shelf list and the tile list both expose `ListItems`, hash `0x67223da7`, but +their element types differ. Shelf elements are 16-byte references. Playlist tile +elements are complete 1200-byte inline structures. Treating every `ListItems` +array as an array of handles corrupts the interpretation. + +### Records, handles and references + +The helper API represents a DataModelReference as: + +```cpp +struct Ref { + std::uintptr_t record; // authored record pointer, +0x00 + std::uint64_t handle; // runtime ValueContext, +0x08 +}; +``` + +A record pointer and a model handle are different forms of identity. A handle is +not a memory address; resolve its type and storage through the model manager. +On the verified loader at `0x141918520` (RVA `0x1918520`), a named record supplies +its type at `+0x18` and typed data at `+0x20`. The UI dumper also reads its name +from `+0x28`. + +The outer shelf reference points to an anchored model. Within that model: + +```text +AnchoredContentPresenterViewModel + -> field 0x214d4984 + -> field 0x25e4d6c8 : DataModelReference + record +0x00 + ValueContext +0x08 : TileListViewModel handle +``` + +The tile-list title is a separate nested value: + +```text +TileListViewModel + -> CommonListData 0xfaec2d05 + -> label field 0xcb79482d + -> text field 0x4d8e01b9 +``` + +Use field metadata to obtain offsets and types. These hashes were recovered from +the imported type-info assets: `FieldNameHash` in authored values corresponds to +`NameHash` in field metadata. They are not names to recover by assuming an +ordinary string-hash algorithm. Type schema hashes form another lookup concern. + +## Why the constructor hook matters + +Hooking the public model-create wrapper did not reveal the authored music list. +The two creation paths meet lower down: + +```text +models.create wrapper: 0x141918860 + calls shared constructor at 0x141918a1e + +authored record loader: 0x141918520 + calls shared constructor at 0x14191868f + +shared constructor: 0x141912670 (RVA 0x1912670) +``` + +**CONFIRMED:** the authored loader bypasses `models.create` while still using the +shared constructor. Absence from a wrapper hook did not mean the model was never +created. + +The verified shared-constructor signature is: + +```cpp +std::uint64_t construct( + std::uintptr_t manager, + std::uint8_t mode, + std::uint64_t id, + std::uintptr_t type, + bool flag, + std::uintptr_t record); +``` + +The shelf hook calls the original constructor first. On its return, it recognizes +the exact music list and inserts the new entry before the native caller continues +to bind widgets. It returns the original handle unchanged. Installation checks +an exact 32-byte executable fingerprint; a mismatch leaves this hook uninstalled. +See [local_music_ui.h](../Extension/Music/local_music_ui.h) for the contract and +[local_profile_runtime.cpp](../Extension/Profile/local_profile_runtime.cpp) for +installation. That profile-runtime change only installs the music hook; it does +not change cosmetic ownership or progression. + +## Building an independent shelf + +The successful duplicate experiment cloned Liked's anchored presenter with a new +handle. That rendered a fourth shelf, but still referenced Liked's tile list. +Cloning only the outer presenter does not make its contents independent. + +The Mods implementation performs these steps under the native model write lock: + +1. Read the original three shelf entries and retain the Featured anchor as the + source of native playlist rows. +2. Create an anchored model and copy Liked's anchored template into it. +3. Create a separate TileListViewModel and copy Liked's tile-list template. + Use resolved runtime data if available, otherwise verified authored record + data of the expected type. +4. Publish the new title text, `Mods`, through the nested label field. +5. Clear the new tile array. Retarget the new anchor's presenter reference to + `{record = 0, handle = new_tiles.handle}`. +6. Insert the new outer shelf reference at index zero. The result is + **Mods, Featured, Liked, Newly Discovered**. +7. Populate the owned tile list from mod playlist rows in Featured. + +The native template supplies layout and presentation defaults. Creating both +models separately supplies independent state. Changing the label on the original +Liked tile list would rename the original shelf too. + +## Keeping the native tile behavior + +A row contains more than its visible title. It carries nested presentation data +and references used by native behavior. Rather than inventing a replacement row, +the implementation copies the existing Featured rows for mod playlists. + +The tested inline path to the row's tile-model reference is: + +```text +row 0xdeb20e0f + -> 0x214d4984 + -> 0x0fb0d794 + -> 0xa704272a + -> 0xc52416ef + -> 0x0abf7c31 : DataModelReference +``` + +Resolve that reference's runtime handle, then read field `0xeb4f9b4c` to obtain +the playlist handle. Playlist field `0xa5b84b8a` contains its identifier. Rows +whose identifiers start with `mod:` are included in Mods. The native copies +retain the existing actions and artwork references; filtering does not create +new playlist identities or replace the music provider. + +The client update path polls every 250 ms because Featured presentation rows may +be hydrated after shelf construction. It follows the captured Featured anchor, +checks the current music provider's model manager, and republishes only when the +filtered row bytes or destination count change. A cached copy of source row bytes +avoids repeatedly publishing merely because native copying changes storage. + +This adds a presentation shelf. Mod playlists can still appear in Featured; +the implementation does not remove them there or write them into Liked and Newly +Discovered. + +## Mutation and ownership + +Use [menu_data::Context](../Extension/UI/NativeMenu/native_menu_data.h) on the +client thread while holding `game::ModelWriteLock`. Its helpers validate model +types, resolve field metadata, and publish through native model operations. + +| Helper | Purpose | +| --- | --- | +| `type_of`, `address` | Resolve a handle and validate live typed storage | +| `member` | Read a field's metadata, including its offset and type | +| `field`, `path` | Resolve native handles for fields being read or published | +| `create`, `copy` | Create an owned root and copy a typed template | +| `text`, `set`, `array` | Publish native values rather than assign raw pointers | +| `destroy` | Release an owned root, with type/expiration checks | + +`Context::array` reads the element stride from the array's metadata and checks +that byte length matches element count. Its write helper builds the native array +header and performs synchronous typed publication. Native copying handles strings, +delegates, references and nested arrays; a borrowed pointer into a temporary +`std::vector` must not be installed as persistent model storage. + +Raw traversal is useful for inspecting inline values before nested field handles +are available. The Mods hook uses metadata offsets for those reads. It still +uses native publication for writes. A raw memory snapshot containing pointers is +not a self-contained owned model. + +Handle lifetime also matters. The implementation releases newly created roots +if template preparation fails and releases its previous owned anchor/tile roots +when replacing its tracked generation in the same manager. Reopening and switching +screens must be tested: a valid handle from one observation is not proof of its +validity after scene teardown. + +## Inspecting a screen yourself + +ReSkate's console exposes the live model dumper: + +```text +ui dump roots +ui dump MusicPlaylistManager +ui dump 0x48455d84 +ui dump 0x0e3be640 +ui dump 0x74b1ea1d +ui dump 0x74b1ea1d 5 +``` + +The last form schedules the dump after five seconds, allowing time to open the +screen. Output goes to the game's `logs` directory. Read +[native_menu_dump.cpp](../Extension/UI/NativeMenu/native_menu_dump.cpp) for the +walker and [console_commands.cpp](../Extension/Multiplayer/console_commands.cpp) +for command registration. + +Start with roots, identify the presentation type, follow record and handle +references, and compare observations before and after opening the screen. The +dumper has depth, element and node limits; a truncated dump cannot prove that a +field or model is absent. + +For a new native UI change, establish four separate results: the intended model +was identified, native publication changed its value, widgets displayed the +change, and user interaction still worked. Test reopening as well as first open. +For shelves, test empty contents, multiple mod playlists and controller navigation. +Those are useful future regression checks, not claims that every case has already +passed for this implementation. + +## Lessons that transfer to other screens + +Recognize the particular authored resource before touching shared UI types. Trace +all relevant creation paths rather than relying on a convenient wrapper. Check +when widgets bind before choosing a mutation point. Clone every layer that needs +independent state, preserve native rows when their behavior is useful, and publish +through the engine's typed operations. + +The Mods shelf demonstrates those techniques on one screen and one executable +build. Other screens need their own identity checks, schemas, timing observations +and interaction tests.