From 28118efc2735a71a0c38bda2e7955d35cb2e7d24 Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Tue, 13 Oct 2026 03:37:45 -0500 Subject: [PATCH] Objects: other players' objects are ghosts by default, and their card is a visitor's Objects other players place are spawned locally, so the game took every one for the local player's own: all were solid, and their card offered the owner's buttons and named the wrong player. - Other players' objects are ghosts the skater passes through, as in the game's own sessions. Each player's row in the session menu has an "Objects: solid / ghost" switch, remembered by Steam ID; a dedicated server's own objects (its parks) are solid unless switched off. `mp objects-solid on|off` does the same. - The card's own Disable and Enable on one object stand over that player's setting until the setting is changed. - The card on an object someone else placed no longer offers Edit or Delete: the game's "is this mine" and "may this be edited" checks answer for who really placed it. It offers Disable instead. - The card names the owner of the object it is for, read from the card's own script, instead of the owner of the object nearest the skater. - A dedicated server has a player card of its own, "Server", shown on what it placed. Server: the Mods folder is kept when the configuration is reloaded. A park mod was reported as not being in the Mods folder after a reload. --- CMakeLists.txt | 2 +- Engine/Game/Build/20260929/local_placements.h | 57 ++++ Engine/Game/Multiplayer/session_model.h | 2 + Extension/Multiplayer/Hud/native_party.cpp | 24 ++ Extension/Multiplayer/Hud/native_party.h | 4 + .../Multiplayer/Hud/native_party_hooks.cpp | 59 +++- .../Multiplayer/Hud/native_party_internal.h | 10 + Extension/Multiplayer/Session/session.cpp | 6 +- .../Multiplayer/Session/session_commands.cpp | 20 +- .../Multiplayer/Session/session_internal.h | 7 + .../Multiplayer/Session/session_send.cpp | 14 +- .../Multiplayer/Session/session_view.cpp | 1 + Extension/Multiplayer/Test/mesh_tests.cpp | 2 + Extension/Multiplayer/console_commands.cpp | 5 +- Extension/Objects/network_object_runtime.cpp | 259 +++++++++++++++++- Extension/Objects/network_object_runtime.h | 13 +- Extension/Objects/object_prompt.h | 47 +--- Extension/UI/Overlay/multiplayer_session.cpp | 17 ++ Server/GUI/server_gui.cpp | 1 + Server/server_engine.cpp | 3 + 20 files changed, 475 insertions(+), 78 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 4d6b98e8..007c40df 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,7 +44,7 @@ if(DINGOSDK_BUILD_PARK_EDITOR_TESTS AND WIN32) target_link_libraries(dingosdk_park_editor_ui_tests PRIVATE dingosdk_imgui dingosdk_json dingosdk_game_archives dingosdk_content_cache dingosdk_logging) add_test(NAME park_editor_ui COMMAND dingosdk_park_editor_ui_tests "${CMAKE_CURRENT_BINARY_DIR}/editor-preview" "${DINGOSDK_TEST_GAME_ROOT}") add_executable(dingosdk_park_editor_transaction_tests Extension/Objects/ParkEditor/Test/park_editor_transaction_tests.cpp) - target_link_libraries(dingosdk_park_editor_transaction_tests PRIVATE dingosdk_local_profile dingosdk_logging) + target_link_libraries(dingosdk_park_editor_transaction_tests PRIVATE dingosdk_local_profile dingosdk_logging dingosdk_hooks) add_test(NAME park_editor_transactions COMMAND dingosdk_park_editor_transaction_tests) add_executable(dingosdk_profile_migration_tests Extension/Profile/Test/profile_migration_tests.cpp) target_link_libraries(dingosdk_profile_migration_tests PRIVATE dingosdk_local_profile) diff --git a/Engine/Game/Build/20260929/local_placements.h b/Engine/Game/Build/20260929/local_placements.h index 9798f4a0..7efc06e0 100644 --- a/Engine/Game/Build/20260929/local_placements.h +++ b/Engine/Game/Build/20260929/local_placements.h @@ -1,4 +1,5 @@ #pragma once +#include #include namespace dingosdk::game::build::v20260929::local_placements { @@ -12,6 +13,62 @@ inline constexpr std::uintptr_t player_component_offset = 0x6f7cf60; inline constexpr std::uintptr_t client_ready_offset = 0x7215038; // Pointer to the teleport manager; its first uint32 is 0 while idle. inline constexpr std::uintptr_t teleport_manager = 0x71ee790; +// The game's own ghosting of placed objects (walk-through, and solid again only once the +// skater is clear of them). The client keeps a 0x50-byte record per object; 0x7b3470 decides +// each one's ghost byte (+0x3e) from a forced-solid byte (+0x3c), a forced-ghost byte (+0x3b), +// a mode (everyone solid, party and friends solid, only your own solid) and whether the local +// player is the native owner (0x7befd0). ReSkate's copies of other players' objects all have +// the local player as native owner, so only the forced bytes can ghost them. +// Expression native 0x9447529e: void (const u64* entity, bool ghost). true forces the ghost; +// false lets go of a forced ghost (or, on an object that was not forced, forces it solid), +// and either way the record is decided again at once. Client realm, client thread. +inline constexpr std::uintptr_t object_ghost = 0x7ae620; +// What that native does, in parts. It finds the record store through the calling thread's +// current context (0x18b6ae0 reads it from thread-local storage) plus client_ready_offset; +// the store is ready when its +0x100 byte is set and keeps its records at +0x48..+0x50. +// A record: entity u64 at +0x10, a group id at +0x28, active at +0x3a, forced ghost +0x3b, +// forced solid +0x3c, a "decide now" byte +0x3d, ghost +0x3e, shown +0x3f. 0x7b33e0 decides one +// record again: void (store*, record*). The rules it follows are the build kit settings +// (0x18d6ef0(*0x7482ce0, 0x723a510)): nothing is decided while their +0x8f byte is clear. +inline constexpr std::uintptr_t thread_context = 0x18b6ae0, object_ghost_decide = 0x7b33e0; +// A realm's context by its id: void* (context* out, u32 realm). ReSkate's placement updates run +// in the local server's context, so the client's store is reached through the client realm. +// Setting the store's +0xfb byte has the client decide every record again on its next update. +// A record's entity is the client's copy, with an id of its own. What the two sides share is the +// object's number: the index part of the server's entity (bits 8 up), which the client's +// component has at +0x24 and its records at +0x28 (the "group id" above). +inline constexpr std::uintptr_t realm_context = 0x18b6e40; +inline constexpr std::array realm_context_prefix{ + 0x89,0x54,0x24,0x10,0x53,0x48,0x83,0xec,0x30,0x4c,0x8b,0x05,0xe8,0xb7,0x70,0x05}; +inline constexpr std::uint32_t client_realm = 0xbf0f9789, server_realm = 0x98be5555; +inline constexpr std::uintptr_t settings_lookup = 0x18d6ef0, settings_manager = 0x7482ce0, build_kit_settings = 0x723a510; +inline constexpr std::array object_ghost_prefix{ + 0x48,0x89,0x6c,0x24,0x20,0x56,0x48,0x83,0xec,0x20,0x48,0x8b,0xf1,0x0f,0xb6,0xea}; +// Expression native 0xcba97fb7: bool (const u64* entity), whether the placed object that entity +// belongs to is the local player's (its component's owner at +0x10 against the local player's +// id). The object's menu is the owner's or a visitor's by it. object_root finds that object from +// any entity of it: void (u64 out[2], u64 entity, bool); out[0] is the object's own entity. +// object_component: void (const u64 object[2], u64 out[]); out[1] is set and out[2] is the +// placed-object component when the object has one. +inline constexpr std::uintptr_t object_owned = 0x7ad210, object_root = 0x6fdb00, object_component = 0x46aa70; +// Expression native 0x0b092a7b: bool (const u64 *const *entities), an array of placed objects' +// entities (count in the u32 before its first element, top bit apart). Whether all of them may +// be edited now; the owner is not part of it. The object card's script (flow_buildkit +// aa058fc, graph 0x25bf9270) puts the Edit button on the card by it, and Delete or +// Enable/Disable by object_owned. +inline constexpr std::uintptr_t objects_editable = 0x7a6a10; +inline constexpr std::array objects_editable_prefix{ + 0x40,0x53,0x55,0x48,0x83,0xec,0x28,0x48,0x8b,0xd9,0xba,0x89,0x97,0x0f,0xbf,0x48}; +// What was tried and left (2026-10-13): giving the client's copies their real owner (component +// +0x10). The game then hides them unless its store's +0xfa is set (its service's answer to +// whether others' objects may be seen) and their +0x3a is not 0x7f, takes the far ones away +// (0x7c4c50), and looks the owner's card up by a model the session does not publish. +inline constexpr std::array object_component_prefix{ + 0x40,0x53,0x48,0x81,0xec,0x80,0x00,0x00,0x00,0x48,0x8b,0x05,0x40,0x99,0xd5,0x06}; +inline constexpr std::array object_owned_prefix{ + 0x48,0x81,0xec,0xc8,0x00,0x00,0x00,0x48,0x8b,0x05,0xa2,0x71,0xa1,0x06,0x48,0x33}; +inline constexpr std::array object_root_prefix{ + 0x48,0x89,0x5c,0x24,0x18,0x55,0x57,0x41,0x56,0x48,0x8d,0x6c,0x24,0xb9,0x48,0x81}; // Reference only (PlacementCreateRecipe mirrors these, nothing calls them): the // create-message handler 0x87da20; the free-roam drop 0x7af5b0, whose park // context byte (127) comes from 0x6f7c750; the source enum codec 0x58f96c0/0x58f8c50. diff --git a/Engine/Game/Multiplayer/session_model.h b/Engine/Game/Multiplayer/session_model.h index 98983685..cf5c1519 100644 --- a/Engine/Game/Multiplayer/session_model.h +++ b/Engine/Game/Multiplayer/session_model.h @@ -73,6 +73,8 @@ struct MultiplayerPlayer { // The player's party (0 = none), whether they lead it, and whether it is the local player's. std::uint32_t party{}; bool party_leader{}, party_member{}; + // The local player made this player's placed objects solid; they are ghosts otherwise. + bool objects_solid{}; }; // A party invite waiting for an answer (dedicated servers). struct MultiplayerPartyInvite { diff --git a/Extension/Multiplayer/Hud/native_party.cpp b/Extension/Multiplayer/Hud/native_party.cpp index 930e4fee..39d05ae7 100644 --- a/Extension/Multiplayer/Hud/native_party.cpp +++ b/Extension/Multiplayer/Hud/native_party.cpp @@ -105,6 +105,10 @@ GroupStatus group_status(const PartyRoster &roster, unsigned capacity, const Par g.join = player.party_open ? 2 : 1; return g; } +// The dedicated server's card is no player's: its player id is none the roster gives (those +// count up from 0x7e000000), and not 0, which the game takes for the local player's without +// its own service (a placed object's menu then offered Edit on what the server placed). +constexpr std::uint32_t server_player_id = 0x7effffff; Handle player_record(Address manager, const PartyPlayer &player, std::uint32_t player_id, bool party = true, bool friend_record = false, bool online = true, std::int32_t colour = -1, const GroupStatus &group = {}) { @@ -313,6 +317,15 @@ void party_profile(Address manager, Handle info, const GroupStatus &group) { publish_value(manager, field(manager, info, 18, 0x11cca848, 257), base + engine::bool_type, group.members > 1); } } // namespace +void set_native_party_server(std::uint64_t id, std::string_view name) noexcept { + try { + auto &s = state(); + if (s.server_id == id && s.server_name == name) return; + s.server_id = id; + s.server_name = name; + s.server_changed = true; + } catch (...) {} +} void update_native_party(std::uintptr_t base, const PartyRoster &roster, unsigned capacity, bool overlay) noexcept { auto &s = state(); @@ -326,6 +339,7 @@ void update_native_party(std::uintptr_t base, const PartyRoster &roster, unsigne auto previous = s.published.load(std::memory_order_acquire); bool changed = !previous || previous->social_revision != steam->revision || previous->capacity != capacity || previous->overlay != overlay; + changed |= std::exchange(s.server_changed, false); bool refreshed{}, talking{}; if (previous) for (std::size_t i = 0; i < roster.size(); ++i) { const auto &old = previous->records[i].player; @@ -425,6 +439,16 @@ void update_native_party(std::uintptr_t base, const PartyRoster &roster, unsigne next->friends.push_back(std::move(record)); } } + if (s.server_id) { + auto &server = next->server; + server.player.id = s.server_id; + server.player.name = s.server_name; + const auto &old = previous ? previous->server : Record{}; + server.handle = previous && previous->manager == manager && old.handle && old.player.id == s.server_id && + old.player.name == s.server_name && game::native_data().models.value(manager, old.handle, 0, 0) + ? old.handle + : player_record(manager, server.player, server_player_id, false, false, true); + } // Make the identity lookup available before publishing arrays that can // immediately invoke native UI observers. next->index(); diff --git a/Extension/Multiplayer/Hud/native_party.h b/Extension/Multiplayer/Hud/native_party.h index 856e908b..d738b97c 100644 --- a/Extension/Multiplayer/Hud/native_party.h +++ b/Extension/Multiplayer/Hud/native_party.h @@ -30,6 +30,10 @@ using PartyRoster = std::array; // already hides that indicator outside a party. void update_native_party(std::uintptr_t base, const PartyRoster &roster, unsigned capacity, bool overlay = true) noexcept; +// The dedicated server this session is on (0: none), and the name to show for it. A server is +// no player: it is in none of the game's lists, and has a player card only so that what it +// placed (its parks) can say who placed it. Game thread, before update_native_party. +void set_native_party_server(std::uint64_t id, std::string_view name) noexcept; void update_party_position(const Pose *pose) noexcept; // Points the party Spectate camera at a session player's skater from code (the // throwdown turn camera). 0 stops a spectate this function started. Game thread. diff --git a/Extension/Multiplayer/Hud/native_party_hooks.cpp b/Extension/Multiplayer/Hud/native_party_hooks.cpp index 003b7c48..398724c8 100644 --- a/Extension/Multiplayer/Hud/native_party_hooks.cpp +++ b/Extension/Multiplayer/Hud/native_party_hooks.cpp @@ -10,6 +10,12 @@ #include "Extension/Objects/network_object_runtime.h" #include "Extension/Objects/object_prompt.h" #include "Engine/Game/Build/20260929/profile.h" +#include +#include +#include +#include +#include +#include #include #include #include @@ -19,6 +25,10 @@ namespace dingosdk::multiplayer { using namespace native_party_detail; namespace { +// Above zero on the thread that is running the placed-object prompt's graph (object_prompt.h). +thread_local int object_prompt_running{}; +// That run's VM and graph resource: the object the card is for is one of its locals. +thread_local Address object_prompt_vm{}, object_prompt_resource{}; Handle find_model_hook(Address manager, Address type, std::uint64_t id, std::uint32_t name_space, std::uint8_t realm) { auto &s = state(); @@ -37,15 +47,17 @@ Handle find_model_hook(Address manager, Address type, std::uint64_t id, return game::native_data().models.value(manager, record->handle, 0, 0) ? record->handle : 0; }; if (const auto handle = find(p->record(id))) return handle; - return find(p->friend_record(id)); + if (const auto handle = find(p->friend_record(id))) return handle; + return find(p->server_record(id)); } catch (...) { return 0; } } std::uint64_t get_info_hook(Address ui, std::uint64_t id) { try { const auto published = state().published.load(std::memory_order_acquire); if (state().installed.load(std::memory_order_acquire) && published && id) { - const auto record = published->record(id); - if (const auto found = record ? record : published->friend_record(id)) { + auto record = published->record(id); + if (!record) record = published->friend_record(id); + if (const auto found = record ? record : published->server_record(id)) { Address manager{}; if (!memory::peek(ui + 0x140, manager) || manager != published->manager) return 0; game::ModelWriteLock lock(manager); @@ -682,19 +694,35 @@ void clear_stale_cooldowns() { // the SDK started pressed no Coop button here, so IsCoop still says solo and the row never comes // (analysis/coop-challenges-re/coop_criteria_hud.md). It is set just before either graph runs. constexpr std::uint32_t graph_objective_list = 0xeae864ca, graph_challenge_rewards = 0x1e01dd61; -// Above zero on the thread that is running the placed-object prompt's graph (object_prompt.h). -thread_local int object_prompt_running{}; // profile_runtime::player_info_override: while that prompt runs, the info of whoever placed the // object nearest the skater, when that is another player the party HUD has a record for. std::uint64_t object_owner_info() noexcept { if (!object_prompt_running) return 0; try { auto &s = state(); - const auto position = object_prompt::viewer_position(); - if (!position || !s.installed.load(std::memory_order_acquire)) return 0; - const auto owner = profile_runtime::nearest_network_object_owner(*position, object_prompt::reach); + if (!s.installed.load(std::memory_order_acquire) || !object_prompt_vm || !object_prompt_resource) return 0; + // The object the card is for: the script's local 0x380, read from the card's model at + // its start, well before it asks whose card to show (flow_buildkit aa058fc: frame + // 0xc0, constants 0x1be0; a script of another shape is left alone). + std::uint32_t frame{}, constants{}; + Address registers{}, locals{}; + std::uint64_t entity{}; + if (!memory::peek(object_prompt_resource + 0x20, frame) || frame != 0xc0 || + !memory::peek(object_prompt_resource + 0x24, constants) || constants != 0x1be0 || + !memory::peek(object_prompt_vm + 0x30, registers) || !registers || + !memory::peek(registers + 0xc0 + 16, locals) || !locals || !memory::peek(locals + 0x380, entity)) return 0; + const auto owner = profile_runtime::network_object_owner_of(entity); Address ui{}; if (!owner || !memory::peek(s.base + engine::ui_manager, ui) || !ui) return 0; + // A dedicated server's own objects (its parks) name the server: it is in no player list, + // so its card is not one get_info finds. + if (const auto published = s.published.load(std::memory_order_acquire); + published && published->server.handle && published->server.player.id == owner) { + Address manager{}; + if (!memory::peek(ui + 0x140, manager) || manager != published->manager) return 0; + game::ModelWriteLock lock(manager); + return game::native_data().models.value(manager, published->server.handle, 0, 0) ? published->server.handle : 0; + } return get_info_hook(ui, owner); } catch (...) { return 0; @@ -739,9 +767,18 @@ void run_expression(Address vm, std::uint32_t pc, Address profiler, void (*runne const auto hash = *reinterpret_cast(resource + 0x10); struct Prompt { bool on; - explicit Prompt(bool running) : on(running) { if (on) ++object_prompt_running; } - ~Prompt() { if (on) --object_prompt_running; } - } prompt(hash == object_prompt::graph); + explicit Prompt(bool running, Address vm, Address resource) : on(running) { + if (!on) return; + ++object_prompt_running; + object_prompt_vm = vm; + object_prompt_resource = resource; + } + ~Prompt() { + if (!on) return; + --object_prompt_running; + object_prompt_vm = object_prompt_resource = 0; + } + } prompt(hash == object_prompt::graph, vm, resource); if ((hash == graph_objective_list || hash == graph_challenge_rewards) && state().challenge_coop.load(std::memory_order_acquire)) { try { show_coop_objectives(); } catch (const std::exception &e) { status(e.what()); } diff --git a/Extension/Multiplayer/Hud/native_party_internal.h b/Extension/Multiplayer/Hud/native_party_internal.h index a4bcf1b1..e129a30d 100644 --- a/Extension/Multiplayer/Hud/native_party_internal.h +++ b/Extension/Multiplayer/Hud/native_party_internal.h @@ -52,6 +52,7 @@ struct GroupStatus { struct Record { PartyPlayer player; Handle handle{}; std::uint32_t player_id{}; std::int32_t colour = -1; bool friend_of{}; GroupStatus group; }; struct Published { Address manager{}; std::array records; std::vector friends; + Record server; // the dedicated server's card (set_native_party_server): in no list std::uint64_t social_revision{}; unsigned capacity = max_players; bool overlay = true; // Built by index() once per publication, for the lookup hooks: the first record // with a handle for each player ID, in records and in friends, and the first @@ -82,6 +83,10 @@ struct Published { Address manager{}; std::array records; s const auto found = friend_ids.find(id); return found == friend_ids.end() ? nullptr : &friends[found->second]; } + // The dedicated server's card, by its id: what it placed asks for its owner by id. + const Record *server_record(std::uint64_t id) const { + return server.handle && server.player.id == id ? &server : nullptr; + } const Record *slot_record(std::size_t slot) const { return slot < slot_records.size() && slot_records[slot] ? &records[std::size_t{slot_records[slot]} - 1] : nullptr; } @@ -116,6 +121,11 @@ struct State { std::set owned; std::set owned_ids; ULONGLONG next_update{}; + // set_native_party_server: the session's dedicated server, and whether it changed since + // the last publication. + std::uint64_t server_id{}; + std::string server_name; + bool server_changed{}; // From the last publication: whether the party menu's slots were bound (until they // are, it is retried every 500 ms) and the local player card it was given. bool party_ready{}; diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 31f13aeb..368e2764 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -1,6 +1,5 @@ #include "session_internal.h" #include "Engine/Core/Debug/trace.h" -#include "Extension/Objects/object_prompt.h" #include "Extension/Multiplayer/Hud/native_player_ui.h" #include "Extension/Multiplayer/Hud/native_indicators.h" #include "Extension/Multiplayer/Hud/native_party.h" @@ -72,7 +71,6 @@ void reset_peer(Session &s, std::size_t slot) { s.peers[slot] = {}; } void stop(Session &s, std::string reason) { - object_prompt::forget_viewer(); s.local_typing_until = 0; // A join that ends before the host's map was ready never got in. if (s.mode == Mode::join && s.awaiting_map && !s.travelling && s.join_started) @@ -345,6 +343,8 @@ void publish_party(Session &s) { // Nobody is in one just for being in the same lobby or on the same server. set_native_party_changes(session); const bool shown = session ? s.local_party != 0 : s.mode != Mode::off; + // What a dedicated server placed (its parks) says "Server", as its chat lines do. + set_native_party_server(dedicated_host(s) ? s.host_id : 0, "Server"); update_native_party(s.base, roster, capacity, shown); } // Links the players' own throwdowns (Extension/Throwdowns/throwdown_relay.cpp). Runs @@ -452,8 +452,6 @@ std::uint64_t far_sample_interval(const Peer &p, float distance) { } void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::uint64_t now) { if (!world_playing(s, local)) return; - // For naming who placed the object in front of the skater (object_prompt.h). - object_prompt::set_viewer(local.pose.root.position); const auto view = latest_game_view(); // ReSkate's nametags and chat bubbles: every shown player, placed above their head // each frame. diff --git a/Extension/Multiplayer/Session/session_commands.cpp b/Extension/Multiplayer/Session/session_commands.cpp index b1dbd830..14f887de 100644 --- a/Extension/Multiplayer/Session/session_commands.cpp +++ b/Extension/Multiplayer/Session/session_commands.cpp @@ -629,7 +629,7 @@ bool queue_command(std::string_view action, std::string_view argument, std::stri action != "chat-bubbles" && action != "chat-bubbles-own" && action != "chat-bubbles-distance" && action != "chat-bubbles-duration" && action != "chat-bubbles-history" && !own_mark_command(action) && - action != "voice" && action != "voice-mute" && + action != "voice" && action != "voice-mute" && action != "objects-solid" && action != "voice-volume" && action != "voice-allow" && action != "voice-range" && action != "chat" && action != "typing" && action != "ban" && action != "unban" && action != "world-layer-sync" && action != "noclip-allow" && action != "nobail-allow" && action != "boosts-allow" && action != "tuning-enforce" && action != "tp" && @@ -829,6 +829,24 @@ std::string command(std::string_view action, std::string_view argument, std::str publish(s); return enabled == "on" ? "Player voice muted." : "Player voice unmuted."; } + if (action == "objects-solid") { + const auto split = argument.find(' '); + if (split == std::string_view::npos) return "Choose a player whose objects to make solid."; + std::uint64_t id{}; + const auto result = std::from_chars(argument.data(), argument.data() + split, id); + const auto enabled = argument.substr(split + 1); + // "server": the dedicated server's own objects (its parks), which no player row stands for. + if (argument.substr(0, split) == "server" && dedicated_host(s)) id = s.host_id; + else if (result.ec != std::errc{} || result.ptr != argument.data() + split) id = 0; + if (!id || !find_peer(s, id) || (enabled != "on" && enabled != "off")) + return "objects-solid on|off"; + const bool solid = enabled == "on"; + s.objects_solid[id] = solid; + set_network_object_owner_solid(id, solid); + profile_runtime::set_local_preference("SolidObjects." + std::to_string(id), solid); + publish(s); + return solid ? "That player's objects are solid for you." : "That player's objects are ghosts for you."; + } // Host-only settings check the mode themselves; guests get a refusal. using Setting = std::string (*)(Session &, std::string_view); static constexpr std::pair settings[] = { diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 03977268..0cfb6e48 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -338,6 +338,8 @@ struct Session { std::uint64_t secret{}, epoch{}, map{}, host_id{}, next_send{}, last_hello{}, last_roster{}, last_cosmetic_capture{}, last_routes{}, last_network_log{}, network_now{}; std::uint32_t sequence{}, roster_sequence{}; + // Whose placed objects are solid for this player (the rest are ghosts): see objects_solid(). + std::map objects_solid; std::uint64_t local_pose_count{}, logged_local_pose_count{}; std::size_t last_skater_bones{}, last_board_bones{}; std::optional sent_appearance; @@ -512,6 +514,11 @@ std::vector chat_commands(const Session &s); void send_throwdown(Session &s, std::vector message); // Tell the session this player is typing (`on`) or has stopped. Relayed like chat. void send_typing(Session &s, bool on); +// Whether `id`'s placed objects are solid for this player. The saved choice for them when +// there is one; else a player's are ghosts, and a dedicated server's own (its parks, which +// everyone is there to skate) are solid. Read when they are first seen and handed to the +// objects (network_object_runtime.h). +bool objects_solid(Session &s, std::uint64_t id); // session_send.cpp Packet packet(Session &s, PacketKind kind, std::uint64_t now); void reset_direct(Session &s, Peer &p, std::uint64_t now); diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index 886fa026..9fa1dc71 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -292,6 +292,15 @@ std::string send_party_chat(Session &s, std::string_view typed) { add_chat(s, local, s.transport.name(local), "[Party] " + text, true); return {}; } +bool objects_solid(Session &s, std::uint64_t id) { + const auto [choice, first] = s.objects_solid.try_emplace(id); + if (first) { + const bool server = dedicated_host(s) && id == s.host_id; + choice->second = profile_runtime::local_preference("SolidObjects." + std::to_string(id)).value_or(server); + set_network_object_owner_solid(id, choice->second); + } + return choice->second; +} void send_typing(Session &s, bool on) { if (s.mode != Mode::host && s.mode != Mode::join) return; const auto now = now_us(); @@ -490,8 +499,11 @@ void sync_objects(Session &s, const NativeFrame &local, std::uint64_t now) { return; std::vector owners; for (const auto &peer : active_peers(s)) - if (const auto &objects = visible_objects(s, peer); peer.handshaken && peer.world_ready && objects.revision()) + if (const auto &objects = visible_objects(s, peer); peer.handshaken && peer.world_ready && objects.revision()) { + // Before its objects are spawned: a dedicated server is in no player list to be seen in. + (void)objects_solid(s, peer.member.id); owners.push_back({peer.member.id, peer.member.epoch, objects.layout()}); + } set_remote_network_objects(captured->map, owners); s.object_owners_sent = std::move(current); s.object_map_sent = captured->map; diff --git a/Extension/Multiplayer/Session/session_view.cpp b/Extension/Multiplayer/Session/session_view.cpp index aad9a3d3..88a8d07e 100644 --- a/Extension/Multiplayer/Session/session_view.cpp +++ b/Extension/Multiplayer/Session/session_view.cpp @@ -339,6 +339,7 @@ void publish(Session &s, const NativeFrame *local) { view.roster.back().party = p.member.party; view.roster.back().party_leader = p.member.party_leader; view.roster.back().party_member = party_member(s, p.member.id); + view.roster.back().objects_solid = objects_solid(s, p.member.id); if (!p.rate_at) { p.rate_at = now; p.native_rate_count = native_count; diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index ce0aa711..229c92cd 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -29,6 +29,7 @@ void apply_host_park_choices(const ParkChoices& choices) { std::optional capture_local_network_objects() { return {}; } void set_remote_network_objects(std::string_view, std::span) {} void clear_remote_network_objects() {} +void set_network_object_owner_solid(std::uint64_t, bool) {} std::vector> removed_object_owners; void remove_remote_network_objects(std::uint64_t owner, std::uint64_t epoch) { if (owner && epoch) removed_object_owners.emplace_back(owner, epoch); @@ -290,6 +291,7 @@ std::uintptr_t remote_board_entity() noexcept { return 0; } std::uint64_t remote_skater_generation() noexcept { return 0; } void note_remote_distance(float) noexcept {} void update_native_party(std::uintptr_t, const PartyRoster &, unsigned, bool) noexcept {} +void set_native_party_server(std::uint64_t, std::string_view) noexcept {} void set_native_party_changes(bool) noexcept {} bool post_native_party_invite(std::uint64_t) noexcept { return false; } void set_native_nametags_enabled(bool) noexcept {} diff --git a/Extension/Multiplayer/console_commands.cpp b/Extension/Multiplayer/console_commands.cpp index 851daf7f..45755a7a 100644 --- a/Extension/Multiplayer/console_commands.cpp +++ b/Extension/Multiplayer/console_commands.cpp @@ -47,6 +47,8 @@ void register_multiplayer_commands(Commands ®istry) { Command{"chat-bubbles-history", "How many recent messages stack above a skater (1-8)"}, Command{"score-check", "Host: keep players whose mods change scoring or physics out of throwdowns and " "coop challenges (on, off, toggle; on by default)"}, + Command{"objects-solid", "Make a player's placed objects solid for you, or ghosts you pass through: " + " on|off"}, Command{"retry", "Retry remote skater creation after an error"}, Command{"test", "Test a Steam socket-pair round trip"}, Command{"status", "Show multiplayer connection and animation status"}}) { @@ -75,7 +77,8 @@ void register_multiplayer_commands(Commands ®istry) { if (std::string_view(c.name) == "chat-bubbles-history") args.push_back(argument("lines")); if (std::string_view(c.name) == "chat" || std::string_view(c.name) == "server" || std::string_view(c.name) == "party" || - std::string_view(c.name) == "tp" || std::string_view(c.name) == "tphere") { + std::string_view(c.name) == "tp" || std::string_view(c.name) == "tphere" || + std::string_view(c.name) == "objects-solid") { auto message = argument("message"); message.rest = true; args.push_back(std::move(message)); diff --git a/Extension/Objects/network_object_runtime.cpp b/Extension/Objects/network_object_runtime.cpp index d3e486b3..3cdb43cb 100644 --- a/Extension/Objects/network_object_runtime.cpp +++ b/Extension/Objects/network_object_runtime.cpp @@ -1,4 +1,9 @@ #include "network_object_runtime.h" +#include "Engine/Core/Hooks/hooks.h" +#include "Engine/Core/Log/logging.h" +#include "Engine/Core/Platform/memory.h" +#include "Engine/Game/Build/addresses.h" +#include "Engine/Game/Build/20260929/local_placements.h" #include "Extension/Customization/local_customization_runtime.h" #include "Extension/Multiplayer/Session/object_state.h" #include "Extension/Multiplayer/Session/peer_slots.h" @@ -6,7 +11,14 @@ #include "local_placements_runtime.h" #include "Extension/Objects/ParkEditor/park_editor_runtime.h" #include +#include #include +#include +#include +#include +#include +#include +#include #include namespace dingosdk::profile_runtime { @@ -15,6 +27,9 @@ using Key = std::tuple; struct Live { std::uint64_t entity{}, delete_sent{}, delete_started{}, refresh{}; profile::PlacedObject applied; + // Whether the native ghost is forced on it, and when that is next made sure of. + bool ghosted{}; + std::uint64_t ghost_refresh{}; }; struct Create { Key key; @@ -40,6 +55,187 @@ State &state() { static State value; return value; } +// The players whose objects are solid for the local player. Not part of State: it outlives +// a change of world. Under the native lock, like State. +std::set &solid_owners() { + static std::set value; + return value; +} +// How the client knows a placed object: the object's number, which is the index part of the +// server's entity for it (bits 8 up; the low byte is that entity's generation). The client's +// copy has an entity of its own, but its component (+0x24) and its record (+0x28) carry this +// number (measured 2026-10-13: server entities 0x25'00000503 and 0x25'00001104 were objects +// 5 and 0x11 on the client). +std::uint32_t object_number(std::uint64_t server_entity) noexcept { + return static_cast(server_entity >> 8) & 0xffffff; +} +// The numbers of the objects other players placed, and who placed each, for the game's "is +// this object mine?" (object_owned_hook): every copy has the local player as its native owner, +// so the game would offer the owner's menu on all of them. The game asks as each object appears +// on the client, so a number is here from the moment the server's entity is known. Written by +// the object update, read by the client's scripts. +std::mutex remote_objects_mutex; +std::map remote_objects; +std::size_t published_objects{}; // how many objects there were when remote_objects was last rebuilt +// What the local player chose for single objects with the game's own menu (Disable, and +// enabling one again): that is the game's ghost native, called on the client. A choice stands +// over the owner's setting until that setting is changed. Under remote_objects_mutex. +std::map object_choices; +void publish_remote_objects(const State &s) { + std::map numbers; + for (const auto &[key, live] : s.live) + if (std::get<0>(key)) numbers[object_number(live.entity)] = std::get<0>(key); + std::lock_guard lock(remote_objects_mutex); + remote_objects = std::move(numbers); + std::erase_if(object_choices, [](const auto &choice) { return !remote_objects.contains(choice.first); }); + published_objects = s.live.size(); +} +void add_remote_object(const Key &key, std::uint64_t entity) { + if (!std::get<0>(key)) return; + std::lock_guard lock(remote_objects_mutex); + remote_objects[object_number(entity)] = std::get<0>(key); +} +// The number of the placed object an entity is part of, as the game's own checks find it. +std::optional client_object_number(std::uint64_t entity) noexcept { + namespace placements = addr::local_placements; + const auto base = local_runtime().base; + std::array root{}; + reinterpret_cast(base + placements::object_root)(root.data(), entity, true); + std::array found{}; // [1] valid, [2] the component + reinterpret_cast(base + placements::object_component)(root.data(), found.data()); + std::uint32_t number{}; + if (!found[1] || !found[2] || !memory::peek(static_cast(found[2]) + 0x24, number)) return std::nullopt; + return number; +} +void (*object_ghost_original)(const std::uint64_t *, bool){}; +void object_ghost_hook(const std::uint64_t *entity, bool ghost) { + object_ghost_original(entity, ghost); + try { + if (!entity) return; + const auto number = client_object_number(*entity); + if (!number) return; + std::lock_guard lock(remote_objects_mutex); + if (remote_objects.contains(*number)) object_choices[*number] = ghost; + } catch (...) {} +} +// Another player's object is not the local player's to edit: the game's own check for the +// card's Edit button does not ask whose the object is (every object is its owner's to see, +// where the game runs its own service). +bool (*objects_editable_original)(const std::uint64_t *const *){}; +bool objects_editable_hook(const std::uint64_t *const *entities) { + const bool editable = objects_editable_original(entities); + if (!editable || !entities) return editable; + try { + const std::uint64_t *first{}; + std::uint32_t count{}; + if (!memory::peek(reinterpret_cast(entities), first) || !first || + !memory::peek(reinterpret_cast(first) - 4, count)) return editable; + count &= 0x7fffffff; + for (std::uint32_t i = 0; i < count && i < 64; ++i) { + const auto number = client_object_number(first[i]); + if (!number) continue; + std::unique_lock lock(remote_objects_mutex, std::try_to_lock); + if (lock.owns_lock() && remote_objects.contains(*number)) return false; + } + } catch (...) {} + return editable; +} +bool (*object_owned_original)(const std::uint64_t *){}; +bool object_owned_hook(const std::uint64_t *entity) { + const bool owned = object_owned_original(entity); + if (!owned || !entity) return owned; + try { + const auto number = client_object_number(*entity); + if (!number) return owned; + std::unique_lock lock(remote_objects_mutex, std::try_to_lock); + return !(lock.owns_lock() && remote_objects.contains(*number)); + } catch (...) { + return owned; + } +} +void install_object_owned_hook() noexcept { + static bool attempted{}; + if (std::exchange(attempted, true)) return; + namespace placements = addr::local_placements; + const auto base = local_runtime().base; + std::array owned{}, root{}, component{}; + auto *const target = reinterpret_cast(base + placements::object_owned); + if (!memory::read(base + placements::object_owned, owned) || owned != placements::object_owned_prefix || + !memory::read(base + placements::object_root, root) || root != placements::object_root_prefix || + !memory::read(base + placements::object_component, component) || component != placements::object_component_prefix || + hook_prepare(target, reinterpret_cast(&object_owned_hook), reinterpret_cast(&object_owned_original)) != HookOk || + hook_enable(target) != HookOk) { + logging::write(logging::Level::warning, logging::Channel::runtime, + "Object owners: the game's owner check could not be replaced; other players' objects offer the owner's menu."); + return; + } + std::array editable{}; + auto *const editable_target = reinterpret_cast(base + placements::objects_editable); + if (!memory::read(base + placements::objects_editable, editable) || editable != placements::objects_editable_prefix || + hook_prepare(editable_target, reinterpret_cast(&objects_editable_hook), + reinterpret_cast(&objects_editable_original)) != HookOk || + hook_enable(editable_target) != HookOk) + logging::write(logging::Level::warning, logging::Channel::runtime, + "Object owners: the game's edit check could not be replaced; other players' objects offer Edit."); + // The same functions tell which object the menu's Disable was pressed on. + std::array ghost{}; + auto *const ghost_target = reinterpret_cast(base + placements::object_ghost); + if (!memory::read(base + placements::object_ghost, ghost) || ghost != placements::object_ghost_prefix || + hook_prepare(ghost_target, reinterpret_cast(&object_ghost_hook), reinterpret_cast(&object_ghost_original)) != HookOk || + hook_enable(ghost_target) != HookOk) + logging::write(logging::Level::warning, logging::Channel::runtime, + "Object ghosting: the menu's own Disable could not be followed; an object disabled there turns solid again."); +} +// Makes placed object `number` a ghost for the local player, or lets it be solid again, +// through the game's own record of it (local_placements.h). False when the client has no +// record of it yet (it comes a moment after the object), or when this build's functions are +// not the ones expected: objects then stay solid, as they always were. +bool set_object_ghost(std::uint32_t number, bool ghost) noexcept { + namespace placements = addr::local_placements; + static const bool known = [] { + std::array lookup{}; + const auto base = local_runtime().base; + const bool same = memory::read(base + placements::realm_context, lookup) && lookup == placements::realm_context_prefix; + if (!same) + logging::write(logging::Level::warning, logging::Channel::runtime, + "Object ghosting: the game's functions differ; other players' objects stay solid."); + return same; + }(); + if (!known) return false; + const auto base = local_runtime().base; + // The records are the client's, and this runs in the local server's context, where the + // game's own function (which takes the calling thread's context) finds no record store. + // So the client's context is asked for by name, the record is marked here, and the store + // is told to decide every record again, which the client does on its next update. + std::uintptr_t client{}; + reinterpret_cast(base + placements::realm_context)( + &client, placements::client_realm); + std::uint32_t offset{}; + std::uintptr_t first{}, last{}; + std::uint8_t ready{}; + if (!client || !memory::peek(base + placements::client_ready_offset, offset) || offset > 0x1000000) return false; + const auto store = client + offset; + if (!memory::peek(store + 0x100, ready) || !ready || !memory::peek(store + 0x48, first) || !memory::peek(store + 0x50, last) || + !first || last < first || (last - first) % 0x50 || (last - first) / 0x50 > 65536) + return false; + // An object can have several records under its number; the active one carries the flags. + std::uintptr_t record{}; + for (auto at = first; at != last && !record; at += 0x50) { + std::uint32_t held{}; + std::uint8_t active{}; + if (memory::peek(at + 0x28, held) && held == number && memory::peek(at + 0x3a, active) && active) record = at; + } + if (!record) return false; + // As the game's own native sets them: a forced ghost, or none (and solid forced only on a + // record that was not a forced ghost, which ReSkate never asks for). + auto *const bytes = reinterpret_cast(record); + if ((bytes[0x3b] != 0) != ghost) { + bytes[0x3c] = 0; + bytes[0x3b] = ghost; + *reinterpret_cast(store + 0xfb) = 1; + } + return true; +} // Other players' objects are still being created: the client tick keeps calling // update_network_objects (tick_network_objects) instead of waiting for the 500 ms // customization update, which paced a big park at two objects a second. @@ -64,23 +260,16 @@ void refresh_budget() { extra_budget.store(static_cast(count), std::memory_order_release); } } // namespace network_objects_detail -std::uint64_t nearest_network_object_owner(const std::array &position, float reach) noexcept { +std::uint64_t network_object_owner_of(std::uint64_t client_entity) noexcept { + using namespace network_objects_detail; try { - std::unique_lock lock(local_runtime().native_mutex, std::try_to_lock); + if (!client_entity) return 0; + const auto number = client_object_number(client_entity); + if (!number) return 0; + std::unique_lock lock(remote_objects_mutex, std::try_to_lock); if (!lock.owns_lock()) return 0; - float best = reach * reach; - std::uint64_t owner{}; - // Local objects are in `live` too, under owner 0, so the nearest being your own answers 0. - for (const auto &[key, live] : network_objects_detail::state().live) { - float squared{}; - for (std::size_t i = 0; i < 3; ++i) - squared += (live.applied.position[i] - position[i]) * (live.applied.position[i] - position[i]); - if (squared < best) { - best = squared; - owner = std::get<0>(key); - } - } - return owner; + const auto remote = remote_objects.find(*number); + return remote == remote_objects.end() ? 0 : remote->second; } catch (...) { return 0; } @@ -130,6 +319,7 @@ bool network_object_observe(std::uint64_t entity, const profile::PlacedObject &o auto applied = object; applied.id = s.creating->object.id; s.live[s.creating->key] = {entity, 0, 0, 0, std::move(applied)}; + network_objects_detail::add_remote_object(s.creating->key, entity); s.dirty.insert(s.creating->key); s.creating.reset(); network_objects_detail::refresh_budget(); @@ -141,6 +331,7 @@ bool network_object_observe(std::uint64_t entity, const profile::PlacedObject &o auto applied = object; applied.id = late->second.object.id; s.live[late->second.key] = {entity, 0, 0, 0, std::move(applied)}; + network_objects_detail::add_remote_object(late->second.key, entity); s.dirty.insert(late->second.key); s.failed_creates.erase(late->second.key); s.late_creates.erase(late); @@ -271,6 +462,7 @@ void update_network_objects() { } void update_network_object_moves() { auto &s = network_objects_detail::state(); + if (s.live.empty() && network_objects_detail::published_objects) network_objects_detail::publish_remote_objects(s); if (s.map != placements_runtime().map || !network_object_native_ready() || s.live.empty()) return; const auto now = GetTickCount64(); @@ -278,9 +470,26 @@ void update_network_object_moves() { // rest are put back where their owner has them every 10 s (the game's own tools can // move a copy, and a move made before its physics body existed placed only the // model), not every second as before. A failed move retries a second later. + network_objects_detail::install_object_owned_hook(); const auto move = [&](auto it) { auto &live = it->second; const auto wanted = s.desired.find(it->first); + // Another player's object is a ghost unless they were made solid. Set when that + // changes, and a ghost again with each refresh: one forced before the game had its + // record of the object did nothing. + if (const auto owner = std::get<0>(it->first); owner && !live.delete_sent && now >= live.ghost_refresh) { + const auto number = network_objects_detail::object_number(live.entity); + bool ghost = !network_objects_detail::solid_owners().contains(owner); + { + std::lock_guard choices(network_objects_detail::remote_objects_mutex); + if (const auto chosen = network_objects_detail::object_choices.find(number); + chosen != network_objects_detail::object_choices.end()) ghost = chosen->second; + } + // The client's record of a new object comes a moment after the object: try again soon. + const bool set = network_objects_detail::set_object_ghost(number, ghost); + if (set) live.ghosted = ghost; + live.ghost_refresh = now + (set ? 10000 : 1000); + } if (!live.delete_sent && wanted != s.desired.end() && wanted->second.item == live.applied.item && (wanted->second != live.applied || now >= live.refresh)) { try { @@ -319,6 +528,8 @@ void update_network_object_moves() { if (++it == s.live.end()) it = s.live.begin(); } + // When an object went: its number must not name whatever takes it next. + if (s.live.size() != network_objects_detail::published_objects) network_objects_detail::publish_remote_objects(s); } } // namespace dingosdk::profile_runtime @@ -393,6 +604,24 @@ void clear_remote_network_objects() { s.dirty.clear(); profile_runtime::network_objects_detail::refresh_budget(); } +void set_network_object_owner_solid(std::uint64_t owner, bool solid) { + using namespace profile_runtime; + if (!owner) return; + std::lock_guard lock(local_runtime().native_mutex); + auto &owners = network_objects_detail::solid_owners(); + if (solid == owners.contains(owner)) return; + if (solid) owners.insert(owner); + else owners.erase(owner); + // Their objects go first in the next pass. + auto &s = network_objects_detail::state(); + std::lock_guard choices(network_objects_detail::remote_objects_mutex); + for (auto &[key, live] : s.live) + if (std::get<0>(key) == owner) { + network_objects_detail::object_choices.erase(network_objects_detail::object_number(live.entity)); + live.ghost_refresh = 0; + s.dirty.insert(key); + } +} void remove_remote_network_objects(std::uint64_t owner, std::uint64_t epoch) { if (!owner || !epoch) return; diff --git a/Extension/Objects/network_object_runtime.h b/Extension/Objects/network_object_runtime.h index 00389a87..8d4d2bd0 100644 --- a/Extension/Objects/network_object_runtime.h +++ b/Extension/Objects/network_object_runtime.h @@ -16,6 +16,10 @@ bool clear_lobby_guest_objects(); void set_remote_network_objects(std::string_view map, std::span owners); void clear_remote_network_objects(); void remove_remote_network_objects(std::uint64_t owner, std::uint64_t epoch); +// Other players' objects are ghosts for the local player (the game's own ghosting: the skater +// passes through them) unless their owner is made solid here. It holds for every object +// that player has or places, in this world and the next, until it is changed. +void set_network_object_owner_solid(std::uint64_t owner, bool solid); std::string network_object_status(); // Client update thread, every frame: queues the next of other players' objects // while some are still to be created (a no-op otherwise). @@ -42,9 +46,8 @@ bool network_object_observe(std::uint64_t entity, const profile::PlacedObject &o bool network_object_native_ready(); std::uint32_t network_object_extra_budget() noexcept; void move_network_object(std::uint64_t entity, const profile::PlacedObject &object); -// The player (SteamID64) whose object is nearest `position`, among every object in the -// world within `reach` metres; 0 when that is one of the local player's own, or none is -// near. Asked from inside a UI script, so it never waits: 0 when another thread is -// changing the objects. -std::uint64_t nearest_network_object_owner(const std::array &position, float reach) noexcept; +// The player (SteamID64) who placed the object a client entity is part of: 0 when it is one +// of the local player's own, or no placed object. For the client's scripts (the thread they +// run on); it never waits. +std::uint64_t network_object_owner_of(std::uint64_t client_entity) noexcept; } // namespace dingosdk::profile_runtime diff --git a/Extension/Objects/object_prompt.h b/Extension/Objects/object_prompt.h index 742a676e..b0edef79 100644 --- a/Extension/Objects/object_prompt.h +++ b/Extension/Objects/object_prompt.h @@ -1,45 +1,14 @@ #pragma once -#include -#include #include -#include -// The game's prompt on a placed object names who placed it by asking for the local -// player's info, which is right in the game's own sessions (it only shows on your -// objects there) and wrong in ReSkate's, where other players' objects are spawned -// locally: every object read as your own. While that prompt's graph runs, the -// local-player lookup answers with the owner of the object nearest the skater instead -// (native_party_hooks.cpp), found among the objects ReSkate spawned for other players. +// The game's card on a placed object names who placed it by asking for the local player's +// info, which is right in the game's own sessions (it only shows on your objects there) and +// wrong in ReSkate's, where other players' objects are spawned locally: every object read +// as your own. While that card's script runs, the local-player lookup answers with the owner +// of the object the card is for instead (native_party_hooks.cpp), which the script holds in +// one of its locals. namespace dingosdk::object_prompt { -// The prompt's graph, as the expression VM names it (the hash at resource + 0x10). +// The card's script (flow_buildkit aa058fc, OnBegin), as the expression VM names it (the +// hash at resource + 0x10). inline constexpr std::uint32_t graph = 0x25bf9270; -// How far from the skater the object may be. The prompt is about the nearest one wherever -// it is, so this only rules out a position that is nowhere near the park. -inline constexpr float reach = 1337.f; - -// Where the local skater is, as the multiplayer session saw them this frame. Written -// every frame and read from a UI script, so it takes no lock; a torn read only moves -// the point by a frame. -struct Viewer { - std::atomic x{}, y{}, z{}; - std::atomic known{}; -}; -inline Viewer &viewer() noexcept { - static Viewer value; - return value; -} -inline void set_viewer(const std::array &position) noexcept { - auto &v = viewer(); - v.x.store(position[0], std::memory_order_relaxed); - v.y.store(position[1], std::memory_order_relaxed); - v.z.store(position[2], std::memory_order_relaxed); - v.known.store(true, std::memory_order_relaxed); -} -inline void forget_viewer() noexcept { viewer().known.store(false, std::memory_order_relaxed); } -inline std::optional> viewer_position() noexcept { - const auto &v = viewer(); - if (!v.known.load(std::memory_order_relaxed)) return std::nullopt; - return std::array{v.x.load(std::memory_order_relaxed), v.y.load(std::memory_order_relaxed), - v.z.load(std::memory_order_relaxed)}; -} } // namespace dingosdk::object_prompt diff --git a/Extension/UI/Overlay/multiplayer_session.cpp b/Extension/UI/Overlay/multiplayer_session.cpp index a4f8e3a6..2139796e 100644 --- a/Extension/UI/Overlay/multiplayer_session.cpp +++ b/Extension/UI/Overlay/multiplayer_session.cpp @@ -532,6 +532,23 @@ void session_page(SkateMenu &menu, const Model &model) { : "Remove them and keep them out of every lobby you host."); right -= px(14); } + // Their placed objects: ghosts for you (skated and walked through) until made solid. + if (peer && peer->id != mp.local_id) { + const float button = px(124); + right -= button; + ImGui::SetCursorScreenPos(ImVec2(right, middle)); + if (peer->objects_solid) skate_theme::push_primary_button(); + if (ImGui::Button(peer->objects_solid ? "Objects: solid" : "Objects: ghost", ImVec2(button, 0))) { + std::array unused{}; + send_private(menu, "objects-solid", std::to_string(peer->id) + (peer->objects_solid ? " off" : " on"), unused, false); + } + if (peer->objects_solid) skate_theme::pop_primary_button(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", peer->objects_solid + ? "Everything this player places is solid for you. Press to pass through their objects instead." + : "You pass through everything this player places. Press to make their objects solid, to skate them."); + right -= px(14); + } const float status_width = menu.bold->CalcTextSizeA(px(12), FLT_MAX, 0, status).x + px(14); right -= status_width; ImGui::SetCursorScreenPos(ImVec2(right, middle)); diff --git a/Server/GUI/server_gui.cpp b/Server/GUI/server_gui.cpp index 943b0539..5b452137 100644 --- a/Server/GUI/server_gui.cpp +++ b/Server/GUI/server_gui.cpp @@ -2697,6 +2697,7 @@ void draw_settings_tab(ServerStatusSnapshot& stats) { try { auto cfg_path = ServerEngine::instance().folder() / "ReSkateServer.json"; auto loaded = load_config(cfg_path); + loaded.mods = ServerEngine::instance().config().mods; // where the Mods folder is: not in the file ServerEngine::instance().config() = loaded; sync_form_from_config(s_form, loaded); show_notification("Reloaded configuration from ReSkateServer.json.", 3.5, blue); diff --git a/Server/server_engine.cpp b/Server/server_engine.cpp index fb64f0d7..25a08590 100644 --- a/Server/server_engine.cpp +++ b/Server/server_engine.cpp @@ -245,6 +245,9 @@ void ServerEngine::run_startup() { try { config_ = load_config(config_file_); } catch (...) {} + // Not part of the file, so a reload leaves it empty: park mods were then looked for + // beside the server instead of in Mods ("is not in the Mods folder"). + config_.mods = folder_ / "Mods"; // If auto-update is configured and updates are enabled, check and apply update before opening ports if (config_.auto_update && updates_enabled()) {