From b22906240048baef4ea9f00a55cdf31febd3d21d Mon Sep 17 00:00:00 2001 From: AyeZeeBB Date: Fri, 9 Oct 2026 00:10:11 -0500 Subject: [PATCH] Skater effects on other players: costume and board trails, and contact sparks and dust Other players' skaters and skateboards showed none of the game's skater effects: not the trail or fire a costume or deck comes with, and not the sparks, dust and puffs of a skater touching the world. Costume and skateboard effects. The game gives another player's skater and skateboard the right effect sets by itself, but builds them before their outfit is on, so nothing showed. They are now built again half a second after the outfit is applied. An always-on effect that a skater's item puts on the skateboard's wheel joints (the Ghost Skater trail) follows the skater's own copy of those joints, which only this player's skater keeps up to date: for other players it is taken out of the skater's sets and built on their skateboard, which has the same joints. Contact effects. They come from a skater touching the world, which only happens in that player's own game. Each game now captures its skater's contacts (position, velocity, surface normal, surface material) and sends up to three a tick in a new unreliable packet, PacketKind::effects. Other games play each through the game's own function, so the effect is still chosen from the surface and the speed. A contact is shown as late as that player's poses are, only within 30 m of their skater, and only for a material the level's table has: the game reads that table without a bound. Server. A dedicated server relays effects packets to players within 150 m of the sender, at most one packet a tick from each player. New setting players.sync_effects (default true), console and admin command "effects on|off". Off: the server relays none, and the roster tells games neither to send their contacts nor to build other players' costume and skateboard effects. Player-hosted lobbies relay through the host and always share them. The roster carries one more byte for the setting. Still protocol 45, which has not been released. New console command "mp effects": what has been captured and played, and why the last contact was refused if one was. --- Engine/Game/Build/20260929/native_vfx.h | 62 +++ Extension/Multiplayer/Net/effects.h | 50 ++ Extension/Multiplayer/Net/protocol.cpp | 34 +- Extension/Multiplayer/Net/protocol.h | 11 +- Extension/Multiplayer/Remote/native_vfx.cpp | 440 ++++++++++++++++++ Extension/Multiplayer/Remote/native_vfx.h | 22 + Extension/Multiplayer/Session/room.h | 9 + Extension/Multiplayer/Session/session.cpp | 23 +- .../Multiplayer/Session/session_internal.h | 5 + .../Multiplayer/Session/session_receive.cpp | 10 +- .../Multiplayer/Session/session_send.cpp | 12 +- Extension/Multiplayer/Steam/steam_transport.h | 2 +- Extension/Multiplayer/Test/mesh_tests.cpp | 6 + Extension/Multiplayer/Test/protocol_tests.cpp | 30 ++ Extension/Multiplayer/console_commands.cpp | 6 + Server/README.txt | 7 + Server/Test/server_config_tests.cpp | 11 + Server/server_commands.cpp | 9 +- Server/server_config.cpp | 2 + Server/server_config.h | 4 + Server/server_host.cpp | 15 +- Server/server_host.h | 1 + cmake/Runtime.cmake | 1 + 23 files changed, 763 insertions(+), 9 deletions(-) create mode 100644 Engine/Game/Build/20260929/native_vfx.h create mode 100644 Extension/Multiplayer/Net/effects.h create mode 100644 Extension/Multiplayer/Remote/native_vfx.cpp create mode 100644 Extension/Multiplayer/Remote/native_vfx.h diff --git a/Engine/Game/Build/20260929/native_vfx.h b/Engine/Game/Build/20260929/native_vfx.h new file mode 100644 index 0000000..b857707 --- /dev/null +++ b/Engine/Game/Build/20260929/native_vfx.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include + +// Skater effects (sparks, dust, landing puffs, costume and board effects): the game's skater +// effects manager. +namespace dingosdk::game::build::v20260929::native_vfx { +struct Function { + std::uintptr_t rva; + std::array prefix; +}; +// (manager, key, component): a skater's effects component registers with the manager. +inline constexpr Function register_skater{ + 0x4874800, {0x48,0x8b,0xc4,0x53,0x48,0x83,0xec,0x60,0x48,0x89,0x68,0x08,0x48,0x8d,0x59,0x78}}; +// (manager, key, impact): the effect of one contact with the world, chosen by the surface's +// material and the speed. Each call is one effect that ends by itself. Reads the calling +// thread's world. +inline constexpr Function impact{ + 0x486cda0, {0x48,0x8b,0xc4,0x4c,0x89,0x40,0x18,0x48,0x89,0x50,0x10,0x48,0x89,0x48,0x08,0x48}}; +// (manager, key): the skater's effects are stopped and built again from its settings at the +// manager's next update. Takes the manager's own lock. +inline constexpr Function refresh{ + 0x4874a20, {0x40,0x53,0x48,0x83,0xec,0x40,0x48,0x89,0x6c,0x24,0x50,0x48,0x8d,0x99,0xb0,0x00}}; +// (manager, uint64 *effects_entity, int id, settings, char flag, char queued): builds a skater's +// effects from its settings, a pointer to an array of effect sets (pointers, bit 2 a flag; +// the count sits four bytes before the first). Called for each refreshed skater from the +// manager's update. +inline constexpr Function build_effects{ + 0x48700c0, {0x44,0x89,0x44,0x24,0x18,0x48,0x89,0x54,0x24,0x10,0x48,0x89,0x4c,0x24,0x08,0x55}}; +// Manager: the root of its map of registered skaters; a node's id and effects component. +inline constexpr std::size_t manager_skaters_root = 0x58, skater_node_id = 0x20, skater_node_component = 0x30; +// An effects component: what holds its entity. +inline constexpr std::size_t component_owner = 0x18; +// (rig, joint id, 1): the joint's index in the rig's skeleton, or -1. Reads no pose. +inline constexpr Function joint_index{ + 0x12e08b0, {0x48,0x8b,0x89,0x38,0x01,0x00,0x00,0xe9,0x84,0x76,0x5b,0x03,0xcc,0xcc,0xcc,0xcc}}; +// An effects component's rig: component -> its rig's owner -> the rig; the rig's skeleton. An +// effect set's effects (an array of pointers), and the joint each sits on (-1: none). +inline constexpr std::size_t component_rig_owner = 0x50, rig_owner_rig = 0x78, rig_skeleton = 0x138, set_items = 0x30, + item_joint = 0x44; +// How an effect set is started, and the value for one that is always on. +inline constexpr std::size_t set_trigger = 0x80; +inline constexpr std::int32_t trigger_always = 3; +// (out): the calling thread's world, or null. +inline constexpr Function thread_world{ + 0x18b6ae0, {0x48,0x89,0x5c,0x24,0x08,0x57,0x48,0x83,0xec,0x20,0x65,0x48,0x8b,0x04,0x25,0x58}}; +// Offsets into the world, each stored as a 32-bit value: the effects manager, and the object +// that holds the level's material grid. +inline constexpr std::uintptr_t manager_offset = 0x77abaa8, materials_offset = 0x74f8e38; +inline constexpr std::size_t manager_ready = 0x240; // byte: the manager is in use +inline constexpr std::size_t materials_ready = 0x50; // byte: the grid object is in use +inline constexpr std::size_t materials_grid = 0x28; // the grid +inline constexpr std::size_t grid_data = 8; // its data (bit 2 is a flag) +inline constexpr std::size_t grid_index_table = 0x48; // data: pointer to the index table +// The effects component of a skater entity. +inline constexpr std::uintptr_t component_vtable = 0x65ec508; +inline constexpr std::size_t component_size = 0x68; +// The impact: position, velocity and normal as four floats each, then the material. +inline constexpr std::size_t impact_size = 0x40, impact_velocity = 0x10, impact_normal = 0x20, impact_material = 0x30; +inline constexpr std::uint32_t material_valid = 0x20; +inline constexpr unsigned material_shift = 6; +} diff --git a/Extension/Multiplayer/Net/effects.h b/Extension/Multiplayer/Net/effects.h new file mode 100644 index 0000000..13fa0c6 --- /dev/null +++ b/Extension/Multiplayer/Net/effects.h @@ -0,0 +1,50 @@ +#pragma once +#include +#include +#include +#include +#include + +// Skater effects other players see: the sparks, dust and puffs the game makes where a skater or +// their skateboard touches the world. Each is one contact; the game picks the effect from the +// surface's material and the speed. +namespace dingosdk::multiplayer { +struct Impact { + std::array position{}; // where, in the world + std::array velocity{}; // of the contact, metres a second; the game caps its length + std::array normal{}; // of the surface + std::uint16_t material{}; // the surface's index in the level's material table + bool operator==(const Impact &) const = default; +}; +inline constexpr std::size_t max_impacts = 3; // in one packet: a frame or two of contacts +inline constexpr float max_impact_speed = 50; // the game's own cap +inline constexpr std::uint16_t max_impact_material = 0x1fff; +inline constexpr std::size_t impact_wire_size = 12 + 6 + 3 + 2; +inline bool valid_impact(const Impact &impact) noexcept { + float speed{}, facing{}; + for (unsigned i = 0; i < 3; ++i) { + if (!std::isfinite(impact.position[i]) || std::abs(impact.position[i]) > 1000000.f || + !std::isfinite(impact.velocity[i]) || !std::isfinite(impact.normal[i])) + return false; + speed += impact.velocity[i] * impact.velocity[i]; + facing += impact.normal[i] * impact.normal[i]; + } + // A little over the cap and the unit length: both are rounded on the way. + return speed <= (max_impact_speed + 1) * (max_impact_speed + 1) && facing <= 1.1f && + impact.material <= max_impact_material; +} +inline bool valid_impacts(std::span impacts) noexcept { + if (impacts.empty() || impacts.size() > max_impacts) return false; + for (const auto &impact : impacts) + if (!valid_impact(impact)) return false; + return true; +} +// What a packet carries of one: the velocity to a centimetre a second, the normal in 127ths. +inline Impact wire_impact(Impact impact) noexcept { + for (unsigned i = 0; i < 3; ++i) { + impact.velocity[i] = std::round(std::clamp(impact.velocity[i], -max_impact_speed, max_impact_speed) * 100.f) / 100.f; + impact.normal[i] = std::round(std::clamp(impact.normal[i], -1.f, 1.f) * 127.f) / 127.f; + } + return impact; +} +} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Net/protocol.cpp b/Extension/Multiplayer/Net/protocol.cpp index d56eeb6..3750076 100644 --- a/Extension/Multiplayer/Net/protocol.cpp +++ b/Extension/Multiplayer/Net/protocol.cpp @@ -375,6 +375,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 throw std::invalid_argument("Invalid object update"); if (p.kind == PacketKind::voice && (!p.map || !p.source || !valid_voice(p.voice))) throw std::invalid_argument("Invalid voice packet"); + if (p.kind == PacketKind::effects && (!p.map || !p.source || !valid_impacts(p.impacts))) + throw std::invalid_argument("Invalid effects packet"); if (p.kind == PacketKind::chat && (!p.source || !valid_chat_text(p.text))) throw std::invalid_argument("Invalid chat message"); if (p.kind == PacketKind::admin && (!p.source || !valid_admin_text(p.text))) @@ -427,7 +429,8 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 p.kind != PacketKind::objects && p.kind != PacketKind::voice && p.kind != PacketKind::chat && p.kind != PacketKind::admin && p.kind != PacketKind::bans && p.kind != PacketKind::maps && p.kind != PacketKind::throwdown && p.kind != PacketKind::teleport && p.kind != PacketKind::physics_tuning && - p.kind != PacketKind::party && p.kind != PacketKind::scoring && p.kind != PacketKind::physics_extras) + p.kind != PacketKind::party && p.kind != PacketKind::scoring && p.kind != PacketKind::physics_extras && + p.kind != PacketKind::effects) throw std::invalid_argument("Unknown packet kind"); const auto payload = greeting ? 72 : p.kind == PacketKind::away @@ -550,6 +553,17 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 w.integer(p.tuning.size(), 2); w.bytes.insert(w.bytes.end(), p.tuning.begin(), p.tuning.end()); } + if (p.kind == PacketKind::effects) { + w.integer(p.impacts.size(), 1); + for (const auto &impact : p.impacts) { + for (const auto v : impact.position) w.integer(std::bit_cast(v), 4); + for (const auto v : impact.velocity) + w.integer(static_cast(static_cast(std::lround(v * 100.f))), 2); + for (const auto v : impact.normal) + w.integer(static_cast(static_cast(std::lround(v * 127.f))), 1); + w.integer(impact.material, 2); + } + } if (p.kind == PacketKind::physics_extras) { w.integer(p.extras.size(), 2); w.bytes.insert(w.bytes.end(), p.extras.begin(), p.extras.end()); @@ -620,6 +634,7 @@ std::vector encode(const Packet &p, bool compact_pose, std::uint32 if (!valid_object_limit(p.object_limit)) throw std::invalid_argument("Invalid object limit"); w.integer(p.object_limit, 2); w.integer(p.object_scaling ? 1 : 0, 1); + w.integer(p.sync_effects ? 1 : 0, 1); w.integer(p.force_world_layers, 1); // One mode per world-layer catalog row; both peers read the same catalog // from the same game build. @@ -827,6 +842,20 @@ std::optional decode(std::span bytes) noexcept { return {}; p.tuning.assign(bytes.begin() + static_cast(r.at), bytes.end()); r.at = bytes.size(); + } else if (p.kind == PacketKind::effects) { + const auto count = r.integer(1); + if (!p.map || !p.source || !count || count > max_impacts || count * impact_wire_size != bytes.size() - r.at) + return {}; + p.impacts.resize(static_cast(count)); + for (auto &impact : p.impacts) { + for (auto &v : impact.position) v = r.number(); + for (auto &v : impact.velocity) + v = static_cast(static_cast(static_cast(r.integer(2)))) / 100.f; + for (auto &v : impact.normal) + v = static_cast(static_cast(static_cast(r.integer(1)))) / 127.f; + impact.material = static_cast(r.integer(2)); + } + if (!valid_impacts(p.impacts)) return {}; } else if (p.kind == PacketKind::physics_extras) { const auto length = r.integer(2); if (!p.source || length > max_physics_extras || length != bytes.size() - r.at) @@ -949,6 +978,9 @@ std::optional decode(std::span bytes) noexcept { const auto scaling = r.integer(1); if (scaling > 1) return {}; p.object_scaling = scaling != 0; + const auto shared_effects = r.integer(1); + if (shared_effects > 1) return {}; + p.sync_effects = shared_effects != 0; const auto forced = r.integer(1); if (forced > 1) return {}; p.force_world_layers = forced != 0; diff --git a/Extension/Multiplayer/Net/protocol.h b/Extension/Multiplayer/Net/protocol.h index 9bd6c31..216936c 100644 --- a/Extension/Multiplayer/Net/protocol.h +++ b/Extension/Multiplayer/Net/protocol.h @@ -3,6 +3,7 @@ #include "Extension/Multiplayer/Session/peer_slots.h" #include "Extension/Multiplayer/Session/object_state.h" #include "Extension/Multiplayer/Remote/audio_state.h" +#include "Extension/Multiplayer/Net/effects.h" #include "Extension/Multiplayer/Voice/voice_state.h" #include "Engine/Game/Multiplayer/distance_settings.h" #include "Engine/Game/Multiplayer/object_placement.h" @@ -88,7 +89,10 @@ enum class PacketKind : std::uint16_t { // The host's physics that its tuning does not carry: the trainer's tuning-class values and // trick multipliers (Engine/Game/Multiplayer/session_physics.h; opaque here, empty = the // game's own). Like physics_tuning, only the host sends it and a dedicated server never does. - physics_extras = 30 + physics_extras = 30, + // A player's skater touching the world (effects.h): the game's sparks, dust and puffs for + // it, shown on that player's skater by everyone near. Unreliable, relayed like voice. + effects = 31 }; // Packet::party_action. Requests go from a player to whoever hosts, a dedicated server or a // lobby's host (party_player = the other player involved, 0 for leave/open/close); invited @@ -191,6 +195,10 @@ struct Packet { // Roster: players may place objects at another size than their own. Off: a dedicated server // shares every player's objects at their own size (its admins' excepted). bool object_scaling{true}; + // Roster: players see each other's skater effects (effects.h, and the trails and fire of + // each other's costumes and skateboards). Off: a dedicated server relays none and games + // neither send theirs nor show other players'. + bool sync_effects{true}; // Bumped each time the host deletes all guest objects. Guests delete their // own session objects when it changes after their first roster. std::uint32_t object_clears{}; @@ -223,6 +231,7 @@ struct Packet { std::array teleport{}; // teleport: where the receiver goes (world position) std::vector tuning; // physics_tuning: 0..max_physics_tuning bytes std::vector extras; // physics_extras: 0..max_physics_extras bytes + std::vector impacts; // effects: 1..max_impacts contacts PartyAction party_action = PartyAction::leave; // party: what is asked or told std::uint64_t party_player{}; // party: the other player (see PartyAction) // scoring: the sender's scoring fingerprint, 0 for the game's own; `text` names the mods diff --git a/Extension/Multiplayer/Remote/native_vfx.cpp b/Extension/Multiplayer/Remote/native_vfx.cpp new file mode 100644 index 0000000..4e44b05 --- /dev/null +++ b/Extension/Multiplayer/Remote/native_vfx.cpp @@ -0,0 +1,440 @@ +#include "native_vfx.h" +#include "native_skater.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/engine.h" +#include "Engine/Game/Build/20260929/native_vfx.h" +#include "Extension/Multiplayer/Session/peer_slots.h" +#include +#include +#include +#include +#include +#include +#include +#include + +// Other players' skaters and skateboards are the game's own entities, and the game gives each +// the effects its outfit comes with (a costume's trail, a deck's fire) as it does this player's. +// Three things are missing for them, and are done here: +// - The game builds their effects before their outfit is on. They are built again after it. +// - An effect a skater's item puts on the skateboard's wheels follows the skater's own copy of +// those joints, which only this player's skater keeps up to date. It is built on the +// skateboard, which has the same joints, instead. +// - Sparks, dust and puffs come from a skater touching the world, which only happens in that +// player's game. Each game sends its contacts (effects.h) and the others play them. +namespace dingosdk::multiplayer { +namespace { +namespace vfx = addr::native_vfx; +using Address = std::uintptr_t; +bool readable(Address p, void *out, std::size_t size) { + if (p < 0x10000 || p > memory::highest_user_address - size) + return false; + __try { + std::memcpy(out, reinterpret_cast(p), size); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +template T get(Address p, std::size_t off = 0) { + T value{}; + readable(p + off, &value, sizeof(value)); + return value; +} +Address ptr(Address p, std::size_t off = 0) { return get
(p, off); } +constexpr Address set_of(Address element) noexcept { return element & ~Address{4}; } + +using Register = Address (*)(Address manager, Address key, Address component); +using ImpactCall = Address (*)(Address manager, Address key, const void *impact); +using Refresh = Address (*)(Address manager, Address key); +using ThreadWorld = Address *(*)(Address *out); +using BuildEffects = Address (*)(Address manager, std::uint64_t *entity, int id, Address *settings, char flag, char queued); +using JointIndex = int (*)(Address rig, std::uint32_t joint, int); + +// A material no surface names: the game then uses the level's default one. +constexpr std::uint16_t default_material = max_impact_material; +struct Registered { + Address key{}, component{}; +}; +// Another player's skater and skateboard, and the effect sets of the skater's that are built on +// the skateboard. +struct Pair { + Address skater{}, board{}, board_component{}; + std::vector
moved; + bool changed{}; // `moved` is not what the skateboard was last built with +}; +struct State { + Address base{}; + DWORD client_thread{}; + bool attempted{}, installed{}; + std::string issue; + Register register_skater{}; + ImpactCall impact{}; + BuildEffects build_effects{}; + std::mutex mutex; + std::vector captured; // the local game's, since the last drain + std::vector registered; // every effects component seen registering, newest last + std::vector pairs; + std::array seen{}; // materials this game's own contacts named + std::atomic captured_count{}, played{}, refused{}, refreshed{}, moved{}, changes{}; + std::atomic materials{}; // the level's material index table, as last read + std::string refusal; // why the last one was refused (client thread; status reads under mutex) +}; +State &state() { + static auto *s = new State; + return *s; +} +// One other player: their skater and skateboard entities, and each one's effects component and +// its key with the manager. +struct Remote { + Address entity{}, key{}, component{}, board{}, board_key{}, board_component{}; + std::uint64_t generation{}, refresh_at{}, next_lookup{}, changes{}; +}; +Remote &remote() { + static auto *r = new PeerStorage; + return r->current(); +} +Address register_hook(Address manager, Address key, Address component) { + auto &s = state(); + { + std::lock_guard lock(s.mutex); + std::erase_if(s.registered, [&](const Registered &r) { return r.component == component; }); + if (s.registered.size() >= 512) s.registered.erase(s.registered.begin(), s.registered.begin() + 256); + s.registered.push_back({key, component}); + } + return s.register_skater(manager, key, component); +} +// Only the game calls through here: this game's own skater touching the world. What is played +// for other players goes straight to the original. +Address impact_hook(Address manager, Address key, const void *native) { + auto &s = state(); + const auto result = s.impact(manager, key, native); + const auto error = GetLastError(); + std::array vectors{}; + std::uint32_t material{}; + if (readable(reinterpret_cast
(native), vectors.data(), sizeof(vectors)) && + readable(reinterpret_cast
(native) + vfx::impact_material, &material, sizeof(material))) { + Impact impact; + for (unsigned i = 0; i < 3; ++i) { + impact.position[i] = vectors[i]; + impact.velocity[i] = vectors[4 + i]; + impact.normal[i] = vectors[8 + i]; + } + impact.material = material & vfx::material_valid + ? static_cast((material >> vfx::material_shift) & max_impact_material) + : default_material; + impact = wire_impact(impact); + if (valid_impact(impact)) { + std::lock_guard lock(s.mutex); + s.seen[impact.material] = true; + if (s.captured.size() >= 16) s.captured.erase(s.captured.begin()); + s.captured.push_back(impact); + ++s.captured_count; + } + } + SetLastError(error); + return result; +} +// The effects component registered with the manager under this id. +void walk(Address node, int id, Address &component, unsigned depth = 0) { + if (!node || depth > 24 || component) return; + if (get(node, vfx::skater_node_id) == id) { + component = ptr(node, vfx::skater_node_component); + return; + } + walk(ptr(node, 8), id, component, depth + 1); + walk(ptr(node), id, component, depth + 1); +} +// Whether an effects component belongs to this skater or skateboard entity. +bool owns(Address component, Address entity) { + if (!component || !entity) return false; + if (ptr(ptr(component, vfx::component_owner)) == entity) return true; + for (std::size_t off = 8; off < vfx::component_size; off += 8) + if (const auto q = ptr(component, off); q && (q == entity || ptr(q) == entity)) return true; + return false; +} +int call_joint(State &s, Address rig, std::uint32_t joint) noexcept { + __try { + return reinterpret_cast(s.base + vfx::joint_index.rva)(rig, joint, 1); + } __except (EXCEPTION_EXECUTE_HANDLER) { + return -1; + } +} +// Whether every effect of a set sits on a joint this rig has (a skateboard's wheels). +bool set_fits(State &s, Address set, Address rig) { + const auto items = ptr(set_of(set), vfx::set_items); + const auto count = items ? get(items - 4) & 0x7fffffffU : 0U; + if (!rig || !ptr(rig, vfx::rig_skeleton) || !count || count > 64) return false; + for (std::uint32_t i = 0; i < count; ++i) { + const auto joint = get(set_of(ptr(items, i * 8ULL)), vfx::item_joint); + if (joint == 0xffffffffU || call_joint(s, rig, joint) < 0) return false; + } + return true; +} +// A skater's or skateboard's effects are built from its sets. For another player's, the +// skater's wheel effects are taken out of the skater's and added to the skateboard's. +Address build_hook(Address manager, std::uint64_t *entity, int id, Address *settings, char flag, char queued) { + auto &s = state(); + thread_local std::vector
block; + Address data{}, replaced{}; + if (settings && readable(reinterpret_cast
(settings), &data, sizeof(data)) && data) { + Address component{}; + walk(ptr(manager, vfx::manager_skaters_root), id, component); + const auto count = get(data - 4) & 0x7fffffffU; + std::lock_guard lock(s.mutex); + const auto pair = component && count <= 256 + ? std::find_if(s.pairs.begin(), s.pairs.end(), + [&](const Pair &p) { return owns(component, p.skater) || owns(component, p.board); }) + : s.pairs.end(); + if (pair != s.pairs.end()) { + block.assign(1, 0); + if (owns(component, pair->skater)) { + const auto rig = ptr(ptr(pair->board_component, vfx::component_rig_owner), vfx::rig_owner_rig); + std::vector
moved; + for (std::uint32_t i = 0; i < count; ++i) { + const auto element = ptr(data, i * 8ULL); + // Always on only: the others wait on the skater's own state. + if (get(set_of(element), vfx::set_trigger) == vfx::trigger_always && set_fits(s, element, rig)) + moved.push_back(element); + else block.push_back(element); + } + if (moved != pair->moved) { + pair->moved = std::move(moved); + pair->changed = true; + ++s.changes; + } + } else { + for (std::uint32_t i = 0; i < count; ++i) block.push_back(ptr(data, i * 8ULL)); + for (const auto element : pair->moved) + if (std::none_of(block.begin() + 1, block.end(), [&](Address own) { return set_of(own) == set_of(element); })) + block.push_back(element); + pair->changed = false; + } + const auto total = static_cast(block.size() - 1); + if (total != count) { + block[0] = (Address{total} << 32) | total; // capacity, then the count + replaced = reinterpret_cast
(block.data() + 1); + ++s.moved; + } + } + } + return s.build_effects(manager, entity, id, replaced ? &replaced : settings, flag, queued); +} +template bool attach(State &s, const vfx::Function &function, void *replacement, Original &original) { + void *forward{}; + auto *target = reinterpret_cast(s.base + function.rva); + if (hook_prepare(target, replacement, &forward) != HookOk || !forward) { + s.issue = std::format("cannot prepare the hook at {:#x}", function.rva); + return false; + } + original = reinterpret_cast(forward); + if (hook_enable(target) != HookOk) { + s.issue = std::format("cannot enable the hook at {:#x}", function.rva); + return false; + } + return true; +} +bool matches(State &s, const vfx::Function &function) { + std::array actual{}; + if (readable(s.base + function.rva, actual.data(), actual.size()) && actual == function.prefix) return true; + s.issue = std::format("the game's function at {:#x} differs", function.rva); + return false; +} +Address thread_world(State &s) noexcept { + Address world{}; + __try { + reinterpret_cast(s.base + vfx::thread_world.rva)(&world); + } __except (EXCEPTION_EXECUTE_HANDLER) { + world = 0; + } + return world; +} +Address manager(State &s, Address world) { + if (!world) return 0; + const auto m = world + get(s.base + vfx::manager_offset); + return get(m, vfx::manager_ready) ? m : 0; +} +// Whether the game can be handed this material: its index table is read without a bound. The +// table's length sits before it, as with the game's other arrays; a material this game's own +// contacts have named is known good whatever that says. +bool material_known(State &s, Address world, std::uint16_t material) { + const auto object = world + get(s.base + vfx::materials_offset); + if (!get(object, vfx::materials_ready)) return false; // the game would crash on it + const auto grid = ptr(object, vfx::materials_grid); + if (!grid) return false; + if (material == default_material) return true; + const auto table = ptr(ptr(grid, vfx::grid_data) & ~Address{4}, vfx::grid_index_table); + const auto count = table ? get(table - 4) & 0x7fffffffU : 0U; + s.materials.store(count); + if (count <= max_impact_material && material < count) return true; + std::lock_guard lock(s.mutex); + return s.seen[material]; +} +// The current slot's skater's and skateboard's keys with the manager, from the components +// that registered for them. +Remote &resolve(State &s) { + auto &r = remote(); + const auto entity = remote_skater_entity(), board = remote_board_entity(); + const auto generation = remote_skater_generation(); + if (!entity) { + r.key = r.board_key = r.component = r.board_component = 0; + return r; + } + const auto now = GetTickCount64(); + if (r.entity == entity && r.board == board && r.generation == generation && + ((r.key && (r.board_key || !board)) || now < r.next_lookup)) + return r; + r.entity = entity; + r.board = board; + r.generation = generation; + r.key = r.board_key = r.component = r.board_component = 0; + r.next_lookup = now + 500; + std::lock_guard lock(s.mutex); + for (auto found = s.registered.rbegin(); found != s.registered.rend(); ++found) { + if (!found->key || ptr(found->component) != s.base + vfx::component_vtable) continue; + if (!r.key && owns(found->component, entity)) { + r.key = found->key; + r.component = found->component; + } else if (!r.board_key && board && owns(found->component, board)) { + r.board_key = found->key; + r.board_component = found->component; + } + } + if (r.key && r.board_key) { + std::erase_if(s.pairs, [&](const Pair &p) { return p.skater == entity || p.board == board; }); + if (s.pairs.size() >= 256) s.pairs.erase(s.pairs.begin()); + s.pairs.push_back({entity, board, r.board_component, {}, false}); + } + return r; +} +bool call_impact(State &s, Address manager, Address key, const void *native) noexcept { + __try { + s.impact(manager, key, native); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +bool call_refresh(State &s, Address manager, Address key) noexcept { + __try { + reinterpret_cast(s.base + vfx::refresh.rva)(manager, key); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} +void refuse(State &s, const char *why) { + ++s.refused; + std::lock_guard lock(s.mutex); + s.refusal = why; +} +} // namespace + +void prepare_effects(std::uintptr_t base) noexcept { + auto &s = state(); + if (s.attempted) return; + s.attempted = true; + s.base = base; + s.client_thread = GetCurrentThreadId(); + try { + s.installed = matches(s, vfx::register_skater) && matches(s, vfx::impact) && matches(s, vfx::refresh) && + matches(s, vfx::thread_world) && matches(s, vfx::build_effects) && matches(s, vfx::joint_index) && + attach(s, vfx::register_skater, ®ister_hook, s.register_skater) && + attach(s, vfx::build_effects, &build_hook, s.build_effects) && + attach(s, vfx::impact, &impact_hook, s.impact); + } catch (...) { + s.issue = "the hooks could not be installed"; + } + if (!s.installed) + logging::log(logging::Level::warning, logging::Channel::runtime, + "Multiplayer: skater effects are not shared: {}.", s.issue); +} +std::vector drain_impacts() { + auto &s = state(); + std::lock_guard lock(s.mutex); + auto out = std::exchange(s.captured, {}); + if (out.size() > max_impacts) out.erase(out.begin(), out.end() - max_impacts); + return out; +} +void play_impact(const Impact &impact) noexcept { + auto &s = state(); + if (!s.installed || GetCurrentThreadId() != s.client_thread || !valid_impact(impact)) return; + try { + const auto world = thread_world(s); + const auto m = manager(s, world); + if (!m) return refuse(s, "the game's effects are not running on this thread"); + if (!material_known(s, world, impact.material)) return refuse(s, "a surface this level does not have"); + const auto key = resolve(s).key; + if (!key) return refuse(s, "the player's skater has no effects of its own yet"); + alignas(16) std::array native{}; + const auto vector = [&](std::size_t at, const std::array &v) { + std::memcpy(native.data() + at, v.data(), sizeof(v)); + }; + vector(0, impact.position); + vector(vfx::impact_velocity, impact.velocity); + vector(vfx::impact_normal, impact.normal); + const std::uint32_t material = + impact.material == default_material + ? 0U + : (std::uint32_t{impact.material} << vfx::material_shift) | vfx::material_valid; + const std::array tail{material, material, 0U, std::bit_cast(10.f)}; + std::memcpy(native.data() + vfx::impact_material, tail.data(), sizeof(tail)); + if (call_impact(s, m, key, native.data())) ++s.played; + else refuse(s, "the game faulted playing one"); + } catch (...) { + } +} +void note_remote_outfit(std::uint64_t now) noexcept { remote().refresh_at = now + 500000; } +void tick_remote_effects(std::uint64_t now) noexcept { + auto &s = state(); + auto &r = remote(); + if (!s.installed || GetCurrentThreadId() != s.client_thread) return; + const auto changes = s.changes.load(); + const bool due = r.refresh_at && now >= r.refresh_at; + if (!due && changes == r.changes) return; + try { + const auto m = manager(s, thread_world(s)); + resolve(s); + if (!m || !r.key || (r.board && !r.board_key)) { + if (due) r.refresh_at = now + 500000; // not registered yet + return; + } + r.changes = changes; + // The skater first: what it hands over is there when the skateboard is built. + bool board = due; + if (due) { + r.refresh_at = 0; + if (call_refresh(s, m, r.key)) ++s.refreshed; + } else { + // The game built their skater again by itself and its wheel effects changed. + std::lock_guard lock(s.mutex); + board = std::any_of(s.pairs.begin(), s.pairs.end(), [&](const Pair &p) { return p.skater == r.entity && p.changed; }); + } + if (board && r.board_key && call_refresh(s, m, r.board_key)) ++s.refreshed; + } catch (...) { + } +} +void reset_effects() noexcept { + auto &s = state(); + each_peer([] { remote() = {}; }); + std::lock_guard lock(s.mutex); + s.captured.clear(); + s.pairs.clear(); + s.refusal.clear(); +} +std::string native_effects_status() { + auto &s = state(); + if (!s.attempted) return "Effects: not started (join or host a session, or run `mp echo`)."; + if (!s.installed) return "Effects: not shared: " + s.issue + "."; + std::lock_guard lock(s.mutex); + return std::format("Effects: {} of your contacts captured; {} played on other players, {} refused{}{}; " + "{} skaters and skateboards built again after their outfit, {} with wheel effects moved to the " + "skateboard; {} players' skaters known.", + s.captured_count.load(), s.played.load(), s.refused.load(), + s.refusal.empty() ? "" : " (last: ", s.refusal.empty() ? std::string{} : s.refusal + ")", + s.refreshed.load(), s.moved.load(), s.pairs.size()); +} +} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Remote/native_vfx.h b/Extension/Multiplayer/Remote/native_vfx.h new file mode 100644 index 0000000..22878cc --- /dev/null +++ b/Extension/Multiplayer/Remote/native_vfx.h @@ -0,0 +1,22 @@ +#pragma once +#include "Extension/Multiplayer/Net/effects.h" +#include +#include + +namespace dingosdk::multiplayer { +// Skater effects on other players' skaters (native_vfx.cpp). Client thread unless said. +// Installs the hooks once; safe to call again. +void prepare_effects(std::uintptr_t base) noexcept; +// The local game's contacts since the last call, oldest first: the newest max_impacts. +std::vector drain_impacts(); +// For the current PeerScope slot's skater: the game's effect for one of that player's contacts. +void play_impact(const Impact &impact) noexcept; +// For the current PeerScope slot's skater, after its outfit changed: the effects its costume +// and skateboard come with (trails, fire) are built again, as the game built them before the +// outfit was on (tick_remote_effects asks for it, every frame the player is shown). +void note_remote_outfit(std::uint64_t now) noexcept; +void tick_remote_effects(std::uint64_t now) noexcept; +void reset_effects() noexcept; +// One line for the console: what has been captured and played, and what is in the way. +std::string native_effects_status(); +} // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Session/room.h b/Extension/Multiplayer/Session/room.h index 3d95151..7e29e0c 100644 --- a/Extension/Multiplayer/Session/room.h +++ b/Extension/Multiplayer/Session/room.h @@ -157,6 +157,15 @@ struct OutfitBudget { return ++count <= outfit_burst; } }; +// Skater effects: at most one packet per network tick, a few contacts each. +struct EffectBudget { + std::uint64_t since{}; + std::size_t packets{}; + bool accept(std::uint64_t now) noexcept { + if (now < since || now - since >= 1000000) { since = now; packets = 0; } + return ++packets <= multiplayer_tick_rates.back() + 30U; + } +}; // Skater sound: at most one packet per network tick, a few samples each. struct SoundBudget { std::uint64_t since{}; diff --git a/Extension/Multiplayer/Session/session.cpp b/Extension/Multiplayer/Session/session.cpp index 2ec0e09..4ca729f 100644 --- a/Extension/Multiplayer/Session/session.cpp +++ b/Extension/Multiplayer/Session/session.cpp @@ -15,6 +15,7 @@ #include "Extension/Objects/network_object_runtime.h" #include "Extension/Objects/ParkEditor/park_editor_runtime.h" #include "Extension/Multiplayer/Remote/native_audio.h" +#include "Extension/Multiplayer/Remote/native_vfx.h" #include "Extension/Skater/client_source_spawn.h" #include "Extension/Skater/no_bail.h" #include "Extension/Skater/physics_tuning.h" @@ -75,6 +76,7 @@ void stop(Session &s, std::string reason) { s.object_placement = ObjectPlacement::everyone; s.object_limit = 0; s.object_scaling = true; + s.sync_effects = true; set_lobby_object_limit(0); s.server_admin = false; s.server_bans.clear(); @@ -130,6 +132,7 @@ void stop(Session &s, std::string reason) { s.used_slots = 0; s.object_owners_valid = false; reset_audio(); + reset_effects(); s.public_host = s.started_map = s.roster_dirty = false; s.awaiting_map = s.join_map_authorized = s.map_load_submitted = false; s.join_started = s.last_map_request = s.last_map_load_check = 0; @@ -203,6 +206,7 @@ void clear_world(Session &s, std::uint64_t now) { peer.last_packet = now; } reset_audio(); + reset_effects(); s.started_map = false; s.context = s.parent = 0; s.sent_appearance.reset(); @@ -487,11 +491,27 @@ void render(Session &s, std::uintptr_t client, const NativeFrame &local, std::ui native_pass = true; } update_remote_cosmetics(s.base, local, *p.appearance.value(), p.cosmetic_status); + // The effects their costume and board items come with follow the outfit. + if (p.applied_cosmetics != p.cosmetic_revision && s.sync_effects) note_remote_outfit(now); p.last_cosmetic_apply = now; p.applied_cosmetics = p.cosmetic_revision; }; - // The player's sound for this frame, every frame they are shown. + // The player's sound for this frame, every frame they are shown, and the effects of their + // skater's contacts that are due. const auto present_audio = [&](Peer &p) { + tick_remote_effects(now); + if (!s.sync_effects) p.impacts.clear(); + while (!p.impacts.empty() && p.impacts.front().first <= now) { + const auto &impact = p.impacts.front().second; + float distance{}; + for (unsigned i = 0; i < 3; ++i) { + const auto d = impact.position[i] - p.render_pose.root.position[i]; + distance += d * d; + } + // Only where their skater is: a contact far from it is not theirs to place. + if (distance <= 30.f * 30.f) play_impact(impact); + p.impacts.pop_front(); + } if (const auto audio = p.audio.sample(now)) { // Preserve discrete audio changes immediately, but let // unchanged continuous inputs and spatial transforms @@ -872,6 +892,7 @@ void tick(std::uintptr_t base, std::uintptr_t client, bool ready, std::string_vi if (!install_entity_hooks(base, entity_detail) && !entity_detail.empty()) logging::log(logging::Level::warning, logging::Channel::runtime, "Multiplayer: {}", entity_detail); prepare_remote_audio(base); + prepare_effects(base); logging::log(logging::Level::info, logging::Channel::runtime, "Multiplayer: remote-player hooks installed in {} ms (once, before anyone joins).", (now_us() - started) / 1000); diff --git a/Extension/Multiplayer/Session/session_internal.h b/Extension/Multiplayer/Session/session_internal.h index 07a9d23..2ff4291 100644 --- a/Extension/Multiplayer/Session/session_internal.h +++ b/Extension/Multiplayer/Session/session_internal.h @@ -117,6 +117,10 @@ struct Peer { VoiceBudget voice_budget; OutfitBudget outfit_budget; SoundBudget sound_budget; + EffectBudget effect_budget; + // Their skater's contacts with the world (effects.h), each with when to show it: as late + // as their poses are shown, so the sparks are where their skater is. + std::deque> impacts; ChatRate chat_rate; // Chat proofs (session_receive.cpp): the copies of their lines this player sent us over // their own Steam connection, and the lines the host passed on as theirs that wait for one. @@ -156,6 +160,7 @@ struct Session { ObjectPlacement object_placement = ObjectPlacement::everyone; unsigned object_limit{}; // objects each player may have placed; 0: no limit bool object_scaling{true}; // the dedicated server lets players resize what they place + bool sync_effects{true}; // the dedicated server has players see each other's skater effects // What guests may use: the host's choice, or the host's roster for a guest. bool guest_noclip = true, guest_no_bail = true, guest_boosts = true; // Host: guests skate with its physics tuning. Guest: the host's roster says so (a diff --git a/Extension/Multiplayer/Session/session_receive.cpp b/Extension/Multiplayer/Session/session_receive.cpp index fedbb72..5ed38bf 100644 --- a/Extension/Multiplayer/Session/session_receive.cpp +++ b/Extension/Multiplayer/Session/session_receive.cpp @@ -274,6 +274,7 @@ void apply_roster(Session &s, const Packet &p, std::uint64_t now) { apply_object_placement(s, p.object_placement); apply_object_limit(s, p.object_limit); // after server_admin, which exempts an admin s.object_scaling = !dedicated_host(s) || p.object_scaling; + s.sync_effects = !dedicated_host(s) || p.sync_effects; apply_guest_tools(s, p.guest_noclip, p.guest_no_bail, p.guest_boosts); s.enforce_tuning = p.enforce_tuning; s.server_votes = dedicated_host(s) ? p.server_votes : 0; @@ -337,7 +338,14 @@ bool accept_data(Peer &peer, const Packet &p, std::uint64_t now) { } } else if (p.kind == PacketKind::audio) accepted = peer.sound_budget.accept(now, p.audio.size()) && peer.audio.push(p, now); - else if (p.kind == PacketKind::pose) + else if (p.kind == PacketKind::effects) { + accepted = peer.effect_budget.accept(now); + if (accepted) { + const auto due = now + std::max(100000, peer.received_pose_interval + 50000); + for (const auto &impact : p.impacts) peer.impacts.emplace_back(due, impact); + while (peer.impacts.size() > 64) peer.impacts.pop_front(); + } + } else if (p.kind == PacketKind::pose) accepted = peer.poses.push_validated(p, now); if (accepted) { peer.last_packet = now; diff --git a/Extension/Multiplayer/Session/session_send.cpp b/Extension/Multiplayer/Session/session_send.cpp index f2b392b..22152a5 100644 --- a/Extension/Multiplayer/Session/session_send.cpp +++ b/Extension/Multiplayer/Session/session_send.cpp @@ -1,6 +1,7 @@ #include "session_internal.h" #include "Extension/Multiplayer/Remote/native_cosmetics.h" #include "Extension/Multiplayer/Remote/native_audio.h" +#include "Extension/Multiplayer/Remote/native_vfx.h" #include "Extension/Profile/local_profile_runtime.h" #include "Extension/Objects/network_object_runtime.h" #include "Extension/Multiplayer/Net/wire_codec.h" @@ -317,7 +318,8 @@ void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std: std::vector outgoing; unsigned direct_sent{}; const bool gameplay = packet.kind == PacketKind::pose || packet.kind == PacketKind::audio || - packet.kind == PacketKind::voice || packet.kind == PacketKind::cosmetics; + packet.kind == PacketKind::voice || packet.kind == PacketKind::cosmetics || + packet.kind == PacketKind::effects; for (auto &p : active_peers(s)) { if (!p.handshaken || p.member.id == except || (s.mode == Mode::host && gameplay && !p.world_ready) || @@ -325,7 +327,7 @@ void broadcast(Session &s, const Packet &packet, bool reliable, bool fresh, std: continue; // Chat and throwdown messages always travel through the host, which relays // them once to everyone else; a direct copy as well would deliver them twice. - if ((packet.kind == PacketKind::chat || packet.kind == PacketKind::throwdown) && s.mode == Mode::join && + if ((packet.kind == PacketKind::chat || packet.kind == PacketKind::throwdown || packet.kind == PacketKind::effects) && s.mode == Mode::join && p.member.id != s.host_id) continue; if (packet.kind == PacketKind::voice) { @@ -714,6 +716,12 @@ void send_local(Session &s, const NativeFrame &local, std::uint64_t now, std::ui } deliver(a, std::any_of(a.audio.begin(), a.audio.end(), [](const auto &sample) { return sample.event; }), true); } + // This skater's contacts with the world, for the sparks and dust others see on it. + if (auto impacts = drain_impacts(); !impacts.empty() && s.sync_effects) { + auto e = packet(s, PacketKind::effects, now); + e.impacts = std::move(impacts); + deliver(e, false, true); + } } } // namespace session_detail } // namespace dingosdk::multiplayer diff --git a/Extension/Multiplayer/Steam/steam_transport.h b/Extension/Multiplayer/Steam/steam_transport.h index 8ef4d06..94ee58b 100644 --- a/Extension/Multiplayer/Steam/steam_transport.h +++ b/Extension/Multiplayer/Steam/steam_transport.h @@ -6,7 +6,7 @@ namespace dingosdk::multiplayer { enum class TrafficLane : std::uint16_t { gameplay = 0, control = 1, cosmetics = 2, voice = 3 }; constexpr TrafficLane traffic_lane(PacketKind kind) { return kind == PacketKind::voice ? TrafficLane::voice - : kind == PacketKind::pose || kind == PacketKind::audio ? TrafficLane::gameplay + : kind == PacketKind::pose || kind == PacketKind::audio || kind == PacketKind::effects ? TrafficLane::gameplay : kind == PacketKind::cosmetics ? TrafficLane::cosmetics : TrafficLane::control; } struct TransportPeer { diff --git a/Extension/Multiplayer/Test/mesh_tests.cpp b/Extension/Multiplayer/Test/mesh_tests.cpp index 8f74835..ef47608 100644 --- a/Extension/Multiplayer/Test/mesh_tests.cpp +++ b/Extension/Multiplayer/Test/mesh_tests.cpp @@ -278,6 +278,12 @@ void set_native_compass_enabled(bool) noexcept {} void prepare_native_indicators(std::uintptr_t) noexcept {} void prepare_player_ui(std::uintptr_t) noexcept {} void prepare_remote_audio(std::uintptr_t) noexcept {} +void prepare_effects(std::uintptr_t) noexcept {} +std::vector drain_impacts() { return {}; } +void play_impact(const Impact &) noexcept {} +void note_remote_outfit(std::uint64_t) noexcept {} +void tick_remote_effects(std::uint64_t) noexcept {} +void reset_effects() noexcept {} bool install_entity_hooks(std::uintptr_t, std::string &) noexcept { return true; } void publish_custom_nametags(std::uintptr_t, std::vector, std::optional>, bool, bool, float, float, bool) noexcept {} void set_custom_nametags_enabled(bool) noexcept {} diff --git a/Extension/Multiplayer/Test/protocol_tests.cpp b/Extension/Multiplayer/Test/protocol_tests.cpp index 7453fe8..d59d163 100644 --- a/Extension/Multiplayer/Test/protocol_tests.cpp +++ b/Extension/Multiplayer/Test/protocol_tests.cpp @@ -585,6 +585,9 @@ void object_codec() { const auto listed = decode(encode(served)); check(listed && listed->members.size() == 34 && listed->capacity == 32, "A server's roster past its limit failed to round-trip"); check(listed && listed->vote == served.vote, "A server's vote failed to round-trip"); + served.sync_effects = false; + const auto plain = decode(encode(served)); + check(plain && !plain->sync_effects && listed && listed->sync_effects, "The roster's skater effects rule failed to round-trip"); served.object_scaling = false; const auto fixed = decode(encode(served)); check(fixed && !fixed->object_scaling && listed && listed->object_scaling, "The roster's object scaling rule failed to round-trip"); @@ -889,6 +892,33 @@ void dedicated_server_codec() { tuning.tuning.assign(max_physics_tuning + 1, 0); check(reject(tuning), "Oversized physics tuning encoded"); Packet extras; + // Skater effects: a player's contacts with the world, a few to a packet. + { + Packet fx; + fx.kind = PacketKind::effects; fx.session = 9; fx.epoch = 10; fx.map = 11; fx.source = 12; + dingosdk::multiplayer::Impact slide; + slide.position = {594.5f, 199.15f, 1070.1f}; + slide.velocity = {16.97f, -8.44f, 0.f}; + slide.normal = {0.f, 1.f, 0.f}; + slide.material = 49; + fx.impacts = {dingosdk::multiplayer::wire_impact(slide), dingosdk::multiplayer::wire_impact(slide)}; + const auto back = decode(encode(fx)); + check(back && back->kind == PacketKind::effects && back->impacts == fx.impacts, "Skater effects failed to round-trip"); + fx.impacts.assign(dingosdk::multiplayer::max_impacts + 1, slide); + bool refused{}; + try { encode(fx); } catch (const std::invalid_argument &) { refused = true; } + check(refused, "More contacts than a packet carries were encoded"); + fx.impacts = {slide}; + fx.impacts[0].velocity = {60.f, 0.f, 0.f}; + refused = false; + try { encode(fx); } catch (const std::invalid_argument &) { refused = true; } + check(refused, "A contact faster than the game allows was encoded"); + fx.impacts[0] = slide; + fx.impacts[0].material = 0x2000; + refused = false; + try { encode(fx); } catch (const std::invalid_argument &) { refused = true; } + check(refused, "A material past the game's table was encoded"); + } extras.kind = PacketKind::physics_extras; extras.session = 9; extras.epoch = 10; extras.map = 11; extras.source = player; check(decode(encode(extras)) && decode(encode(extras))->kind == PacketKind::physics_extras && decode(encode(extras))->extras.empty(), "Empty physics extras (the game's own) lost"); diff --git a/Extension/Multiplayer/console_commands.cpp b/Extension/Multiplayer/console_commands.cpp index 2962ea0..e93a3b0 100644 --- a/Extension/Multiplayer/console_commands.cpp +++ b/Extension/Multiplayer/console_commands.cpp @@ -3,6 +3,7 @@ #include "Extension/Multiplayer/Hud/native_party.h" #include "Extension/Multiplayer/Remote/native_skater.h" #include "Extension/Multiplayer/Remote/remote_collision.h" +#include "Extension/Multiplayer/Remote/native_vfx.h" #include "Extension/Throwdowns/throwdown_lab.h" #include "Extension/Throwdowns/throwdown_debug_text.h" #include "Extension/Throwdowns/throwdown_relay.h" @@ -163,6 +164,11 @@ void register_multiplayer_commands(Commands ®istry) { out(multiplayer::puppet_saving_status()); }; registry.add(std::move(savings)); + auto effects = action("mp effects", + "Skater effects shared with other players: what has been captured and played", + Group::gameplay, {}); + effects.run = [](const Model &, const Values &, const Output &out) { out(multiplayer::native_effects_status()); }; + registry.add(std::move(effects)); auto hud_choice = argument("reskate|game", Type::text, true); hud_choice.complete = [](const Model &, auto) { return std::vector{"reskate", "game"}; }; auto hud = action("throwdown-hud", diff --git a/Server/README.txt b/Server/README.txt index 59da72b..63731ad 100644 --- a/Server/README.txt +++ b/Server/README.txt @@ -131,6 +131,12 @@ allow_object_scaling Let players place objects bigger or smaller than their objects at their own size and turns the size controls off in their park editor. Admins can still resize theirs. Console: object-scaling on|off. +sync_effects Let players see each other's skater effects (default true): + sparks and dust where a skater touches the world, and + the trails and fire of costumes and skateboards. false + relays none and players' games show each other without + them, which saves a little traffic and drawing on a busy + server. Console: effects on|off. announce_throwdowns Tell everyone in chat when a throwdown drop is placed (default true). @@ -277,6 +283,7 @@ and change voice, distances, placement and kicks from the Multiplayer menu. placement everyone|admins|nobody clear-objects objects |off How many objects each player may have placed. object-scaling on|off Whether players may resize the objects they place. + effects on|off Whether players see each other's skater effects. noclip on|off nobail on|off boosts on|off What players may use (admins always can). tuning on|off Everyone on the game's own physics tuning. diff --git a/Server/Test/server_config_tests.cpp b/Server/Test/server_config_tests.cpp index 7fc59ca..794f73c 100644 --- a/Server/Test/server_config_tests.cpp +++ b/Server/Test/server_config_tests.cpp @@ -204,6 +204,17 @@ int run() { check(config_error(away).find("afk_kick_minutes") != std::string::npos, "An away timer over a day was accepted"); std::filesystem::remove_all(folder / "data"); } + // Skater effects: shared unless turned off, and kept in the file. + { + ServerConfig plain; + check(plain.sync_effects, "Skater effects are not shared by default"); + plain.file = folder / "plain.json"; + plain.sync_effects = false; + save_config(plain); + check(!load_config(plain.file).sync_effects && text(plain.file).find("\"sync_effects\": false") != std::string::npos, + "The skater effects setting was not kept"); + std::filesystem::remove_all(folder / "data"); + } // Object scaling: allowed unless turned off, and kept in the file. { ServerConfig sized; diff --git a/Server/server_commands.cpp b/Server/server_commands.cpp index 8885381..ee60302 100644 --- a/Server/server_commands.cpp +++ b/Server/server_commands.cpp @@ -16,7 +16,7 @@ constexpr std::string_view help_text = "status | net [player] | players | say | msg | msg-party | msg-admins | kick | ban [name] | unban | bans\n" "map | maps | name | password | welcome | listed on|off\n" "voice on|off | voice-range <50-1000> | distances | crowd |off | rate | bone-scale <1-8>|off\n" - "placement everyone|admins|nobody | objects |off | object-scaling on|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" + "placement everyone|admins|nobody | objects |off | object-scaling on|off | effects on|off | clear-objects | noclip on|off | nobail on|off | boosts on|off | tuning on|off\n" "tpall [player] | tphere | votes [map|kick|tod on|off|] | vote-cancel\n" "map-pool [add|remove |clear] | rotation [|off]\n" "park | park random | layer-sync on|off | layer default|on|off | tod \n" @@ -457,6 +457,13 @@ std::string Host::command(std::string_view line, std::uint64_t admin) { return changed(*value ? "Players can resize the objects they place." : "Placed objects are their own size for everyone. Admins can still resize theirs."); } + if (name == "effects") { + const auto value = on_off(argument); + if (!value) return std::string("effects on|off (now ") + (config_.sync_effects ? "on" : "off") + ")"; + config_.sync_effects = *value; + return changed(*value ? "Players see each other's skater effects." + : "Players no longer see each other's skater effects. Ones already showing stay until that player's skater is shown again."); + } if (name == "votes") { // votes | votes on|off| | votes seconds|cooldown const auto [what_text, value_text] = split(argument); diff --git a/Server/server_config.cpp b/Server/server_config.cpp index c7535a6..91b431a 100644 --- a/Server/server_config.cpp +++ b/Server/server_config.cpp @@ -105,6 +105,7 @@ Layout layout(const ServerConfig &c) { players.set("object_placement", placement_text(c.object_placement)); players.set("object_limit", c.object_limit); players.set("allow_object_scaling", c.object_scaling); + players.set("sync_effects", c.sync_effects); players.set("announce_throwdowns", c.announce_throwdowns); auto &anti_cheat = root.section("anti_cheat"); @@ -260,6 +261,7 @@ ServerConfig load_config(const std::filesystem::path &file, std::vector> sound; // a skater's samples, shared by everyone sent them const bool gameplay = packet.kind == PacketKind::pose || packet.kind == PacketKind::audio || - packet.kind == PacketKind::voice || packet.kind == PacketKind::cosmetics; + packet.kind == PacketKind::voice || packet.kind == PacketKind::cosmetics || + packet.kind == PacketKind::effects; for (auto &[id, guest] : guests_) { auto &p = *guest; if (!p.handshaken || id == except || (gameplay && !p.world_ready)) continue; @@ -387,6 +388,15 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6 if (!p.unmet.empty() && (packet.kind == PacketKind::pose || packet.kind == PacketKind::audio || packet.kind == PacketKind::cosmetics) && p.unmet.contains(packet.source)) continue; + // A skater's effects go to those near enough to see them: within 150 m. + if (packet.kind == PacketKind::effects && source && source->latest_root && p.latest_root) { + float distance{}; + for (unsigned i = 0; i < 3; ++i) { + const auto d = p.latest_root->position[i] - source->latest_root->position[i]; + distance += d * d; + } + if (distance > 150.f * 150.f) continue; + } // A skater's sound goes to those who would hear it: within the full pose rate's reach, // and in a crowd only from the nearest (the same players sent at the full rate). // A game stops a sound it hears nothing more of after a second. @@ -577,6 +587,7 @@ void Host::send_roster() { p.object_placement = config_.object_placement; p.object_limit = config_.object_limit; p.object_scaling = config_.object_scaling; + p.sync_effects = config_.sync_effects; p.guest_noclip = config_.noclip; p.guest_no_bail = config_.no_bail; p.guest_boosts = config_.boosts; @@ -743,6 +754,8 @@ bool Host::accept_data(Guest &source, const Packet &p) { if (accepted) source.cosmetic_packet = encode_wire(p); } else if (p.kind == PacketKind::audio) accepted = source.sound_budget.accept(now_, p.audio.size()) && source.audio.push(p, now_); + else if (p.kind == PacketKind::effects) + accepted = config_.sync_effects && source.effect_budget.accept(now_); else if (p.kind == PacketKind::pose) accepted = source.poses.push_validated(p, now_); // The server never plays anything back: keep only what ordering needs. diff --git a/Server/server_host.h b/Server/server_host.h index 125dc77..2a49a33 100644 --- a/Server/server_host.h +++ b/Server/server_host.h @@ -144,6 +144,7 @@ class Host { VoiceBudget voice_budget; OutfitBudget outfit_budget; SoundBudget sound_budget; + EffectBudget effect_budget; ChatRate chat_rate; ChatBudget admin_budget, throwdown_budget, party_budget; SpeedCheck speed; // how fast their game runs, from their pose timestamps diff --git a/cmake/Runtime.cmake b/cmake/Runtime.cmake index 4274cc0..c7b6c86 100644 --- a/cmake/Runtime.cmake +++ b/cmake/Runtime.cmake @@ -31,6 +31,7 @@ add_library(dingosdk_runtime SHARED Extension/Multiplayer/Remote/puppet_cost.cpp Extension/Multiplayer/Remote/native_cosmetics.cpp Extension/Multiplayer/Remote/native_audio.cpp + Extension/Multiplayer/Remote/native_vfx.cpp Extension/Multiplayer/Voice/voice_chat.cpp Extension/Multiplayer/Voice/native_voice.cpp Extension/Multiplayer/Hud/native_player_ui.cpp