#1 - quicr module

This commit is contained in:
Martin Slachta
2026-07-22 17:34:44 +02:00
parent a04f0dc262
commit f4174eb0c7
177 changed files with 5309 additions and 2265 deletions
+3
View File
@@ -24,9 +24,12 @@ target_include_directories(tw_server_lib
target_link_libraries(tw_server_lib
PUBLIC
towards
tw::io
tw::network
tw::protocol
tw::message_protocol
tw::serialization
tw::quicr
glm::glm
EnTT::EnTT
Jolt
+58
View File
@@ -0,0 +1,58 @@
# Build the toolchain stage on its own and pass it back in to skip the apt step:
# docker build -f modules/server/Dockerfile --target toolchain -t tw_toolchain .
# docker build -f modules/server/Dockerfile --build-arg TOOLCHAIN_IMAGE=tw_toolchain .
ARG TOOLCHAIN_IMAGE=toolchain
# ── toolchain stage ──────────────────────────────────────────────────────────
FROM ubuntu:24.04 AS toolchain
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
clang \
libstdc++-14-dev \
cmake \
ninja-build \
mold \
git \
ca-certificates \
pkg-config \
glslang-tools \
libvulkan-dev \
libsdl2-dev \
libprotobuf-dev protobuf-compiler \
libpq-dev \
libpqxx-dev \
&& rm -rf /var/lib/apt/lists/*
ENV CC=clang
ENV CXX=clang++
# ── build stage ──────────────────────────────────────────────────────────────
FROM ${TOOLCHAIN_IMAGE} AS builder
WORKDIR /src
COPY . .
# glm, entt, Jolt, spdlog, expected, Catch2 and tracy are cloned at configure time
RUN cmake -B /build -G Ninja -DCMAKE_BUILD_TYPE=Release \
&& cmake --build /build --target tw_server
# Stage the shared libraries the binary was linked against; the runtime image has
# no package manager. glibc and its loader stay behind, they come with that image.
RUN mkdir -p /rootfs/usr/lib/x86_64-linux-gnu \
&& ldd /build/modules/server/tw_server \
| awk '/=> \//{ print $3 }' \
| grep -vE '/(libc|libm|libdl|libpthread|librt|libresolv|libanl)\.so' \
| xargs -I{} cp -L {} /rootfs/usr/lib/x86_64-linux-gnu/
# ── runtime stage ─────────────────────────────────────────────────────────────
FROM gcr.io/distroless/cc-debian13:nonroot
COPY --from=builder /rootfs/ /
COPY --from=builder /build/modules/server/tw_server /usr/local/bin/tw_server
# player connections, zone-server peering
EXPOSE 8101/udp 8102/udp
ENTRYPOINT ["/usr/local/bin/tw_server"]
+99 -1
View File
@@ -1,3 +1,101 @@
# Server
The authoritative server executable source code.
The authoritative server executable.
## Running
```
Usage: tw_server [--quicr-port <port>] [--cluster-port <port>]
--quicr-port UDP port for player connections (default: 8101)
--cluster-port UDP port for zone-server peering (default: 8102)
```
Metrics reporting to TimescaleDB stays off until `TIMESCALEDB_HOST` is set:
| Variable | Default |
|------------------------|----------------|
| `TIMESCALEDB_HOST` | unset (off) |
| `TIMESCALEDB_PORT` | `5432` |
| `TIMESCALEDB_DB` | `mmo` |
| `TIMESCALEDB_USER` | `mmo` |
| `TIMESCALEDB_PASSWORD` | empty |
| `TIMESCALEDB_TABLE` | `zone_metrics` |
## Building natively
See the root `README.md`. The target is `tw_server` and the binary lands in
`<build-dir>/modules/server/tw_server`.
## Building the image
`Dockerfile` has three stages: a toolchain stage holding the C++ build
environment, a build stage that compiles `tw_server` with clang, and a runtime
stage that carries only the binary and its shared libraries. The build context is
the repository root, so run it from there:
```bash
$ docker build -f modules/server/Dockerfile -t tw_server .
```
The toolchain stage installs the compiler and tools (clang, cmake, ninja, mold,
glslangValidator) and the development packages CMake looks for (Vulkan, SDL2,
protobuf, libpq, libpqxx) from apt. It touches no source, so it only rebuilds
when that package list changes.
The build stage configures a Release build on top of it with `CC=clang` /
`CXX=clang++`. glm, entt, Jolt, spdlog, expected, Catch2 and tracy are cloned
while configuring, so the build needs network access. `.dockerignore` keeps the
local build directories and those cloned sources out of the context.
## Reusing the toolchain
Layer caching already keeps apt out of a rebuild, but the cache is local and dies
with `docker builder prune`. To pin the toolchain down, build that stage on its
own and tag it:
```bash
$ docker build -f modules/server/Dockerfile --target toolchain -t tw_toolchain .
```
`TOOLCHAIN_IMAGE` then points the build stage at it, and the apt step is skipped
outright rather than cache-hit:
```bash
$ docker build -f modules/server/Dockerfile \
--build-arg TOOLCHAIN_IMAGE=tw_toolchain -t tw_server .
```
The default is the in-file `toolchain` stage, so a plain build still works
standalone. Any registry tag works too, which is the useful form on CI. To carry
it between machines by hand:
```bash
$ docker save tw_toolchain | zstd -o tw_toolchain.tar.zst
$ zstd -dc tw_toolchain.tar.zst | docker load
```
The runtime stage is `gcr.io/distroless/cc-debian13:nonroot` — glibc, libstdc++
and a `nonroot` user, no shell and no package manager. Since nothing can be
installed there, the build stage walks `ldd` over the binary and stages every
shared library it resolved into `/rootfs`, which the runtime stage copies in
whole. glibc and the loader are filtered out of that list: the binary is built
against 2.39 and the runtime image ships 2.41, which runs it, but the two must
not be mixed.
Nothing in the image can be executed except the server, so `docker exec` and
`docker run --entrypoint` are of no use for poking around. Swap the base for
`gcr.io/distroless/cc-debian13:debug-nonroot` when a busybox shell is needed.
Run it, publishing both UDP ports:
```bash
$ docker run --rm -p 8101:8101/udp -p 8102:8102/udp tw_server
```
Arguments after the image name reach the binary, and environment variables are
passed as usual:
```bash
$ docker run --rm -p 9101:9101/udp -e TIMESCALEDB_HOST=timescale \
tw_server --quicr-port 9101
```
+10 -6
View File
@@ -1,6 +1,8 @@
#pragma once
#include "protocol/quicr/QuicrConnection.hpp"
#include "network/SessionId.hpp"
#include "message_protocol/MessageConnection.hpp"
#include <cstdint>
@@ -8,16 +10,18 @@ namespace tw::net {
struct PlayerSession {
public:
uint32_t session_id;
SessionId session_id;
quicr::QuicrConnection* quicr_connection;
msg::MessageConnection* connection;
uint32_t last_frame;
uint32_t acked_frame;
PlayerSession(uint32_t session_id, quicr::QuicrConnection* quicr_connection) :
PlayerSession(SessionId session_id, msg::MessageConnection* connection) :
session_id(session_id),
quicr_connection(std::move(quicr_connection)),
last_frame(0)
connection(connection),
last_frame(0),
acked_frame(0)
{ }
};
+28 -22
View File
@@ -1,42 +1,48 @@
#include "ZoneClusterLink.hpp"
#include "Address.hpp"
#include <spdlog/spdlog.h>
#include <stdexcept>
namespace tw::net {
ZoneClusterLink::ZoneClusterLink(int port)
: m_endpoint(quicr::QuicrEndpoint::create().value()),
m_listener(quicr::QuicrConnectionListener::listen(m_endpoint.get()).value())
{
if (auto r = m_endpoint->bind(port); !r) {
throw std::runtime_error("ZoneClusterLink: failed to bind to port " + std::to_string(port));
namespace {
std::unique_ptr<msg::MessageEndpoint> bind_endpoint(int port) {
auto endpoint_r = msg::MessageEndpoint::bind(port);
if(!endpoint_r) {
throw std::runtime_error("ZoneClusterLink: failed to bind to port " + std::to_string(port) +
": " + endpoint_r.error().message());
}
return std::move(endpoint_r.value());
}
}
ZoneClusterLink::ZoneClusterLink(int port) :
m_endpoint(bind_endpoint(port)),
m_messages(m_endpoint.get()) {
m_endpoint->set_on_peer_connected([](msg::PeerId peer) {
spdlog::info("Zone peer {} connected", peer);
});
spdlog::info("ZoneClusterLink listening on port {}", port);
}
void ZoneClusterLink::update() {
m_endpoint->poll();
quicr::QuicrConnection* connection;
while ((connection = m_listener->listen())) {
spdlog::info("Zone peer connected from {}", connection->address().to_string());
m_peers.push_back(connection);
}
m_endpoint->update();
}
quicr::QuicrConnection* ZoneClusterLink::connect_to_peer(const std::string& host, int port) {
auto result = m_endpoint->connect(Address(host, port));
if (!result) {
spdlog::error("ZoneClusterLink: failed to connect to peer {}:{}", host, port);
msg::MessageConnection* ZoneClusterLink::connect_to_peer(const std::string& host, int port) {
auto peer_r = m_endpoint->connect(host, port);
if (!peer_r) {
spdlog::error("ZoneClusterLink: failed to connect to peer {}:{}: {}",
host, port, peer_r.error().message());
return nullptr;
}
quicr::QuicrConnection* conn = result.value();
m_peers.push_back(conn);
spdlog::info("ZoneClusterLink: connected to peer {}:{}", host, port);
return conn;
return peer_r.value();
}
} // namespace tw::net
+26 -32
View File
@@ -1,12 +1,10 @@
#pragma once
#include "MessageRegistry.hpp"
#include "protocol/quicr/QuicrConnection.hpp"
#include "protocol/quicr/QuicrConnectionListener.hpp"
#include "protocol/quicr/QuicrEndpoint.hpp"
#include "ProtobufMessages.hpp"
#include "message_protocol/MessageConnection.hpp"
#include "message_protocol/MessageEndpoint.hpp"
#include <memory>
#include <span>
#include <string>
#include <vector>
@@ -14,51 +12,47 @@
namespace tw::net {
// Listens on a dedicated QUICr port for incoming zone-server peer connections
// and opens outgoing connections to known peers.
// Listens on a dedicated port for incoming zone-server peer connections and
// opens outgoing connections to known peers.
//
// All connections (incoming and outgoing) are collected in m_peers so that
// broadcast messages (ZoneHello, ZoneBye) reach every peer uniformly.
//
// Accepted QuicrConnection* pointers are owned by the endpoint and remain valid
// for the lifetime of this object.
// Incoming and outgoing peers are held together so that broadcast messages
// (ZoneHello, ZoneBye) reach every peer uniformly.
class ZoneClusterLink {
std::unique_ptr<quicr::QuicrEndpoint> m_endpoint;
std::unique_ptr<quicr::QuicrConnectionListener> m_listener;
std::vector<quicr::QuicrConnection*> m_peers;
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
ProtobufMessages m_messages;
public:
explicit ZoneClusterLink(int port);
// Poll for datagrams and accept any pending peer connections.
// Poll for messages and accept any pending peer connections.
void update();
// Connect to a remote zone peer and register it in the peer list.
quicr::QuicrConnection* connect_to_peer(const std::string& host, int port);
msg::MessageConnection* connect_to_peer(const std::string& host, int port);
// Serialize and send a protobuf message to a single peer.
// Send a protobuf message to a single peer.
template<typename T>
void send_mesg(quicr::QuicrConnection* conn, const T& msg) {
std::string payload;
if (!msg.SerializeToString(&payload)) {
spdlog::error("ZoneClusterLink: failed to serialize message");
return;
void send_mesg(msg::MessageConnection* peer, const T& msg) {
auto send_r = m_messages.send(peer, msg, false);
if(!send_r) {
spdlog::error("ZoneClusterLink: failed to send to peer {}: {}",
peer->peer_id(), send_r.error().message());
}
std::vector<std::byte> bytes(payload.size() + sizeof(uint32_t));
uint32_t type = tw::Message<T>::value;
memcpy(bytes.data(), &type, sizeof(type));
memcpy(bytes.data() + sizeof(uint32_t), payload.data(), payload.size());
conn->send_message(bytes, false);
}
// Broadcast a protobuf message to all connected peers.
// Send a protobuf message to all connected peers.
template<typename T>
void broadcast(const T& msg) {
for (auto* peer : m_peers)
send_mesg(peer, msg);
m_messages.broadcast(msg);
}
std::span<quicr::QuicrConnection* const> peers() const { return m_peers; }
std::vector<msg::MessageConnection*> peers() const {
return m_endpoint->peers();
}
msg::MessageEndpoint& endpoint() {
return *m_endpoint;
}
};
} // namespace tw::net
+24 -1
View File
@@ -68,6 +68,8 @@ void ZoneManager::on_player_move(SessionId session_id, mmo::PlayerMoveMessage&&
entt::entity entity = client_entity(session_id);
if (entity == entt::null) return;
m_session_input_frame[session_id] = message.frame_idx();
auto* controller = m_world->registry().try_get<CharacterController>(entity);
if (controller) {
controller->set_input(
@@ -97,6 +99,11 @@ const im::Interest* ZoneManager::get_interest(im::InterestId id) const {
return m_interest_system->get_interest(id);
}
uint32_t ZoneManager::acked_input_frame(im::InterestId interest_id) const {
auto it = m_session_acked_frame.find(interest_id);
return it != m_session_acked_frame.end() ? it->second : 0;
}
// ── Private helpers ───────────────────────────────────────────────────────────
void ZoneManager::check_neighbor_transfers() {
@@ -169,9 +176,25 @@ void ZoneManager::tick(uint32_t frame_idx, float delta_time) {
m_world->step(delta_time);
FrameMarkEnd("World step");
// Re-stamp each client entity's newest input onto the server's frame index.
// The input ring is keyed by client frame numbers (independent counter),
// but physics lookup uses the server's frame counter. By re-stamping onto
// the server frame, input(server_frame_idx) resolves by exact match rather
// than fallback, ensuring deterministic and correct input consumption.
for (const auto& [interest_id, entity] : m_client_entities) {
auto* controller = m_world->registry().try_get<CharacterController>(entity);
if (!controller) continue;
controller->set_input(frame_idx, controller->input());
}
FrameMarkStart("Physics step");
m_physics_world.step(frame_idx, delta_time);
// Fixed step, not the measured interval: the simulation has to advance by the
// same amount every frame for a replay of the same inputs to land in the same
// place.
m_physics_world.step(frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME);
FrameMarkEnd("Physics step");
m_session_acked_frame = m_session_input_frame;
}
} // namespace tw::net
+5
View File
@@ -40,6 +40,8 @@ class ZoneManager : public ZoneProxy {
std::unordered_map<im::InterestId, entt::entity> m_client_entities;
std::unordered_map<entt::entity, im::InterestId> m_entity_sessions; // reverse map
std::unordered_map<im::InterestId, uint32_t> m_session_input_frame; // most recent input frame received
std::unordered_map<im::InterestId, uint32_t> m_session_acked_frame; // input frame consumed by last tick
std::vector<NeighborEntry> m_neighbors;
uint32_t m_next_neighbor_id = 0x80000000u;
@@ -83,6 +85,9 @@ public:
// Returns the current interest state for any registered id, or nullptr.
const im::Interest* get_interest(im::InterestId id) const;
// Returns the input frame that this session's entity was last simulated with, or 0 if unknown.
uint32_t acked_input_frame(im::InterestId interest_id) const;
// Runs one tick: interest queries, neighbour transfer checks, world step, physics step.
void tick(uint32_t frame_idx, float delta_time);
};
+27 -8
View File
@@ -25,7 +25,6 @@ ZoneServer::ZoneServer(ZoneServerConfiguration config)
m_network_receiver(std::make_unique<NetworkReceiver>(
m_player_session_registry.get(), config.quicr_port, m_metrics_reporter.get()
)),
m_message_dispatcher(std::make_unique<MessageDispatcher>(m_network_receiver.get())),
m_replicator(std::make_unique<StateReplicator<SpatialBackendType>>(
m_player_session_registry.get(),
m_network_receiver.get()
@@ -58,9 +57,16 @@ ZoneServer::ZoneServer(ZoneServerConfiguration config)
}
}
void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMessage&& message) {
void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMessage message) {
auto it = m_session_zone.find(session_id);
if (it == m_session_zone.end()) return;
// Echoed back in the next snapshot, so the client can tell how long its
// input took to come back.
if (auto* session = m_player_session_registry->session(session_id)) {
session->last_frame = message.frame_idx();
}
it->second->on_player_move(session_id, std::move(message));
}
@@ -80,7 +86,6 @@ static void register_signal_handler() {
void ZoneServer::update_clients(uint32_t frame_idx) {
m_network_receiver->update();
m_message_dispatcher->drain_queue();
while (m_network_receiver->peek_new_session()) {
auto session_id = m_network_receiver->pop_new_session();
@@ -96,6 +101,10 @@ void ZoneServer::update_clients(uint32_t frame_idx) {
zone->add_client(session_id, entity);
m_session_zone[session_id] = zone;
mmo::SetControlledEntity mesg = {};
mesg.set_entity_id((uint32_t)entity);
m_network_receiver->send_mesg(session_id, mesg);
spdlog::info("Client {} connected", session_id);
}
}
@@ -106,13 +115,13 @@ void ZoneServer::run() {
uint32_t frame_idx = 1;
m_message_dispatcher->set_handler<mmo::PlayerMoveMessage>(
[&](uint64_t session_id, mmo::PlayerMoveMessage mesg) {
player_update_handler(static_cast<SessionId>(session_id), std::move(mesg));
m_network_receiver->set_handler<mmo::PlayerMoveMessage>(
[this](SessionId session_id, const mmo::PlayerMoveMessage& mesg) {
player_update_handler(session_id, mesg);
});
m_message_dispatcher->set_handler<mmo::chat::SendChatMessageRequest>(
[&](uint64_t session_id, mmo::chat::SendChatMessageRequest mesg) {
m_network_receiver->set_handler<mmo::chat::SendChatMessageRequest>(
[this](SessionId session_id, const mmo::chat::SendChatMessageRequest& mesg) {
});
while (!quit.load()) {
@@ -127,6 +136,16 @@ void ZoneServer::run() {
for (auto& zone : m_zones) {
zone->tick(frame_idx, lock_step.delta_time());
// Copy acked frames from zone to sessions before replication.
// This ensures snapshots carry the input frame actually consumed by this tick,
// not frames that arrive in the trailing network update.
for (const auto& [session_id, session_zone] : m_session_zone) {
if (session_zone != zone.get()) continue;
if (auto* session = m_player_session_registry->session(session_id)) {
session->acked_frame = zone->acked_input_frame(session_id);
}
}
m_replicator->replicate(zone->registry(), zone->interest());
}
+1 -3
View File
@@ -10,7 +10,6 @@
#include "ZoneManager.hpp"
#include "ZoneServerConfiguration.hpp"
#include "monitoring/MetricsReporter.hpp"
#include "network/MessageDispatcher.hpp"
#include "network/NetworkReceiver.hpp"
#include "network/PlayerSessionRegistry.hpp"
#include "replication/StateReplicator.hpp"
@@ -21,7 +20,6 @@ class ZoneServer {
std::unique_ptr<NetworkMetricsReporter> m_metrics_reporter;
std::unique_ptr<PlayerSessionRegistry> m_player_session_registry;
std::unique_ptr<NetworkReceiver> m_network_receiver;
std::unique_ptr<MessageDispatcher> m_message_dispatcher;
std::unique_ptr<StateReplicator<SpatialBackendType>> m_replicator;
ZoneClusterLink m_cluster_link;
ZoneCoordinator m_coordinator;
@@ -30,7 +28,7 @@ class ZoneServer {
std::unordered_map<SessionId, ZoneManager*> m_session_zone;
uint32_t m_own_zone_id = 0;
void player_update_handler(SessionId session_id, mmo::PlayerMoveMessage&& message);
void player_update_handler(SessionId session_id, mmo::PlayerMoveMessage message);
void update_clients(uint32_t frame_idx);
public:
@@ -1,17 +0,0 @@
#pragma once
#include <cstdint>
#include <vector>
namespace tw::net {
class InboundMessage {
public:
uint32_t session_id;
std::vector<std::byte> payload;
InboundMessage(uint32_t session_id, std::vector<std::byte> payload)
: session_id(session_id), payload(std::move(payload)) {}
};
}
@@ -1,21 +0,0 @@
#pragma once
#include <optional>
#include <span>
#include <spdlog/spdlog.h>
namespace tw::net {
class MessageDeserializer {
public:
template<typename T>
static std::optional<T> deserialize(std::span<std::byte> data) {
T result = {};
result.ParseFromArray(data.data(), data.size());
return result;
}
};
}
@@ -1,67 +0,0 @@
#pragma once
#include "InboundMessage.hpp"
#include "MessageQueue.hpp"
#include "NetworkError.hpp"
#include "MessageDeserializer.hpp"
#include "monitoring/TimescaleDbMetricsReporter.hpp"
#include "network/NetworkReceiver.hpp"
#include "packets/Packet.hpp"
#include <tl/expected.hpp>
#include <functional>
#include <tracy/Tracy.hpp>
namespace tw::net {
typedef std::function<tl::expected<void, NetworkError>(uint32_t, std::span<std::byte>)> MessageHandler;
class MessageDispatcher {
NetworkReceiver* m_network;
std::unordered_map<uint32_t, MessageHandler> m_handlers;
public:
MessageDispatcher(NetworkReceiver* network) :
m_network(network) {
}
template<typename T>
void set_handler(std::function<void(uint32_t, T)> handler) {
m_handlers[static_cast<uint32_t>(Message<T>::value)] =
[handler, this](uint32_t session_id, std::span<std::byte> data) -> tl::expected<void, NetworkError> {
ZoneScopedN("Handling message");
size_t size = 0;
auto r = MessageDeserializer::deserialize<T>(data);
if(!r) {
return {};
}
handler(session_id, *r);
return {};
};
}
void drain_queue() {
while(!m_network->inbound_queue()->is_empty()) {
auto msg = m_network->inbound_queue()->pop();
uint32_t message_type = *(uint32_t*)msg->payload.data();
if(m_handlers.find(message_type) == m_handlers.end()) {
spdlog::error("No handler for message type {}", message_type);
continue;
}
auto r = m_handlers[message_type](msg->session_id, std::span<std::byte>(msg->payload).subspan(sizeof(uint32_t)));
if(!r) {
spdlog::error("Failed to handle message of type {}: {}", message_type, r.error().message());
// TODO: Add session failure
continue;
}
}
}
};
}
@@ -1,32 +0,0 @@
#pragma once
#include <cstdint>
#include <queue>
namespace tw::net {
template<typename T>
class MessageQueue {
std::queue<T> m_queue;
public:
MessageQueue() : m_queue() {
}
bool is_empty() {
return m_queue.empty();
}
void push(T mesg) {
m_queue.push(mesg);
}
T pop() {
auto mesg = m_queue.front();
m_queue.pop();
return mesg;
}
};
}
+51 -46
View File
@@ -1,72 +1,77 @@
#include "NetworkReceiver.hpp"
#include "network/InboundMessage.hpp"
#include "network/MessageQueue.hpp"
#include "network/PlayerSessionRegistry.hpp"
#include "protocol/quicr/QuicrConnectionListener.hpp"
#include <stdexcept>
namespace tw::net {
namespace {
std::unique_ptr<msg::MessageEndpoint> bind_endpoint(int32_t port) {
auto endpoint_r = msg::MessageEndpoint::bind(port);
if(!endpoint_r) {
throw std::runtime_error("NetworkReceiver: failed to bind to port " + std::to_string(port) +
": " + endpoint_r.error().message());
}
return std::move(endpoint_r.value());
}
}
NetworkReceiver::NetworkReceiver(
PlayerSessionRegistry* session_registry,
int32_t udp_port,
NetworkMetricsReporter* metrics_reporter
) :
m_session_registry(session_registry),
m_inbound_queue(new MessageQueue<InboundMessage*>),
m_quicr_endpoint(quicr::QuicrEndpoint::create().value()),
m_quicr_listener(quicr::QuicrConnectionListener::listen(m_quicr_endpoint.get()).value()),
m_endpoint(bind_endpoint(udp_port)),
m_messages(m_endpoint.get()),
m_metrics_reporter(metrics_reporter)
{
m_quicr_endpoint->bind(udp_port);
// Registering the session here rather than after update() means a peer
// already has one by the time its first message is dispatched.
m_endpoint->set_on_peer_connected([this](msg::PeerId peer) {
auto session_id = m_session_registry->register_session(peer, m_endpoint->peer(peer));
m_new_sessions.push_back(session_id);
});
spdlog::info("Running on port: {}", udp_port);
}
bool NetworkReceiver::listen_quicr() {
quicr::QuicrConnection* connection = nullptr;
while((connection = m_quicr_listener->listen())) {
spdlog::info("Quicr client tries to connect");
void NetworkReceiver::report_traffic() {
const uint64_t received = m_endpoint->bytes_received();
const uint64_t sent = m_endpoint->bytes_sent();
auto session_id = m_session_registry->register_session(connection);
m_metrics_reporter->add_inbound(received - m_reported_bytes_received);
m_metrics_reporter->add_outbound(sent - m_reported_bytes_sent);
m_new_sessions.push_back(session_id);
}
return true;
}
void NetworkReceiver::listen() {
listen_quicr();
}
void NetworkReceiver::process_streams() {
m_quicr_endpoint->poll();
std::vector<std::byte> buffer(64 * 1024);
for(auto& client : m_session_registry->sessions()) {
auto read_r = client->quicr_connection->read_into(buffer);
if(!read_r) {
spdlog::error("Failed to read from QUICr stream");
continue;
}
if(*read_r == 0) {
continue;
}
m_metrics_reporter->add_inbound(*read_r);
auto data = std::vector<std::byte>(buffer.begin(), buffer.begin() + *read_r);
m_inbound_queue->push(new InboundMessage(client->session_id, data));
}
m_reported_bytes_received = received;
m_reported_bytes_sent = sent;
}
void NetworkReceiver::update() {
m_quicr_endpoint->poll();
m_endpoint->update();
listen();
report_traffic();
}
process_streams();
size_t NetworkReceiver::send_framed(SessionId session_id, std::span<const std::byte> message) {
if(message.empty()) {
return 0;
}
auto* session = m_session_registry->session(session_id);
if(session == nullptr) {
return 0;
}
auto send_r = session->connection->send_framed(message, false);
if(!send_r) {
spdlog::error("Failed to send to session {}: {}", session_id, send_r.error().message());
return 0;
}
return message.size();
}
}
+48 -61
View File
@@ -1,35 +1,40 @@
#pragma once
#include "MessageQueue.hpp"
#include "InboundMessage.hpp"
#include "MessageRegistry.hpp"
#include "ProtobufMessages.hpp"
#include "SessionId.hpp"
#include "monitoring/MetricsReporter.hpp"
#include "monitoring/TimescaleDbMetricsReporter.hpp"
#include "network/PlayerSessionRegistry.hpp"
#include "protocol/quicr/QuicrConnectionListener.hpp"
#include "protocol/quicr/QuicrEndpoint.hpp"
#include "message_protocol/MessageEndpoint.hpp"
#include <spdlog/spdlog.h>
#include <cstdint>
#include <deque>
#include <functional>
#include <memory>
#include <span>
namespace tw::net {
/**
* Accepts player connections and routes their messages to the handlers the
* zone server registers, translating peers into sessions on the way.
*/
class NetworkReceiver {
PlayerSessionRegistry *m_session_registry;
PlayerSessionRegistry* m_session_registry;
std::unique_ptr<quicr::QuicrEndpoint> m_quicr_endpoint;
std::unique_ptr<quicr::QuicrConnectionListener> m_quicr_listener;
MessageQueue<InboundMessage*>* m_inbound_queue;
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
ProtobufMessages m_messages;
std::deque<SessionId> m_new_sessions;
NetworkMetricsReporter* m_metrics_reporter;
bool listen_quicr();
uint64_t m_reported_bytes_received = 0;
uint64_t m_reported_bytes_sent = 0;
void listen();
void process_streams();
void report_traffic();
public:
NetworkReceiver(
@@ -38,8 +43,22 @@ public:
NetworkMetricsReporter* metrics_reporter
);
MessageQueue<InboundMessage*>* inbound_queue() {
return m_inbound_queue;
/**
* Calls `handler` for every T that arrives, along with the session that
* sent it.
*/
template<typename T>
void set_handler(std::function<void(SessionId, const T&)> handler) {
m_messages.set_handler<T>(
[this, handler = std::move(handler)](msg::PeerId peer, const T& message) {
const SessionId session_id = m_session_registry->session_for_peer(peer);
if(session_id == 0) {
spdlog::warn("Dropped a message from peer {}, which has no session", peer);
return;
}
handler(session_id, message);
});
}
bool peek_new_session() const {
@@ -47,67 +66,35 @@ public:
}
SessionId pop_new_session() {
auto session = m_new_sessions.front();
auto session_id = m_new_sessions.front();
m_new_sessions.pop_front();
return session;
return session_id;
}
void update();
template<typename T>
size_t send_mesg(SessionId session_id, const T& mesg) {
std::string payload;
if(!mesg.SerializeToString(&payload)) {
spdlog::error("Failed to serialize message");
auto* session = m_session_registry->session(session_id);
if(session == nullptr) {
spdlog::warn("Cannot send to session {}, it is not registered", session_id);
return 0;
}
int32_t length = payload.length();
if(length == 0) {
auto send_r = m_messages.send(session->connection, mesg, true);
if(!send_r) {
spdlog::error("Failed to send to session {}: {}", session_id, send_r.error().message());
return 0;
}
std::vector<std::byte> bytes(length + sizeof(uint32_t));
uint32_t type = Message<T>::value;
auto payload_bytes = std::as_writable_bytes(std::span(payload));
memcpy(bytes.data(), &type, sizeof(type));
memcpy(bytes.data() + sizeof(uint32_t), payload_bytes.data(), payload_bytes.size());
auto session = m_session_registry->session(session_id);
m_metrics_reporter->add_outbound(bytes.size());
session->quicr_connection->send_message(bytes, false);
return bytes.size();
return msg::MessageHeader::SIZE + mesg.ByteSizeLong();
}
/**
* Send a pre-serialised raw byte payload directly, with no additional
* framing. The caller is responsible for including any type tag and
* length prefix in the payload (as tw::serial::WorldStateWriter does).
*
* This avoids the Protobuf SerializeToString heap allocation entirely —
* the span points into the caller's BinaryBuffer, which is reused every
* frame.
* Sends a message the caller has already framed, so that a writer holding
* its own buffer does not have to be copied through an encoder.
*/
size_t send_raw(SessionId session_id, std::span<const std::byte> payload) {
if(payload.empty()) {
return 0;
}
auto* session = m_session_registry->session(session_id);
if(!session) {
return 0;
}
// QuicrConnection::send_message takes a std::vector<std::byte>.
// We copy the span here. If the QUICr layer is ever refactored to
// accept a span we can remove this copy entirely.
std::vector<std::byte> bytes(payload.begin(), payload.end());
m_metrics_reporter->add_outbound(bytes.size());
session->quicr_connection->send_message(bytes, false);
return bytes.size();
}
size_t send_framed(SessionId session_id, std::span<const std::byte> message);
};
}
@@ -1,14 +0,0 @@
#pragma once
#include <vector>
namespace tw::net {
struct OutboundMessage {
std::vector<std::byte> payload;
OutboundMessage(std::vector<std::byte> payload)
: payload(std::move(payload)) {}
};
} // namespace tw::net
@@ -1,6 +1,5 @@
#include "PlayerSessionRegistry.hpp"
#include "PlayerSession.hpp"
#include "protocol/quicr/QuicrConnection.hpp"
namespace tw::net {
@@ -9,13 +8,16 @@ SessionId PlayerSessionRegistry::generate_session_id() {
return next_id++;
}
SessionId PlayerSessionRegistry::register_session(quicr::QuicrConnection* quicr_connection) {
auto session = new PlayerSession(quicr_connection->self_id(), quicr_connection);
m_session_vec.emplace_back(session);
SessionId PlayerSessionRegistry::register_session(msg::PeerId peer, msg::MessageConnection* connection) {
const SessionId session_id = generate_session_id();
m_session_map.emplace(quicr_connection->self_id(), session);
auto session = std::make_unique<PlayerSession>(session_id, connection);
m_session_vec.emplace_back(session.get());
return quicr_connection->self_id();
m_session_map.emplace(session_id, std::move(session));
m_peer_sessions.emplace(peer, session_id);
return session_id;
}
}
@@ -2,7 +2,10 @@
#include "PlayerSession.hpp"
#include "SessionId.hpp"
#include "protocol/quicr/QuicrConnection.hpp"
#include "message_protocol/MessageConnection.hpp"
#include "message_protocol/PeerId.hpp"
#include <memory>
#include <unordered_map>
#include <vector>
@@ -14,6 +17,10 @@ class PlayerSessionRegistry {
std::unordered_map<SessionId, std::unique_ptr<PlayerSession>> m_session_map;
// Sessions are identified by a dense id of their own, so the wider peer id
// the network layer assigns stays at the boundary.
std::unordered_map<msg::PeerId, SessionId> m_peer_sessions;
SessionId generate_session_id();
public:
@@ -26,7 +33,13 @@ public:
return session != m_session_map.end() ? session->second.get() : nullptr;
}
SessionId register_session(quicr::QuicrConnection* quicr_connection);
/** The session belonging to a peer, or zero if the peer has none. */
SessionId session_for_peer(msg::PeerId peer) const {
auto session = m_peer_sessions.find(peer);
return session != m_peer_sessions.end() ? session->second : 0;
}
SessionId register_session(msg::PeerId peer, msg::MessageConnection* connection);
void unregister_session(SessionId);
};
@@ -1,5 +1,6 @@
#pragma once
#include "MessageRegistry.hpp"
#include "network/NetworkReceiver.hpp"
#include "network/PlayerSessionRegistry.hpp"
#include "systems/Interest.hpp"
@@ -34,8 +35,9 @@ class StateReplicator {
// Per-client backing buffers reused every frame.
std::vector<tw::serial::BinaryBuffer> m_frames;
// Header(12) + spawn_hdr(4) + despawn_hdr(4) + 512 entities × 16 bytes
static constexpr std::size_t kInitialCapacity = 20 + 512 * 16;
// Header(16) + spawn_hdr(4) + despawn_hdr(4) + 512 entities × 16 bytes
static constexpr std::size_t kHeaderCapacity = 24;
static constexpr std::size_t kInitialCapacity = kHeaderCapacity + 512 * 16;
public:
StateReplicator(
@@ -87,14 +89,14 @@ public:
if (!state) continue;
const std::size_t needed =
20
kHeaderCapacity
+ state->spawn().size() * 4
+ state->despawn().size() * 4
+ state->interest().size() * 16;
m_frames[i].reserve(needed);
writers[i].reset();
writers[i].begin(session->last_frame);
writers[i].begin(session->acked_frame, Message<mmo::WorldStateMessage>::value);
writers[i].write_spawns(state->spawn());
writers[i].write_despawns(state->despawn());
}
@@ -118,10 +120,9 @@ public:
ZoneScopedN("Sending messages");
for (std::size_t i = 0; i < session_count; ++i) {
spdlog::info("Sending");
if (!client_states[i]) continue;
writers[i].end();
m_network->send_raw(sessions[i]->session_id, writers[i].view());
m_network->send_framed(sessions[i]->session_id, writers[i].view());
}
}
}