Mod Manager: a pause menu tab after Mod Options to browse, install, update and switch mods in game

A third ReSkate tab in the game's pause menu (native_menu.cpp slot 2, the Store's tab art), drawn by
the overlay as the other two are (mod_manager_page.inl, included into hub_page.cpp):

- BROWSE: search, categories in a panel of their own (shown only, hidden or either, kept in the
  profile), sort, a featured carousel and rows (most popular, recently updated, new, the biggest
  categories), a grid for results, and a mod's own page with its README, what it brings with it,
  and INSTALL, switch on or off and uninstall. Works from a controller: the carousel's card is
  one stop that left and right turn, and B goes back from a mod's page (the page's Back is held
  meanwhile).
- MY MODS: the Mods folder with the states the ReSkate menu shows; UPDATES: Update all.
- The foot of the page shows the install's progress and the apply's, and Apply (View on a pad).
  Leaving the menu with changes not applied asks first (a two-answer card in message_card.cpp,
  answered by the runtime as the message is); LATER closes the menu.

Behind it, Extension/Assets/mod_store: Thunderstore's listing (cached on disk), the installed mods,
and a queue that downloads and installs packages with their dependencies, updates and uninstalls
them. An update or uninstall of a mod the game holds open waits in Mods/.reskate-pending and the
launcher makes it before the next start (launcher_mods::apply_pending). Package icons load into
their own SRV slots (store_icons.cpp), sharing the launcher's icon cache.

