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.
This commit is contained in:
AyeZeeBB committed 2026-10-09 00:10:11 -05:00
1 parent 574860da72
commit b229062400
23 files changed
+763 -9

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <array>
#include <cstdint>
// 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<unsigned char, 16> 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;
}
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <algorithm>
#include <array>
#include <cmath>
#include <cstdint>
#include <span>
// 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<float, 3> position{}; // where, in the world
std::array<float, 3> velocity{}; // of the contact, metres a second; the game caps its length
std::array<float, 3> 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<const Impact> 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
+33 -1
View File
@@ -375,6 +375,8 @@ std::vector<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> 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<std::uint32_t>(v), 4);
for (const auto v : impact.velocity)
w.integer(static_cast<std::uint16_t>(static_cast<std::int16_t>(std::lround(v * 100.f))), 2);
for (const auto v : impact.normal)
w.integer(static_cast<std::uint8_t>(static_cast<std::int8_t>(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<std::uint8_t> 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<Packet> decode(std::span<const std::uint8_t> bytes) noexcept {
return {};
p.tuning.assign(bytes.begin() + static_cast<std::ptrdiff_t>(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<std::size_t>(count));
for (auto &impact : p.impacts) {
for (auto &v : impact.position) v = r.number();
for (auto &v : impact.velocity)
v = static_cast<float>(static_cast<std::int16_t>(static_cast<std::uint16_t>(r.integer(2)))) / 100.f;
for (auto &v : impact.normal)
v = static_cast<float>(static_cast<std::int8_t>(static_cast<std::uint8_t>(r.integer(1)))) / 127.f;
impact.material = static_cast<std::uint16_t>(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<Packet> decode(std::span<const std::uint8_t> 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;
+10 -1
View File
@@ -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<float, 3> teleport{}; // teleport: where the receiver goes (world position)
std::vector<std::uint8_t> tuning; // physics_tuning: 0..max_physics_tuning bytes
std::vector<std::uint8_t> extras; // physics_extras: 0..max_physics_extras bytes
std::vector<Impact> 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
+440
View File
@@ -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 <Windows.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <bit>
#include <cstring>
#include <format>
#include <mutex>
// 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<const void *>(p), size);
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
template <class T> 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<Address>(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<Address> 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<Impact> captured; // the local game's, since the last drain
std::vector<Registered> registered; // every effects component seen registering, newest last
std::vector<Pair> pairs;
std::array<bool, max_impact_material + 1> seen{}; // materials this game's own contacts named
std::atomic<std::uint64_t> captured_count{}, played{}, refused{}, refreshed{}, moved{}, changes{};
std::atomic<unsigned> 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<Remote>;
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<float, 12> vectors{};
std::uint32_t material{};
if (readable(reinterpret_cast<Address>(native), vectors.data(), sizeof(vectors)) &&
readable(reinterpret_cast<Address>(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<std::uint16_t>((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<int>(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<JointIndex>(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<std::uint32_t>(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<std::uint32_t>(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<Address> block;
Address data{}, replaced{};
if (settings && readable(reinterpret_cast<Address>(settings), &data, sizeof(data)) && data) {
Address component{};
walk(ptr(manager, vfx::manager_skaters_root), id, component);
const auto count = get<std::uint32_t>(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<Address> 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<std::int32_t>(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<std::uint32_t>(block.size() - 1);
if (total != count) {
block[0] = (Address{total} << 32) | total; // capacity, then the count
replaced = reinterpret_cast<Address>(block.data() + 1);
++s.moved;
}
}
}
return s.build_effects(manager, entity, id, replaced ? &replaced : settings, flag, queued);
}
template <class Original> bool attach(State &s, const vfx::Function &function, void *replacement, Original &original) {
void *forward{};
auto *target = reinterpret_cast<void *>(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<Original>(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<unsigned char, 16> 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<ThreadWorld>(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<std::uint32_t>(s.base + vfx::manager_offset);
return get<std::uint8_t>(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<std::uint32_t>(s.base + vfx::materials_offset);
if (!get<std::uint8_t>(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<std::uint32_t>(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<Refresh>(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, &register_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<Impact> 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<std::uint8_t, vfx::impact_size> native{};
const auto vector = [&](std::size_t at, const std::array<float, 3> &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<std::uint32_t, 4> tail{material, material, 0U, std::bit_cast<std::uint32_t>(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
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "Extension/Multiplayer/Net/effects.h"
#include <string>
#include <vector>
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<Impact> 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
+9
View File
@@ -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{};
+22 -1
View File
@@ -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);
@@ -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<std::pair<std::uint64_t, Impact>> 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
@@ -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<std::uint64_t>(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;
+10 -2
View File
@@ -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> 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
@@ -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 {
@@ -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<Impact> 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<NametagPlayer>, std::optional<std::array<float, 3>>, bool, bool, float, float, bool) noexcept {}
void set_custom_nametags_enabled(bool) noexcept {}
@@ -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");
@@ -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 &registry) {
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<std::string>{"reskate", "game"}; };
auto hud = action("throwdown-hud",
+7
View File
@@ -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 <number>|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.
+11
View File
@@ -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;
+8 -1
View File
@@ -16,7 +16,7 @@ constexpr std::string_view help_text =
"status | net [player] | players | say <text> | msg <player> <text> | msg-party <player> <text> | msg-admins <text> | kick <player> | ban <player or SteamID64> [name] | unban <SteamID64> | bans\n"
"map <name, e.g. San Vansterdam> | maps | name <text> | password <text|off> | welcome <text|off> | listed on|off\n"
"voice on|off | voice-range <50-1000> | distances <full> <half> <half-return> <low> | crowd <n>|off | rate <KB/s> | bone-scale <1-8>|off\n"
"placement everyone|admins|nobody | objects <number>|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 <number>|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 <player> | votes [map|kick|tod on|off|<percent>] | vote-cancel\n"
"map-pool [add|remove <map>|clear] | rotation [<minutes>|off]\n"
"park <lot> <layout> | park random | layer-sync on|off | layer <key> default|on|off | tod <time|default>\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 <map|kick|tod> on|off|<percent> | votes seconds|cooldown <n>
const auto [what_text, value_text] = split(argument);
+2
View File
@@ -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<std::str
: placement == "admins" || placement == "host" ? ObjectPlacement::host_only : ObjectPlacement::everyone;
c.object_limit = get("players", "object_limit", c.object_limit);
c.object_scaling = get("players", "allow_object_scaling", c.object_scaling);
c.sync_effects = get("players", "sync_effects", c.sync_effects);
c.announce_throwdowns = get("players", "announce_throwdowns", c.announce_throwdowns);
c.speed_check = get("anti_cheat", "speed_hack", c.speed_check, {"speed_check"});
+4
View File
@@ -106,6 +106,10 @@ struct ServerConfig {
// Players may place objects at another size than their own. Off: every player's objects
// are shared at their own size; admins may still resize theirs.
bool object_scaling = true;
// Players see each other's skater effects: sparks and dust where a skater touches the world,
// and the trails and fire of costumes and skateboards. Off: nothing of them is relayed and
// players' games show each other without them.
bool sync_effects = true;
// Whether players may use noclip (and teleport) / No Bail / the boosts (admins always may).
bool noclip = true, no_bail = true, boosts = true;
// Players skate with the game's own physics tuning, not copies they edited.
+14 -1
View File
@@ -367,7 +367,8 @@ void Host::broadcast(const Packet &packet, bool reliable, bool fresh, std::uint6
outgoing.reserve(guests_.size());
std::shared_ptr<const std::vector<AudioSample>> 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.
+1
View File
@@ -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
+1
View File
@@ -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