#include "protocol.h" #include #include #include #include #include namespace dingosdk::multiplayer { namespace { constexpr std::uint32_t magic = 0x31504d52; // RMP1, little endian constexpr std::size_t header_size = packet_header_size; std::size_t utf8_length(std::string_view text, std::size_t at) noexcept; // defined with the text checks below std::uint8_t encode_park(unsigned lot, std::string_view choice) { if (choice.empty() || choice == "empty") return 0; for (unsigned family = 0; family < park_families.size(); ++family) for (unsigned variant = 1; variant <= park_lots[lot].counts[family]; ++variant) if (choice == park_id(family, variant)) return static_cast(1 + family * 16 + variant - 1); throw std::invalid_argument("Invalid park selection"); } std::string decode_park(unsigned lot, unsigned code) { if (!code) return "empty"; const auto family = (code - 1) / 16, variant = (code - 1) % 16 + 1; if (family >= park_families.size() || variant > park_lots[lot].counts[family]) throw std::invalid_argument("Invalid park selection"); return park_id(family, variant); } struct Writer { std::vector bytes; void integer(std::uint64_t value, unsigned width) { for (unsigned i = 0; i < width; ++i) bytes.push_back(static_cast(value >> (8 * i))); } void transform(const Transform &t) { for (float v : t.position) integer(std::bit_cast(v), 4); for (float v : t.rotation) integer(std::bit_cast(v), 4); for (float v : t.scale) integer(std::bit_cast(v), 4); } void compact_transform(const Transform &t) { unsigned largest{}; for (unsigned i = 1; i < 4; ++i) if (std::abs(t.rotation[i]) > std::abs(t.rotation[largest])) largest = i; const bool wide = std::any_of(t.position.begin(), t.position.end(), [](float v) { return std::abs(v) > 32.767f; }); const bool scale = t.scale != std::array{1, 1, 1}; integer((largest << 2) | (wide ? 1U : 0U) | (scale ? 2U : 0U), 1); for (float v : t.position) { if (wide) integer(std::bit_cast(v), 4); else integer(static_cast(static_cast(std::lround(v * 1000.f))), 2); } float norm{}; for (float v : t.rotation) norm += v * v; const float factor = (t.rotation[largest] < 0 ? -1.f : 1.f) * 46339.5358f / std::sqrt(norm); for (unsigned i = 0; i < 4; ++i) if (i != largest) integer(static_cast( static_cast(std::lround(t.rotation[i] * factor))), 2); if (scale) for (float v : t.scale) integer(std::bit_cast(v), 4); } void words(const std::vector &values) { integer(values.size(), 2); for (auto value : values) integer(value, 4); } void recipe(const CosmeticRecipe &r) { integer(r.key, 4); integer(r.version, 4); words(r.scalars); integer(r.items.size(), 2); for (const auto &item : r.items) { integer(item.slot, 4); integer(item.asset.size(), 2); bytes.insert(bytes.end(), item.asset.begin(), item.asset.end()); words(item.parameters); } } }; struct Reader { std::span bytes; std::size_t at{}; std::uint64_t integer(unsigned width) { if (at > bytes.size() || width > bytes.size() - at) throw std::runtime_error("Truncated packet"); std::uint64_t result{}; for (unsigned i = 0; i < width; ++i) result |= std::uint64_t{bytes[at++]} << (8 * i); return result; } float number() { return std::bit_cast(static_cast(integer(4))); } Transform transform() { Transform t; for (auto &v : t.position) v = number(); for (auto &v : t.rotation) v = number(); for (auto &v : t.scale) v = number(); if (!valid_transform(t)) throw std::runtime_error("Invalid transform"); return t; } Transform compact_transform() { Transform t; const auto flags = integer(1); if (flags & ~15ULL) throw std::runtime_error("Unknown packed transform flags"); for (auto &v : t.position) v = (flags & 1) ? number() : static_cast(integer(2)) / 1000.f; const unsigned largest = static_cast(flags >> 2); float norm{}; for (unsigned i = 0; i < 4; ++i) if (i != largest) { t.rotation[i] = static_cast(integer(2)) / 46339.5358f; norm += t.rotation[i] * t.rotation[i]; } if (norm > 1.0001f) throw std::runtime_error("Invalid packed quaternion"); t.rotation[largest] = std::sqrt(std::max(0.f, 1.f - norm)); if (flags & 2) for (auto &v : t.scale) v = number(); if (!valid_transform(t)) throw std::runtime_error("Invalid packed transform"); return t; } std::vector words(std::size_t limit) { const auto count = integer(2); if (count > limit || count > (bytes.size() - at) / 4) throw std::runtime_error("Invalid cosmetic parameter count"); std::vector result; result.reserve(static_cast(count)); for (std::size_t i = 0; i < count; ++i) result.push_back(static_cast(integer(4))); return result; } CosmeticRecipe recipe() { CosmeticRecipe r; r.key = static_cast(integer(4)); r.version = static_cast(integer(4)); r.scalars = words(max_cosmetic_scalars); const auto count = integer(2); if (!count || count > max_cosmetic_slots) throw std::runtime_error("Invalid cosmetic slot count"); r.items.reserve(static_cast(count)); for (std::size_t i = 0; i < count; ++i) { CosmeticSlot item; item.slot = static_cast(integer(4)); const auto length = integer(2); if (length > max_cosmetic_asset || length > bytes.size() - at) throw std::runtime_error("Invalid cosmetic asset length"); item.asset.assign(reinterpret_cast(bytes.data() + at), static_cast(length)); at += static_cast(length); item.parameters = words(max_cosmetic_parameters); r.items.push_back(std::move(item)); } return r; } }; } // namespace bool valid_roster(std::span members, unsigned capacity) noexcept { if (capacity < 2 || capacity > max_players || members.empty() || members.size() > capacity) return false; for (std::size_t i = 0; i < members.size(); ++i) { const auto &m = members[i]; // The host comes first, and may be a dedicated server rather than a player. const bool server = i == 0 && game_server_steam_id(m.id); const bool identity = individual_steam_id(m.id) || server; if (!identity || !m.epoch || m.name.size() > 128 || (i == 0 && m.admin)) return false; // A server is in no party; only a party's leader leads or opens it. if ((server && m.party) || (!m.party && (m.party_leader || m.party_open)) || (m.party_open && !m.party_leader)) return false; // Names reach the game's own UI: whole UTF-8 characters only, and no controls. for (std::size_t at = 0; at < m.name.size();) { const auto length = utf8_length(m.name, at); const auto c = static_cast(m.name[at]); if (!length || c < 32 || c == 127) return false; at += length; } unsigned leaders = m.party_leader ? 1U : 0U, size = 1; for (std::size_t j = 0; j < members.size(); ++j) { if (j < i && members[j].id == m.id) return false; if (j != i && m.party && members[j].party == m.party) { leaders += members[j].party_leader ? 1U : 0U; ++size; } } // Each party has one leader and at least one other member. if (m.party && (leaders != 1 || size < 2)) return false; } return true; } bool valid_party_request(PartyAction action, std::uint64_t player) noexcept { switch (action) { case PartyAction::invite: case PartyAction::accept: case PartyAction::decline: case PartyAction::join: case PartyAction::kick: case PartyAction::promote: case PartyAction::invited: case PartyAction::withdrawn: return individual_steam_id(player); case PartyAction::leave: case PartyAction::open: case PartyAction::close: return player == 0; } return false; } bool valid_routes(std::span members) noexcept { if (members.size() > max_remote_players) return false; for (std::size_t i = 0; i < members.size(); ++i) { const auto &m = members[i]; if (!individual_steam_id(m.id) || !m.epoch || !m.name.empty() || m.admin) return false; for (std::size_t j = 0; j < i; ++j) if (members[j].id == m.id) return false; } return true; } bool valid_appearance(const Appearance &a) noexcept { if (a.skater.key != skater_recipe_key || a.skater.version != 2 || a.board.key != board_recipe_key || a.board.version != 1) return false; std::size_t size = header_size + 13 + mark_items * 8; for (const auto *r : {&a.skater, &a.board}) { if (r->scalars.size() > max_cosmetic_scalars || r->items.empty() || r->items.size() > max_cosmetic_slots) return false; size += 12 + r->scalars.size() * 4; for (std::size_t i = 0; i < r->items.size(); ++i) { const auto &item = r->items[i]; if (!item.slot || item.asset.size() > max_cosmetic_asset || item.parameters.size() > max_cosmetic_parameters) return false; for (unsigned char c : item.asset) if (c < 32 || c == 127) return false; for (std::size_t j = 0; j < i; ++j) if (r->items[j].slot == item.slot) return false; size += 8 + item.asset.size() + item.parameters.size() * 4; } } return size <= max_appearance_bytes; } bool valid_transform(const Transform &t) noexcept { for (float v : t.position) if (!std::isfinite(v) || std::abs(v) > 1000000) return false; for (float v : t.scale) if (!std::isfinite(v) || v < .0001f || v > 100) return false; float norm{}; for (float v : t.rotation) { if (!std::isfinite(v)) return false; norm += v * v; } return norm > .8f && norm < 1.2f; } bool valid_pose(const Pose &p) noexcept { return valid_transform(p.root) && p.skater.size() <= max_skater_bones && p.board.size() <= max_board_bones && std::all_of(p.skater.begin(), p.skater.end(), valid_transform) && std::all_of(p.board.begin(), p.board.end(), valid_transform); } void offset_pose(Pose &pose, const std::array &offset) { // Both native rigs reserve bone 0 and put their world-space anchor in // bone 1. Child bones stay local. Board packet index 0 is the separate // entity transform, so its rig anchor is packet index 2. for (std::size_t i = 0; i < offset.size(); ++i) { pose.root.position[i] += offset[i]; if (pose.skater.size() > 1) pose.skater[1].position[i] += offset[i]; if (!pose.board.empty()) pose.board.front().position[i] += offset[i]; if (pose.board.size() > 2) pose.board[2].position[i] += offset[i]; } } bool valid_map_destination(std::string_view value) noexcept { const auto split = value.find('|'); return !value.empty() && value.size() <= 256 && split != std::string_view::npos && split > 0 && value.find('|', split + 1) == std::string_view::npos && std::none_of(value.begin(), value.end(), [](unsigned char c) { return c < 32 || c == 127; }); } std::string_view greeting_error(const Packet &p, std::uint64_t session, std::uint64_t map, const std::array &build, std::uint64_t peer_epoch) noexcept { if ((p.kind != PacketKind::hello && p.kind != PacketKind::welcome && p.kind != PacketKind::challenge && p.kind != PacketKind::peer_hello && p.kind != PacketKind::peer_welcome) || !p.session || !p.epoch || p.session != session) return "Join code or multiplayer protocol did not match."; if (p.build != build) return "Both players need the same supported game build."; if (!map || p.map != map) return "Map mismatch. Load the same map on both machines, then join again."; if (peer_epoch && p.epoch != peer_epoch) return "Peer changed level or session. Join again."; return {}; } namespace { void coarsen(Transform &t, unsigned bits) noexcept { if (!bits) return; unsigned largest{}; float norm{}; for (unsigned i = 0; i < 4; ++i) { norm += t.rotation[i] * t.rotation[i]; if (std::abs(t.rotation[i]) > std::abs(t.rotation[largest])) largest = i; } if (!(norm > 0.f) || !std::isfinite(norm)) return; // As Writer::compact_transform packs it: the three smaller components, the largest positive. constexpr float scale = 46339.5358f; const float factor = (t.rotation[largest] < 0 ? -1.f : 1.f) * scale / std::sqrt(norm); const float step = static_cast(1U << bits); const float most = std::floor(32767.f / step) * step; // a rounded value must still fit 16 bits float sum{}; for (unsigned i = 0; i < 4; ++i) { if (i == largest) continue; const float packed = std::clamp(std::round(t.rotation[i] * factor / step) * step, -most, most); t.rotation[i] = packed / scale; sum += t.rotation[i] * t.rotation[i]; } t.rotation[largest] = std::sqrt(std::max(0.f, 1.f - sum)); } } // namespace void limit_bone_scale(Pose &pose, float limit) noexcept { if (!(limit >= 1.f)) return; const auto hold = [&](Transform &t) { for (float &axis : t.scale) axis = std::isfinite(axis) ? std::clamp(axis, 1.f / limit, limit) : 1.f; }; hold(pose.root); for (auto &t : pose.skater) hold(t); for (auto &t : pose.board) hold(t); } void coarsen_rotations(Pose &pose, unsigned bits) noexcept { bits = std::min(bits, 12U); coarsen(pose.root, std::min(bits, 6U)); // which way they face matters from further off than a finger does for (auto &t : pose.skater) coarsen(t, bits); for (auto &t : pose.board) coarsen(t, bits); } std::vector encode(const Packet &p, bool compact_pose) { return encode(p, compact_pose, p.pose_interval_us); } std::vector encode(const Packet &p, bool compact_pose, std::uint32_t pose_interval_us) { if (!p.session || !p.epoch || (!p.world && p.kind != PacketKind::map_request) || (p.kind == PacketKind::pose && (!p.map || !valid_pose(p.pose))) || (p.kind == PacketKind::cosmetics && (!p.map || !valid_appearance(p.appearance))) || (p.kind == PacketKind::audio && (!p.map || !valid_audio_batch(p.audio)))) throw std::invalid_argument("Invalid multiplayer packet"); if (p.kind == PacketKind::roster && (!p.map || !valid_roster(p.members, p.capacity) || !p.distances.valid() || !p.voice_policy.revision)) throw std::invalid_argument("Invalid session roster"); if (p.kind == PacketKind::routes && (!p.map || !valid_routes(p.members))) throw std::invalid_argument("Invalid direct route report"); if (p.kind == PacketKind::objects && (!p.map || !p.source || !valid_object_chunk(p.objects))) 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::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))) throw std::invalid_argument("Invalid admin message"); if (p.kind == PacketKind::throwdown && (!p.source || p.throwdown.empty() || p.throwdown.size() > max_throwdown_message)) throw std::invalid_argument("Invalid throwdown message"); if (p.kind == PacketKind::physics_tuning && (!p.source || p.tuning.size() > max_physics_tuning)) throw std::invalid_argument("Invalid physics tuning"); if (p.kind == PacketKind::physics_extras && (!p.source || p.extras.size() > max_physics_extras)) throw std::invalid_argument("Invalid physics extras"); if (p.kind == PacketKind::party && (!p.source || !valid_party_request(p.party_action, p.party_player))) throw std::invalid_argument("Invalid party message"); if (p.kind == PacketKind::scoring && (!p.source || (!p.text.empty() && !valid_admin_text(p.text)))) throw std::invalid_argument("Invalid scoring report"); if (p.kind == PacketKind::teleport && (!p.source || std::any_of(p.teleport.begin(), p.teleport.end(), [](float v) { return !std::isfinite(v) || std::abs(v) > 1e6f; }))) throw std::invalid_argument("Invalid teleport"); if (p.kind == PacketKind::bans && (!p.source || p.bans.size() > max_ban_rows || p.ban_total < p.bans.size() || std::any_of(p.bans.begin(), p.bans.end(), [](const auto &ban) { return !individual_steam_id(ban.id) || !valid_member_name(ban.name); }))) throw std::invalid_argument("Invalid ban list"); if (p.kind == PacketKind::maps && (!p.source || p.maps.size() > max_server_maps || !valid_map_pool(p.map_pool, p.maps.size()) || p.map_rotation > max_map_rotation || std::any_of(p.maps.begin(), p.maps.end(), [](const auto &asset) { return !valid_map_asset(asset); }))) throw std::invalid_argument("Invalid server map list"); if (p.kind == PacketKind::hello && !p.text.empty() && !valid_member_name(p.text)) throw std::invalid_argument("Invalid player name"); if (p.kind == PacketKind::roster && !valid_voice_range(p.voice_range)) throw std::invalid_argument("Invalid voice range"); if (p.kind == PacketKind::map_offer && (!valid_map_destination(p.destination) || p.map != map_hash(p.destination))) throw std::invalid_argument("Invalid host map destination"); if ((p.kind == PacketKind::map_offer || p.kind == PacketKind::world_state) && !valid_map_label(p.map_label)) throw std::invalid_argument("Invalid map name"); if (p.kind == PacketKind::world_state && (p.destination.empty() ? (p.map != 0 || p.world_ready) : (!valid_map_destination(p.destination) || p.map != map_hash(p.destination)))) throw std::invalid_argument("Invalid world transition"); const bool packed = compact_pose && p.kind == PacketKind::pose; const bool greeting = p.kind == PacketKind::hello || p.kind == PacketKind::welcome || p.kind == PacketKind::challenge || p.kind == PacketKind::peer_hello || p.kind == PacketKind::peer_welcome || p.kind == PacketKind::map_request || p.kind == PacketKind::map_offer; if (!greeting && p.kind != PacketKind::pose && p.kind != PacketKind::away && p.kind != PacketKind::cosmetics && p.kind != PacketKind::audio && p.kind != PacketKind::roster && p.kind != PacketKind::routes && p.kind != PacketKind::world_state && p.kind != PacketKind::world_ready && 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) throw std::invalid_argument("Unknown packet kind"); const auto payload = greeting ? 72 : p.kind == PacketKind::away ? 0 : 44 + (p.pose.skater.size() + p.pose.board.size()) * 40; Writer w; w.bytes.reserve(header_size + payload); w.integer(magic, 4); w.integer(protocol_version, 2); w.integer(packed ? 8U : static_cast(p.kind), 2); w.integer(payload, 4); w.integer(p.sequence, 4); w.integer(p.session, 8); w.integer(p.map, 8); w.integer(p.epoch, 8); w.integer(p.time_us, 8); w.integer(p.source, 8); w.integer(p.world, 8); if (greeting) { w.bytes.insert(w.bytes.end(), p.build.begin(), p.build.end()); w.integer(p.challenge, 8); w.bytes.insert(w.bytes.end(), p.proof.begin(), p.proof.end()); if (p.kind == PacketKind::map_offer) { w.integer(p.map_authorized ? 1 : 0, 1); w.integer(p.destination.size(), 2); w.bytes.insert(w.bytes.end(), p.destination.begin(), p.destination.end()); w.integer(p.map_label.size(), 1); w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end()); } if (p.kind == PacketKind::hello) { w.integer(p.text.size(), 1); w.bytes.insert(w.bytes.end(), p.text.begin(), p.text.end()); } } else if (p.kind == PacketKind::world_state) { w.bytes.insert(w.bytes.end(), p.build.begin(), p.build.end()); w.integer(p.world_ready ? 1 : 0, 1); w.integer(p.destination.size(), 2); w.bytes.insert(w.bytes.end(), p.destination.begin(), p.destination.end()); w.integer(p.map_label.size(), 1); w.bytes.insert(w.bytes.end(), p.map_label.begin(), p.map_label.end()); } else if (p.kind == PacketKind::world_ready) { w.integer(p.world_ready ? 1 : 0, 1); } else if (p.kind == PacketKind::pose) { w.integer(p.pose.skater.size(), 2); w.integer(p.pose.board.size(), 2); if (!valid_pose_interval(pose_interval_us)) throw std::invalid_argument("Invalid pose update interval"); // Version 19 stores TPS, preserving fractional-millisecond rates such // as 30/60/120 without silently rounding them to 33/16/8 ms. w.integer((1000000U / pose_interval_us) | (p.player_collision ? 0x8000U : 0U), 2); const auto transform = [&](const Transform &t) { if (packed) w.compact_transform(t); else w.transform(t); }; transform(p.pose.root); for (const auto &t : p.pose.skater) transform(t); for (const auto &t : p.pose.board) transform(t); } else if (p.kind == PacketKind::cosmetics) { w.recipe(p.appearance.skater); w.recipe(p.appearance.board); w.integer(p.appearance.card.background, 4); w.integer(p.appearance.card.emblem, 4); w.integer(p.appearance.card.title, 4); w.integer((p.appearance.hide_tag ? 1 : 0) | (p.appearance.hide_items ? 2 : 0), 1); for (const auto &style : p.appearance.marks) { w.integer(static_cast(style.mode), 1); for (const auto part : style.from) w.integer(part, 1); for (const auto part : style.to) w.integer(part, 1); w.integer(style.speed, 1); } } else if (p.kind == PacketKind::audio) { w.integer(p.audio.size(), 2); AudioState previous; for (const auto &s : p.audio) { w.integer(s.age_us | (s.event ? 0x80000000U : 0), 4); std::uint64_t values{}, selectors{}, flags{}; for (std::size_t i = 0; i < audio_float_count; ++i) if (std::bit_cast(s.state.values[i]) != std::bit_cast(previous.values[i])) values |= 1ULL << i; for (std::size_t i = 0; i < audio_selector_count; ++i) if (s.state.selectors[i] != previous.selectors[i]) selectors |= 1ULL << i; for (std::size_t i = 0; i < audio_flag_count; ++i) flags |= std::uint64_t{s.state.flags[i]} << i; w.integer(values, 8); w.integer(selectors, 4); w.integer(flags, 6); for (std::size_t i = 0; i < audio_float_count; ++i) if (values & (1ULL << i)) w.integer(std::bit_cast(s.state.values[i]), 4); for (std::size_t i = 0; i < audio_selector_count; ++i) if (selectors & (1ULL << i)) w.integer(s.state.selectors[i], 4); previous = s.state; } } if (p.kind == PacketKind::voice) { w.integer(std::bit_cast(p.voice.distance), 4); w.integer(std::bit_cast(p.voice.gain), 4); w.integer(p.voice.policy_revision, 4); w.integer(p.voice.bytes.size(), 2); w.bytes.insert(w.bytes.end(), p.voice.bytes.begin(), p.voice.bytes.end()); } if (p.kind == PacketKind::chat || p.kind == PacketKind::admin) { w.integer(p.text.size(), 2); w.bytes.insert(w.bytes.end(), p.text.begin(), p.text.end()); } if (p.kind == PacketKind::throwdown) { w.integer(p.throwdown.size(), 2); w.bytes.insert(w.bytes.end(), p.throwdown.begin(), p.throwdown.end()); } if (p.kind == PacketKind::teleport) for (const auto v : p.teleport) w.integer(std::bit_cast(v), 4); if (p.kind == PacketKind::party) { w.integer(static_cast(p.party_action), 1); w.integer(p.party_player, 8); } if (p.kind == PacketKind::physics_tuning) { w.integer(p.tuning.size(), 2); w.bytes.insert(w.bytes.end(), p.tuning.begin(), p.tuning.end()); } if (p.kind == PacketKind::physics_extras) { w.integer(p.extras.size(), 2); w.bytes.insert(w.bytes.end(), p.extras.begin(), p.extras.end()); } if (p.kind == PacketKind::scoring) { w.integer(p.scoring, 8); w.integer(p.text.size(), 2); w.bytes.insert(w.bytes.end(), p.text.begin(), p.text.end()); } if (p.kind == PacketKind::maps) { w.integer(p.maps.size(), 2); for (const auto &asset : p.maps) { w.integer(asset.size(), 1); w.bytes.insert(w.bytes.end(), asset.begin(), asset.end()); } w.integer(p.map_pool.size(), 2); for (const auto index : p.map_pool) w.integer(index, 2); w.integer(p.map_rotation, 2); } if (p.kind == PacketKind::bans) { w.integer(p.ban_total, 4); w.integer(p.bans.size(), 2); for (const auto &ban : p.bans) { w.integer(ban.id, 8); w.integer(static_cast(ban.added), 8); w.integer(ban.name.size(), 1); w.bytes.insert(w.bytes.end(), ban.name.begin(), ban.name.end()); } } if (p.kind == PacketKind::roster) { w.integer(p.capacity, 1); w.integer(p.members.size(), 1); for (const auto &m : p.members) { w.integer(m.id, 8); w.integer(m.epoch, 8); w.integer(m.name.size(), 1); w.bytes.insert(w.bytes.end(), m.name.begin(), m.name.end()); w.integer((m.admin ? 1U : 0U) | (m.party_leader ? 2U : 0U) | (m.party_open ? 4U : 0U) | (m.speeding ? 8U : 0U) | (m.scoring ? 16U : 0U), 1); w.integer(m.party, 4); } w.integer(p.distances.full_rate_return, 2); w.integer(p.distances.half_rate_start, 2); w.integer(p.distances.half_rate_return, 2); w.integer(p.distances.low_rate_start, 2); for (unsigned lot = 0; lot < p.parks.size(); ++lot) w.integer(encode_park(lot, p.parks[lot]), 1); w.integer(p.object_clears, 4); if (!valid_multiplayer_tps(p.tps)) throw std::invalid_argument("Invalid session TPS"); w.integer(p.tps, 1); w.integer(static_cast(p.object_placement), 1); if (!valid_object_limit(p.object_limit)) throw std::invalid_argument("Invalid object limit"); w.integer(p.object_limit, 2); 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. // No layers (a dedicated server without the catalog) means every layer at its default. if (p.layers.size() > 0xffff || (p.layers.empty() && p.force_world_layers)) throw std::runtime_error("Invalid world layer state"); w.integer(p.layers.size(), 2); for (const auto &choice : p.layers) { if (choice >= world_layer_modes.size()) throw std::runtime_error("Invalid world layer mode"); w.integer(choice, 1); } w.integer(p.voice_policy.allowed, 1); w.integer(p.voice_policy.revision, 4); w.integer(static_cast(std::lround(p.voice_range)), 2); if (p.server_votes > 7) throw std::invalid_argument("Invalid server votes"); w.integer((p.guest_noclip ? 1U : 0U) | (p.guest_no_bail ? 2U : 0U) | (p.guest_boosts ? 4U : 0U) | (static_cast(p.server_votes) << 3) | (p.enforce_tuning ? 64U : 0U), 1); } if (p.kind == PacketKind::routes) { w.integer(p.members.size(), 1); for (const auto &m : p.members) { w.integer(m.id, 8); w.integer(m.epoch, 8); } } if (p.kind == PacketKind::objects) { const auto &chunk = p.objects; w.integer(chunk.base, 8); w.integer(chunk.revision, 8); w.integer(chunk.part, 2); w.integer(chunk.parts, 2); w.integer(chunk.objects.size(), 2); w.integer(chunk.removed.size(), 2); for (const auto &object : chunk.objects) { w.integer(object.id, 8); w.integer(object.item.size(), 2); w.bytes.insert(w.bytes.end(), object.item.begin(), object.item.end()); for (float value : object.position) w.integer(std::bit_cast(value), 4); for (float value : object.rotation) w.integer(std::bit_cast(value), 4); w.integer(std::bit_cast(object.scale), 4); } for (const auto id : chunk.removed) w.integer(id, 8); } const auto actual_payload = w.bytes.size() - header_size; for (unsigned i = 0; i < 4; ++i) w.bytes[8 + i] = static_cast(actual_payload >> (8 * i)); return std::move(w.bytes); } static bool read_map_label(Reader &r, std::span bytes, Packet &p) { const auto length = r.integer(1); if (length > max_member_name || length > bytes.size() - r.at) return false; p.map_label.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at += static_cast(length); return valid_map_label(p.map_label); } std::optional decode(std::span bytes) noexcept { try { if (bytes.size() < header_size || bytes.size() > max_packet) return {}; Reader r{bytes}; if (r.integer(4) != magic || r.integer(2) != protocol_version) return {}; Packet p; const auto kind = r.integer(2); const bool packed = kind == 8; p.kind = packed ? PacketKind::pose : static_cast(kind); if (r.integer(4) != bytes.size() - header_size) return {}; p.sequence = static_cast(r.integer(4)); p.session = r.integer(8); p.map = r.integer(8); p.epoch = r.integer(8); p.time_us = r.integer(8); p.source = r.integer(8); p.world = r.integer(8); if (!p.session || !p.epoch || (!p.world && p.kind != PacketKind::map_request)) return {}; if (p.kind == PacketKind::hello || p.kind == PacketKind::welcome || p.kind == PacketKind::challenge || p.kind == PacketKind::peer_hello || p.kind == PacketKind::peer_welcome || p.kind == PacketKind::map_request || p.kind == PacketKind::map_offer) { for (auto &v : p.build) v = static_cast(r.integer(1)); p.challenge = r.integer(8); for (auto &v : p.proof) v = static_cast(r.integer(1)); if (p.kind == PacketKind::map_offer) { const auto authorized = r.integer(1), length = r.integer(2); if (authorized > 1 || length > 256 || length > bytes.size() - r.at) return {}; p.map_authorized = authorized != 0; p.destination.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at += static_cast(length); if (!valid_map_destination(p.destination) || p.map != map_hash(p.destination) || !read_map_label(r, bytes, p)) return {}; } if (p.kind == PacketKind::hello) { const auto length = r.integer(1); if (length > max_member_name || length > bytes.size() - r.at) return {}; p.text.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at += static_cast(length); if (!p.text.empty() && !valid_member_name(p.text)) return {}; } } else if (p.kind == PacketKind::world_state || p.kind == PacketKind::world_ready) { if (p.kind == PacketKind::world_state) for (auto &v : p.build) v = static_cast(r.integer(1)); const auto ready = r.integer(1); if (ready > 1) return {}; p.world_ready = ready != 0; if (p.kind == PacketKind::world_state) { const auto length = r.integer(2); if (length > 256 || length > bytes.size() - r.at) return {}; p.destination.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at += static_cast(length); if (p.destination.empty() ? (p.map != 0 || p.world_ready) : (!valid_map_destination(p.destination) || p.map != map_hash(p.destination))) return {}; if (!read_map_label(r, bytes, p)) return {}; } } else if (p.kind == PacketKind::pose) { const auto skater = r.integer(2), board = r.integer(2); const auto rate = static_cast(r.integer(2)); const auto tps = rate & 0x7fffU; p.player_collision = (rate & 0x8000U) != 0; if (!valid_multiplayer_tps(tps) && tps != 10 && tps != 5) return {}; p.pose_interval_us = 1000000U / tps; if (!p.map || skater > max_skater_bones || board > max_board_bones || !valid_pose_interval(p.pose_interval_us) || (!packed && bytes.size() != header_size + 46 + (skater + board) * 40)) return {}; const auto transform = [&] { return packed ? r.compact_transform() : r.transform(); }; p.pose.root = transform(); p.pose.skater.reserve(static_cast(skater)); p.pose.board.reserve(static_cast(board)); for (std::uint64_t i = 0; i < skater; ++i) p.pose.skater.push_back(transform()); for (std::uint64_t i = 0; i < board; ++i) p.pose.board.push_back(transform()); } else if (p.kind == PacketKind::cosmetics) { p.appearance.skater = r.recipe(); p.appearance.board = r.recipe(); p.appearance.card.background = static_cast(r.integer(4)); p.appearance.card.emblem = static_cast(r.integer(4)); p.appearance.card.title = static_cast(r.integer(4)); const auto flags = r.integer(1); p.appearance.hide_tag = (flags & 1) != 0; p.appearance.hide_items = (flags & 2) != 0; bool styled = true; for (auto &style : p.appearance.marks) { style.mode = static_cast(r.integer(1)); for (auto &part : style.from) part = static_cast(r.integer(1)); for (auto &part : style.to) part = static_cast(r.integer(1)); style.speed = static_cast(r.integer(1)); styled = styled && valid_mark_style(style); } if (flags > 3 || !styled || !p.map || !valid_appearance(p.appearance)) return {}; } else if (p.kind == PacketKind::audio) { const auto count = r.integer(2); if (!p.map || !count || count > max_audio_samples) return {}; p.audio.resize(static_cast(count)); AudioState previous; for (auto &s : p.audio) { const auto age = static_cast(r.integer(4)); s.age_us = age & 0x7fffffffU; s.event = (age & 0x80000000U) != 0; const auto values = r.integer(8), selectors = r.integer(4), flags = r.integer(6); if ((values >> audio_float_count) || (selectors >> audio_selector_count) || (flags >> audio_flag_count)) return {}; s.state = previous; for (std::size_t i = 0; i < audio_float_count; ++i) if (values & (1ULL << i)) s.state.values[i] = r.number(); for (std::size_t i = 0; i < audio_selector_count; ++i) if (selectors & (1ULL << i)) s.state.selectors[i] = static_cast(r.integer(4)); for (std::size_t i = 0; i < audio_flag_count; ++i) s.state.flags[i] = static_cast((flags >> i) & 1); previous = s.state; } if (!valid_audio_batch(p.audio)) return {}; } else if (p.kind == PacketKind::voice) { p.voice.distance = r.number(); p.voice.gain = r.number(); p.voice.policy_revision = static_cast(r.integer(4)); const auto count = r.integer(2); if (!p.map || !p.source || !count || count > max_voice_bytes || count != bytes.size() - r.at) return {}; p.voice.bytes.assign(bytes.begin() + r.at, bytes.end()); r.at = bytes.size(); if (!valid_voice(p.voice)) return {}; } else if (p.kind == PacketKind::chat) { const auto length = r.integer(2); if (!p.source || !length || length > multiplayer_chat_max_bytes || length != bytes.size() - r.at) return {}; p.text.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at = bytes.size(); if (!valid_chat_text(p.text)) return {}; } else if (p.kind == PacketKind::throwdown) { const auto length = r.integer(2); if (!p.source || !length || length > max_throwdown_message || length != bytes.size() - r.at) return {}; p.throwdown.assign(bytes.begin() + static_cast(r.at), bytes.end()); r.at = bytes.size(); } else if (p.kind == PacketKind::physics_tuning) { const auto length = r.integer(2); if (!p.source || length > max_physics_tuning || length != bytes.size() - r.at) return {}; p.tuning.assign(bytes.begin() + static_cast(r.at), bytes.end()); r.at = bytes.size(); } 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) return {}; p.extras.assign(bytes.begin() + static_cast(r.at), bytes.end()); r.at = bytes.size(); } else if (p.kind == PacketKind::scoring) { p.scoring = r.integer(8); const auto length = r.integer(2); if (!p.source || length > max_admin_text || length != bytes.size() - r.at) return {}; p.text.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at = bytes.size(); if (!p.text.empty() && !valid_admin_text(p.text)) return {}; } else if (p.kind == PacketKind::party) { const auto action = r.integer(1); p.party_action = static_cast(action); p.party_player = r.integer(8); if (!p.source || !valid_party_request(p.party_action, p.party_player)) return {}; } else if (p.kind == PacketKind::teleport) { if (!p.source) return {}; for (auto &v : p.teleport) { v = r.number(); if (!std::isfinite(v) || std::abs(v) > 1e6f) return {}; } } else if (p.kind == PacketKind::admin) { const auto length = r.integer(2); if (!p.source || !length || length > max_admin_text || length != bytes.size() - r.at) return {}; p.text.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at = bytes.size(); if (!valid_admin_text(p.text)) return {}; } else if (p.kind == PacketKind::maps) { const auto count = r.integer(2); if (!p.source || count > max_server_maps) return {}; for (std::uint64_t i = 0; i < count; ++i) { const auto length = r.integer(1); if (length > max_map_asset || length > bytes.size() - r.at) return {}; std::string asset(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at += static_cast(length); if (!valid_map_asset(asset)) return {}; p.maps.push_back(std::move(asset)); } const auto pooled = r.integer(2); if (pooled > p.maps.size()) return {}; for (std::uint64_t i = 0; i < pooled; ++i) p.map_pool.push_back(static_cast(r.integer(2))); p.map_rotation = static_cast(r.integer(2)); if (!valid_map_pool(p.map_pool, p.maps.size()) || p.map_rotation > max_map_rotation) return {}; } else if (p.kind == PacketKind::bans) { p.ban_total = static_cast(r.integer(4)); const auto count = r.integer(2); if (!p.source || count > max_ban_rows || count > p.ban_total) return {}; for (std::uint64_t i = 0; i < count; ++i) { MultiplayerBan ban; ban.id = r.integer(8); ban.added = static_cast(r.integer(8)); const auto length = r.integer(1); if (length > max_member_name || length > bytes.size() - r.at) return {}; ban.name.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at += static_cast(length); if (!individual_steam_id(ban.id) || !valid_member_name(ban.name)) return {}; p.bans.push_back(std::move(ban)); } } else if (p.kind == PacketKind::roster) { p.capacity = static_cast(r.integer(1)); const auto count = r.integer(1); if (!p.map || count > max_players) return {}; for (std::uint64_t i = 0; i < count; ++i) { Member m; m.id = r.integer(8); m.epoch = r.integer(8); const auto length = r.integer(1); if (length > 128 || length > bytes.size() - r.at) return {}; m.name.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at += static_cast(length); const auto flags = r.integer(1); if (flags > 31) return {}; m.admin = (flags & 1) != 0; m.speeding = (flags & 8) != 0; m.scoring = (flags & 16) != 0; m.party_leader = (flags & 2) != 0; m.party_open = (flags & 4) != 0; m.party = static_cast(r.integer(4)); p.members.push_back(std::move(m)); } p.distances.full_rate_return = static_cast(r.integer(2)); p.distances.half_rate_start = static_cast(r.integer(2)); p.distances.half_rate_return = static_cast(r.integer(2)); p.distances.low_rate_start = static_cast(r.integer(2)); for (unsigned lot = 0; lot < p.parks.size(); ++lot) p.parks[lot] = decode_park(lot, static_cast(r.integer(1))); p.object_clears = static_cast(r.integer(4)); p.tps = static_cast(r.integer(1)); if (!valid_multiplayer_tps(p.tps)) return {}; const auto placement = r.integer(1); if (!valid_object_placement(placement)) return {}; p.object_placement = static_cast(placement); const auto object_limit = r.integer(2); if (!valid_object_limit(object_limit)) return {}; p.object_limit = static_cast(object_limit); const auto forced = r.integer(1); if (forced > 1) return {}; p.force_world_layers = forced != 0; const auto layer_count = r.integer(2); if (layer_count != world_layers().size() && (layer_count || p.force_world_layers)) return {}; p.layers.resize(world_layers().size()); for (std::uint64_t i = 0; i < layer_count; ++i) { auto &choice = p.layers[static_cast(i)]; const auto mode = r.integer(1); if (mode >= world_layer_modes.size()) return {}; choice = static_cast(mode); } const auto voice_allowed = r.integer(1); p.voice_policy.revision = static_cast(r.integer(4)); if (voice_allowed > 1 || !p.voice_policy.revision) return {}; p.voice_policy.allowed = voice_allowed != 0; p.voice_range = static_cast(r.integer(2)); if (!valid_voice_range(p.voice_range)) return {}; const auto tools = r.integer(1); if (tools > 127) return {}; p.guest_noclip = (tools & 1) != 0; p.guest_no_bail = (tools & 2) != 0; p.guest_boosts = (tools & 4) != 0; p.server_votes = static_cast((tools >> 3) & 7); p.enforce_tuning = (tools & 64) != 0; if (!valid_roster(p.members, p.capacity) || !p.distances.valid()) return {}; } else if (p.kind == PacketKind::routes) { const auto count = r.integer(1); if (!p.map || count > max_remote_players) return {}; for (std::uint64_t i = 0; i < count; ++i) p.members.push_back({r.integer(8), r.integer(8), {}}); if (!valid_routes(p.members)) return {}; } else if (p.kind == PacketKind::objects) { auto &chunk = p.objects; chunk.base = r.integer(8); chunk.revision = r.integer(8); chunk.part = static_cast(r.integer(2)); chunk.parts = static_cast(r.integer(2)); const auto count = r.integer(2), removed = r.integer(2); if (!p.map || !p.source || count + removed > object_chunk_entries) return {}; for (std::uint64_t i = 0; i < count; ++i) { NetworkObject object; object.id = r.integer(8); const auto length = r.integer(2); if (length > 256 || length > bytes.size() - r.at) return {}; object.item.assign(reinterpret_cast(bytes.data() + r.at), static_cast(length)); r.at += static_cast(length); for (float &value : object.position) value = r.number(); for (float &value : object.rotation) value = r.number(); object.scale = r.number(); chunk.objects.push_back(std::move(object)); } for (std::uint64_t i = 0; i < removed; ++i) chunk.removed.push_back(r.integer(8)); if (!valid_object_chunk(chunk)) return {}; } else if (p.kind != PacketKind::away) return {}; if (r.at != bytes.size()) return {}; return p; } catch (...) { return {}; } } namespace { // Length of the UTF-8 sequence starting at `at`, 0 when it is malformed, // overlong, a surrogate or past U+10FFFF. std::size_t utf8_length(std::string_view text, std::size_t at) noexcept { const auto byte = [&](std::size_t i) { return static_cast(text[i]); }; const unsigned lead = byte(at); if (lead < 0x80) return 1; std::size_t length{}; std::uint32_t code{}, minimum{}; if ((lead & 0xE0) == 0xC0) { length = 2; code = lead & 0x1F; minimum = 0x80; } else if ((lead & 0xF0) == 0xE0) { length = 3; code = lead & 0x0F; minimum = 0x800; } else if ((lead & 0xF8) == 0xF0) { length = 4; code = lead & 0x07; minimum = 0x10000; } else return 0; if (at + length > text.size()) return 0; for (std::size_t i = 1; i < length; ++i) { if ((byte(at + i) & 0xC0) != 0x80) return 0; code = (code << 6) | (byte(at + i) & 0x3F); } if (code < minimum || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF)) return 0; return length; } bool chat_control(std::string_view text, std::size_t at, std::size_t length) noexcept { const auto lead = static_cast(text[at]); // C0 controls and DEL, and the C1 range (U+0080..U+009F, encoded C2 80..C2 9F). return (length == 1 && (lead < 0x20 || lead == 0x7F)) || (length == 2 && lead == 0xC2 && static_cast(text[at + 1]) < 0xA0); } } // namespace bool valid_chat_text(std::string_view text) noexcept { if (text.empty() || text.size() > multiplayer_chat_max_bytes) return false; bool visible{}; for (std::size_t at = 0; at < text.size();) { const auto length = utf8_length(text, at); if (!length || chat_control(text, at, length)) return false; visible |= text[at] != ' '; at += length; } return visible; } bool valid_member_name(std::string_view text) noexcept { if (text.size() > max_member_name) return false; for (std::size_t at = 0; at < text.size();) { const auto length = utf8_length(text, at); if (!length || chat_control(text, at, length)) return false; at += length; } return true; } bool valid_map_asset(std::string_view asset) noexcept { return !asset.empty() && asset.size() <= max_map_asset && std::all_of(asset.begin(), asset.end(), [](char c) { return c > 32 && c < 127 && c != '|'; }); } bool valid_map_label(std::string_view label) noexcept { return label.empty() || (label.size() <= max_member_name && valid_member_name(label)); } bool valid_map_pool(std::span pool, std::size_t maps) noexcept { if (pool.size() > maps) return false; for (std::size_t i = 0; i < pool.size(); ++i) if (pool[i] >= maps || std::find(pool.begin(), pool.begin() + i, pool[i]) != pool.begin() + i) return false; return true; } bool valid_admin_text(std::string_view text) noexcept { if (text.empty() || text.size() > max_admin_text) return false; for (std::size_t at = 0; at < text.size();) { const auto length = utf8_length(text, at); if (!length || chat_control(text, at, length)) return false; at += length; } return true; } std::string clean_chat_text(std::string_view text) { std::string result; for (std::size_t at = 0; at < text.size();) { const auto length = utf8_length(text, at); if (!length) { ++at; continue; } if (chat_control(text, at, length)) { // Tabs and line breaks read as a space; other controls vanish. if (text[at] == '\t' || text[at] == '\n' || text[at] == '\r') result += ' '; at += length; continue; } if (result.size() + length > multiplayer_chat_max_bytes) break; result.append(text.substr(at, length)); at += length; } const auto first = result.find_first_not_of(' '); if (first == std::string::npos) return {}; return result.substr(first, result.find_last_not_of(' ') - first + 1); } std::string clean_roster_name(std::string_view text) { auto name = clean_chat_text(text); if (name.size() > 128) { std::size_t cut = 128; while (cut && (static_cast(name[cut]) & 0xC0) == 0x80) --cut; name.resize(cut); } return name; } bool newer_sequence(std::uint32_t a, std::uint32_t b) noexcept { const auto d = a - b; return d && d < 0x80000000u; } std::uint64_t map_hash(std::string_view value) noexcept { std::uint64_t result = 14695981039346656037ull; for (unsigned char c : value) { if (c >= 'A' && c <= 'Z') c += 'a' - 'A'; if (c == '\\') c = '/'; result = (result ^ c) * 1099511628211ull; } return result; } std::array to_matrix(const Transform &t) { const auto q = interpolate(t, t, 0).rotation; const float x = q[0], y = q[1], z = q[2], w = q[3]; return {(1 - 2 * y * y - 2 * z * z) * t.scale[0], (2 * x * y + 2 * z * w) * t.scale[0], (2 * x * z - 2 * y * w) * t.scale[0], 0, (2 * x * y - 2 * z * w) * t.scale[1], (1 - 2 * x * x - 2 * z * z) * t.scale[1], (2 * y * z + 2 * x * w) * t.scale[1], 0, (2 * x * z + 2 * y * w) * t.scale[2], (2 * y * z - 2 * x * w) * t.scale[2], (1 - 2 * x * x - 2 * y * y) * t.scale[2], 0, t.position[0], t.position[1], t.position[2], 0}; } Transform from_matrix(const std::array &input) { auto m = input; Transform t; t.position = {m[12], m[13], m[14]}; for (unsigned i = 0; i < 3; ++i) { const auto k = i * 4; t.scale[i] = std::sqrt(m[k] * m[k] + m[k + 1] * m[k + 1] + m[k + 2] * m[k + 2]); if (t.scale[i] < .0001f) throw std::invalid_argument("Degenerate pose matrix"); for (unsigned j = 0; j < 3; ++j) m[k + j] /= t.scale[i]; } const float trace = m[0] + m[5] + m[10]; auto &q = t.rotation; if (trace > 0) { const float s = std::sqrt(trace + 1) * 2; q = {(m[6] - m[9]) / s, (m[8] - m[2]) / s, (m[1] - m[4]) / s, .25f * s}; } else if (m[0] > m[5] && m[0] > m[10]) { const float s = std::sqrt(1 + m[0] - m[5] - m[10]) * 2; q = {.25f * s, (m[4] + m[1]) / s, (m[8] + m[2]) / s, (m[6] - m[9]) / s}; } else if (m[5] > m[10]) { const float s = std::sqrt(1 + m[5] - m[0] - m[10]) * 2; q = {(m[4] + m[1]) / s, .25f * s, (m[9] + m[6]) / s, (m[8] - m[2]) / s}; } else { const float s = std::sqrt(1 + m[10] - m[0] - m[5]) * 2; q = {(m[8] + m[2]) / s, (m[9] + m[6]) / s, .25f * s, (m[1] - m[4]) / s}; } if (!valid_transform(t)) throw std::invalid_argument("Invalid pose matrix"); return t; } std::optional parse_invite(std::string_view text) noexcept { const auto split = text.find('-'); if (split == text.npos || split == 0 || text.size() - split - 1 != 16) return {}; Invite i; const auto a = std::from_chars(text.data(), text.data() + split, i.steam_id); const auto b = std::from_chars(text.data() + split + 1, text.data() + text.size(), i.secret, 16); if (a.ec != std::errc{} || a.ptr != text.data() + split || b.ec != std::errc{} || b.ptr != text.data() + text.size() || !i.secret || (!individual_steam_id(i.steam_id) && !game_server_steam_id(i.steam_id))) return {}; return i; } std::string format_invite(Invite i) { constexpr char hex[] = "0123456789abcdef"; std::string out = std::to_string(i.steam_id) + "-"; for (int n = 15; n >= 0; --n) out += hex[(i.secret >> (n * 4)) & 15]; return out; } } // namespace dingosdk::multiplayer