Tests: mod_store_view (search, categories, the front page's rows, install plans), the pending
updates and uninstalls in mod_manager_tests, and the third tab in native_menu_view_tests.
This commit is contained in:
AyeZeeBB committed 2026-10-24 19:55:56 -05:00
1 parent 54716f2798
commit 725c18ef3d
25 files changed
+3164 -52

No files matched your search

+55 -19
View File
@@ -25,7 +25,7 @@ using menu_view::page_count, menu_view::tools_page;
// back before our models are released. Native tabs are found by their label, not index.
constexpr std::string_view store_tab_label = "ID_MENU_STORE", social_tab_label = "ID_MENU_SOCIAL";
constexpr unsigned social_tab_index = 4; // Before the Store is removed.
constexpr std::array<unsigned, page_count> page_slots{0, 1};
constexpr std::array<unsigned, page_count> page_slots{0, 1, 2};
// The Store item taken out of the pause menu, its index there, and that menu.
struct RemovedStore { Value item; unsigned index{}; Handle core{}; Address manager{};std::uint32_t key{}; };
RemovedStore removed_store;
@@ -76,7 +76,7 @@ Address action(const Context& context, std::string command, std::string argument
const bool unused = *slot == count;
if (unused) {
static const std::array functions{callbacks<0>(std::make_index_sequence<max_actions>{}),
callbacks<1>(std::make_index_sequence<max_actions>{})};
callbacks<1>(std::make_index_sequence<max_actions>{}), callbacks<2>(std::make_index_sequence<max_actions>{})};
// Native void-action descriptor used by this build. Only the invokers are
// replaced; it is never added to the game's global function registry.
const auto prototype = context.base + addr::native_menu::action_prototype;
@@ -191,8 +191,9 @@ namespace {
// Which of ReSkate's pages is on screen, for the overlay (native_menu_page): when one was last
// seen selected with the pause menu up (GetTickCount64; 0: not now), and which.
std::atomic<std::uint64_t> page_seen{};
std::atomic<int> page_shown{}; // 0: Multiplayer, 1: Mod Options
std::atomic<int> page_shown{}; // 0: Multiplayer, 1: Mod Options, 2: Mod Manager
std::atomic<bool> game_in_menu{};
std::atomic<bool> back_wanted{}; // hold_native_menu_back
// Returns the key of the tab the pause menu has selected (0: none of ours can be told).
std::uint32_t restore_last_tab(const Context& context, Value menu) {
static std::uint32_t remembered{};
@@ -234,6 +235,21 @@ std::uint32_t restore_last_tab(const Context& context, Value menu) {
}
return key_at(selected);
}
void build_back_action(const Context& context, State& s);
// The Mod Manager's Back while a mod's page is open: active, with no input and no navigation, so it
// swallows Escape and B (see build_back_action) and the overlay takes them; given back otherwise.
void hold_back(const Context& context, State& s, bool held) {
if (!held) {
build_back_action(context, s);
return;
}
const auto base = context.path(s.page, {0x10810f0b, button_field, 0xa704272a, 0xc52416ef, 0xa704272a});
const auto back = context.field(base, 0xa2b71c93);
context.set(context.field(back, 0x3d00de27), Address{});
context.text(context.field(back, 0xb1e14cd6), "");
context.set(context.field(base, 0x4b3146e9), true);
s.back_held = true;
}
// Page Back is a button action: input UI_Back, navigation "ToggleMenu", no
// delegate, handled by the shared navigation handler. An active action with
// neither swallows Escape/controller B. Build it directly rather than copying
@@ -248,6 +264,7 @@ void build_back_action(const Context& context, State& s) {
context.set(context.field(back, 0x3d00de27), back_input);
context.text(context.field(back, 0xb1e14cd6), "ToggleMenu");
context.set(context.field(base, 0x4b3146e9), true);
s.back_held = false;
}
void insert(const Context& context) {
auto& s = state();
@@ -464,8 +481,8 @@ void initialize(const Context& context, Root root, const MultiplayerModel& model
context.set(context.field(backdrop_stack, 0x3b14d43c), false);
context.set(context.field(backdrop_stack, 0x2a98baa3), false);
build_back_action(context, s);
static constexpr std::array<const char*, page_count> titles{"MULTIPLAYER", "MOD OPTIONS"},
tab_names{"Multiplayer", "Mod Options"};
static constexpr std::array<const char*, page_count> titles{"MULTIPLAYER", "MOD OPTIONS", "MOD MANAGER"},
tab_names{"Multiplayer", "Mod Options", "Mod Manager"};
context.text(context.path(s.page, {0x3781b603, text_field}), titles[s.slot]);
context.set(context.field(s.page, 0xe1d821a3), true);
context.set(context.field(s.page, 0x616cb924), false);
@@ -512,28 +529,41 @@ void initialize(const Context& context, Root root, const MultiplayerModel& model
context.copy(context.field(s.menu_item, 0xd78d240b),
context.address(context.path(s.core, {content, 0x54c9abee})));
tab_label(context, s.menu_item, tab_names[s.slot], menu_key());
// Seed both states with an already-live native fallback: Social for
// Multiplayer, Hub for ReSkate. Optional art must never gate either page.
// Copy only visual fields; actions remain attached to our own models.
// Seed every state with an already-live native fallback: Social for
// Multiplayer, Hub for ReSkate, and the Store's own art for the Mod Manager
// (the Store tab ReSkate takes out; Hub's when it is gone already). Optional
// art must never gate a page. Copy only visual fields; actions remain
// attached to our own models.
const auto core_items = context.path(s.core, {content, items});
const auto source_tab = context.field(context.element(core_items, s.slot ? 1 : social_index(context, s.core)), tab_data);
Value source_item = context.element(core_items, s.slot ? 1 : social_index(context, s.core));
if (s.slot == menu_view::mods_page) {
unsigned core_count{}, core_stride{};
context.array(core_items, 16, core_count, core_stride);
if (const auto store = find_tab(context, core_items, core_count, store_tab_label)) source_item = context.element(core_items, *store);
else if (removed_store.manager == context.manager && removed_store.item.handle &&
context.type_of(removed_store.item.handle) == removed_store.item.type) source_item = removed_store.item;
}
const auto source_tab = context.field(source_item, tab_data);
const auto target_tab = context.field(s.menu_item, tab_data);
for (const auto hash : {0xd4f0f044U, 0xbc92d1faU, 0xa3c18619U})
context.copy(context.field(target_tab, hash), context.address(context.field(source_tab, hash)));
// Prefer the exact requested glyph, resolving it from the locked native
// image registry rather than searching neighboring widget allocations.
// (The Mod Manager asks for none: the Store's art is what it wants.)
static constexpr std::array<const char*, page_count> idle_icons{"UI/Textures/Icons/HUB/img_Icon_SocialDefault_512",
"UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Idle_1024"};
"UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Idle_1024", nullptr};
static constexpr std::array<const char*, page_count> focused_icons{"UI/Textures/Icons/HUB/img_Icon_SocialFocused_512",
"UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Focused_1024"};
"UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Focused_1024", nullptr};
const auto* idle = idle_icons[s.slot];
const auto* focused = focused_icons[s.slot];
bool requested_icon{};
try { requested_icon = publish_tab_icons(context, target_tab, idle, focused); }
catch (const std::exception&) { /* The authored fallback is already retained. */ }
if (!requested_icon) logging::printf(logging::Level::info, logging::Channel::ui,
"Native %s menu: using native fallback icons; optional pair unavailable: %s / %s",
tab_names[s.slot], idle, focused);
if (idle && focused) {
try { requested_icon = publish_tab_icons(context, target_tab, idle, focused); }
catch (const std::exception&) { /* The authored fallback is already retained. */ }
if (!requested_icon) logging::printf(logging::Level::info, logging::Channel::ui,
"Native %s menu: using native fallback icons; optional pair unavailable: %s / %s",
tab_names[s.slot], idle, focused);
}
render(context, model);
insert(context);
status(std::string("Native ") + tab_names[s.slot] + " menu: tab registered.");
@@ -594,8 +624,9 @@ void tick_page(std::uintptr_t base, bool loading) noexcept {
hide_top_bar_currency(context, active.model);
update_hub(context);
// Tell the overlay while this page is the one on screen: it draws the body.
const bool ours = shown_tab == our_key || shown_tab == our_key + tools_page * 0x100;
const auto& shown_page = page_state(shown_tab == our_key ? 0 : tools_page);
const bool ours = shown_tab >= our_key && shown_tab < our_key + page_count * 0x100 && (shown_tab - our_key) % 0x100 == 0;
const unsigned shown_slot = ours ? (shown_tab - our_key) / 0x100 : 0;
const auto& shown_page = page_state(shown_slot);
bool on_screen = ours && game_in_menu.load(std::memory_order_relaxed) && shown_page.page.handle &&
context.type_of(shown_page.page.handle) == shown_page.page.type;
// The game's side menu (pause pressed again) and what opens from it (Settings...) go
@@ -613,9 +644,12 @@ void tick_page(std::uintptr_t base, bool loading) noexcept {
} catch (...) {}
}
// (Said at once when another page takes its place, not left to go stale.)
page_shown.store(shown_tab == our_key ? 0 : 1, std::memory_order_relaxed);
page_shown.store(static_cast<int>(shown_slot), std::memory_order_relaxed);
page_seen.store(on_screen ? now : 0, std::memory_order_relaxed);
}
if (s.slot == menu_view::mods_page && active.model.handle == s.core.handle && s.page.handle &&
context.type_of(s.page.handle) == s.page.type && back_wanted.load(std::memory_order_relaxed) != s.back_held)
hold_back(context, s, !s.back_held);
bool pending{};
{ std::lock_guard pending_lock(s.mutex); pending = !s.pending.empty(); }
if (active.model.handle == s.core.handle && s.page.handle && now < s.next_render && !pending &&
@@ -750,6 +784,8 @@ overlay::HubPage native_menu_page() noexcept {
if (!seen || GetTickCount64() - seen > 200) return {};
return {true, page_shown.load(std::memory_order_relaxed)};
}
void hold_native_menu_back(bool held) noexcept { back_wanted.store(held, std::memory_order_relaxed); }
bool native_pause_menu_open() noexcept { return game_in_menu.load(std::memory_order_relaxed); }
void tick_native_menu(std::uintptr_t base, bool loading) noexcept {
const ui::OwnUiWrites own_writes; // Custom labels are not the native trick list.
run_pending_ui_dump(base);