Music: add a native Mods shelf above Featured

Create independent anchored and tile-list models before native widget binding.
Populate Mods from Featured rows with mod playlist IDs while retaining native
presentation and actions. Leave existing playlist contexts and cosmetic
ownership unchanged.

Document the native UI model flow, verified loader entry points, typed
publication, and inspection workflow for future modders.
This commit is contained in:
ReSkate local committed 2026-10-05 05:04:37 +01:00
1 parent c930b512e6
commit e145a67db9
4 files changed
+538 -2

No files matched your search

+210 -1
View File
@@ -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 <algorithm>
#include <utility>
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<std::size_t>(width), '0');
for (int i = width - 1; i >= 0; --i, value >>= 4) out[static_cast<std::size_t>(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<std::byte> 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<std::uint32_t> 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<md::Ref>(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<std::uintptr_t>(md::read<std::uintptr_t>(source_items.type) + 0x30);
md::require(stride == 1200 && md::read<std::uint32_t>(md::read<std::uintptr_t>(row_type)) == 0xdeb20e0fU,
"Music tile row schema differs.");
std::vector<std::byte> filtered;
unsigned mods{};
for (unsigned i = 0; i < count; ++i) {
const auto at = reinterpret_cast<std::uintptr_t>(source.data() + std::size_t{i} * stride);
const auto tile = md::read<md::Ref>(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<std::uint64_t>(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::Root>{{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<std::uint32_t>(md::read<std::uintptr_t>(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<std::uint32_t>(md::read<std::uintptr_t>(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<std::uintptr_t>(tile_ref.record + 0x18) == tile_type)
tile_data = md::read<std::uintptr_t>(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<const std::byte>{}, 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<std::byte> 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<std::uint32_t>(md::read<std::uintptr_t>(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<std::uintptr_t>(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\"}"); }
}
}
}
+10
View File
@@ -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);
}
+8 -1
View File
@@ -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<unsigned char, 32> 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);
+310
View File
@@ -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.