#include "native_menu.h" #include "native_menu_internal.h" #include "native_menu_dump.h" #include "Extension/Multiplayer/Session/session.h" #include "Engine/Core/Log/logging.h" #include "Engine/Core/Platform/launcher_support.h" #include "Extension/Profile/local_profile_runtime.h" #include "Engine/Game/Build/addresses.h" #include "Engine/Game/Build/20260929/engine.h" #include "Engine/Game/Build/20260929/native_menu.h" #include #include namespace dingosdk::multiplayer { using namespace menu_data; using namespace native_menu_detail; namespace { using menu_view::page_count, menu_view::tools_page; // ReSkate has no store: while its tabs are in, the native Store tab is taken out, and it is put // 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 page_slots{0, 1}; // The Store item taken out of the pause menu, its index there, and that menu. struct RemovedStore { std::vector item; unsigned index{}; Address core{}; }; RemovedStore removed_store; thread_local State* current_state{}; std::uint32_t menu_key() { return our_key + state().slot * 0x100; } std::mutex callbacks_mutex; overlay::CallbacksV3 tools_callbacks{}; MenuLifetime lifetime; void status(std::string message) { auto& s = state(); std::lock_guard lock(s.mutex); if (s.status == message) return; s.status = std::move(message); logging::write(logging::Level::info, logging::Channel::ui, s.status); } template void activate() noexcept { if (lifetime.blocked()) return; auto& s = page_state(Slot); if (Index >= s.action_count.load(std::memory_order_acquire)) return; try { // Under the lock: a render pass can hand this slot to another command. std::lock_guard lock(s.mutex); const auto& action = s.actions[Index].action; if (action.generation != s.generation.load(std::memory_order_acquire)) return; if (s.pending.size() < 8) s.pending.push_back(action); } catch (...) { } } template auto callbacks(std::index_sequence) { return std::array{&activate...}; } } // namespace namespace native_menu_detail { State& page_state(unsigned slot) { static auto* values = new std::array; return (*values)[slot]; } State& state() { return current_state ? *current_state : page_state(0); } Address action(const Context& context, std::string command, std::string argument) { auto& s = state(); const auto generation = s.generation.load(); const auto count = s.action_count.load(); const auto slot = action_slot(count, max_actions, [&](std::size_t i) -> const Action& { return s.actions[i].action; }, command, argument, s.pass); require(slot.has_value(), "Native menu action limit reached; restart to refresh the menu."); auto& result = s.actions[*slot]; const bool unused = *slot == count; if (unused) { static const std::array functions{callbacks<0>(std::make_index_sequence{}), callbacks<1>(std::make_index_sequence{})}; // 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; require(read
(prototype + 0x40) == context.base + addr::native_menu::action_invoker && read(prototype + 0x68) == 0, "Native menu action ABI differs."); require(memory::read_bytes(prototype, result.descriptor.data(), result.descriptor.size()), "Native menu action template is unavailable."); const auto function = reinterpret_cast
(functions[s.slot][count]); for (const auto offset : {0x30, 0x40, 0x48}) std::memcpy(result.descriptor.data() + offset, &function, sizeof(function)); result.info = reinterpret_cast
(result.descriptor.data()); } { // activate() reads the slot on the game's own thread. std::lock_guard lock(s.mutex); if (unused || result.action.command != command || result.action.argument != argument) { result.action.command = std::move(command); result.action.argument = std::move(argument); } result.action.generation = generation; result.action.pass = s.pass; } if (unused) s.action_count.store(count + 1, std::memory_order_release); return reinterpret_cast
(&result.info); } Address blueprint(std::string_view name) { const auto& assets = state().assets; const auto found = assets.find(name); if (found == assets.end()) throw std::runtime_error("Widget not loaded: " + std::string(name)); require(read
(found->second + 8) == state().base + addr::native_menu::widget_blueprint_type, "Native menu widget asset has expired."); return found->second; } Value make(const Context& context, Schema schema) { auto& s = state(); // The menu root's generation is part of our identity. A later pause-menu // instance cannot accidentally acquire another instance's page or inputs. const auto value = context.create(schema, (s.owner ^ 0x52534d5000000000ULL) + s.slot * 0x100000 + s.next_id++); s.owned_models.track(value); return value; } } // namespace native_menu_detail namespace { void render(const Context& context, const MultiplayerModel& model) { auto& s = state(); ++s.pass; const auto publish = [&](const std::vector& rows, Value list, unsigned slot, float width) { s.row_width = width; std::vector visible; for (const auto& entry : rows) { if (entry.input) add_input(context, visible, entry.id, entry.title, entry.argument); else if (entry.button) add_button(context, visible, entry.id, entry.title, entry.command, entry.argument, entry.primary); else add_text(context, visible, entry.id, entry.title); } publish_rows(context, list, slot, visible); }; if (s.slot == tools_page) { const auto selected = read(context.address(context.path(s.page, {content, 0x18f8355b}))); if (selected >= 0 && selected < static_cast(section_count)) s.section = static_cast
(selected); for (unsigned i = 0; i < native_tools::sections.size(); ++i) { const auto view = native_tools::render(s.tools, s.tools_model, s.tools_callbacks, i); publish(view.main, s.lists[i], i * 2, main_width); publish(view.side, s.side_lists[i], i * 2 + 1, side_width); } return; } if (model.active != s.was_active) { s.section = model.active ? Section::session : Section::browser; s.selected_player.clear(); s.protected_lobby.clear(); s.feedback.clear(); s.feedback_until = 0; // Republish native widget lists when membership ends, even if a list // happens to have the same row IDs as the previous offline page. for (auto& displayed : s.displayed) displayed.clear(); context.set(context.path(s.page, {content, 0x18f8355b}), static_cast(s.section)); } else { const auto selected = read(context.address(context.path(s.page, {content, 0x18f8355b}))); if (selected >= 0 && selected < static_cast(section_count)) s.section = static_cast
(selected); } s.was_active = model.active; // The browser coming into view (the game menu opening, or switching to it) // fetches the latest lobbies. const bool browsing = s.section == Section::browser && !model.active && !model.lobby_joining; if (browsing && !s.browsing && !model.lobby_searching) queue_command("browse", "", {}); s.browsing = browsing; if (s.feedback_until && (GetTickCount64() >= s.feedback_until || model.status != s.feedback_status)) { s.feedback.clear(); s.feedback_until = 0; } // Populate every section before it can be shown; tab switches never wait for // the next model polling interval to create their controls. for (unsigned i = 0; i < section_count; ++i) render_section(context, model, static_cast
(i)); } void ensure_body(const Context& context) { auto& s = state(); const auto list = context.path(s.page, {content, items}); unsigned count{}, stride{}; const auto current = context.array(list, section_count, count, stride); require(stride == stack_item.size, "Native page body schema differs."); if (count == s.section_total) return; std::vector bytes(stack_item.size * s.section_total); for (unsigned i = 0; i < s.section_total; ++i) require(memory::read_bytes(context.address(s.bodies[i]), bytes.data() + i * stack_item.size, stack_item.size), "Native menu body is unavailable."); context.array(list, bytes, s.section_total); context.set(context.path(s.page, {content, 0x18f8355b}), static_cast(s.section)); context.set(context.path(s.page, {content, 0x55511280}), static_cast(s.section)); } void tab_label(const Context& context, Value item, const std::string& name, std::uint32_t key) { const auto tab = context.field(item, tab_data); context.text(context.path(tab, {label, text_field}), name); const auto info = make(context, {0xb50f9e3a, 48}); context.set(context.field(tab, 0x87a8a504), Ref{0, info.handle}); context.text(context.field(info, 0x1c1d36ea), name); context.text(context.field(info, 0xe538148f), "ReSkate.Multiplayer." + name); context.set(context.field(info, 0xd0f2dee5), key); } std::optional find_tab(const Context& context, Value list, unsigned count, std::string_view name) { for (unsigned i = 0; i < count; ++i) if (context.text(context.path(context.element(list, i), {tab_data, label, text_field}), 64) == name) return i; return {}; } unsigned social_index(const Context& context, Value menu) { const auto list = context.path(menu, {content, items}); unsigned count{}, stride{}; context.array(list, 16, count, stride); return find_tab(context, list, count, social_tab_label).value_or(social_tab_index); } // Each time the pause menu opens the game rebuilds its native tabs (new keys, // the Store back in place) without ours and selects its default slot. Remember // the last ReSkate tab the player had open; when our tabs have gone missing, // keep that memory and select it again once they are re-inserted. Only one // page slot runs this; the tab stack is shared. // The pause menu's top bar shows the player's wallets (San Van Bucks, // Influence) through one presenter on the menu core. ReSkate has no store and // the wallets' icons live on the retired CDN, so they draw as empty boxes: // publish an empty presenter instead. Checked each tick; cheap when already empty. constexpr std::uint32_t top_bar_currency = 0x75320e5f; void hide_top_bar_currency(const Context& context, Value menu) { const auto field = context.field(menu, top_bar_currency); const auto current = read(context.address(field)); if (current.blueprint || current.data.record || current.data.handle) context.set(field, Widget{}); } } // namespace void update_hub(const Context& context); namespace { void restore_last_tab(const Context& context, Value menu) { static std::uint32_t remembered{}; static bool reopening{}; static int previous = -2; const auto list = context.path(menu, {content, items}); const auto target = context.path(menu, {content, 0x18f8355b}); unsigned count{}, stride{}; const auto values = context.array(list, 16, count, stride); if (stride != stack_item.size) return; const auto key_at = [&](int index) { std::uint32_t key{}; if (index >= 0 && static_cast(index) < count) std::memcpy(&key, values.data() + index * stride + 0x568, 4); return key; }; const auto owned = [](std::uint32_t key) { return key >= our_key && key < our_key + page_count * 0x100 && (key - our_key) % 0x100 == 0; }; bool present{}; for (unsigned i = 0; i < count; ++i) present |= owned(key_at(static_cast(i))); if (!present) { reopening = true; previous = -2; return; } const auto selected = read(context.address(target)); if (reopening) { reopening = false; for (unsigned i = 0; remembered && i < count; ++i) if (key_at(static_cast(i)) == remembered) { if (static_cast(i) != selected) { context.set(target, static_cast(i)); context.set(context.path(menu, {content, 0x55511280}), static_cast(i)); logging::printf(logging::Level::info, logging::Channel::ui, "Pause menu reopened on ReSkate tab %u.", i); } previous = static_cast(i); return; } } if (selected == previous) return; previous = selected; if (selected >= 0) remembered = owned(key_at(selected)) ? key_at(selected) : 0; } // 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 // Map's: the game rebuilds Map's page on every open, and a copy taken before // that page is filled in leaves Back inert. Rebuilt whenever our tabs return. void build_back_action(const Context& context, State& s) { const auto base = context.path(s.page, {0x10810f0b, button_field, 0xa704272a, 0xc52416ef, 0xa704272a}); const auto back = context.field(base, 0xa2b71c93); const Address back_input = named_asset(context, blueprint("UI/Foundations/Templates/Page/Page_Widget"), "Configurations/Input/UI/UI_Back"); if (!back_input) { status("Native menu: Back input unavailable; waiting for Map's page."); return; } context.set(context.field(back, 0x3d00de27), back_input); context.text(context.field(back, 0xb1e14cd6), "ToggleMenu"); context.set(context.field(base, 0x4b3146e9), true); } void insert(const Context& context) { auto& s = state(); const auto list = context.path(s.core, {content, items}); unsigned count{}, stride{}; auto values = context.array(list, 16, count, stride); require(stride == stack_item.size && count >= 5 && count < 16, "Native pause menu stack differs."); std::vector keys(count); for (unsigned i = 0; i < count; ++i) std::memcpy(&keys[i], values.data() + i * stride + 0x568, 4); for (unsigned i = 0; i < count; ++i) { std::uint32_t key{}; std::memcpy(&key, values.data() + i * stride + 0x568, 4); if (key == menu_key()) { const auto existing = context.element(list, i); const auto widget = read(context.address(context.field(existing, content))); if (widget.data.handle != s.page.handle) context.copy(existing, context.address(s.menu_item)); else for (const auto hash : {0xd4f0f044U, 0xbc92d1faU, 0xa3c18619U}) context.copy(context.path(existing, {tab_data, hash}), context.address(context.path(s.menu_item, {tab_data, hash}))); return; } } bool has_tools{}; { std::lock_guard callbacks_lock(callbacks_mutex); has_tools = tools_callbacks.read_model != nullptr; } const bool offline = launcher::offline_mode(); const auto missing = menu_view::missing_tabs(keys, our_key, has_tools, !offline); // Offline, the Multiplayer tab whose key ends the scan above never exists. if (offline && missing.empty()) return; // Prepare both pages before exposing either. Their initial insertion is // atomic and ordered; never reorder live stack items or their focus bindings. for (const auto slot : missing) { const auto& candidate = page_state(slot); if (candidate.core.handle != s.core.handle || !candidate.page.handle || !candidate.menu_item.handle || context.type_of(candidate.page.handle) != candidate.page.type) return; } require(count + missing.size() <= 16, "Native pause menu has no room for ReSkate tabs."); // Take the Store item out. Pause-menu detection needs at least five items in the stack, so // only together with ours. if (const auto store = find_tab(context, list, count, store_tab_label); store && !missing.empty() && count - 1 + missing.size() >= 5) { const auto at = values.begin() + static_cast(*store) * stride; removed_store = {std::vector(at, at + stride), *store, s.core.handle}; values.erase(at, at + stride); --count; } for (const auto slot : missing) { auto& owned = page_state(slot); const auto old_size = values.size(); values.resize(old_size + stride); require(memory::read_bytes(context.address(owned.menu_item), values.data() + old_size, stride), "Native menu item is unavailable."); ++count; } context.array(list, values, count); // The menu was reopened (our tabs had gone): refresh every page's Back. for (unsigned slot = 0; slot < page_count; ++slot) { auto& page_owner = page_state(slot); if (page_owner.core.handle == s.core.handle && page_owner.page.handle && context.type_of(page_owner.page.handle) == page_owner.page.type) { const auto* previous = current_state; current_state = &page_owner; try { build_back_action(context, page_owner); } catch (...) {} current_state = const_cast(previous); } } // StackTabs observes Content.Items and creates its focus bindings. Existing // tab models, identities and callbacks remain in their original order. } Value owned_style(const Context& context, const char* name, Schema schema) { const auto found = state().assets.find(name); if (found == state().assets.end()) throw std::runtime_error(std::string("Native menu style has not loaded yet: ") + name); const auto record = found->second; require(read
(record + 8) == context.base + addr::native_menu::asset_record_type && read
(record + 0x18) == context.type(schema), "Native menu style schema differs."); const auto value = make(context, schema); context.copy(value, read
(record + 0x20)); return value; } Value make_list(const Context& context, float item_height = 192.f) { const auto list = make(context, linear_list); context.array(context.field(list, 0x67223da7), std::vector{}); context.set(context.field(list, 0x55ca89f4), blueprint("UI/Foundations/Components/Lists/Shared/AnchoredContentPresenterListItem_Widget")); context.set(context.field(list, 0x19ff199a), item_height); context.set(context.field(list, 0xebca7354), 32.f); context.set(context.field(list, 0x4cb61cac), 1); context.set(context.field(list, 0x4554761c), true); context.set(context.field(list, 0x8bbf7d67), true); // Scroll with wheel/focus; avoid permanent rails. context.set(context.field(list, 0xafe8434b), true); // Scroll bar (believed; set on few native lists). context.set(context.field(list, 0xc33d3081), true); // RememberFocusedIndex. return list; } void initialize(const Context& context, Root root, const MultiplayerModel& model) { auto& s = state(); s.generation.fetch_add(1, std::memory_order_acq_rel); s.core = root.model; s.owner = root.model.handle; s.next_id = 1; s.rows.clear(); for (auto& displayed : s.displayed) displayed.clear(); s.feedback.clear(); for (auto& nodes : s.fixed_rows) nodes.clear(); s.selected_player.clear(); s.copy_code_requested = false; s.protected_lobby.clear(); s.feedback_until = 0; s.was_active = false; s.browsing = false; s.browser = {}; s.section = Section::browser; s.assets.clear(); for (unsigned slot = 0; slot < page_count && s.assets.empty(); ++slot) { const auto& other = page_state(slot); if (slot != s.slot && other.manager == context.manager && other.core.handle == root.model.handle && other.page.handle && context.type_of(other.page.handle) == other.page.type) s.assets = other.assets; } if (s.assets.empty()) s.assets = blueprints(context, root.model); // Validate every required widget before exposing the new tab. for (const auto* name : {"UI/Foundations/Templates/Page/Page_Widget", "UI/Foundations/Components/Lists/LinearList/Widget/LinearList_Widget", "UI/Foundations/Components/Lists/Shared/AnchoredContentPresenterListItem_Widget", "UI/Foundations/Components/Buttons/LabelButton/LabelButton_Widget", "UI/Foundations/Components/Text/InputField/InputField_Widget", "UI/Foundations/Components/Text/Label/Widget/Label_Widget", anchored_widget, horizontal_widget, vertical_widget}) blueprint(name); s.menu_style = owned_style(context, "ButtonStyleList/TileButton.RoughPartial.Default", {0x3d13ff54, 120}); s.primary_style = owned_style(context, "ButtonStyleList/ButtonStyle.CTA.Hero", {0x3d13ff54, 120}); // Hero's authored focus fill is empty. Give our copy the native blue focus // treatment while retaining its yellow idle fill; shared styles stay intact. const auto focus_field = context.member(s.menu_style.type, 0x343abac3); const auto focused_field = context.member(focus_field.type, 0xf622ea66); const auto native_row_style = read
(s.assets.at("ButtonStyleList/TileButton.RoughPartial.Default") + 0x20); context.copy(context.path(s.primary_style, {0x343abac3, 0xf622ea66}), native_row_style + focus_field.offset + focused_field.offset); s.page = make(context, page); s.menu_item = make(context, stack_item); // Social's authored page layout is already resident. Borrow it through a // typed reference rather than waiting for an unrelated page style to load. const auto social_widget = read(context.address(context.field( context.element(context.path(s.core, {content, items}), social_index(context, s.core)), content))); const Value social{social_widget.data.handle, context.type_of(social_widget.data.handle)}; require(social.type == s.page.type, "Native Social page is unavailable."); context.copy(context.field(s.page, 0x22705a14), context.address(context.field(social, 0x22705a14))); // A translucent black backdrop over the paused game. The style is our own // copy (released with the page), so the shared native style is untouched. constexpr Schema background_style{0x9f5f4db5, 64}; Value backdrop_style; try { backdrop_style = owned_style(context, "BackgroundStylesList/BackgroundStyle.Black.Opaque", background_style); } catch (const std::exception&) { backdrop_style = owned_style(context, "BackgroundStylesList/BackgroundStyle.Dark", background_style); } context.set(context.field(backdrop_style, 0x87638719), false); // Opaque: off, so opacity applies. context.set(context.field(backdrop_style, 0x22092eae), 0.75f); // Opacity. const auto backdrop = make(context, {0x4d7e89f7, 32}); context.set(context.field(backdrop, 0xff242844), 11); context.set(context.field(backdrop, 0xccf9c3c6), Ref{0, backdrop_style.handle}); const auto backdrop_item = make(context, stack_item); context.set(context.field(backdrop_item, content), Widget{ blueprint("UI/Foundations/Layers/Backgrounds/Background_Widget"), {0, backdrop.handle}}); context.set(context.field(backdrop_item, key_field), menu_key() + 16); const auto backdrop_stack = context.field(s.page, 0x2d393a3a); context.copy(context.field(backdrop_item, 0xd78d240b), context.address(context.field(backdrop_stack, 0x54c9abee))); std::vector backdrop_bytes(stack_item.size); require(memory::read_bytes(context.address(backdrop_item), backdrop_bytes.data(), backdrop_bytes.size()), "Native backdrop is unavailable."); context.array(context.field(backdrop_stack, items), backdrop_bytes, 1); context.set(context.field(backdrop_stack, 0x18f8355b), 0); context.set(context.field(backdrop_stack, 0x369babfe), true); 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 titles{"MULTIPLAYER", "CUSTOM STUFF"}, tab_names{"Multiplayer", "Custom Stuff"}; 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); const auto sections = s.slot == tools_page ? std::vector(native_tools::sections.begin(), native_tools::sections.end()) : std::vector{"Server Browser", "Host Lobby", "Join by Code", "Current Session", "Voice Chat"}; s.section_total = static_cast(sections.size()); std::vector body_bytes(stack_item.size * sections.size()); for (unsigned i = 0; i < sections.size(); ++i) { const bool scrolling = scrolling_list(s.slot, i * 2), side_scrolling = scrolling_list(s.slot, i * 2 + 1); // Level lists (World, Custom Maps) use the taller rows of a first column. const bool level_list = i == 0 || (s.slot == tools_page && i == native_tools::custom_section); s.lists[i] = scrolling ? make_list(context, level_list ? 192.f : 112.f) : make(context, vertical_panel); s.side_lists[i] = side_scrolling ? make_list(context, 112.f) : make(context, vertical_panel); const auto columns = make(context, horizontal_panel); const auto partition = context.field(columns, 0x2f1e0746); context.set(context.field(partition, 0x51fa3f1c), 0.f); context.set(context.field(partition, 0x307f2934), 96.f); context.set(context.field(partition, 0x613f2c2), main_width + 48.f); context.set(context.field(partition, 0x4fdf869b), 0); context.set(context.field(columns, 0xf750f0a2), Widget{blueprint(scrolling ? "UI/Foundations/Components/Lists/LinearList/Widget/LinearList_Widget" : vertical_widget), {0, s.lists[i].handle}}); context.set(context.field(columns, 0x29023866), Widget{blueprint(side_scrolling ? "UI/Foundations/Components/Lists/LinearList/Widget/LinearList_Widget" : vertical_widget), {0, s.side_lists[i].handle}}); const auto body = s.bodies[i] = make(context, stack_item); context.set(context.field(body, content), Widget{blueprint(horizontal_widget), {0, columns.handle}}); context.set(context.field(body, key_field), menu_key() + i + 1); // The native push operation supplies Transition. Direct publication // must initialize it too, or an active item still renders blank. context.copy(context.field(body, 0xd78d240b), context.address(context.path(s.page, {content, 0x54c9abee}))); tab_label(context, body, sections[i], menu_key() + i + 1); require(memory::read_bytes(context.address(body), body_bytes.data() + i * stack_item.size, stack_item.size), "Native menu body is unavailable."); } context.array(context.path(s.page, {content, items}), body_bytes, static_cast(sections.size())); context.set(context.path(s.page, {content, 0x18f8355b}), 0); // TargetIndex. context.set(context.path(s.page, {content, 0x55511280}), 0); context.set(context.path(s.page, {content, 0x369babfe}), true); context.set(context.path(s.page, {content, 0x3b14d43c}), true); // IsInteractable. context.set(context.path(s.page, {content, 0xc912aa1d}), false); // Prebuild all four sections. context.set(context.path(s.page, {content, 0x10db7267}), false); // Keep native widgets between tab switches. context.set(context.path(s.page, {content, 0x2a98baa3}), false); // Our page owns its content across widget teardown. context.set(context.field(s.menu_item, content), Widget{blueprint("UI/Foundations/Templates/Page/Page_Widget"), {0, s.page.handle}}); context.set(context.field(s.menu_item, key_field), menu_key()); 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. 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); 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. static constexpr std::array idle_icons{"UI/Textures/Icons/HUB/img_Icon_SocialDefault_512", "UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Idle_1024"}; static constexpr std::array focused_icons{"UI/Textures/Icons/HUB/img_Icon_SocialFocused_512", "UI/Textures/Skatepedia/img_Skatepedia_SkateTricks_Focused_1024"}; 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); render(context, model); insert(context); status(std::string("Native ") + tab_names[s.slot] + " menu: tab registered."); } } // namespace void tick_page(std::uintptr_t base, bool loading) noexcept { auto& s = state(); if (loading || !base || !game::native_data().models.create) { if (s.core.handle) { s.generation.fetch_add(1); s.core = {}; s.page = {}; s.rows.clear(); s.assets.clear(); s.next_scan = 0; s.next_retry = 0; } return; } const auto now = GetTickCount64(); if (now < s.next_update || now < s.next_retry) return; s.next_update = now + 50; std::string card_request; std::optional card_name; try { if (s.slot == tools_page) { { std::lock_guard callbacks_lock(callbacks_mutex); s.tools_callbacks = tools_callbacks; } if (!s.tools_callbacks.read_model) return; std::deque requests; { std::lock_guard pending_lock(s.mutex); requests.swap(s.pending); } if (!requests.empty() || now >= s.next_render) s.tools_callbacks.read_model(s.tools_callbacks.user, s.tools_model); for (const auto& request : requests) if (request.generation == s.generation.load()) { // The card name field can only be read under the model lock below. if (request.command == "card-name" || request.command == "card-name-reset") card_request = request.command; else native_tools::activate(s.tools, s.tools_model, s.tools_callbacks, request.command, request.argument); s.next_render = 0; } } const auto ui = read
(base + addr::engine::ui_manager); if (!ui) return; const auto manager = read
(ui + 0x140); if (!manager) return; if (s.manager && s.manager != manager) { s.owned_models.manager_replaced(); s.generation.fetch_add(1); s.core = {}; s.page = {}; s.next_scan = 0; s.next_retry = 0; } s.base = base; s.manager = manager; const Context context(base, manager); game::ModelWriteLock lock(manager); Root active; const auto populated = [&](Value candidate) { if (!candidate.handle || context.type_of(candidate.handle) != candidate.type) return false; const auto list = context.path(candidate, {content, items}); const auto data = read
(context.address(list)); const auto count = data ? read(data - 4) & 0x7fffffff : 0; return count >= 5 && count <= 16; }; if (populated(s.core)) active = {s.core, context.address(s.core)}; else if (now >= s.next_scan) { // All three page slots look for the same pause-menu core, and finding it copies the // whole model registry: one scan serves every slot for its 250 ms. A cached handle is // checked against its type again (populated) and its storage looked up afresh. static struct { Address manager{}; ULONGLONG at{}; std::vector roots; } shared; if (shared.manager != manager || now - shared.at >= 250) { shared.roots = context.roots({core.hash}); shared.manager = manager; shared.at = now; } for (const auto& candidate : shared.roots) { if (populated(candidate.model)) { active = {candidate.model, context.address(candidate.model)}; break; } } if (!active.model.handle) s.next_scan = now + 250; } if (!active.model.handle) return; if (!s.slot) { restore_last_tab(context, active.model); hide_top_bar_currency(context, active.model); update_hub(context); } 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 && context.type_of(s.page.handle) == s.page.type && read(context.address(context.path(s.page, {content, 0x18f8355b}))) == static_cast(s.section)) return; const auto model = multiplayer::model(); s.next_render = now + 200; if (active.model.handle != s.core.handle || !s.page.handle || context.type_of(s.page.handle) != s.page.type) { if (now < s.next_retry) return; s.next_retry = now + 500; initialize(context, active, model); s.next_retry = 0; s.failures = 0; } else { if (!s.slot) process_actions(context, model); else if (card_request == "card-name") card_name = input_text(context, "card-name"); else if (card_request == "card-name-reset") { clear_input(context, "card-name"); card_name.emplace(); } ensure_body(context); render(context, model); insert(context); } } catch (const std::exception& e) { s.generation.fetch_add(1); s.core = {}; s.page = {}; s.failures = std::min(s.failures + 1, 5U); s.next_retry = now + std::min(30000, 1000ULL << s.failures); status(std::string("Native multiplayer menu: ") + e.what()); } catch (...) { s.generation.fetch_add(1); s.core = {}; s.page = {}; s.failures = std::min(s.failures + 1, 5U); s.next_retry = now + std::min(30000, 1000ULL << s.failures); status("Native multiplayer menu: unavailable."); } if (!card_name) return; // Persist outside the native model lock. Only the local card changes. try { set_local_player_card_name(*card_name); s.tools.feedback = local_profile_player_card().feedback; } catch (...) { s.tools.feedback = "Couldn't save the card name. Try again."; } s.next_render = 0; } bool prepare_native_menu_level_load(std::uintptr_t base) noexcept { if (std::all_of(page_slots.begin(), page_slots.end(), [](unsigned slot) { return page_state(slot).owned_models.empty(); })) return true; try { const auto ui = read
(base + addr::engine::ui_manager); const auto manager = ui ? read
(ui + 0x140) : 0; require(manager != 0, "Native menu manager is unavailable during level cleanup."); const Context context(base, manager); game::ModelWriteLock lock(manager); for (unsigned slot = 0; slot < page_count; ++slot) { auto& s = page_state(slot); ++s.generation; { std::lock_guard pending_lock(s.mutex); s.pending.clear(); } if (s.manager != manager) { s.owned_models.manager_replaced(); continue; } s.owned_models.release([&] { if (!s.core.handle || context.type_of(s.core.handle) != s.core.type) return; const auto stack = context.field(s.core, content); const auto list = context.field(stack, items); unsigned count{}, stride{}; const auto values = context.array(list, 16, count, stride); require(stride == stack_item.size, "Native menu cleanup stack differs."); const auto key_offset = context.member(context.type(stack_item), key_field).offset; std::vector retained; bool ours{}; for (unsigned i = 0; i < count; ++i) { const auto first = values.data() + i * stride; std::uint32_t key{}; std::memcpy(&key, first + key_offset, sizeof(key)); if (key >= our_key && key < our_key + page_count * 0x100 && (key - our_key) % 0x100 == 0) { ours = true; continue; } retained.insert(retained.end(), first, first + stride); } // Put the native Store item back at its original index: it is out while ours are in. if (ours && removed_store.core == s.core.handle && removed_store.item.size() == stride && removed_store.index * stride <= retained.size()) { retained.insert(retained.begin() + static_cast(removed_store.index) * stride, removed_store.item.begin(), removed_store.item.end()); removed_store = {}; } if (retained.size() == values.size() && std::equal(retained.begin(), retained.end(), values.begin())) return; // Return focus to the first native tab before removing our // appended items. The remaining native tabs keep their order. context.set(context.field(stack, 0x18f8355b), 0); context.set(context.field(stack, 0x55511280), 0); context.array(list, retained, static_cast(retained.size() / stride)); }, [&](Value value) { context.destroy(value); }); s.core = {}; s.page = {}; s.menu_item = {}; s.rows.clear(); s.assets.clear(); s.next_scan = s.next_retry = s.next_update = s.next_render = 0; s.copy_code_requested = false; } return true; } catch (const std::exception& e) { logging::write(logging::Level::warning, logging::Channel::ui, std::string("Native menu cleanup failed: ") + e.what()); return false; } catch (...) { return false; } } void native_menu_before_level_transition(std::uintptr_t base, unsigned next) noexcept { lifetime.before_transition(next, [&] { // Both shutdown and multiplayer pause dumps fault in the engine // while destroying a 0x590-byte menu item with an unloaded widget asset. // Native level/sublevel exits bypass ReSkate's explicit load scheduler. // Release roots before those transitions and block ticks/actions until // state 13/21 announces the next active world. const bool owned = std::any_of(page_slots.begin(), page_slots.end(), [](unsigned slot) { return !page_state(slot).owned_models.empty(); }); const bool cleaned = prepare_native_menu_level_load(base); if (owned || !cleaned) logging::printf(cleaned ? logging::Level::info : logging::Level::warning, logging::Channel::ui, cleaned ? "Native ReSkate menu models released before state %u." : "Native ReSkate menu cleanup could not finish before state %u.", next); }); } void tick_native_menu(std::uintptr_t base, bool loading) noexcept { run_pending_ui_dump(base); if (lifetime.blocked()) return; for (unsigned slot = 0; slot < page_count; ++slot) { current_state = &page_state(slot); current_state->slot = slot; tick_page(base, loading); // Clipboard ownership can involve other windows; keep it outside the // native model write lock and use the latest session code on activation. process_clipboard(); } current_state = nullptr; } void set_native_menu_callbacks(const overlay::CallbacksV3& callbacks) { std::lock_guard lock(callbacks_mutex); tools_callbacks = callbacks; } } // namespace dingosdk::multiplayer