mirror of
https://github.com/Dingo-Shenanigans/ReSkate.git
synced 2026-10-25 13:04:42 +01:00
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:
1 parent
c930b512e6
commit
e145a67db9
4 files changed
+538
-2
No files matched your search
@@ -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\"}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user