#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
@@ -26,8 +26,8 @@ target_link_libraries(${PROJECT_NAME}
PUBLIC
tw::network
tl::expected
tw::messaging
tw::protocol
tw::message_protocol
protobuf::libprotobuf
tw::chat::lib
spdlog::spdlog
@@ -1,16 +1,20 @@
#pragma once
#include "MessageSession.hpp"
#include "ProtobufMessages.hpp"
#include "SendChatMessage.hpp"
#include "ChatClientError.hpp"
#include "models/ChatMessage.hpp"
#include "message_protocol/MessageEndpoint.hpp"
#include <functional>
#include <memory>
#include <string>
namespace tw::chat {
class ChatClient {
tw::MessageSession m_session;
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
msg::MessageConnection* m_server;
ProtobufMessages m_messages;
std::function<void(ChatMessage)> m_on_message;
std::function<void(tl::expected<void, ChatClientError>)> m_on_send_response;
@@ -22,45 +22,68 @@ tl::expected<void, tw::chat::ChatClientError> from_error_code(mmo::chat::ChatErr
namespace tw::chat {
namespace {
std::unique_ptr<tw::msg::MessageEndpoint> create_endpoint() {
auto endpoint_r = tw::msg::MessageEndpoint::create();
if (!endpoint_r) {
throw std::runtime_error("Chat client failed to create an endpoint: " +
endpoint_r.error().message());
}
return std::move(endpoint_r.value());
}
tw::msg::MessageConnection* connect_to_server(tw::msg::MessageEndpoint* endpoint,
const std::string& server_address,
int16_t port) {
auto server_r = endpoint->connect(server_address, port);
if (!server_r) {
throw std::runtime_error("Chat client failed to connect: " + server_r.error().message());
}
return server_r.value();
}
}
ChatClient::ChatClient(const std::string& server_address, int16_t port)
: m_session(net::Address(server_address, port))
: m_endpoint(create_endpoint())
, m_server(connect_to_server(m_endpoint.get(), server_address, port))
, m_messages(m_endpoint.get())
{
m_session.set_handler(CHAT_MESSAGE_BROADCAST_REQUEST, [this](std::span<const std::byte> data) {
if (!m_on_message) return;
mmo::chat::ChatMessageBroadcastRequest proto;
if (!proto.ParseFromArray(data.data(), static_cast<int>(data.size()))) return;
ChatMessage msg;
msg.channel_id = proto.channel_id();
msg.client_id = proto.sender_id();
msg.message = proto.message();
msg.timestamp = ChatMessage::Clock::now();
m_on_message(std::move(msg));
});
m_messages.set_handler<mmo::chat::ChatMessageBroadcastRequest>(
[this](msg::PeerId, const mmo::chat::ChatMessageBroadcastRequest& proto) {
if (!m_on_message) return;
ChatMessage msg;
msg.channel_id = proto.channel_id();
msg.client_id = proto.sender_id();
msg.message = proto.message();
msg.timestamp = ChatMessage::Clock::now();
m_on_message(std::move(msg));
});
m_session.set_handler(CHAT_SEND_MESSAGE_RESPONSE, [this](std::span<const std::byte> data) {
if (!m_on_send_response) return;
mmo::chat::SendChatMessageResponse proto;
if (!proto.ParseFromArray(data.data(), static_cast<int>(data.size()))) return;
m_on_send_response(from_error_code(proto.error()));
});
m_messages.set_handler<mmo::chat::SendChatMessageResponse>(
[this](msg::PeerId, const mmo::chat::SendChatMessageResponse& proto) {
if (!m_on_send_response) return;
m_on_send_response(from_error_code(proto.error()));
});
m_session.set_handler(CHAT_JOIN_CHANNEL_RESPONSE, [this](std::span<const std::byte> data) {
if (!m_on_join_response) return;
mmo::chat::JoinChannelResponse proto;
if (!proto.ParseFromArray(data.data(), static_cast<int>(data.size()))) return;
m_on_join_response(static_cast<uint32_t>(proto.channel_id()), from_error_code(proto.error()));
});
m_messages.set_handler<mmo::chat::JoinChannelResponse>(
[this](msg::PeerId, const mmo::chat::JoinChannelResponse& proto) {
if (!m_on_join_response) return;
m_on_join_response(static_cast<uint32_t>(proto.channel_id()), from_error_code(proto.error()));
});
m_session.set_handler(CHAT_LEAVE_CHANNEL_RESPONSE, [this](std::span<const std::byte> data) {
if (!m_on_leave_response) return;
mmo::chat::LeaveChannelResponse proto;
if (!proto.ParseFromArray(data.data(), static_cast<int>(data.size()))) return;
m_on_leave_response(static_cast<uint32_t>(proto.channel_id()), from_error_code(proto.error()));
});
m_messages.set_handler<mmo::chat::LeaveChannelResponse>(
[this](msg::PeerId, const mmo::chat::LeaveChannelResponse& proto) {
if (!m_on_leave_response) return;
m_on_leave_response(static_cast<uint32_t>(proto.channel_id()), from_error_code(proto.error()));
});
}
void ChatClient::update() {
m_session.update();
m_endpoint->update();
}
tl::expected<void, ChatClientError> ChatClient::send_mesg(SendChatMessage message) {
@@ -68,11 +91,7 @@ tl::expected<void, ChatClientError> ChatClient::send_mesg(SendChatMessage messag
mesg.set_channel_id(message.channel_id);
mesg.set_message(message.message);
std::vector<std::byte> buf(mesg.ByteSizeLong());
(void)mesg.SerializeToArray(buf.data(), static_cast<int>(buf.size()));
auto send_r = m_session.send(Message<mmo::chat::SendChatMessageRequest>::value,
std::span(buf), true);
auto send_r = m_messages.send(m_server, mesg, true);
if (!send_r)
return tl::make_unexpected(ChatClientError::PermissionDenied);
return {};
@@ -13,8 +13,9 @@ target_include_directories(${PROJECT_NAME}
target_link_libraries(${PROJECT_NAME}
PRIVATE
tw::chat::service
tw::messaging
tw::protocol
tw::message_protocol
tw::network
tw::quicr
spdlog::spdlog
)
@@ -3,7 +3,7 @@
#include "Chat.pb.h"
#include "MessageRegistry.hpp"
#include <spdlog/spdlog.h>
#include <cstring>
#include <stdexcept>
namespace tw::chat {
@@ -16,108 +16,68 @@ static mmo::chat::ChatErrorCode to_error_code(tl::expected<void, ChatServerError
return mmo::chat::CHAT_ERROR_CODE_CHANNEL_NOT_FOUND;
}
template<typename T>
static std::vector<std::byte> serialize(const T& msg) {
std::vector<std::byte> buf(msg.ByteSizeLong());
(void)msg.SerializeToArray(buf.data(), static_cast<int>(buf.size()));
return buf;
static std::unique_ptr<msg::MessageEndpoint> bind_endpoint(int port) {
auto endpoint_r = msg::MessageEndpoint::bind(port);
if (!endpoint_r) {
throw std::runtime_error("Chat server failed to bind to port " + std::to_string(port) +
": " + endpoint_r.error().message());
}
return std::move(endpoint_r.value());
}
ChatServerController::ChatServerController(int port)
: m_endpoint(net::quicr::QuicrEndpoint::create_and_bind(port).value())
, m_listener(net::quicr::QuicrConnectionListener::listen(m_endpoint.get()).value())
: m_endpoint(bind_endpoint(port))
, m_messages(m_endpoint.get())
, m_service([this](uint64_t id, const ChatMessage& msg) { broadcast(id, msg); })
{
m_endpoint->set_on_peer_connected([](msg::PeerId client_id) {
spdlog::info("Chat client connected: {}", client_id);
});
register_handlers();
spdlog::info("Chat server listening on port {}", port);
}
void ChatServerController::register_handlers() {
m_handlers[Message<mmo::chat::SendChatMessageRequest>::value] =
[this](uint64_t client_id, std::span<const std::byte> data) {
mmo::chat::SendChatMessageRequest msg;
msg.ParseFromArray(data.data(), static_cast<int>(data.size()));
m_messages.set_handler<mmo::chat::SendChatMessageRequest>(
[this](msg::PeerId client_id, const mmo::chat::SendChatMessageRequest& msg) {
mmo::chat::SendChatMessageResponse r;
r.set_channel_id(msg.channel_id());
r.set_error(to_error_code(m_service.send_message(client_id, msg.channel_id(), msg.message())));
send_to(client_id, Message<mmo::chat::SendChatMessageResponse>::value, serialize(r));
};
(void)m_messages.send_to(client_id, r, false);
});
m_handlers[Message<mmo::chat::JoinChannelRequest>::value] =
[this](uint64_t client_id, std::span<const std::byte> data) {
mmo::chat::JoinChannelRequest msg;
msg.ParseFromArray(data.data(), static_cast<int>(data.size()));
m_messages.set_handler<mmo::chat::JoinChannelRequest>(
[this](msg::PeerId client_id, const mmo::chat::JoinChannelRequest& msg) {
m_service.join_channel(client_id, msg.channel_id());
mmo::chat::JoinChannelResponse r;
r.set_channel_id(msg.channel_id());
r.set_error(mmo::chat::CHAT_ERROR_CODE_OK);
send_to(client_id, Message<mmo::chat::JoinChannelResponse>::value, serialize(r));
};
(void)m_messages.send_to(client_id, r, false);
});
m_handlers[Message<mmo::chat::LeaveChannelRequest>::value] =
[this](uint64_t client_id, std::span<const std::byte> data) {
mmo::chat::LeaveChannelRequest msg;
msg.ParseFromArray(data.data(), static_cast<int>(data.size()));
m_messages.set_handler<mmo::chat::LeaveChannelRequest>(
[this](msg::PeerId client_id, const mmo::chat::LeaveChannelRequest& msg) {
m_service.leave_channel(client_id, msg.channel_id());
mmo::chat::LeaveChannelResponse r;
r.set_channel_id(msg.channel_id());
r.set_error(mmo::chat::CHAT_ERROR_CODE_OK);
send_to(client_id, Message<mmo::chat::LeaveChannelResponse>::value, serialize(r));
};
(void)m_messages.send_to(client_id, r, false);
});
}
void ChatServerController::update() {
m_endpoint->poll();
net::quicr::QuicrConnection* conn = nullptr;
while ((conn = m_listener->listen())) {
m_connections.emplace(conn->self_id(), conn);
spdlog::info("Chat client connected: {}", conn->self_id());
}
for (auto& [client_id, conn] : m_connections) {
auto r = conn->read_into(m_recv_buf);
if (!r || *r == 0) continue;
dispatch(client_id, std::span(m_recv_buf.data(), *r));
}
m_endpoint->update();
}
void ChatServerController::dispatch(uint64_t client_id, std::span<const std::byte> data) {
constexpr size_t HEADER = sizeof(uint32_t) * 2;
if (data.size() < HEADER) {
spdlog::warn("ChatServerController: dropped short datagram ({} bytes)", data.size());
return;
}
uint32_t type{};
std::memcpy(&type, data.data(), sizeof(type));
if (type >= m_handlers.size() || !m_handlers[type]) {
spdlog::warn("ChatServerController: no handler for type {}", type);
return;
}
m_handlers[type](client_id, data.subspan(HEADER));
}
void ChatServerController::send_to(uint64_t client_id, uint32_t type,
std::span<const std::byte> payload, bool reliable) {
auto it = m_connections.find(client_id);
if (it == m_connections.end()) return;
constexpr uint32_t SEQ_NONE = 0;
std::vector<std::byte> buf(sizeof(type) + sizeof(SEQ_NONE) + payload.size());
std::memcpy(buf.data(), &type, sizeof(type));
std::memcpy(buf.data() + sizeof(type), &SEQ_NONE, sizeof(SEQ_NONE));
std::memcpy(buf.data() + sizeof(type) + sizeof(SEQ_NONE), payload.data(), payload.size());
(void)it->second->send_message(std::span(buf), reliable);
}
void ChatServerController::broadcast(uint64_t client_id, const ChatMessage& msg) {
void ChatServerController::broadcast(msg::PeerId client_id, const ChatMessage& msg) {
mmo::chat::ChatMessageBroadcastRequest bcast;
bcast.set_channel_id(msg.channel_id);
bcast.set_sender_id(msg.client_id);
bcast.set_message(msg.message);
send_to(client_id, Message<mmo::chat::ChatMessageBroadcastRequest>::value,
serialize(bcast), true);
(void)m_messages.send_to(client_id, bcast, true);
}
} // namespace tw::chat
@@ -1,28 +1,18 @@
#pragma once
#include "ChatService.hpp"
#include "protocol/quicr/QuicrEndpoint.hpp"
#include "protocol/quicr/QuicrConnectionListener.hpp"
#include "ProtobufMessages.hpp"
#include "message_protocol/MessageEndpoint.hpp"
#include <array>
#include <cstdint>
#include <functional>
#include <span>
#include <unordered_map>
#include <vector>
#include <memory>
namespace tw::chat {
class ChatServerController {
static constexpr size_t MAX_TYPES = 32;
std::unique_ptr<net::quicr::QuicrEndpoint> m_endpoint;
std::unique_ptr<net::quicr::QuicrConnectionListener> m_listener;
std::unordered_map<uint64_t, net::quicr::QuicrConnection*> m_connections;
std::vector<std::byte> m_recv_buf{64 * 1024};
ChatService m_service;
std::array<std::function<void(uint64_t, std::span<const std::byte>)>, MAX_TYPES> m_handlers{};
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
ProtobufMessages m_messages;
ChatService m_service;
public:
explicit ChatServerController(int port = CHAT_DEFAULT_PORT);
@@ -31,10 +21,7 @@ public:
private:
void register_handlers();
void dispatch(uint64_t client_id, std::span<const std::byte> data);
void send_to(uint64_t client_id, uint32_t type, std::span<const std::byte> payload,
bool reliable = false);
void broadcast(uint64_t client_id, const ChatMessage& msg);
void broadcast(msg::PeerId client_id, const ChatMessage& msg);
};
} // namespace tw::chat
@@ -5,7 +5,8 @@ add_executable(${PROJECT_NAME} ChatMockClient.cpp)
target_link_libraries(${PROJECT_NAME}
PRIVATE
tw::protocol
tw::messaging
tw::message_protocol
tw::network
tw::quicr
spdlog::spdlog
)
@@ -1,7 +1,8 @@
#include "Address.hpp"
#include "MessageSession.hpp"
#include "ProtobufMessages.hpp"
#include "MessageRegistry.hpp"
#include "Chat.pb.h"
#include "message_protocol/MessageEndpoint.hpp"
#include <spdlog/spdlog.h>
#include <atomic>
@@ -56,30 +57,42 @@ int main(int argc, char* argv[]) {
return 1;
}
tw::MessageSession session(tw::net::Address{std::string{host}, port});
auto endpoint_r = tw::msg::MessageEndpoint::create();
if (!endpoint_r) {
spdlog::error("Failed to create an endpoint: {}", endpoint_r.error().message());
return 1;
}
auto& endpoint = endpoint_r.value();
auto server_r = endpoint->connect(host, port);
if (!server_r) {
spdlog::error("Failed to connect: {}", server_r.error().message());
return 1;
}
auto* server = server_r.value();
tw::ProtobufMessages messages(endpoint.get());
spdlog::info("Connecting to {}:{}...", host, port);
const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (!session.is_established()) {
while (!server->is_established()) {
if (std::chrono::steady_clock::now() > deadline) {
spdlog::error("Connection timed out");
return 1;
}
session.update();
endpoint->update();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
spdlog::info("Connected. Joining channel {}...", channel_id);
session.set_handler(tw::Message<mmo::chat::ChatMessageBroadcastRequest>::value,
[](std::span<const std::byte> data) {
mmo::chat::ChatMessageBroadcastRequest bcast;
bcast.ParseFromArray(data.data(), static_cast<int>(data.size()));
messages.set_handler<mmo::chat::ChatMessageBroadcastRequest>(
[](tw::msg::PeerId, const mmo::chat::ChatMessageBroadcastRequest& bcast) {
std::println("[ch:{}] <{}> {}", bcast.channel_id(), bcast.sender_id(), bcast.message());
});
mmo::chat::JoinChannelRequest join;
join.set_channel_id(channel_id);
(void)session.request(
(void)server->request(
tw::Message<mmo::chat::JoinChannelRequest>::value,
serialize(join),
[channel_id](std::span<const std::byte> data) {
@@ -102,7 +115,7 @@ int main(int argc, char* argv[]) {
mmo::chat::SendChatMessageRequest msg;
msg.set_channel_id(channel_id);
msg.set_message(line);
(void)session.request(
(void)server->request(
tw::Message<mmo::chat::SendChatMessageRequest>::value,
serialize(msg),
[channel_id](std::span<const std::byte> data) {
@@ -113,7 +126,7 @@ int main(int argc, char* argv[]) {
}
}
session.update();
endpoint->update();
}
return 0;
+5
View File
@@ -4,12 +4,14 @@ add_subdirectory(shaders)
file(GLOB FILES
src/*.cpp
src/app/*.cpp
src/network/*.cpp
src/world/*.cpp
src/io/*.cpp
src/draw/*.cpp
src/draw/RenderPasses/*.cpp
src/debug/*.cpp
src/debug/metrics/*.cpp
src/debug/tools/*.cpp
)
@@ -20,11 +22,14 @@ target_link_libraries(${PROJECT_NAME}
PUBLIC
towards
tw::network
tw::metrics
tw::quicr
loft::common
loft::base
loft_window
loft::render_graph
tw::protocol
tw::message_protocol
tw::serialization
tw::gui
imgui::imgui
+119
View File
@@ -0,0 +1,119 @@
#include "ClientArgs.hpp"
#include <arpa/inet.h>
#include <charconv>
#include <algorithm>
#include <cctype>
namespace tw::app {
namespace {
std::string_view trim(std::string_view str) {
// Skip leading whitespace
size_t start = 0;
while(start < str.length() && std::isspace(static_cast<unsigned char>(str[start]))) {
++start;
}
// Skip trailing whitespace
size_t end = str.length();
while(end > start && std::isspace(static_cast<unsigned char>(str[end - 1]))) {
--end;
}
return str.substr(start, end - start);
}
bool is_valid_ipv4(std::string_view ip_str) {
// Use inet_pton to validate IPv4 format
struct in_addr addr;
return inet_pton(AF_INET, std::string(ip_str).c_str(), &addr) == 1;
}
tl::expected<int, std::string> parse_port(std::string_view port_str) {
if(port_str.empty()) {
return 8080; // Default port
}
int port = 0;
const char* end = port_str.data() + port_str.length();
auto result = std::from_chars(port_str.data(), end, port);
// from_chars stops at the first character it cannot use, so a partially
// numeric port like "80x" would otherwise be accepted as 80.
if(result.ec != std::errc() || result.ptr != end) {
return tl::make_unexpected("port must be numeric");
}
if(port < 1 || port > 65535) {
return tl::make_unexpected("port must be between 1 and 65535");
}
return port;
}
} // anonymous namespace
tl::expected<net::Address, std::string> parse_address(std::string_view text) {
// Trim whitespace
text = trim(text);
if(text.empty()) {
return tl::make_unexpected("address cannot be empty");
}
// Find the colon to split host and port
size_t colon_pos = text.rfind(':');
std::string_view host;
std::string_view port_str;
if(colon_pos == std::string_view::npos) {
// No colon found: treat entire string as port or host
// If it's all digits, treat as port; otherwise as host (will fail validation)
bool all_digits = !text.empty() && std::all_of(text.begin(), text.end(),
[](unsigned char c) { return std::isdigit(c); });
if(all_digits) {
host = "127.0.0.1";
port_str = text;
} else {
// Treat as host with no port
host = text;
port_str = "";
}
} else {
host = text.substr(0, colon_pos);
port_str = text.substr(colon_pos + 1);
}
// Validate host
if(host.empty()) {
return tl::make_unexpected("host cannot be empty");
}
if(!is_valid_ipv4(host)) {
return tl::make_unexpected("not a valid IPv4 address");
}
// Parse port
auto port_result = parse_port(port_str);
if(!port_result) {
return tl::make_unexpected(port_result.error());
}
int port = port_result.value();
return net::Address(std::optional<std::string>(std::string(host)), port);
}
std::optional<std::string> server_arg(int argc, char** argv) {
for(int i = 1; i < argc - 1; ++i) {
if(std::string_view(argv[i]) == "--server") {
return std::string(argv[i + 1]);
}
}
return std::nullopt;
}
} // namespace tw::app
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <string_view>
#include <string>
#include <optional>
#include <tl/expected.hpp>
#include "Address.hpp"
namespace tw::app {
/**
* Parse an address string into a network address.
*
* Accepts "host:port" or a bare port number. Bare port uses 127.0.0.1.
* Missing port defaults to 8080. Trims surrounding whitespace.
* Validates the host with inet_pton and returns an error string
* for non-IPv4 addresses or invalid ports.
*/
tl::expected<net::Address, std::string> parse_address(std::string_view text);
/**
* Extract the --server argument value from the command line.
*
* Scans argv for --server and returns the following argument,
* or nothing if the flag is absent or has no value.
*/
std::optional<std::string> server_arg(int argc, char** argv);
} // namespace tw::app
@@ -0,0 +1,31 @@
#include "FavouriteServers.hpp"
#include <algorithm>
namespace tw::app {
FavouriteServers::FavouriteServers()
: FileAddressList("favourite_servers.txt") {
}
const char* FavouriteServers::name() const {
return "Favourites";
}
void FavouriteServers::add(const std::string& entry) {
// Check if already present
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
if(it != m_entries.end()) {
return; // Already present, do nothing
}
// Append at the end
m_entries.push_back(entry);
// Cap at MAX_ENTRIES
if(m_entries.size() > MAX_ENTRIES) {
m_entries.resize(MAX_ENTRIES);
}
}
} // namespace tw::app
@@ -0,0 +1,33 @@
#pragma once
#include "FileAddressList.hpp"
namespace tw::app {
/**
* Manages a list of favourite server addresses.
*
* Persists addresses to a text file, one "ip:port" per line.
* Appended in the order they are added, de-duplicated,
* capped at 32 entries. File path is
* $XDG_CONFIG_HOME/towards/favourite_servers.txt, falling back to
* $HOME/.config/towards/favourite_servers.txt. If both variables are
* unset, keeps the list in memory only.
*/
class FavouriteServers : public FileAddressList {
static constexpr size_t MAX_ENTRIES = 32;
public:
FavouriteServers();
const char* name() const override;
/**
* Add an address to the favourites list.
* Appended at the end if not already present, capped at 32.
* Does not persist to disk; call save() after modifying.
*/
void add(const std::string& entry) override;
};
} // namespace tw::app
+110
View File
@@ -0,0 +1,110 @@
#include "FileAddressList.hpp"
#include <spdlog/spdlog.h>
#include <filesystem>
#include <fstream>
#include <cstdlib>
#include <algorithm>
namespace tw::app {
namespace {
std::string get_config_dir() {
// Try XDG_CONFIG_HOME first
const char* xdg_config_home = std::getenv("XDG_CONFIG_HOME");
if(xdg_config_home && xdg_config_home[0] != '\0') {
return std::string(xdg_config_home) + "/towards";
}
// Fall back to $HOME/.config/towards
const char* home = std::getenv("HOME");
if(home && home[0] != '\0') {
return std::string(home) + "/.config/towards";
}
// Both unset
return "";
}
} // anonymous namespace
FileAddressList::FileAddressList(const std::string& file_name) {
std::string config_dir = get_config_dir();
if(config_dir.empty()) {
spdlog::debug("XDG_CONFIG_HOME and HOME not set; address lists will not be persisted");
m_can_save = false;
return;
}
m_path = config_dir + "/" + file_name;
m_can_save = true;
}
void FileAddressList::load() {
if(m_path.empty()) {
return; // No config path available
}
std::ifstream file(m_path);
if(!file.is_open()) {
// File doesn't exist or can't be read; this is not an error
return;
}
m_entries.clear();
std::string line;
while(std::getline(file, line)) {
// Trim whitespace from the line
size_t start = line.find_first_not_of(" \t\r\n");
size_t end = line.find_last_not_of(" \t\r\n");
if(start != std::string::npos) {
line = line.substr(start, end - start + 1);
if(!line.empty()) {
m_entries.push_back(line);
}
}
}
}
void FileAddressList::save() const {
if(!m_can_save || m_path.empty()) {
return; // Cannot save without config path
}
// Create the directory if needed
std::filesystem::path config_path(m_path);
std::filesystem::path config_dir = config_path.parent_path();
try {
std::filesystem::create_directories(config_dir);
} catch(const std::filesystem::filesystem_error&) {
// If we can't create the directory, silently fail to save
return;
}
// Write entries to file
std::ofstream file(m_path);
if(!file.is_open()) {
return; // Can't open file for writing; silently fail
}
for(const auto& entry : m_entries) {
file << entry << "\n";
}
}
void FileAddressList::remove(const std::string& entry) {
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
if(it != m_entries.end()) {
m_entries.erase(it);
}
}
const std::vector<std::string>& FileAddressList::entries() const {
return m_entries;
}
} // namespace tw::app
@@ -0,0 +1,32 @@
#pragma once
#include "ServerAddressProvider.hpp"
#include <vector>
#include <string>
namespace tw::app {
/**
* Shared behaviour of address lists backed by a file.
*
* One "ip:port" per line. Resolves file path to
* $XDG_CONFIG_HOME/towards/<file_name>, falling back to
* $HOME/.config/towards/<file_name>.
* add() stays pure virtual: subclasses define their own order.
*/
class FileAddressList : public ServerAddressProvider {
protected:
std::vector<std::string> m_entries;
std::string m_path;
bool m_can_save = false;
explicit FileAddressList(const std::string& file_name);
public:
void load() override;
void save() const override;
void remove(const std::string& entry) override;
const std::vector<std::string>& entries() const override;
};
} // namespace tw::app
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "debug/DebugWindowRegistry.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "draw/WorldRenderer.hpp"
#include "io/InputState.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/World.hpp"
namespace tw::app {
/**
* Bundle of runtime-owned subsystems that the game state needs.
*/
struct GameContext {
tw::World* world;
tw::JoltPhysicsWorld* physics_world;
tw::drw::WorldRenderer* renderer;
tw::io::InputManager* input_manager;
tw::dbg::NetworkMetrics* metrics;
tw::dbg::DebugWindowRegistry* debug_windows;
};
} // namespace tw::app
+34
View File
@@ -0,0 +1,34 @@
#include "GameState.hpp"
namespace tw::app {
GameState::GameState(GameContext context, std::unique_ptr<tw::net::ServerConnection> connection)
: m_context(context),
m_connection(std::move(connection)),
m_entity_gui(context.world),
m_network_gui(*context.metrics)
{
m_controller = std::make_unique<tw::ClientWorldController>(
m_context.input_manager,
m_context.world,
m_context.physics_world,
m_context.renderer,
m_connection.get(),
m_context.metrics
);
m_context.debug_windows->add(&m_entity_gui);
m_context.debug_windows->add(&m_network_gui);
}
GameState::~GameState() {
m_context.debug_windows->remove(&m_entity_gui);
m_context.debug_windows->remove(&m_network_gui);
}
void GameState::update(double delta_time) {
m_controller->update(delta_time);
m_context.world->step(delta_time);
}
} // namespace tw::app
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <memory>
#include "GameContext.hpp"
#include "debug/tools/EntityManagerGui.hpp"
#include "debug/tools/NetworkStatsGui.hpp"
#include "network/ServerConnection.hpp"
#include "world/ClientWorldController.hpp"
namespace tw::app {
/**
* The game world state. Owns the connection, world controller, and debug GUIs.
* Responsible for updating the game simulation and rendering debug information.
*/
class GameState {
GameContext m_context;
std::unique_ptr<tw::net::ServerConnection> m_connection;
std::unique_ptr<tw::ClientWorldController> m_controller;
tw::dbg::tools::EntityManagerGui m_entity_gui;
tw::dbg::tools::NetworkStatsGui m_network_gui;
public:
/**
* Constructs the game state with the given context and connection.
* The connection must be established before creating the game state.
*/
GameState(GameContext context, std::unique_ptr<tw::net::ServerConnection> connection);
/**
* Takes the debug panels back out of the menu.
*/
~GameState();
/**
* Updates the game state: draws debug GUIs, updates the controller,
* and steps the world physics.
*/
void update(double delta_time);
};
} // namespace tw::app
+182
View File
@@ -0,0 +1,182 @@
#include "LobbyState.hpp"
#include "ClientArgs.hpp"
#include "imgui.h"
#include <cstdio>
namespace tw::app {
namespace {
const ImVec4 FAVOURITES_COLOUR{1.0f, 0.8f, 0.2f, 1.0f};
const ImVec4 RECENT_COLOUR{0.7f, 0.7f, 0.7f, 1.0f};
}
LobbyState::LobbyState(std::optional<tw::net::Address> auto_connect) {
m_recent.load();
m_favourites.load();
set_address_input(m_recent.entries().empty()
? "127.0.0.1:8080"
: m_recent.entries().front());
if(auto_connect) {
begin_connect(*auto_connect);
}
}
void LobbyState::set_address_input(const std::string& address) {
std::snprintf(m_address_input, sizeof(m_address_input), "%s", address.c_str());
}
void LobbyState::begin_connect(tw::net::Address address) {
m_error.clear();
m_connection = std::make_unique<tw::net::ServerConnection>(address);
auto started = m_connection->start();
if(!started) {
m_error = started.error().message();
m_connection.reset();
}
}
void LobbyState::draw_form() {
const float input_width = 200.0f;
ImGui::SetNextItemWidth(input_width);
bool submitted = ImGui::InputText("##address", m_address_input, sizeof(m_address_input),
ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine();
submitted |= ImGui::Button("Connect");
if(!submitted) {
return;
}
auto parsed = parse_address(m_address_input);
if(parsed) {
begin_connect(*parsed);
} else {
m_error = parsed.error();
}
}
void LobbyState::draw_favourite_toggle(const std::string& entry) {
const char* label = m_favourites.contains(entry) ? "[*]" : "[ ]";
// The label alone would collide between the rows drawn in one frame, so the
// entry it acts on is what identifies the button.
std::string button_id = std::string(label) + "##fav_" + entry;
if(ImGui::SmallButton(button_id.c_str())) {
toggle_favourite(entry);
}
}
void LobbyState::toggle_favourite(const std::string& entry) {
if(m_favourites.contains(entry)) {
m_favourites.remove(entry);
} else {
m_favourites.add(entry);
}
m_favourites.save();
}
void LobbyState::draw_provider(ServerAddressProvider& provider, const ImVec4& header_colour) {
ImGui::PushStyleColor(ImGuiCol_Text, header_colour);
ImGui::SeparatorText(provider.name());
ImGui::PopStyleColor();
// BeginChild is one of the two calls whose End must run even when it
// returns false, so the result only decides whether rows are submitted.
if(ImGui::BeginChild(provider.name(), ImVec2(0, 120), ImGuiChildFlags_Borders)) {
for(const auto& entry : provider.entries()) {
draw_favourite_toggle(entry);
ImGui::SameLine();
if(ImGui::Selectable(entry.c_str(), false)) {
set_address_input(entry);
}
if(ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
auto parsed = parse_address(entry);
if(parsed) {
begin_connect(*parsed);
}
}
}
}
ImGui::EndChild();
}
void LobbyState::draw_status() {
if(m_connection) {
switch(m_connection->status()) {
case tw::net::ConnectionStatus::Idle:
break;
case tw::net::ConnectionStatus::Connecting: {
ImGui::TextUnformatted("Connecting...");
ImGui::SameLine();
draw_favourite_toggle(m_connection->address().to_string());
ImGui::SameLine();
if(ImGui::Button("Cancel")) {
m_connection.reset();
}
break;
}
case tw::net::ConnectionStatus::Connected:
ImGui::TextColored(ImVec4(0, 1, 0, 1), "Connected!");
m_recent.add(m_connection->address().to_string());
m_recent.save();
m_result = LobbyResult{std::move(m_connection)};
break;
case tw::net::ConnectionStatus::Failed: {
std::string failed_msg = "Connection failed: " + m_connection->error();
ImGui::TextColored(ImVec4(1, 0, 0, 1), "%s", failed_msg.c_str());
if(ImGui::Button("Dismiss")) {
m_connection.reset();
}
break;
}
}
} else if(!m_error.empty()) {
std::string error_msg = "Error: " + m_error;
ImGui::TextColored(ImVec4(1, 0, 0, 1), "%s", error_msg.c_str());
}
}
void LobbyState::update(double delta_time) {
if(m_connection) {
m_connection->update();
}
// Center the window
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_FirstUseEver,
ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_FirstUseEver);
ImGui::Begin("Lobby", nullptr, ImGuiWindowFlags_NoMove);
draw_form();
draw_provider(m_favourites, FAVOURITES_COLOUR);
draw_provider(m_recent, RECENT_COLOUR);
ImGui::Separator();
draw_status();
ImGui::End();
}
std::optional<LobbyResult> LobbyState::take_result() {
// Moving out of an optional leaves it engaged, which would hand the caller
// a second, empty result on the next frame.
auto result = std::move(m_result);
m_result.reset();
return result;
}
} // namespace tw::app
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <memory>
#include <string>
#include <optional>
#include "RecentServers.hpp"
#include "FavouriteServers.hpp"
#include "ServerAddressProvider.hpp"
#include "network/ServerConnection.hpp"
struct ImVec4;
namespace tw::app {
/**
* The edge out of the lobby: a connection that finished its handshake.
*/
struct LobbyResult {
std::unique_ptr<tw::net::ServerConnection> connection;
};
/**
* The lobby screen state. Renders an address input field, recent server list,
* and manages a connection attempt in flight.
*/
class LobbyState {
static constexpr size_t ADDRESS_INPUT_SIZE = 64;
tw::app::RecentServers m_recent;
tw::app::FavouriteServers m_favourites;
/**
* Edited in place by the input field, so it has to outlive the frame that
* draws it rather than being rebuilt from a string every time.
*/
char m_address_input[ADDRESS_INPUT_SIZE];
std::string m_error;
std::unique_ptr<tw::net::ServerConnection> m_connection;
std::optional<LobbyResult> m_result;
void begin_connect(tw::net::Address address);
void set_address_input(const std::string& address);
void draw_form();
/**
* Renders one address list. The colour is what tells the lists apart on
* screen, so it belongs to the lobby rather than to the list itself.
*/
void draw_provider(ServerAddressProvider& provider, const ImVec4& header_colour);
void draw_favourite_toggle(const std::string& entry);
void toggle_favourite(const std::string& entry);
void draw_status();
public:
/**
* Constructs the lobby state, optionally starting an auto-connect if a
* server address is provided.
*/
explicit LobbyState(std::optional<tw::net::Address> auto_connect);
/**
* Updates the lobby: draws the UI, pumps the connection attempt if one
* is in flight.
*/
void update(double delta_time);
/**
* Returns the transition result if the lobby is done. Moves the result
* out, leaving none behind.
*/
std::optional<LobbyResult> take_result();
};
} // namespace tw::app
+31
View File
@@ -0,0 +1,31 @@
#include "RecentServers.hpp"
#include <algorithm>
namespace tw::app {
RecentServers::RecentServers()
: FileAddressList("recent_servers.txt") {
}
const char* RecentServers::name() const {
return "Recent";
}
void RecentServers::add(const std::string& entry) {
// Remove if already in list (de-duplicate)
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
if(it != m_entries.end()) {
m_entries.erase(it);
}
// Add to front (most recent first)
m_entries.insert(m_entries.begin(), entry);
// Cap at MAX_ENTRIES
if(m_entries.size() > MAX_ENTRIES) {
m_entries.resize(MAX_ENTRIES);
}
}
} // namespace tw::app
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "FileAddressList.hpp"
namespace tw::app {
/**
* Manages a list of recently used server addresses.
*
* Persists addresses to a text file, one "ip:port" per line.
* Most recent first, capped at 8 entries. File path is
* $XDG_CONFIG_HOME/towards/recent_servers.txt, falling back to
* $HOME/.config/towards/recent_servers.txt. If both variables are
* unset, keeps the list in memory only.
*/
class RecentServers : public FileAddressList {
static constexpr size_t MAX_ENTRIES = 8;
public:
RecentServers();
const char* name() const override;
/**
* Add an address to the recents list.
* Most recent first, de-duplicated, capped at 8.
* Does not persist to disk; call save() after connecting.
*/
void add(const std::string& entry) override;
};
} // namespace tw::app
@@ -0,0 +1,42 @@
#pragma once
#include <vector>
#include <string>
namespace tw::app {
/**
* Abstract interface for server address lists.
*
* Implementations manage a list of "ip:port" entries, load/save them,
* and provide a human-readable name for the UI.
*/
class ServerAddressProvider {
public:
virtual ~ServerAddressProvider() = default;
/** Human-readable list name, shown as the section header in the lobby. */
virtual const char* name() const = 0;
virtual const std::vector<std::string>& entries() const = 0;
virtual void load() = 0;
virtual void save() const = 0;
virtual void add(const std::string& entry) = 0;
virtual void remove(const std::string& entry) = 0;
/**
* Check whether an entry exists in the list.
* Implemented over entries() — subclasses need not override.
*/
bool contains(const std::string& entry) const {
const auto& vec = entries();
for(const auto& e : vec) {
if(e == entry) {
return true;
}
}
return false;
}
};
} // namespace tw::app
+44
View File
@@ -0,0 +1,44 @@
#include "DebugUI.hpp"
#include <imgui.h>
namespace tw::dbg {
void DebugUI::draw_dockspace() {
const ImGuiViewport* viewport = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(viewport->WorkPos);
ImGui::SetNextWindowSize(viewport->WorkSize);
ImGui::SetNextWindowViewport(viewport->ID);
const ImGuiWindowFlags flags =
ImGuiWindowFlags_MenuBar | ImGuiWindowFlags_NoDocking |
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoCollapse |
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus |
ImGuiWindowFlags_NoBackground;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0.0f, 0.0f));
ImGui::Begin("##debug_dockspace_host", nullptr, flags);
ImGui::PopStyleVar(3);
// A pass through centre leaves the middle empty until something is docked
// there, which is where the world is drawn.
ImGui::DockSpace(ImGui::GetID("debug_dockspace"), ImVec2(0.0f, 0.0f),
ImGuiDockNodeFlags_PassthruCentralNode);
if(ImGui::BeginMenuBar()) {
m_windows.draw_menu();
ImGui::EndMenuBar();
}
ImGui::End();
}
void DebugUI::draw_windows() {
m_windows.draw_windows();
}
}
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include "debug/DebugWindowRegistry.hpp"
namespace tw::dbg {
/**
* The full screen host the debug panels live in: a menu bar to toggle them and
* a dock space to arrange them in. Draws no background of its own, so the world
* stays visible underneath.
*/
class DebugUI {
DebugWindowRegistry m_windows;
public:
DebugWindowRegistry& windows() {
return m_windows;
}
/**
* Opens the host for this frame. Has to run before anything that should be
* dockable is drawn, since the dock space has to exist by then.
*/
void draw_dockspace();
void draw_windows();
};
}
+26
View File
@@ -0,0 +1,26 @@
#include "DebugWindow.hpp"
#include <imgui.h>
#include <utility>
namespace tw::dbg {
DebugWindow::DebugWindow(std::string id, std::string title, std::string category)
: m_id(std::move(id)),
m_title(std::move(title)),
m_category(std::move(category)),
m_label(m_title + "##" + m_id)
{
}
void DebugWindow::draw() {
if(!m_open) {
return;
}
if(ImGui::Begin(m_label.c_str(), &m_open)) {
draw_contents();
}
ImGui::End();
}
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <string>
namespace tw::dbg {
/**
* A debug panel that can be toggled from the menu bar.
*
* The frame around a panel is drawn here so every one of them gets the same
* close button and docking behaviour; subclasses only fill in the contents.
*/
class DebugWindow {
std::string m_id;
std::string m_title;
std::string m_category;
/**
* The label handed to the ui, "title##id". Saved positions are keyed by the
* whole label, so the visible half can change without losing the layout.
*/
std::string m_label;
bool m_open = false;
protected:
/**
* Fills the panel. Called only while it is open, between begin and end.
*/
virtual void draw_contents() = 0;
public:
DebugWindow(std::string id, std::string title, std::string category);
virtual ~DebugWindow() = default;
DebugWindow(const DebugWindow&) = delete;
DebugWindow& operator=(const DebugWindow&) = delete;
const std::string& id() const { return m_id; }
const std::string& title() const { return m_title; }
const std::string& category() const { return m_category; }
bool is_open() const { return m_open; }
void set_open(bool open) { m_open = open; }
/**
* The flag the menu item toggles, and the one the close button clears.
*/
bool* open_flag() { return &m_open; }
void draw();
};
}
@@ -0,0 +1,59 @@
#include "DebugWindowRegistry.hpp"
#include "DebugWindow.hpp"
#include <imgui.h>
#include <algorithm>
namespace tw::dbg {
void DebugWindowRegistry::add(DebugWindow* window) {
auto remembered = m_open_state.find(window->id());
if(remembered != m_open_state.end()) {
window->set_open(remembered->second);
}
m_windows.push_back(window);
}
void DebugWindowRegistry::remove(DebugWindow* window) {
m_open_state[window->id()] = window->is_open();
std::erase(m_windows, window);
}
void DebugWindowRegistry::draw_menu() {
if(!ImGui::BeginMenu("Windows")) {
return;
}
// Ordered by the first panel that asked for the category, so the menu does
// not reshuffle as panels come and go.
std::vector<std::string> categories;
for(auto* window : m_windows) {
if(std::find(categories.begin(), categories.end(), window->category()) == categories.end()) {
categories.push_back(window->category());
}
}
for(const auto& category : categories) {
if(!ImGui::BeginMenu(category.c_str())) {
continue;
}
for(auto* window : m_windows) {
if(window->category() == category) {
ImGui::MenuItem(window->title().c_str(), nullptr, window->open_flag());
}
}
ImGui::EndMenu();
}
ImGui::EndMenu();
}
void DebugWindowRegistry::draw_windows() {
for(auto* window : m_windows) {
window->draw();
}
}
}
@@ -0,0 +1,46 @@
#pragma once
#include <string>
#include <unordered_map>
#include <vector>
namespace tw::dbg {
class DebugWindow;
/**
* The debug panels that exist right now.
*
* Panels are listed by whatever owns them, for as long as it lives, so the menu
* follows what the client is currently doing. Whether a panel was open is kept
* here rather than on the panel, since the owner is built again on every
* reconnect and the panel would come back closed.
*/
class DebugWindowRegistry {
std::vector<DebugWindow*> m_windows;
/**
* Keyed by panel id, remembered across the panels themselves.
*/
std::unordered_map<std::string, bool> m_open_state;
public:
/**
* Lists a panel, restoring whether it was open last time one with the same
* id was listed. Ownership stays with the caller, which has to remove it
* again before the panel dies.
*/
void add(DebugWindow* window);
void remove(DebugWindow* window);
/**
* One submenu per category, listing every panel in it. Expects to be called
* inside a menu bar.
*/
void draw_menu();
void draw_windows();
};
}
@@ -0,0 +1,159 @@
#pragma once
#include "metrics/MetricSeries.hpp"
#include <chrono>
#include <cstdint>
namespace tw::dbg {
/**
* Per-second history of what the client sends, receives and waits for.
*
* Traffic arrives as running totals, one reading per tick: sample() keeps the
* change since the previous reading, so a bucket sums to the traffic of that
* second and its extremes are the quietest and busiest tick within it.
* Durations are recorded as they are measured.
*/
class NetworkMetrics {
public:
using Interval = std::chrono::seconds;
using Series = metrics::MetricSeries<Interval>;
/** How many seconds of history are kept. */
static constexpr size_t DEFAULT_HISTORY = 300;
/** Running totals as of one tick. */
struct Totals {
uint64_t bytes_sent = 0;
uint64_t bytes_received = 0;
uint64_t messages_sent = 0;
uint64_t messages_received = 0;
};
private:
Series m_bytes_out;
Series m_bytes_in;
Series m_messages_out;
Series m_messages_in;
Series m_response_ms;
Series m_update_ms;
Series m_rollbacks;
Series m_correction_distance;
Series m_ack_lag_frames;
Series m_replayed_frames;
Totals m_previous;
bool m_has_previous = false;
static uint64_t delta(uint64_t current, uint64_t previous) {
return current > previous ? current - previous : 0;
}
static double to_millis(std::chrono::nanoseconds elapsed) {
return std::chrono::duration<double, std::milli>(elapsed).count();
}
public:
explicit NetworkMetrics(size_t history_in_seconds = DEFAULT_HISTORY) :
m_bytes_out(history_in_seconds),
m_bytes_in(history_in_seconds),
m_messages_out(history_in_seconds),
m_messages_in(history_in_seconds),
m_response_ms(history_in_seconds),
m_update_ms(history_in_seconds),
m_rollbacks(history_in_seconds),
m_correction_distance(history_in_seconds),
m_ack_lag_frames(history_in_seconds),
m_replayed_frames(history_in_seconds)
{ }
/**
* Records how much `totals` grew since the previous call. The first call
* only remembers where the counters started.
*/
void sample(const Totals& totals) {
if(m_has_previous) {
m_bytes_out.push((double)delta(totals.bytes_sent, m_previous.bytes_sent));
m_bytes_in.push((double)delta(totals.bytes_received, m_previous.bytes_received));
m_messages_out.push((double)delta(totals.messages_sent, m_previous.messages_sent));
m_messages_in.push((double)delta(totals.messages_received, m_previous.messages_received));
}
m_previous = totals;
m_has_previous = true;
}
/** Time between sending an input and seeing the answer to it. */
void record_response_time(std::chrono::nanoseconds elapsed) {
m_response_ms.push(to_millis(elapsed));
}
/** Time one tick spent moving messages in and out, handlers included. */
void record_update_time(std::chrono::nanoseconds elapsed) {
m_update_ms.push(to_millis(elapsed));
}
const Series& bytes_out() const {
return m_bytes_out;
}
const Series& bytes_in() const {
return m_bytes_in;
}
const Series& messages_out() const {
return m_messages_out;
}
const Series& messages_in() const {
return m_messages_in;
}
const Series& response_ms() const {
return m_response_ms;
}
const Series& update_ms() const {
return m_update_ms;
}
/** Records a rollback event (one sample per rollback). */
void record_rollback() {
m_rollbacks.push(1.0);
}
/** Records the distance in meters of a position correction. */
void record_correction_distance(double meters) {
m_correction_distance.push(meters);
}
/** Records how many frames behind the ack is trailing the current frame. */
void record_ack_lag(uint32_t frames) {
m_ack_lag_frames.push((double)frames);
}
/** Records how many frames were replayed during a rollback. */
void record_replayed_frames(uint32_t frames) {
m_replayed_frames.push((double)frames);
}
const Series& rollbacks() const {
return m_rollbacks;
}
const Series& correction_distance() const {
return m_correction_distance;
}
const Series& ack_lag_frames() const {
return m_ack_lag_frames;
}
const Series& replayed_frames() const {
return m_replayed_frames;
}
};
}
@@ -19,6 +19,7 @@
namespace tw::dbg::tools {
EntityManagerGui::EntityManagerGui(World* world) :
DebugWindow("entity_manager", "Entities", "World"),
m_world(world) {
}
@@ -64,9 +65,7 @@ void EntityManagerGui::draw_entity_components() {
ImGui::EndChild();
}
void EntityManagerGui::draw() {
ImGui::Begin("Transforms");
void EntityManagerGui::draw_contents() {
ImGui::BeginChild("Entities", ImVec2(0, 260), ImGuiChildFlags_Border);
ImGui::SeparatorText("Entities");
@@ -87,8 +86,6 @@ void EntityManagerGui::draw() {
if(m_selected_entity.has_value()) {
draw_entity_components();
}
ImGui::End();
}
}
@@ -1,6 +1,7 @@
#pragma once
#include "debug/ComponentGui.hpp"
#include "debug/DebugWindow.hpp"
#include "entt/entity/fwd.hpp"
#include "world/World.hpp"
@@ -8,7 +9,7 @@
namespace tw::dbg::tools {
class EntityManagerGui {
class EntityManagerGui : public tw::dbg::DebugWindow {
private:
World* m_world;
@@ -29,14 +30,15 @@ private:
...);
}
protected:
void draw_contents() override;
public:
entt::entity& selected() {
return m_selected;
}
EntityManagerGui(World* world);
void draw();
};
}
+88 -74
View File
@@ -2,95 +2,109 @@
#include <imgui.h>
#include <implot.h>
#include <implot_internal.h>
#include "metrics/BucketMetric.hpp"
#include "metrics/MetricSeries.hpp"
#include <algorithm>
#include <chrono>
#include <string>
#include <vector>
namespace tw::dbg::tools {
template<typename T, typename Interval, typename Op = net::SumOp<T>>
/**
* Plots one series against the seconds behind now, ending at the last second
* that has fully elapsed.
*
* The line follows whichever statistic the series is read with. Reading an
* average also shades the quietest and busiest value of each second behind it;
* a total has no such range to show, since its buckets already hold every value
* of that second added together.
*/
class MetricWidget {
std::string m_name;
net::BucketMetric<T, Interval, Op>& m_metric;
using Self = MetricWidget<T, Interval, Op>;
struct {
T constraint_from;
T constraint_to;
T from;
T to;
} y_axis;
bool m_is_scrolling = true;
public:
MetricWidget(
const std::string& name,
net::BucketMetric<T, Interval, Op>& metric
) :
m_name(name),
m_metric(metric)
{
y_axis = {
.constraint_from = 0,
.constraint_to = 500,
.from = 0,
.to = 250
};
using Series = metrics::MetricSeries<std::chrono::seconds>;
private:
std::string m_name;
std::string m_unit;
const Series* m_series;
metrics::MetricField m_field;
/**
* The second in progress is left out: it only holds the part of itself
* that has elapsed, so drawing it makes the newest point drop and climb
* back once a second.
*/
static constexpr size_t SKIP_IN_PROGRESS = 1;
std::vector<double> m_ages;
std::vector<double> m_values;
std::vector<double> m_lows;
std::vector<double> m_highs;
bool has_range() const {
return m_field == metrics::MetricField::Avg;
}
Self& set_x_axis_limits_contraints(T from, T to) {
ImPlot::SetupAxisLimits(ImAxis_X1, from, to);
return *this;
}
Self& set_y_axis_limits(double from, double to) {
return *this;
}
Self& enable_scrolling() {
m_is_scrolling = true;
}
Self& disable_scrolling() {
m_is_scrolling = true;
}
void draw() {
ImGui::PushID(m_name.c_str());
auto head = m_metric.get_head();
auto head_timeline = m_metric.get_head_timeline();
auto tail = m_metric.get_tail();
auto tail_timeline = m_metric.get_tail_timeline();
static float m_metric_history = 10.0f;
ImGui::Checkbox("Is Scrolling", &m_is_scrolling);
if(m_is_scrolling) {
ImGui::SliderFloat("History", &m_metric_history,1,30,"%.1f s");
/** Describes what is drawn, so the numbers always match the line. */
void draw_summary() const {
if(m_values.empty()) {
ImGui::TextUnformatted("no samples yet");
return;
}
ImGui::Text("Min: %i", m_metric.min());
ImGui::Text("Max: %i", m_metric.max());
auto [low, high] = std::minmax_element(m_values.begin(), m_values.end());
if(ImPlot::BeginPlot(m_name.c_str())) {
auto from = tail_timeline.empty() ? *(head_timeline.end() - 1) : *(tail_timeline.end() - 1);
double total = 0.0;
for(double value : m_values) {
total += value;
}
ImPlot::SetupAxes("Time", m_name.c_str(), ImPlotAxisFlags_None, ImPlotAxisFlags_None);
if(m_is_scrolling) {
ImPlot::SetupAxisLimits(ImAxis_X1, from - m_metric_history, from, ImGuiCond_Always);
ImGui::Text("min %.1f %s avg %.1f %s max %.1f %s",
*low, m_unit.c_str(),
total / (double)m_values.size(), m_unit.c_str(),
*high, m_unit.c_str());
}
public:
MetricWidget(std::string name, std::string unit, const Series& series, metrics::MetricField field) :
m_name(std::move(name)),
m_unit(std::move(unit)),
m_series(&series),
m_field(field)
{ }
/** Draws the last `history_in_seconds` seconds of the series. */
void draw(size_t history_in_seconds) {
ImGui::PushID(m_name.c_str());
m_series->linearize(m_ages, m_values, m_field, history_in_seconds, SKIP_IN_PROGRESS);
if(has_range()) {
m_series->linearize(m_ages, m_lows, metrics::MetricField::Min,
history_in_seconds, SKIP_IN_PROGRESS);
m_series->linearize(m_ages, m_highs, metrics::MetricField::Max,
history_in_seconds, SKIP_IN_PROGRESS);
}
draw_summary();
if(ImPlot::BeginPlot(m_name.c_str(), ImVec2(-1.0f, 150.0f))) {
ImPlot::SetupAxes("seconds ago", m_unit.c_str(),
ImPlotAxisFlags_None, ImPlotAxisFlags_AutoFit);
ImPlot::SetupAxisLimits(ImAxis_X1, -(double)history_in_seconds, 0.0, ImGuiCond_Always);
const int count = (int)m_values.size();
if(has_range() && count > 0) {
ImPlot::PlotShaded("range", m_ages.data(), m_lows.data(), m_highs.data(), count);
}
ImPlot::SetupAxisLimits(ImAxis_Y1, 0, m_metric.max() * 2, ImGuiCond_Always);
// ImPlot::SetupAxisLimitsConstraints(ImAxis_Y1, 0, 10000);
if(count > 0) {
ImPlot::PlotLine(m_name.c_str(), m_ages.data(), m_values.data(), count);
}
ImPlot::PlotLine(m_name.c_str(), head_timeline.data(), head.data(), head.size());
ImPlot::PlotLine(m_name.c_str(), tail_timeline.data(), tail.data(), tail.size());
ImPlot::EndPlot();
}
@@ -1,62 +1,70 @@
#pragma once
#include "metrics/BucketMetric.hpp"
#include "metrics/NetworkStatsLogger.hpp"
#include "debug/DebugWindow.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "debug/tools/MetricWidget.hpp"
#include <implot.h>
#include <implot_internal.h>
#include <imgui.h>
namespace tw::dbg::tools {
class NetworkStatsGui {
private:
// MetricWidget<uint32_t, std::chrono::seconds, net::AverageOp<uint32_t>> m_ping_widget;
// MetricWidget<uint32_t, std::chrono::seconds> m_outgoing_widget;
// MetricWidget<uint32_t, std::chrono::seconds> m_incoming_widget;
/**
* Panel over everything the client measured about its traffic.
*
* Traffic is shown as the total of each second, since that is the rate the
* connection actually carried. Durations are shown as the average of each
* second, with the range behind them.
*/
class NetworkStatsGui : public tw::dbg::DebugWindow {
MetricWidget m_response;
MetricWidget m_update;
MetricWidget m_bytes_in;
MetricWidget m_bytes_out;
MetricWidget m_messages_in;
MetricWidget m_messages_out;
MetricWidget m_rollbacks;
MetricWidget m_correction_distance;
MetricWidget m_ack_lag_frames;
MetricWidget m_replayed_frames;
int m_history_in_seconds = 30;
protected:
void draw_contents() override {
ImGui::SliderInt("History", &m_history_in_seconds, 5, 300, "%d s");
const size_t history = (size_t)m_history_in_seconds;
m_response.draw(history);
m_update.draw(history);
m_bytes_in.draw(history);
m_bytes_out.draw(history);
m_messages_in.draw(history);
m_messages_out.draw(history);
ImGui::Separator();
ImGui::TextUnformatted("Prediction");
m_rollbacks.draw(history);
m_correction_distance.draw(history);
m_ack_lag_frames.draw(history);
m_replayed_frames.draw(history);
}
public:
// NetworkStatsGui() :
// m_ping_widget("Ping", net::NetworkStatsLogger::instance()->ping()),
// m_outgoing_widget("Outgoing", net::NetworkStatsLogger::instance()->outgoing()),
// m_incoming_widget("Incoming", net::NetworkStatsLogger::instance()->incoming())
// {
// }
void draw() {
// auto* instance = net::NetworkStatsLogger::instance();
// auto& ping = instance->ping();
ImGui::Begin("Network Stats");
/* auto head = ping.get_head();
auto head_timeline = ping.get_head_timeline();
auto tail = ping.get_tail();
auto tail_timeline = ping.get_tail_timeline();
static float ping_history = 10.0f;
ImGui::SliderFloat("Ping History", &ping_history,1,30,"%.1f s");
if(ImPlot::BeginPlot("Ping")) {
auto from = tail_timeline.empty() ? *(head_timeline.end() - 1) : *(tail_timeline.end() - 1);
ImPlot::SetupAxes("FrameIdx","FPS", ImPlotAxisFlags_None, ImPlotAxisFlags_None);
ImPlot::SetupAxisLimits(ImAxis_X1, from - ping_history, from, ImGuiCond_Always);
ImPlot::SetupAxisLimits(ImAxis_Y1, 0, 120);
ImPlot::SetupAxisLimitsConstraints(ImAxis_Y1, 0, 10000);
ImPlot::PlotLine("Ping", head_timeline.data(), head.data(), head.size());
ImPlot::PlotLine("Ping", tail_timeline.data(), tail.data(), tail.size());
ImPlot::EndPlot();
} */
// m_ping_widget.draw();
// m_outgoing_widget.draw();
// m_incoming_widget.draw();
ImGui::End();
}
explicit NetworkStatsGui(const NetworkMetrics& metrics) :
DebugWindow("network_stats", "Network Stats", "Network"),
m_response("Response", "ms", metrics.response_ms(), metrics::MetricField::Avg),
m_update("Network update", "ms", metrics.update_ms(), metrics::MetricField::Avg),
m_bytes_in("Bytes in", "B/s", metrics.bytes_in(), metrics::MetricField::Sum),
m_bytes_out("Bytes out", "B/s", metrics.bytes_out(), metrics::MetricField::Sum),
m_messages_in("Messages in", "1/s", metrics.messages_in(), metrics::MetricField::Sum),
m_messages_out("Messages out", "1/s", metrics.messages_out(), metrics::MetricField::Sum),
m_rollbacks("Rollbacks", "1/s", metrics.rollbacks(), metrics::MetricField::Sum),
m_correction_distance("Correction distance", "m", metrics.correction_distance(), metrics::MetricField::Avg),
m_ack_lag_frames("Ack lag", "frames", metrics.ack_lag_frames(), metrics::MetricField::Avg),
m_replayed_frames("Replayed frames", "frames", metrics.replayed_frames(), metrics::MetricField::Avg)
{ }
};
}
@@ -1,57 +0,0 @@
#pragma once
#include <format>
#include <vector>
#include "imgui.h"
#include "metrics/NetworkStatsLogger.hpp"
namespace tw::dbg::tools {
class PacketBacklogGui {
private:
std::vector<uint32_t> m_buckets;
uint32_t m_last_backlog_idx;
public:
void draw() {
// auto* instance = net::NetworkStatsLogger::instance();
// size_t size = instance->get_size();
// if(ImGui::BeginTable("Network Packets", 5)) {
// ImGui::TableSetupColumn("Message Type");
// ImGui::TableSetupColumn("Time");
// ImGui::TableSetupColumn("Is From Us");
// ImGui::TableSetupColumn("Target");
// ImGui::TableSetupColumn("Size");
// for(int32_t i = size-1; i >= 0; i--) {
// auto& item = instance->get_item(i);
// ImGui::PushID(item.timepoint.time_since_epoch().count());
// ImGui::TableNextRow();
// ImGui::TableNextColumn();
// ImGui::Text("%i", item.message_type);
// ImGui::TableNextColumn();
// ImGui::Text(std::format("{}", item.timepoint.time_since_epoch()).c_str());
// ImGui::TableNextColumn();
// ImGui::Checkbox("is_sent_from_us", &item.is_sent_by_us);
// ImGui::TableNextColumn();
// ImGui::Text(item.target.to_string().c_str());
// ImGui::TableNextColumn();
// ImGui::Text("%ld", item.buffer.size());
// ImGui::PopID();
// }
// ImGui::EndTable();
// }
}
};
}
@@ -2,7 +2,6 @@
#include "Address.hpp"
#include "ByteBuffer.hpp"
#include "packets/Packet.hpp"
#include <cstring>
namespace tw::dbg::tools {
@@ -7,14 +7,14 @@
namespace tw::dbg::tools {
PerformanceStatsGui::PerformanceStatsGui(LockStep& lock_step) :
DebugWindow("performance_stats", "Performance", "General"),
m_lockstep(lock_step),
fps_history(1000),
frame_idxs(1000)
{
}
void PerformanceStatsGui::draw() {
ImGui::Begin("Stats");
void PerformanceStatsGui::draw_contents() {
ImGui::Text("FPS: %ld", m_lockstep.fps());
fps_history[fps_history_idx] = m_lockstep.fps();
@@ -34,8 +34,6 @@ void PerformanceStatsGui::draw() {
ImPlot::PlotLine("FPS", frame_idxs.data(), fps_history.data(), is_plot_filled ? (int)fps_history.size() : (int)fps_history_idx - 1);
ImPlot::EndPlot();
}
ImGui::End();
}
}
@@ -1,10 +1,11 @@
#pragma once
#include "debug/DebugWindow.hpp"
#include "runtime/LockStep.hpp"
namespace tw::dbg::tools {
class PerformanceStatsGui {
class PerformanceStatsGui : public tw::dbg::DebugWindow {
private:
LockStep& m_lockstep;
@@ -15,10 +16,11 @@ private:
uint32_t frame_idx = 0;
bool is_plot_filled = false;
protected:
void draw_contents() override;
public:
PerformanceStatsGui(LockStep& lock_step);
void draw();
};
}
@@ -0,0 +1,122 @@
#include "PlayerReconciler.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/CharacterBody.hpp"
#include "world/Transform.hpp"
#include <spdlog/spdlog.h>
#include <glm/glm.hpp>
#include <glm/gtx/norm.hpp>
namespace tw::net {
PlayerReconciler::PlayerReconciler(JoltPhysicsWorld* physics)
: m_physics(physics), m_last_reconciled_ack(0), m_rollback_count(0),
m_last_correction_distance(0.0f), m_last_replayed_frames(0), m_last_ack_frame(0)
{
for (auto& record : m_records) {
record.frame = 0;
record.valid = false;
record.input = glm::vec3(0.0f);
record.predicted_position = glm::vec3(0.0f);
}
}
void PlayerReconciler::record_input(uint32_t frame, glm::vec3 input) {
size_t idx = frame % RING_SIZE;
m_records[idx].frame = frame;
m_records[idx].valid = true;
m_records[idx].input = input;
}
void PlayerReconciler::record_prediction(uint32_t frame, glm::vec3 position) {
size_t idx = frame % RING_SIZE;
if (m_records[idx].frame == frame && m_records[idx].valid) {
m_records[idx].predicted_position = position;
}
}
bool PlayerReconciler::reconcile(uint32_t ack_frame, glm::vec3 authoritative_position,
entt::entity player, entt::registry* registry,
uint32_t current_frame)
{
if (ack_frame == 0 || ack_frame <= m_last_reconciled_ack || ack_frame >= current_frame) {
return false;
}
m_last_reconciled_ack = ack_frame;
m_last_ack_frame = ack_frame;
Record& record = m_records[ack_frame % RING_SIZE];
const bool has_prediction = record.valid && record.frame == ack_frame;
// With a prediction to compare against, an answer that already matches costs
// nothing further. This is the case almost every frame.
if (has_prediction) {
float distance = glm::distance(record.predicted_position, authoritative_position);
m_last_correction_distance = distance;
if (distance < kPositionEpsilon) {
return false;
}
}
// Restoring the frame the answer describes keeps everything the simulation
// derived from it, so only the character has to be moved. Without a stored
// frame there is nothing to restore and the answer is taken as it stands.
const bool restored = has_prediction && m_physics->rollback(ack_frame);
place_character(player, registry, authoritative_position, !restored);
replay_from(ack_frame, player, registry, current_frame);
m_last_replayed_frames = current_frame - 1 - ack_frame;
m_rollback_count++;
spdlog::debug("Corrected at frame {}: distance {}, replayed {}, restored {}",
ack_frame, m_last_correction_distance, m_last_replayed_frames, restored);
return true;
}
void PlayerReconciler::place_character(entt::entity player, entt::registry* registry,
glm::vec3 position, bool clear_velocity) {
CharacterBody* body = registry->try_get<CharacterBody>(player);
if (!body) {
return;
}
body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z));
if (clear_velocity) {
body->m_character->SetLinearVelocity(JPH::Vec3::sZero());
body->m_desired_velocity = JPH::Vec3::sZero();
}
}
void PlayerReconciler::replay_from(uint32_t from_frame, entt::entity player,
entt::registry* registry, uint32_t current_frame) {
for (uint32_t f = from_frame + 1; f < current_frame; ++f) {
m_physics->step(f, tw::JoltPhysicsWorld::FIXED_DELTA_TIME, true);
Transform* transform = registry->try_get<Transform>(player);
if (!transform) {
continue;
}
// Frames the ring never saw still need an entry, or the answer to them
// arrives with nothing to compare against and forces another correction.
Record& replayed = m_records[f % RING_SIZE];
replayed.frame = f;
replayed.valid = true;
replayed.predicted_position = transform->position();
}
}
void PlayerReconciler::reset_at(uint32_t frame) {
m_last_reconciled_ack = frame;
for (auto& record : m_records) {
record.valid = false;
}
}
}
@@ -0,0 +1,72 @@
#pragma once
#include <cstdint>
#include <array>
#include <glm/glm.hpp>
#include <entt/entt.hpp>
namespace tw {
class JoltPhysicsWorld;
}
namespace tw::net {
class PlayerReconciler {
private:
struct Record {
uint32_t frame;
bool valid;
glm::vec3 input;
glm::vec3 predicted_position;
};
static constexpr size_t RING_SIZE = 64;
static constexpr float kPositionEpsilon = 0.05f;
tw::JoltPhysicsWorld* m_physics;
std::array<Record, RING_SIZE> m_records;
uint32_t m_last_reconciled_ack = 0;
uint64_t m_rollback_count = 0;
float m_last_correction_distance = 0.0f;
uint32_t m_last_replayed_frames = 0;
uint32_t m_last_ack_frame = 0;
public:
PlayerReconciler(tw::JoltPhysicsWorld* physics);
void record_input(uint32_t frame, glm::vec3 input);
void record_prediction(uint32_t frame, glm::vec3 position);
bool reconcile(uint32_t ack_frame, glm::vec3 authoritative_position,
entt::entity player, entt::registry* registry, uint32_t current_frame);
private:
/**
* Re-simulates `from_frame + 1` up to the newest frame, refreshing the stored
* prediction for each. The inputs come from the character itself, so this
* works even for frames this ring never recorded.
*/
void replay_from(uint32_t from_frame, entt::entity player,
entt::registry* registry, uint32_t current_frame);
/** Places the character at `position` without disturbing the stored frames. */
void place_character(entt::entity player, entt::registry* registry,
glm::vec3 position, bool clear_velocity);
public:
/**
* Drops every stored frame and treats `frame` as already answered. Used when
* the player is placed outright, where nothing recorded before the placement
* describes where it now is.
*/
void reset_at(uint32_t frame);
uint64_t rollback_count() const { return m_rollback_count; }
float last_correction_distance() const { return m_last_correction_distance; }
uint32_t last_replayed_frames() const { return m_last_replayed_frames; }
uint32_t last_ack_frame() const { return m_last_ack_frame; }
};
}
@@ -0,0 +1,84 @@
#include "ServerConnection.hpp"
#include <spdlog/spdlog.h>
namespace tw::net {
ServerConnection::ServerConnection(Address address) :
m_address(address),
m_status(ConnectionStatus::Idle),
m_started_at(Clock::now()) {
}
tl::expected<void, msg::MessageError> ServerConnection::start() {
// Create the endpoint
auto endpoint_r = msg::MessageEndpoint::create();
if(!endpoint_r) {
m_status = ConnectionStatus::Failed;
m_error = endpoint_r.error().message();
return tl::make_unexpected(endpoint_r.error());
}
m_endpoint = std::move(endpoint_r.value());
// Connect to the server
auto server_r = m_endpoint->connect(m_address.ip_string(), m_address.port());
if(!server_r) {
m_status = ConnectionStatus::Failed;
m_error = server_r.error().message();
return tl::make_unexpected(server_r.error());
}
m_server = server_r.value();
m_status = ConnectionStatus::Connecting;
m_started_at = Clock::now();
spdlog::info("Attempting to connect to server at {}", m_address.to_string());
return {};
}
void ServerConnection::update() {
if(!m_endpoint) {
return;
}
m_endpoint->update();
// Check if the connection has become established
if(m_status == ConnectionStatus::Connecting && m_server) {
if(m_server->is_established()) {
m_status = ConnectionStatus::Connected;
spdlog::info("Connected to server at {}", m_address.to_string());
} else {
// Check for timeout
auto elapsed = Clock::now() - m_started_at;
if(elapsed >= CONNECT_TIMEOUT) {
m_status = ConnectionStatus::Failed;
m_error = "No response from " + m_address.to_string();
spdlog::error("Connection timeout to {}", m_address.to_string());
}
}
}
}
ConnectionStatus ServerConnection::status() const {
return m_status;
}
const std::string& ServerConnection::error() const {
return m_error;
}
const Address& ServerConnection::address() const {
return m_address;
}
msg::MessageEndpoint* ServerConnection::endpoint() const {
return m_endpoint.get();
}
msg::MessageConnection* ServerConnection::server() const {
return m_server;
}
}
@@ -0,0 +1,64 @@
#pragma once
#include <memory>
#include <string>
#include <chrono>
#include "Address.hpp"
#include "message_protocol/MessageEndpoint.hpp"
#include "message_protocol/MessageConnection.hpp"
#include "message_protocol/MessageError.hpp"
#include <tl/expected.hpp>
namespace tw::net {
/**
* Status of a server connection attempt or established connection.
*/
enum class ConnectionStatus { Idle, Connecting, Connected, Failed };
/**
* Encapsulates a connection to a game server.
*
* Owns the endpoint and connection, managing the state of the connection
* attempt and providing non-blocking access to send/receive.
*/
class ServerConnection {
using Clock = std::chrono::steady_clock;
std::unique_ptr<msg::MessageEndpoint> m_endpoint;
msg::MessageConnection* m_server = nullptr;
Address m_address;
ConnectionStatus m_status = ConnectionStatus::Idle;
std::string m_error;
Clock::time_point m_started_at;
static constexpr std::chrono::seconds CONNECT_TIMEOUT{5};
public:
/**
* Constructs a connection object for the given address, without starting I/O.
*/
explicit ServerConnection(Address address);
/**
* Starts the connection process by creating an endpoint and connecting to
* the server. Returns an error if the endpoint cannot be created.
*/
tl::expected<void, msg::MessageError> start();
/**
* Updates the connection state: pumps the endpoint, and checks for timeout
* or successful connection. Must be called regularly.
*/
void update();
ConnectionStatus status() const;
const std::string& error() const;
const Address& address() const;
msg::MessageEndpoint* endpoint() const;
msg::MessageConnection* server() const;
};
}
+55 -87
View File
@@ -1,13 +1,11 @@
#include "runtime.hpp"
#include "app/ClientArgs.hpp"
#include "Address.hpp"
#include "SDLWindow.h"
#include "debug/tools/NetworkStatsGui.hpp"
#include "debug/tools/PacketBacklogGui.hpp"
#include "debug/tools/PerformanceStatsGui.hpp"
#include "entt/entity/fwd.hpp"
#include "io/InputState.hpp"
#include "debug/tools/EntityManagerGui.hpp"
#include "debug/tools/PerformanceStatsGui.hpp"
#include "draw/MeshData.hpp"
#include "imgui.h"
@@ -18,6 +16,8 @@
#include "implot_internal.h"
#include "world/Transform.hpp"
#include "spdlog/spdlog.h"
#include <SDL_events.h>
#include <glm/glm.hpp>
#include <tracy/Tracy.hpp>
@@ -32,17 +32,23 @@ std::unique_ptr<lft::win::Window> create_window(const std::string& name, VkExten
});
}
int get_port_from_args(int argc, char** argv) {
try {
if(argc > 1) {
return atoi(argv[1]);
} else {
return 8080;
}
} catch(std::exception& e) {
std::println("Could not parse port from arguments, using default.");
return 8080;
/**
* The address to connect to without asking, if one was given on the command
* line. A malformed one is reported and dropped, leaving the user in the lobby.
*/
static std::optional<net::Address> auto_connect_address(int argc, char** argv) {
auto argument = app::server_arg(argc, argv);
if(!argument) {
return {};
}
auto parsed = app::parse_address(*argument);
if(!parsed) {
spdlog::error("Failed to parse server address: {}", parsed.error());
return {};
}
return *parsed;
}
Runtime::Runtime(int argc, char** argv) :
@@ -52,52 +58,44 @@ Runtime::Runtime(int argc, char** argv) :
m_physics_world(&m_world),
m_world_renderer("towards", m_window.get(), &m_world, &m_files),
m_input_manager(m_window.get()),
m_world_controller(&m_input_manager, &m_world, &m_physics_world, &m_world_renderer, { "127.0.0.1", get_port_from_args(argc, argv) }),
m_lockstep(60)
m_network_metrics(),
m_lockstep(60),
m_debug_ui(),
m_perf_stats(m_lockstep),
m_state(std::in_place_type<app::LobbyState>, auto_connect_address(argc, argv))
{
m_debug_ui.windows().add(&m_perf_stats);
}
// bool Runtime::world_state_packet_handler(uint32_t* p_frame_idx, WorldSnapshotMessage* mesg) {
// uint32_t frame_idx = *p_frame_idx;
Runtime::~Runtime() {
m_debug_ui.windows().remove(&m_perf_stats);
}
// if(mesg->frame_idx < frame_idx) {
// return false;
// }
app::GameContext Runtime::context() {
return app::GameContext{
&m_world,
&m_physics_world,
&m_world_renderer,
&m_input_manager,
&m_network_metrics,
&m_debug_ui.windows(),
};
}
void Runtime::update_state(double delta_time) {
if(auto* lobby = std::get_if<app::LobbyState>(&m_state)) {
lobby->update(delta_time);
// for(int i = 0; i < mesg->player_states.size(); i++) {
// if(!m_players.contains(mesg->player_states[i].id)) {
// auto mesh = m_world_renderer.add_mesh(drw::MeshData::cube(glm::vec3(1.0f)));
// const auto entity = m_world.registry().create();
// m_players.insert({mesg->player_states[i].id, entity});
if(auto result = lobby->take_result()) {
m_state.emplace<app::GameState>(context(), std::move(result->connection));
}
return;
}
// m_world.registry().emplace<Transform>(entity, Transform(mesg->player_states[i].position));
// m_world.registry().emplace<PlayerInfoComponent>(entity,
// PlayerInfoComponent(
// mesg->player_states[i].id,
// mesg->player_states[i].name));
// m_world.registry().emplace<drw::Mesh>(entity, mesh);
// } else {
// auto entity = (entt::entity)mesg->player_states[i].id;
// auto entity_ts = m_world.registry()
// .try_get<Transform>(entity);
// if(entity_ts) {
// entity_ts->transform = glm::translate(glm::mat4(1.0f), mesg->player_states[i].position);
// }
// }
// }
// return true;
// }
std::get<app::GameState>(m_state).update(delta_time);
}
void Runtime::run() {
dbg::tools::EntityManagerGui entity_manager(&m_world);
dbg::tools::PerformanceStatsGui perf_stats(m_lockstep);
dbg::tools::PacketBacklogGui packet_backlog;
dbg::tools::NetworkStatsGui network_stats;
ImPlot::CreateContext();
m_is_running = true;
@@ -110,48 +108,18 @@ void Runtime::run() {
ImGui_ImplSDL2_NewFrame();
ImGui::NewFrame();
// ImGui::DockSpaceOverViewport();
entity_manager.draw();
ImGui::Begin("History");
if(ImGui::BeginTable("historyTable", 2)) {
// for(auto key : m_world_controller.position_history().keys()) {
// ImGui::TableNextRow();
//
// // auto v = *m_world_controller.player_history().get(key).value();
// // auto input = std::format("{} {} {}", v.x, v.y, v.z);
// auto p = *m_world_controller.position_history().get(key).value();
// auto position = std::format("{} {} {}", p.x, p.y, p.z);
//
// ImGui::TableNextColumn();
// ImGui::Text("%u", key);
// // ImGui::TableNextColumn();
// // ImGui::Text(input.c_str());
// ImGui::TableNextColumn();
// ImGui::Text(position.c_str());
// }
ImGui::EndTable();
}
ImGui::End();
bool change_imgui = false;
m_input_manager.update();
if(m_input_manager.is_quit()) {
m_is_running = false;
}
m_world_controller.update(m_lockstep.delta_time());
perf_stats.draw();
packet_backlog.draw();
network_stats.draw();
tw::dbg::ComponentGui<tw::io::InputManager>().draw(&m_input_manager);
// Anything that docks needs the dock space to already be there, so the
// host goes up before the state draws.
m_debug_ui.draw_dockspace();
m_world.step(m_lockstep.delta_time());
update_state(m_lockstep.delta_time());
m_debug_ui.draw_windows();
m_world_renderer.render();
FrameMark;
+22 -10
View File
@@ -1,9 +1,16 @@
#pragma once
#include <variant>
#include "app/GameContext.hpp"
#include "app/GameState.hpp"
#include "app/LobbyState.hpp"
#include "debug/DebugUI.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "debug/tools/PerformanceStatsGui.hpp"
#include "draw/WorldRenderer.hpp"
#include "io/InputState.hpp"
#include "runtime/LockStep.hpp"
#include "world/ClientWorldController.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/World.hpp"
@@ -17,33 +24,38 @@ namespace tw {
class Runtime {
private:
io::Files m_files;
std::unique_ptr<lft::win::Window> m_window;
tw::World m_world;
JoltPhysicsWorld m_physics_world;
tw::drw::WorldRenderer m_world_renderer;
tw::io::InputManager m_input_manager;
tw::ClientWorldController m_world_controller;
tw::dbg::NetworkMetrics m_network_metrics;
tw::LockStep m_lockstep;
bool m_is_running;
/**
* Declared before the state so panels the state owns can still be taken out
* of the menu while the state is being torn down.
*/
tw::dbg::DebugUI m_debug_ui;
tw::dbg::tools::PerformanceStatsGui m_perf_stats;
std::variant<tw::app::LobbyState, tw::app::GameState> m_state;
bool m_is_running;
std::unordered_map<uint32_t, entt::entity> m_players;
void send_player_positions();
app::GameContext context();
void update_state(double delta_time);
public:
const bool is_running() const {
return m_is_running;
}
Runtime(int argc, char** argv);
~Runtime();
void run();
};
+1 -2
View File
@@ -14,8 +14,7 @@ struct CameraData {
CameraData(glm::mat4 projection, Transform view) :
projection(projection),
view(view)
{
}
{ }
};
class Camera {
@@ -4,7 +4,7 @@
#include <chrono>
#include <spdlog/spdlog.h>
#include "Address.hpp"
#include "network/ServerConnection.hpp"
#include "Entity.pb.h"
#include "Login.pb.h"
@@ -12,9 +12,7 @@
#include "PlayerMove.pb.h"
#include "entt/entity/entity.hpp"
#include "messenger/MessageHandler.hpp"
#include "messenger/Messenger.hpp"
#include "TcpStream.hpp"
#include "entt/entity/fwd.hpp"
#include "messages/PlayerMoveMessage.hpp"
#include "metrics/HistoryBuffer.hpp"
#include "world/CharacterBody.hpp"
@@ -22,6 +20,7 @@
#include "world/JoltPhysicsWorld.hpp"
#include "world/WorldEntity.hpp"
#include "tw/serial/WorldStateWriter.hpp"
#include "network/EntityInterpolation.hpp"
namespace tw {
@@ -83,17 +82,6 @@ ClientWorldController::create_entity(const std::string& name, glm::vec3 position
return entity;
}
net::TcpStream create_stream(tw::net::Address address) {
auto stream = net::TcpStream::connect(address);
if(!stream.has_value()) {
spdlog::error("Failed to connect to server");
throw std::runtime_error("Failed to connect to server");
}
stream.value().set_non_blocking();
return std::move(stream.value());
}
std::optional<entt::entity> ClientWorldController::map_from_server_entity(int id) {
if(m_entity_mapping.contains(id)) {
@@ -134,33 +122,45 @@ ClientWorldController::ClientWorldController(
World* world,
JoltPhysicsWorld* physics_world,
drw::WorldRenderer* world_renderer,
tw::net::Address address
net::ServerConnection* connection,
dbg::NetworkMetrics* network_metrics
) :
m_input_manager(inputs),
m_world(world),
m_physics_world(physics_world),
m_world_renderer(world_renderer),
m_player_entity(create_player_entity(world, physics_world, world_renderer)),
// m_player_entity(/* create_player_entity(world, physics_world, world_renderer) */),
m_player_controller(&world_renderer->camera(), glm::vec3()),
m_messenger{address},
m_connection(connection),
m_messages(connection->endpoint()),
m_network_metrics(network_metrics),
m_tick_step(20),
m_is_connected(false),
m_input_send_times(INPUT_SEND_TIME_COUNT),
m_position_history_exporter("/home/martin/output.csv"),
m_entity_interpolator(&m_world->registry(), m_player_entity, 300)
m_entity_interpolator(&m_world->registry(), (entt::entity)0, 300),
m_reconciler(physics_world)
{
m_messenger->set_handler<mmo::LoginResponse>(
[&](mmo::LoginResponse* mesg) {
if(!m_is_connected) {
spdlog::info("Joined the game!");
}
m_messages.set_handler<mmo::LoginResponse>(
[this](msg::PeerId, const mmo::LoginResponse& mesg) {
spdlog::info("Logged in!");
});
m_messenger->set_raw_handler(Message<mmo::WorldStateMessage>::value,
[&](std::span<std::byte> data) -> tl::expected<void, net::NetworkError> {
m_messages.set_handler<mmo::SetControlledEntity>(
[this](msg::PeerId, const mmo::SetControlledEntity& mesg) {
spdlog::info("Setting controlled entity from server id {}", mesg.entity_id());
m_controlled_server_id = mesg.entity_id();
try_bind_player_entity();
});
m_connection->endpoint()->set_handler(Message<mmo::WorldStateMessage>::value,
[this](msg::PeerId, std::span<const std::byte> data) {
serial::WorldStateReader reader(data);
auto header = reader.read_header();
measure_response_time(header.frame_idx);
while(reader.has_spawn()) {
auto spawn = reader.read_spawn();
auto entity = create_entity("test", glm::vec3());
@@ -168,7 +168,11 @@ ClientWorldController::ClientWorldController(
map_server_entity(spawn, entity);
m_entity_interpolator.register_entity(entity);
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == spawn) {
try_bind_player_entity();
} else {
m_entity_interpolator.register_entity(entity);
}
}
while(reader.has_entity()) {
@@ -182,7 +186,57 @@ ClientWorldController::ClientWorldController(
}
glm::vec3 p = {entity_r.position.x, entity_r.position.y, entity_r.position.z};
m_entity_interpolator.add_position_for_entity(entity.value(), p);
if(m_player_entity.has_value() && entity.value() == m_player_entity.value()) {
// The entity was created before its position was known, so the
// body sits at the origin until the server places it. There is
// no predicted history to reconcile against yet.
if(!m_player_position_initialized) {
m_player_position_initialized = true;
snap_player_to(entity.value(), p);
continue;
}
glm::vec3 position_before = glm::vec3(0.0f);
Transform* player_transform = m_world->registry().try_get<Transform>(entity.value());
if(player_transform) {
position_before = player_transform->position();
}
bool reconcile_happened = m_reconciler.reconcile(header.frame_idx, p, entity.value(), &m_world->registry(), m_frame_idx);
if(reconcile_happened) {
// Where the replay actually ended up, which is ahead of the
// acked position by the frames that were re-simulated.
glm::vec3 position_after = player_transform
? player_transform->position()
: p;
glm::vec3 correction_delta = position_before - position_after;
float correction_magnitude = glm::length(correction_delta);
if(correction_magnitude > 5.0f) {
correction_delta = glm::normalize(correction_delta) * 5.0f;
}
m_visual_error += correction_delta;
m_render_curr_position = position_after;
m_render_prev_position = position_after;
m_tick_accumulator = 0.0;
m_network_metrics->record_rollback();
m_network_metrics->record_correction_distance(m_reconciler.last_correction_distance());
m_network_metrics->record_replayed_frames(m_reconciler.last_replayed_frames());
}
if(header.frame_idx != 0) {
uint32_t ack_lag = 0;
if(m_frame_idx >= header.frame_idx) {
ack_lag = m_frame_idx - header.frame_idx;
}
m_network_metrics->record_ack_lag(ack_lag);
}
} else {
m_entity_interpolator.add_position_for_entity(entity.value(), p);
}
EntityPositionHistory* history = m_world->registry().try_get<EntityPositionHistory>(entity.value());
@@ -193,33 +247,111 @@ ClientWorldController::ClientWorldController(
}
// apply_entity_positions();
return {};
});
m_messenger->set_handler<mmo::EntitySpawnMessage>(
[&](mmo::EntitySpawnMessage* mesg) {
auto entity = create_entity(mesg->name(), glm::vec3());
m_messages.set_handler<mmo::EntitySpawnMessage>(
[this](msg::PeerId, const mmo::EntitySpawnMessage& mesg) {
auto entity = create_entity(mesg.name(), glm::vec3());
map_server_entity(mesg->entity_id(), entity);
map_server_entity(mesg.entity_id(), entity);
m_entity_interpolator.register_entity(entity);
if(m_controlled_server_id.has_value() && m_controlled_server_id.value() == mesg.entity_id()) {
try_bind_player_entity();
} else {
m_entity_interpolator.register_entity(entity);
}
});
}
ClientWorldController::~ClientWorldController() {
}
void ClientWorldController::try_bind_player_entity() {
if(!m_controlled_server_id.has_value()) {
return;
}
auto local_entity = map_from_server_entity(m_controlled_server_id.value());
if(!local_entity.has_value()) {
return;
}
if(m_player_entity.has_value() && m_player_entity.value() == local_entity.value()) {
return;
}
entt::entity entity = local_entity.value();
spdlog::info("Binding player entity");
m_player_entity = entity;
Transform* transform = m_world->registry().try_get<Transform>(entity);
glm::vec3 position = transform ? transform->position() : glm::vec3(0.0f);
m_world->registry().emplace<CharacterController>(entity, 20.0f);
m_world->registry().emplace<CharacterBody>(entity, m_physics_world->create_character(
new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)),
position
));
if(m_world->registry().all_of<net::EntityPositionInterpolation>(entity)) {
m_world->registry().remove<net::EntityPositionInterpolation>(entity);
}
}
void ClientWorldController::snap_player_to(entt::entity entity, glm::vec3 position) {
CharacterBody* body = m_world->registry().try_get<CharacterBody>(entity);
if(body) {
body->m_character->SetPosition(JPH::RVec3(position.x, position.y, position.z));
body->m_character->SetLinearVelocity(JPH::Vec3::sZero());
body->m_desired_velocity = JPH::Vec3::sZero();
}
Transform* transform = m_world->registry().try_get<Transform>(entity);
if(transform) {
transform->set_position(position);
}
m_render_prev_position = position;
m_render_curr_position = position;
m_tick_accumulator = 0.0;
m_visual_error = glm::vec3(0.0f);
// Frames simulated before the player was placed describe a position it never
// actually had, so answers to them must not be reconciled against.
m_reconciler.reset_at(m_frame_idx);
}
void ClientWorldController::export_entity_history() {
}
void ClientWorldController::measure_response_time(uint32_t frame_idx) {
// Snapshots carry frame zero until the server has an input to answer, and
// repeat the same frame whenever no newer one arrived in between.
if(frame_idx == 0 || frame_idx <= m_last_measured_frame) {
return;
}
// Anything the send times no longer cover, including a frame we never sent,
// which underflows into a large distance.
if(m_frame_idx - frame_idx >= INPUT_SEND_TIME_COUNT) {
return;
}
m_last_measured_frame = frame_idx;
auto sent_at = m_input_send_times[frame_idx % INPUT_SEND_TIME_COUNT];
m_network_metrics->record_response_time(Clock::now() - sent_at);
}
void ClientWorldController::update(double delta_time) {
m_player_controller.update(m_input_manager, delta_time);
ImGui::Begin("Player Controller");
if(m_player_entity.has_value()) {
ImGui::Text("Player entity ID: %d", (uint32_t)m_player_entity.value());
}
ImGui::Text("Player entity ID: %d", (uint32_t)m_player_entity);
ImGui::Text("Player count: %ld", m_entity_mapping.size());
for(auto mapping : m_entity_mapping) {
ImGui::Text("%d -> %d", (uint32_t)mapping.first, mapping.second);
@@ -228,34 +360,55 @@ void ClientWorldController::update(double delta_time) {
ImGui::End();
if(m_tick_step.update()) {
m_messenger->update();
auto network_start = Clock::now();
m_connection->update();
m_network_metrics->record_update_time(Clock::now() - network_start);
if(!m_is_connected && false) {
return;
} else {
m_network_metrics->sample({
.bytes_sent = m_connection->endpoint()->bytes_sent(),
.bytes_received = m_connection->endpoint()->bytes_received(),
.messages_sent = m_connection->endpoint()->messages_sent(),
.messages_received = m_connection->endpoint()->messages_received()
});
// CharacterController& character = m_world->registry().get<CharacterController>(m_player_entity);
// character.set_input(m_frame_idx, m_player_controller.input());
{
glm::vec3 input = m_player_controller.input();
if(m_player_entity.has_value()) {
CharacterController* controller = m_world->registry().try_get<CharacterController>(m_player_entity.value());
if(controller) {
controller->set_input(m_frame_idx, input);
m_reconciler.record_input(m_frame_idx, input);
}
}
m_physics_world->step(m_frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME, true);
if(m_player_entity.has_value()) {
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
if(player_transform) {
glm::vec3 true_position = player_transform->position();
m_reconciler.record_prediction(m_frame_idx, true_position);
m_render_prev_position = m_render_curr_position;
m_render_curr_position = true_position;
m_tick_accumulator = 0.0;
}
}
mmo::PlayerMoveMessage player_move_message = {};
player_move_message.set_frame_idx(m_frame_idx);
mmo::PlayerInput* player_input = new mmo::PlayerInput();
player_input->set_x(m_player_controller.input().x);
player_input->set_y(m_player_controller.input().y);
player_input->set_z(m_player_controller.input().z);
player_input->set_x(input.x);
player_input->set_y(input.y);
player_input->set_z(input.z);
player_move_message.set_allocated_input(player_input);
auto r = m_messenger->send(player_move_message);
auto r = m_messages.send(m_connection->server(), player_move_message, false);
// CharacterBody& ts = m_world->registry().get<CharacterBody>(m_player_entity);
// auto position = ts.m_character->GetPosition();
// character.position_history().set(m_frame_idx, glm::vec3(position[0], position[1], position[2]));
// EntityInterpolation& interpolation = m_world->registry().get<EntityInterpolation>(m_player_entity);
// interpolation.push(std::chrono::steady_clock::now(), glm::vec3(position[0], position[1], position[2]));
m_input_send_times[m_frame_idx % INPUT_SEND_TIME_COUNT] = Clock::now();
// m_player_controller.set_target(glm::vec3(position[0], position[1], position[2]));
//
export_entity_history();
m_frame_idx++;
@@ -268,9 +421,27 @@ void ClientWorldController::update(double delta_time) {
}
}
m_physics_world->step(m_frame_idx, delta_time);
m_entity_interpolator.update();
if(m_player_entity.has_value()) {
Transform* player_transform = m_world->registry().try_get<Transform>(m_player_entity.value());
if(player_transform) {
m_visual_error *= std::exp(-delta_time * kVisualErrorDecayRate);
if(glm::length(m_visual_error) < 0.001f) {
m_visual_error = glm::vec3(0.0f);
}
m_tick_accumulator += delta_time;
float alpha = glm::clamp(
static_cast<float>(m_tick_accumulator / JoltPhysicsWorld::FIXED_DELTA_TIME),
0.0f, 1.0f
);
glm::vec3 smoothed_position = glm::mix(m_render_prev_position, m_render_curr_position, alpha) + m_visual_error;
player_transform->set_position(smoothed_position);
m_player_controller.set_target(smoothed_position);
}
}
}
}
@@ -3,9 +3,8 @@
#include <entt/entt.hpp>
#include <glm/gtx/io.hpp>
#include "Address.hpp"
#include "TcpStream.hpp"
#include "messenger/MessageHandler.hpp"
#include "ProtobufMessages.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "entt/entity/fwd.hpp"
#include "io/InputState.hpp"
#include "metrics/HistoryBufferExporter.hpp"
@@ -15,7 +14,11 @@
#include "draw/WorldRenderer.hpp"
#include "world/ThirdPersonPlayerController.hpp"
#include "network/EntityPositionInterpolator.hpp"
#include "network/PlayerReconciler.hpp"
namespace tw::net {
class ServerConnection;
}
namespace tw {
@@ -35,10 +38,14 @@ class ClientWorldController {
drw::WorldRenderer* m_world_renderer;
JoltPhysicsWorld* m_physics_world;
entt::entity m_player_entity;
std::optional<entt::entity> m_player_entity;
std::optional<uint32_t> m_controlled_server_id;
ThirdPersonPlayerController m_player_controller;
std::optional<tw::net::MessageHandler> m_messenger;
net::ServerConnection* m_connection;
ProtobufMessages m_messages;
dbg::NetworkMetrics* m_network_metrics;
LockStep m_tick_step;
@@ -47,15 +54,62 @@ class ClientWorldController {
glm::vec3 m_input;
bool m_is_connected;
std::optional<drw::Mesh> m_mesh;
using Clock = std::chrono::steady_clock;
void try_bind_player_entity();
/**
* Places the player at an authoritative position outright, clearing the
* predicted state that led there. Used for the first position the server
* sends, which the local simulation has no history to reconcile against.
*/
void snap_player_to(entt::entity entity, glm::vec3 position);
/**
* Whether the server has placed the player at least once. Entities are
* created before their position arrives, so the body starts at the origin
* and has to be moved once the first position shows up.
*/
bool m_player_position_initialized = false;
/**
* When each input was sent, indexed by its frame. Holds the most recent
* INPUT_SEND_TIME_COUNT frames; an answer that takes longer than that goes
* unmeasured.
*/
static constexpr size_t INPUT_SEND_TIME_COUNT = 256;
std::vector<Clock::time_point> m_input_send_times;
uint32_t m_last_measured_frame = 0;
/**
* Records how long the answer to `frame_idx` took to arrive, ignoring
* frames that were already measured or are too old to still have a send
* time.
*/
void measure_response_time(uint32_t frame_idx);
HistoryBufferExporter<long, glm::vec3> m_position_history_exporter;
net::EntityPositionInterpolator m_entity_interpolator;
net::PlayerReconciler m_reconciler;
/**
* Visual smoothing for render-rate interpolation between 20 Hz ticks.
*/
glm::vec3 m_render_prev_position{0.0f};
glm::vec3 m_render_curr_position{0.0f};
double m_tick_accumulator = 0.0;
/**
* Visual error from reconciliation corrections, decays over time.
*/
glm::vec3 m_visual_error{0.0f};
static constexpr double kVisualErrorDecayRate = 12.0;
/**
* Mapping from the server entity_id to local entity_id
* Server might have the same entity under different name
@@ -65,8 +119,6 @@ class ClientWorldController {
entt::entity create_entity(const std::string& name, glm::vec3 position);
net::MessageHandler create_messenger();
std::optional<entt::entity> map_from_server_entity(int id);
void map_server_entity(int server_id, entt::entity local_id);
@@ -86,7 +138,8 @@ public:
World* world,
JoltPhysicsWorld* physics_world,
drw::WorldRenderer* world_renderer,
tw::net::Address address
net::ServerConnection* connection,
dbg::NetworkMetrics* network_metrics
);
~ClientWorldController();
+26
View File
@@ -0,0 +1,26 @@
project(tw_io)
file(GLOB HEADERS
include/*.hpp
include/io/*.hpp
include/bytebuffer/*.hpp
include/exception/*.hpp
)
add_library(${PROJECT_NAME} INTERFACE)
add_library(tw::io ALIAS ${PROJECT_NAME})
target_sources(${PROJECT_NAME}
INTERFACE FILE_SET HEADERS
BASE_DIRS include
FILES ${HEADERS})
target_include_directories(${PROJECT_NAME}
INTERFACE
${PROJECT_SOURCE_DIR}/include/
)
target_link_libraries(${PROJECT_NAME}
INTERFACE
spdlog::spdlog
tl::expected
)
+18
View File
@@ -0,0 +1,18 @@
# io
Transport-agnostic I/O primitives shared by the networking modules.
Header-only (`tw::io`), namespace `tw::net`.
| Path | Contents |
| --- | --- |
| `io/Read.hpp`, `io/Write.hpp` | `Read<T>` / `Write<T>` interfaces every stream implements (`read_into`, `write`, `flush`, plus `read_exact` helpers) |
| `io/BufferReader.hpp`, `io/BufferWriter.hpp` | Buffering decorators that wrap another `Read<T>` / `Write<T>` |
| `bytebuffer/ByteBuffer.hpp` | `RingByteBuffer` — circular buffer over a caller-owned `std::span` |
| `bytebuffer/ByteBufferReader.hpp`, `ByteBufferWriter.hpp` | Linear cursor read/write over a `std::span` |
| `bytebuffer/ByteBufferCodec.hpp`, `ByteBufferEncoder.hpp`, `ByteBufferDecoder.hpp` | Typed push/pop on top of `RingByteBuffer`, specialize `ByteBufferCodec<T>` for custom types |
| `NetworkError.hpp` | errno-backed error type returned by `Read` / `Write` |
This module owns no sockets and links no transport — it sits below `tw::network`
and `tw::quicr` so both can share buffers and stream interfaces without depending
on each other.
+33
View File
@@ -0,0 +1,33 @@
project(tw_message_protocol)
file(GLOB FILES
src/*.cpp
)
file(GLOB HEADERS
include/message_protocol/*.hpp
)
add_library(${PROJECT_NAME} OBJECT ${FILES})
add_library(tw::message_protocol ALIAS ${PROJECT_NAME})
target_sources(${PROJECT_NAME}
PUBLIC FILE_SET HEADERS
BASE_DIRS include
FILES ${HEADERS})
set_target_properties(${PROJECT_NAME} PROPERTIES POSITION_INDEPENDENT_CODE 1)
target_include_directories(${PROJECT_NAME}
PUBLIC
${PROJECT_SOURCE_DIR}/include/
)
target_link_libraries(${PROJECT_NAME}
PUBLIC
tw::io
tw::quicr
tl::expected
spdlog::spdlog
)
add_subdirectory(tests)
@@ -0,0 +1,127 @@
#pragma once
#include "message_protocol/MessageError.hpp"
#include "message_protocol/MessageHeader.hpp"
#include "message_protocol/MessageType.hpp"
#include "message_protocol/PeerId.hpp"
#include <tl/expected.hpp>
#include <chrono>
#include <cstddef>
#include <functional>
#include <span>
#include <unordered_map>
#include <vector>
namespace tw::net::quicr {
class QuicrConnection;
}
namespace tw::msg {
class MessageDispatcher;
/**
* Sends messages to one peer and routes the ones it sends back.
*
* Owned by the endpoint that created it and valid until the peer disconnects.
* Handlers for message addresses are registered on the endpoint and shared by
* every peer; a connection only holds the reply handlers for requests it made
* itself.
*/
class MessageConnection {
public:
using ReplyHandler = std::function<void(std::span<const std::byte>)>;
private:
struct PendingRequest {
ReplyHandler on_reply;
std::function<void()> on_timeout;
std::chrono::steady_clock::time_point expires_at;
};
net::quicr::QuicrConnection* m_connection;
MessageDispatcher* m_dispatcher;
PeerId m_peer_id;
std::vector<std::byte> m_send_buffer;
std::unordered_map<uint32_t, PendingRequest> m_pending;
uint32_t m_next_seq = 1;
uint64_t m_bytes_sent = 0;
uint64_t m_messages_sent = 0;
uint64_t m_messages_received = 0;
uint32_t next_seq();
tl::expected<void, MessageError> send_impl(MessageType type,
std::span<const std::byte> body,
uint32_t seq,
bool reliable);
public:
MessageConnection(PeerId peer_id,
net::quicr::QuicrConnection* connection,
MessageDispatcher* dispatcher);
MessageConnection(const MessageConnection&) = delete;
MessageConnection& operator=(const MessageConnection&) = delete;
PeerId peer_id() const {
return m_peer_id;
}
uint64_t bytes_sent() const {
return m_bytes_sent;
}
/** Messages handed over for sending since the connection was created. */
uint64_t messages_sent() const {
return m_messages_sent;
}
/** Messages routed from the peer since the connection was created. */
uint64_t messages_received() const {
return m_messages_received;
}
bool is_established() const;
tl::expected<void, MessageError> send(MessageType type,
std::span<const std::byte> body,
bool reliable = false);
/**
* Sends a message the caller has already written a header into, for
* callers that build the whole message in a buffer of their own.
*/
tl::expected<void, MessageError> send_framed(std::span<const std::byte> message,
bool reliable = false);
/**
* Sends `body` and calls `on_reply` with the reply carrying the same
* sequence number, or `on_timeout` if no reply arrives in time.
*/
tl::expected<void, MessageError> request(
MessageType type,
std::span<const std::byte> body,
ReplyHandler on_reply,
std::chrono::milliseconds timeout = std::chrono::seconds(5),
std::function<void()> on_timeout = nullptr,
bool reliable = true);
/**
* Reads everything the peer has sent, routing each message, and returns
* how many bytes were read. `scratch` is used to hold one message at a
* time and may be reused between peers.
*/
size_t receive(std::span<std::byte> scratch);
/** Routes one received message to its reply handler, or to the dispatcher. */
void on_message(std::span<const std::byte> message);
/** Fails every request whose reply did not arrive before `now`. */
void expire_requests(std::chrono::steady_clock::time_point now);
};
}
@@ -0,0 +1,47 @@
#pragma once
#include "message_protocol/MessageType.hpp"
#include "message_protocol/PeerId.hpp"
#include <functional>
#include <span>
#include <unordered_map>
namespace tw::msg {
/**
* Routes a message body to the handler registered for its address.
*
* Never inspects the body, so how it is encoded is entirely the caller's
* concern. At most one handler may be registered per address.
*/
class MessageDispatcher {
public:
using Handler = std::function<void(PeerId, std::span<const std::byte>)>;
private:
std::unordered_map<MessageType, Handler> m_handlers;
public:
/** Registers `handler` for `type`, replacing any handler already there. */
void set_handler(MessageType type, Handler handler) {
m_handlers[type] = std::move(handler);
}
bool has_handler(MessageType type) const {
return m_handlers.contains(type);
}
/** Invokes the handler for `type`. Returns false if there is none. */
bool dispatch(PeerId peer, MessageType type, std::span<const std::byte> body) {
auto handler = m_handlers.find(type);
if(handler == m_handlers.end()) {
return false;
}
handler->second(peer, body);
return true;
}
};
}
@@ -0,0 +1,110 @@
#pragma once
#include "message_protocol/MessageConnection.hpp"
#include "message_protocol/MessageDispatcher.hpp"
#include "message_protocol/MessageError.hpp"
#include "message_protocol/MessageType.hpp"
#include "message_protocol/PeerId.hpp"
#include <tl/expected.hpp>
#include <cstddef>
#include <functional>
#include <memory>
#include <span>
#include <string>
#include <unordered_map>
#include <vector>
namespace tw::net::quicr {
class QuicrEndpoint;
class QuicrConnectionListener;
}
namespace tw::msg {
/**
* Owns the connections to every peer and the handlers shared between them.
*
* An endpoint created with bind() accepts incoming peers; either kind may
* connect() outwards, so one endpoint can serve peers and reach out to others
* at the same time.
*
* update() must be called regularly. Nothing is received and no request ever
* times out between calls.
*/
class MessageEndpoint {
std::unique_ptr<net::quicr::QuicrEndpoint> m_endpoint;
std::unique_ptr<net::quicr::QuicrConnectionListener> m_listener;
MessageDispatcher m_dispatcher;
std::unordered_map<PeerId, std::unique_ptr<MessageConnection>> m_peers;
std::function<void(PeerId)> m_on_peer_connected;
PeerId m_next_peer_id = 1;
std::vector<std::byte> m_receive_buffer;
uint64_t m_bytes_received = 0;
explicit MessageEndpoint(std::unique_ptr<net::quicr::QuicrEndpoint> endpoint);
MessageConnection* add_peer(net::quicr::QuicrConnection* connection);
void accept_peers();
void receive();
public:
~MessageEndpoint();
MessageEndpoint(const MessageEndpoint&) = delete;
MessageEndpoint& operator=(const MessageEndpoint&) = delete;
/** Creates an endpoint that only connects outwards. */
static tl::expected<std::unique_ptr<MessageEndpoint>, MessageError> create();
/** Creates an endpoint that also accepts peers on `port`. */
static tl::expected<std::unique_ptr<MessageEndpoint>, MessageError> bind(int port);
tl::expected<MessageConnection*, MessageError> connect(const std::string& host, int port);
/** Registers `handler` for every peer. */
void set_handler(MessageType type, MessageDispatcher::Handler handler) {
m_dispatcher.set_handler(type, std::move(handler));
}
MessageDispatcher& dispatcher() {
return m_dispatcher;
}
/** Receives pending messages, accepts new peers and times out requests. */
void update();
MessageConnection* peer(PeerId id);
/**
* Calls `handler` for each peer that connects or is accepted, before any
* of that peer's messages are dispatched.
*/
void set_on_peer_connected(std::function<void(PeerId)> handler) {
m_on_peer_connected = std::move(handler);
}
std::vector<MessageConnection*> peers() const;
void broadcast(MessageType type, std::span<const std::byte> body, bool reliable = false);
/** Bytes received since the endpoint was created. */
uint64_t bytes_received() const {
return m_bytes_received;
}
/** Bytes handed to every peer for sending since the endpoint was created. */
uint64_t bytes_sent() const;
/** Messages handed to every peer for sending since the endpoint was created. */
uint64_t messages_sent() const;
/** Messages routed from every peer since the endpoint was created. */
uint64_t messages_received() const;
};
}
@@ -0,0 +1,37 @@
#pragma once
#include <string>
#include <utility>
namespace tw::msg {
enum class MessageErrorType {
NotConnected,
SendFailed,
BindFailed,
ConnectFailed,
};
struct MessageError {
MessageErrorType type;
std::string detail;
explicit MessageError(MessageErrorType type, std::string detail = {}) :
type(type),
detail(std::move(detail)) {
}
std::string message() const {
std::string text;
switch(type) {
case MessageErrorType::NotConnected: text = "Not connected to the peer"; break;
case MessageErrorType::SendFailed: text = "Failed to send the message"; break;
case MessageErrorType::BindFailed: text = "Failed to bind the endpoint"; break;
case MessageErrorType::ConnectFailed: text = "Failed to connect to the peer"; break;
}
return detail.empty() ? text : text + ": " + detail;
}
};
}
@@ -0,0 +1,45 @@
#pragma once
#include "message_protocol/MessageType.hpp"
#include <cstdint>
#include <cstring>
#include <optional>
#include <span>
namespace tw::msg {
/**
* Fixed-size prefix carried by every message.
*
* `seq` correlates a reply with the request that produced it. SEQ_NONE marks a
* message that expects no reply, which is the common case.
*/
struct MessageHeader {
static constexpr uint32_t SEQ_NONE = 0;
static constexpr size_t SIZE = sizeof(MessageType) + sizeof(uint32_t);
MessageType type = 0;
uint32_t seq = SEQ_NONE;
/** Writes the header at the start of `target`, which must hold SIZE bytes. */
void encode(std::span<std::byte> target) const {
std::memcpy(target.data(), &type, sizeof(type));
std::memcpy(target.data() + sizeof(type), &seq, sizeof(seq));
}
/** Reads a header from the start of `source`, or nothing if it is too short. */
static std::optional<MessageHeader> decode(std::span<const std::byte> source) {
if(source.size() < SIZE) {
return {};
}
MessageHeader header;
std::memcpy(&header.type, source.data(), sizeof(header.type));
std::memcpy(&header.seq, source.data() + sizeof(header.type), sizeof(header.seq));
return header;
}
};
}
@@ -0,0 +1,13 @@
#pragma once
#include <cstdint>
namespace tw::msg {
/**
* Address a message is delivered to. Concrete values are assigned by the
* application.
*/
using MessageType = uint32_t;
}
@@ -0,0 +1,13 @@
#pragma once
#include <cstdint>
namespace tw::msg {
/**
* Identifies a remote peer. Assigned when the peer connects or is accepted and
* stable until it disconnects.
*/
using PeerId = uint64_t;
}
@@ -0,0 +1,176 @@
#include "message_protocol/MessageConnection.hpp"
#include "message_protocol/MessageDispatcher.hpp"
#include "quicr/QuicrConnection.hpp"
#include <spdlog/spdlog.h>
#include <cstring>
namespace tw::msg {
namespace {
constexpr size_t INITIAL_SEND_BUFFER_SIZE = 64 * 1024;
}
MessageConnection::MessageConnection(PeerId peer_id,
net::quicr::QuicrConnection* connection,
MessageDispatcher* dispatcher) :
m_connection(connection),
m_dispatcher(dispatcher),
m_peer_id(peer_id),
m_send_buffer(INITIAL_SEND_BUFFER_SIZE) {
}
bool MessageConnection::is_established() const {
return m_connection->state() == net::quicr::QuicrConnectionState::Established;
}
uint32_t MessageConnection::next_seq() {
uint32_t seq = m_next_seq++;
if(m_next_seq == MessageHeader::SEQ_NONE) {
m_next_seq = 1;
}
return seq;
}
tl::expected<void, MessageError> MessageConnection::send_impl(MessageType type,
std::span<const std::byte> body,
uint32_t seq,
bool reliable) {
const size_t size = MessageHeader::SIZE + body.size();
if(m_send_buffer.size() < size) {
m_send_buffer.resize(size);
}
MessageHeader{ type, seq }.encode(m_send_buffer);
std::memcpy(m_send_buffer.data() + MessageHeader::SIZE, body.data(), body.size());
auto send_r = m_connection->send_message(std::span(m_send_buffer).subspan(0, size), reliable);
if(!send_r) {
return tl::make_unexpected(MessageError(MessageErrorType::SendFailed, send_r.error().message()));
}
m_bytes_sent += size;
m_messages_sent++;
return {};
}
tl::expected<void, MessageError> MessageConnection::send(MessageType type,
std::span<const std::byte> body,
bool reliable) {
return send_impl(type, body, MessageHeader::SEQ_NONE, reliable);
}
tl::expected<void, MessageError> MessageConnection::send_framed(std::span<const std::byte> message,
bool reliable) {
if(message.size() < MessageHeader::SIZE) {
return tl::make_unexpected(
MessageError(MessageErrorType::SendFailed, "the message is too short to hold a header"));
}
// send_message takes a writable span, so the bytes are staged in the send
// buffer rather than sent straight from the caller's buffer.
if(m_send_buffer.size() < message.size()) {
m_send_buffer.resize(message.size());
}
std::memcpy(m_send_buffer.data(), message.data(), message.size());
auto send_r = m_connection->send_message(std::span(m_send_buffer).subspan(0, message.size()), reliable);
if(!send_r) {
return tl::make_unexpected(MessageError(MessageErrorType::SendFailed, send_r.error().message()));
}
m_bytes_sent += message.size();
m_messages_sent++;
return {};
}
tl::expected<void, MessageError> MessageConnection::request(MessageType type,
std::span<const std::byte> body,
ReplyHandler on_reply,
std::chrono::milliseconds timeout,
std::function<void()> on_timeout,
bool reliable) {
const uint32_t seq = next_seq();
auto send_r = send_impl(type, body, seq, reliable);
if(!send_r) {
return send_r;
}
m_pending.emplace(seq,
PendingRequest{ std::move(on_reply),
std::move(on_timeout),
std::chrono::steady_clock::now() + timeout });
return {};
}
size_t MessageConnection::receive(std::span<std::byte> scratch) {
size_t total = 0;
while(true) {
auto read_r = m_connection->read_into(scratch);
if(!read_r) {
spdlog::error("Failed to read from peer {}: {}", m_peer_id, read_r.error().message());
break;
}
if(*read_r == 0) {
break;
}
total += *read_r;
m_messages_received++;
on_message(scratch.subspan(0, *read_r));
}
return total;
}
void MessageConnection::on_message(std::span<const std::byte> message) {
auto header = MessageHeader::decode(message);
if(!header) {
spdlog::warn("Dropped a message of {} bytes, too short to hold a header", message.size());
return;
}
auto body = message.subspan(MessageHeader::SIZE);
if(header->seq != MessageHeader::SEQ_NONE) {
auto pending = m_pending.find(header->seq);
if(pending != m_pending.end()) {
auto on_reply = std::move(pending->second.on_reply);
m_pending.erase(pending);
on_reply(body);
return;
}
}
if(!m_dispatcher->dispatch(m_peer_id, header->type, body)) {
spdlog::warn("No handler for message type {}", header->type);
}
}
void MessageConnection::expire_requests(std::chrono::steady_clock::time_point now) {
std::erase_if(m_pending, [&](auto& entry) {
if(entry.second.expires_at > now) {
return false;
}
spdlog::warn("Request {} timed out", entry.first);
if(entry.second.on_timeout) {
entry.second.on_timeout();
}
return true;
});
}
}
@@ -0,0 +1,162 @@
#include "message_protocol/MessageEndpoint.hpp"
#include "quicr/QuicrAddress.hpp"
#include "quicr/QuicrConnection.hpp"
#include "quicr/QuicrConnectionListener.hpp"
#include "quicr/QuicrEndpoint.hpp"
#include <spdlog/spdlog.h>
namespace tw::msg {
namespace {
constexpr size_t RECEIVE_BUFFER_SIZE = 64 * 1024;
}
MessageEndpoint::MessageEndpoint(std::unique_ptr<net::quicr::QuicrEndpoint> endpoint) :
m_endpoint(std::move(endpoint)),
m_receive_buffer(RECEIVE_BUFFER_SIZE) {
}
MessageEndpoint::~MessageEndpoint() = default;
tl::expected<std::unique_ptr<MessageEndpoint>, MessageError> MessageEndpoint::create() {
auto endpoint_r = net::quicr::QuicrEndpoint::create();
if(!endpoint_r) {
return tl::make_unexpected(
MessageError(MessageErrorType::BindFailed, endpoint_r.error().message()));
}
return std::unique_ptr<MessageEndpoint>(new MessageEndpoint(std::move(endpoint_r.value())));
}
tl::expected<std::unique_ptr<MessageEndpoint>, MessageError> MessageEndpoint::bind(int port) {
auto endpoint_r = create();
if(!endpoint_r) {
return endpoint_r;
}
auto& endpoint = endpoint_r.value();
auto bind_r = endpoint->m_endpoint->bind(port);
if(!bind_r) {
return tl::make_unexpected(MessageError(MessageErrorType::BindFailed, bind_r.error().message()));
}
auto listener_r = net::quicr::QuicrConnectionListener::listen(endpoint->m_endpoint.get());
if(!listener_r) {
return tl::make_unexpected(
MessageError(MessageErrorType::BindFailed, listener_r.error().message()));
}
endpoint->m_listener = std::move(listener_r.value());
return endpoint_r;
}
MessageConnection* MessageEndpoint::add_peer(net::quicr::QuicrConnection* connection) {
const PeerId id = m_next_peer_id++;
auto peer = std::make_unique<MessageConnection>(id, connection, &m_dispatcher);
auto* raw = peer.get();
m_peers.emplace(id, std::move(peer));
if(m_on_peer_connected) {
m_on_peer_connected(id);
}
return raw;
}
tl::expected<MessageConnection*, MessageError> MessageEndpoint::connect(const std::string& host, int port) {
auto connection_r = m_endpoint->connect(net::quicr::QuicrAddress(host, port));
if(!connection_r) {
return tl::make_unexpected(
MessageError(MessageErrorType::ConnectFailed, connection_r.error().message()));
}
return add_peer(connection_r.value());
}
void MessageEndpoint::accept_peers() {
if(!m_listener) {
return;
}
while(net::quicr::QuicrConnection* connection = m_listener->listen()) {
add_peer(connection);
}
}
void MessageEndpoint::receive() {
for(auto& [id, peer] : m_peers) {
m_bytes_received += peer->receive(m_receive_buffer);
}
}
void MessageEndpoint::update() {
m_endpoint->poll();
accept_peers();
receive();
const auto now = std::chrono::steady_clock::now();
for(auto& [id, peer] : m_peers) {
peer->expire_requests(now);
}
}
MessageConnection* MessageEndpoint::peer(PeerId id) {
auto peer = m_peers.find(id);
return peer != m_peers.end() ? peer->second.get() : nullptr;
}
std::vector<MessageConnection*> MessageEndpoint::peers() const {
std::vector<MessageConnection*> result;
result.reserve(m_peers.size());
for(const auto& [id, peer] : m_peers) {
result.push_back(peer.get());
}
return result;
}
void MessageEndpoint::broadcast(MessageType type, std::span<const std::byte> body, bool reliable) {
for(auto& [id, peer] : m_peers) {
auto send_r = peer->send(type, body, reliable);
if(!send_r) {
spdlog::error("Failed to send to peer {}: {}", id, send_r.error().message());
}
}
}
uint64_t MessageEndpoint::bytes_sent() const {
uint64_t total = 0;
for(const auto& [id, peer] : m_peers) {
total += peer->bytes_sent();
}
return total;
}
uint64_t MessageEndpoint::messages_sent() const {
uint64_t total = 0;
for(const auto& [id, peer] : m_peers) {
total += peer->messages_sent();
}
return total;
}
uint64_t MessageEndpoint::messages_received() const {
uint64_t total = 0;
for(const auto& [id, peer] : m_peers) {
total += peer->messages_received();
}
return total;
}
}
@@ -0,0 +1,24 @@
project(tw_message_protocol_tests)
file(GLOB FILES
./*.cpp
)
add_executable(${PROJECT_NAME} ${FILES})
# tw::quicr is listed explicitly because CMake does not propagate the object
# files of an OBJECT library through another OBJECT library.
target_link_libraries(${PROJECT_NAME}
PRIVATE
tw::message_protocol
tw::quicr
Catch2::Catch2WithMain
tl::expected
)
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(CTest)
include(Catch)
catch_discover_tests(${PROJECT_NAME})
@@ -0,0 +1,73 @@
#include "message_protocol/MessageDispatcher.hpp"
#include <catch2/catch_test_macros.hpp>
#include <array>
#include <string>
using namespace tw::msg;
namespace {
std::span<const std::byte> as_bytes(const std::array<std::byte, 2>& body) {
return { body.data(), body.size() };
}
}
TEST_CASE("Dispatch reaches the handler bound to the address", "[message_dispatcher]") {
MessageDispatcher dispatcher;
PeerId seen_peer = 0;
size_t seen_size = 0;
dispatcher.set_handler(7, [&](PeerId peer, std::span<const std::byte> body) {
seen_peer = peer;
seen_size = body.size();
});
std::array<std::byte, 2> body{};
REQUIRE(dispatcher.dispatch(99, 7, as_bytes(body)));
REQUIRE(seen_peer == 99);
REQUIRE(seen_size == 2);
}
TEST_CASE("Dispatch to an unbound address reports failure", "[message_dispatcher]") {
MessageDispatcher dispatcher;
std::array<std::byte, 2> body{};
REQUIRE_FALSE(dispatcher.dispatch(1, 7, as_bytes(body)));
}
TEST_CASE("Addresses are routed independently", "[message_dispatcher]") {
MessageDispatcher dispatcher;
std::string called;
dispatcher.set_handler(1, [&](PeerId, std::span<const std::byte>) { called = "first"; });
dispatcher.set_handler(2, [&](PeerId, std::span<const std::byte>) { called = "second"; });
std::array<std::byte, 2> body{};
dispatcher.dispatch(1, 2, as_bytes(body));
REQUIRE(called == "second");
dispatcher.dispatch(1, 1, as_bytes(body));
REQUIRE(called == "first");
}
TEST_CASE("Rebinding an address replaces the handler", "[message_dispatcher]") {
MessageDispatcher dispatcher;
std::string called;
dispatcher.set_handler(7, [&](PeerId, std::span<const std::byte>) { called = "first"; });
dispatcher.set_handler(7, [&](PeerId, std::span<const std::byte>) { called = "second"; });
std::array<std::byte, 2> body{};
dispatcher.dispatch(1, 7, as_bytes(body));
REQUIRE(called == "second");
}
@@ -0,0 +1,45 @@
#include "message_protocol/MessageHeader.hpp"
#include <catch2/catch_test_macros.hpp>
#include <array>
using namespace tw::msg;
TEST_CASE("Header survives a round trip", "[message_header]") {
std::array<std::byte, MessageHeader::SIZE> buffer{};
MessageHeader{ 42, 7 }.encode(buffer);
auto decoded = MessageHeader::decode(buffer);
REQUIRE(decoded.has_value());
REQUIRE(decoded->type == 42);
REQUIRE(decoded->seq == 7);
}
TEST_CASE("Header defaults to expecting no reply", "[message_header]") {
std::array<std::byte, MessageHeader::SIZE> buffer{};
MessageHeader{ 3 }.encode(buffer);
auto decoded = MessageHeader::decode(buffer);
REQUIRE(decoded.has_value());
REQUIRE(decoded->seq == MessageHeader::SEQ_NONE);
}
TEST_CASE("Decoding a message shorter than a header fails", "[message_header]") {
std::array<std::byte, MessageHeader::SIZE - 1> buffer{};
REQUIRE_FALSE(MessageHeader::decode(buffer).has_value());
}
TEST_CASE("Encoding only writes the header", "[message_header]") {
std::array<std::byte, MessageHeader::SIZE + 4> buffer{};
buffer[MessageHeader::SIZE] = std::byte{ 0xAB };
MessageHeader{ 1, 2 }.encode(buffer);
REQUIRE(buffer[MessageHeader::SIZE] == std::byte{ 0xAB });
}
-14
View File
@@ -1,14 +0,0 @@
project(tw_messaging)
add_library(${PROJECT_NAME} INTERFACE)
add_library(tw::messaging ALIAS ${PROJECT_NAME})
target_include_directories(${PROJECT_NAME} INTERFACE ./include/)
target_link_libraries(${PROJECT_NAME}
INTERFACE
tw::network
tw::protocol
tl::expected
protobuf::libprotobuf
)
@@ -1,158 +0,0 @@
#pragma once
#include "Address.hpp"
#include "protocol/quicr/QuicrEndpoint.hpp"
#include "protocol/quicr/QuicrConnection.hpp"
#include "protocol/quicr/QuicrError.hpp"
#include <tl/expected.hpp>
#include <spdlog/spdlog.h>
#include <chrono>
#include <cstring>
#include <functional>
#include <span>
#include <unordered_map>
#include <vector>
namespace tw {
// Bidirectional typed messaging layer. Owns its QuicrEndpoint and connection.
// Wire format: [uint32_t type][uint32_t seq][payload bytes]
// seq == 0 means fire-and-forget; non-zero seq correlates a reply to a request().
// Protocol-agnostic: callers are responsible for serialising/deserialising payloads.
// update() polls the endpoint and dispatches inbound data automatically.
class MessageSession {
static constexpr size_t MAX_TYPES = 32;
static constexpr uint32_t SEQ_NONE = 0;
struct PendingRequest {
std::function<void(std::span<const std::byte>)> handler;
std::chrono::steady_clock::time_point expires_at;
std::function<void()> on_timeout;
};
std::unique_ptr<net::quicr::QuicrEndpoint> m_endpoint;
net::quicr::QuicrConnection* m_conn;
std::vector<std::byte> m_recv_buf{64 * 1024};
std::vector<std::function<void(std::span<const std::byte>)>> m_handlers{MAX_TYPES};
std::unordered_map<uint32_t, PendingRequest> m_pending{};
uint32_t m_next_seq = 1;
public:
// Creates a QuicrEndpoint, connects to the given address, and owns both.
// Throws on failure (via tl::expected::value()).
explicit MessageSession(net::Address address)
: m_endpoint(std::make_unique<net::quicr::QuicrEndpoint>(net::quicr::QuicrEndpoint::create().value()))
, m_conn(m_endpoint->connect(address).value())
{}
MessageSession(const MessageSession&) = delete;
MessageSession& operator=(const MessageSession&) = delete;
MessageSession(MessageSession&&) = default;
MessageSession& operator=(MessageSession&&) = default;
// Register a permanent handler for the given type ID.
void set_handler(uint32_t type, std::function<void(std::span<const std::byte>)> fn) {
if (type >= m_handlers.size()) {
spdlog::warn("MessageSession: type {} exceeds MAX_TYPES", type);
return;
}
m_handlers[type] = std::move(fn);
}
// Send a request with a one-shot response handler matched by sequence number.
// Call update() each game tick to evict timed-out requests.
tl::expected<void, net::quicr::QuicrError> request(
uint32_t type,
std::span<const std::byte> payload,
std::function<void(std::span<const std::byte>)> on_response,
std::chrono::milliseconds timeout = std::chrono::seconds(5),
std::function<void()> on_timeout = nullptr,
bool reliable = true
) {
const uint32_t seq = m_next_seq++;
if (m_next_seq == SEQ_NONE) m_next_seq = 1;
m_pending.emplace(seq, PendingRequest{
std::move(on_response),
std::chrono::steady_clock::now() + timeout,
std::move(on_timeout)
});
return send_impl(type, payload, seq, reliable);
}
// Expire timed-out pending requests, poll the endpoint, and dispatch any
// inbound datagrams. Call once per game tick.
void update() {
const auto now = std::chrono::steady_clock::now();
std::erase_if(m_pending, [&](auto& kv) {
if (kv.second.expires_at > now) return false;
spdlog::warn("MessageSession: request seq={} timed out", kv.first);
if (kv.second.on_timeout) kv.second.on_timeout();
return true;
});
m_endpoint->poll();
while (true) {
auto r = m_conn->read_into(m_recv_buf);
if (!r || *r == 0) break;
dispatch(std::span(m_recv_buf.data(), *r));
}
}
// Decode one framed datagram: [uint32_t type][uint32_t seq][payload].
// Non-zero seq routes to a pending one-shot handler; seq==0 routes by type.
void dispatch(std::span<const std::byte> data) {
constexpr size_t HEADER = sizeof(uint32_t) * 2;
if (data.size() < HEADER) {
spdlog::warn("MessageSession: dropped short datagram ({} bytes)", data.size());
return;
}
uint32_t type{}, seq{};
std::memcpy(&type, data.data(), sizeof(type));
std::memcpy(&seq, data.data() + sizeof(uint32_t), sizeof(seq));
const auto payload = data.subspan(HEADER);
if (seq != SEQ_NONE) {
if (auto it = m_pending.find(seq); it != m_pending.end()) {
auto handler = std::move(it->second.handler);
m_pending.erase(it);
handler(payload);
return;
}
}
if (type >= m_handlers.size() || !m_handlers[type]) {
spdlog::warn("MessageSession: no handler for type {}", type);
return;
}
m_handlers[type](payload);
}
// Send a fire-and-forget message.
tl::expected<void, net::quicr::QuicrError> send(uint32_t type,
std::span<const std::byte> payload,
bool reliable = false) {
return send_impl(type, payload, SEQ_NONE, reliable);
}
bool is_established() const {
return m_conn->state() == net::quicr::QuicrConnectionState::Established;
}
private:
tl::expected<void, net::quicr::QuicrError> send_impl(uint32_t type,
std::span<const std::byte> payload,
uint32_t seq,
bool reliable) {
std::vector<std::byte> buf(sizeof(type) + sizeof(seq) + payload.size());
std::memcpy(buf.data(), &type, sizeof(type));
std::memcpy(buf.data() + sizeof(type), &seq, sizeof(seq));
std::memcpy(buf.data() + sizeof(type) + sizeof(seq), payload.data(), payload.size());
return m_conn->send_message(std::span(buf), reliable);
}
};
} // namespace tw
+19
View File
@@ -0,0 +1,19 @@
project(tw_metrics)
add_subdirectory(tests)
file(GLOB HEADERS
include/metrics/*.hpp
)
add_library(${PROJECT_NAME} INTERFACE)
add_library(tw::metrics ALIAS ${PROJECT_NAME})
target_sources(${PROJECT_NAME}
INTERFACE FILE_SET HEADERS
BASE_DIRS include
FILES ${HEADERS})
target_include_directories(${PROJECT_NAME}
INTERFACE
${PROJECT_SOURCE_DIR}/include/
)
@@ -0,0 +1,86 @@
#pragma once
#include <algorithm>
#include <cstdint>
namespace tw::metrics {
/**
* Aggregate of every value that fell into one bucket.
*
* Answers sum, average, minimum and maximum without keeping the individual
* values. A sample nothing was added to reports zero for all of them, so gaps
* read as zero rather than as an unset extreme.
*/
struct MetricSample {
uint32_t count = 0;
double sum = 0.0;
double min = 0.0;
double max = 0.0;
void add(double value) {
if(count == 0) {
min = value;
max = value;
} else {
min = std::min(min, value);
max = std::max(max, value);
}
sum += value;
count++;
}
/** Folds `other` in, as if its values had been added to this sample. */
void merge(const MetricSample& other) {
if(other.count == 0) {
return;
}
if(count == 0) {
*this = other;
return;
}
min = std::min(min, other.min);
max = std::max(max, other.max);
sum += other.sum;
count += other.count;
}
double avg() const {
return count == 0 ? 0.0 : sum / count;
}
bool is_empty() const {
return count == 0;
}
void reset() {
*this = {};
}
};
/** Statistic to read out of a sample. */
enum class MetricField {
Avg,
Min,
Max,
Sum,
Count
};
inline double value_of(const MetricSample& sample, MetricField field) {
switch(field) {
case MetricField::Avg: return sample.avg();
case MetricField::Min: return sample.min;
case MetricField::Max: return sample.max;
case MetricField::Sum: return sample.sum;
case MetricField::Count: return (double)sample.count;
}
return 0.0;
}
}
@@ -0,0 +1,193 @@
#pragma once
#include "metrics/MetricSample.hpp"
#include <chrono>
#include <cstddef>
#include <stdexcept>
#include <vector>
namespace tw::metrics {
/**
* Ring of samples, one bucket per `Interval` of elapsed time.
*
* Values pushed during the same interval fold into one bucket, and intervals
* that pass without a value become empty buckets, so the distance between two
* buckets always matches the time between them. Once `capacity` buckets are
* held the oldest one is dropped.
*/
template<typename Interval, typename Clock = std::chrono::steady_clock>
class MetricSeries {
public:
using TimePoint = typename Clock::time_point;
private:
std::vector<MetricSample> m_buckets;
/** Index of the newest bucket, in `Interval` units since the clock epoch. */
int64_t m_newest = 0;
/** Buckets holding data, counted back from the newest. */
size_t m_count = 0;
static int64_t bucket_of(TimePoint time) {
return (int64_t)std::chrono::floor<Interval>(time).time_since_epoch().count();
}
size_t slot_of(int64_t index) const {
int64_t size = (int64_t)m_buckets.size();
int64_t slot = index % size;
return (size_t)(slot < 0 ? slot + size : slot);
}
MetricSample& bucket_at(int64_t index) {
return m_buckets[slot_of(index)];
}
const MetricSample& bucket_at(int64_t index) const {
return m_buckets[slot_of(index)];
}
/**
* Moves the newest bucket up to `index`, emptying every bucket the gap
* covers. A gap wider than the ring empties all of it.
*/
void advance_to(int64_t index) {
int64_t steps = index - m_newest;
int64_t capacity = (int64_t)m_buckets.size();
int64_t to_clear = std::min(steps, capacity);
for(int64_t i = 0; i < to_clear; i++) {
bucket_at(index - i).reset();
}
m_newest = index;
m_count = steps >= capacity
? m_buckets.size()
: std::min(m_count + (size_t)steps, m_buckets.size());
}
public:
explicit MetricSeries(size_t capacity) :
m_buckets(capacity)
{
if(capacity == 0) {
throw std::invalid_argument("`capacity` must hold at least one bucket");
}
}
size_t capacity() const {
return m_buckets.size();
}
size_t size() const {
return m_count;
}
bool is_empty() const {
return m_count == 0;
}
/** Index of the newest bucket, in `Interval` units since the clock epoch. */
int64_t newest_index() const {
return m_newest;
}
void push(double value) {
push(value, Clock::now());
}
/**
* Adds `value` to the bucket `at` falls into. A value older than every
* bucket still held is dropped.
*/
void push(double value, TimePoint at) {
int64_t index = bucket_of(at);
if(m_count == 0) {
m_newest = index;
m_count = 1;
bucket_at(index).reset();
bucket_at(index).add(value);
return;
}
if(index > m_newest) {
advance_to(index);
} else if(m_newest - index >= (int64_t)m_count) {
return;
}
bucket_at(index).add(value);
}
/** Newest first: age 0 is the bucket currently being filled. */
const MetricSample& at_age(size_t age) const {
return bucket_at(m_newest - (int64_t)age);
}
/** Aggregate of the newest `buckets` buckets. */
MetricSample window(size_t buckets) const {
MetricSample result;
size_t count = std::min(buckets, m_count);
for(size_t age = 0; age < count; age++) {
result.merge(at_age(age));
}
return result;
}
/** Aggregate of everything still held. */
MetricSample window() const {
return window(m_count);
}
/**
* Writes the newest `buckets` buckets into `xs` and `ys` oldest first, as
* two contiguous arrays. `xs` holds the age of each bucket in `Interval`
* units, so the bucket being filled sits at 0 and older ones run negative.
* Both vectors are resized to the number of points written.
*
* `skip_newest` leaves that many of the newest buckets out. The bucket
* being filled only holds the part of its interval that has elapsed, so
* reading it next to whole ones makes the newest point dip and recover;
* skipping it keeps every point covering the same span of time. Ages stay
* true, so a skipped bucket leaves a gap rather than shifting the rest.
*/
size_t linearize(std::vector<double>& xs,
std::vector<double>& ys,
MetricField field,
size_t buckets,
size_t skip_newest = 0) const {
size_t available = m_count > skip_newest ? m_count - skip_newest : 0;
size_t count = std::min(buckets, available);
xs.resize(count);
ys.resize(count);
for(size_t i = 0; i < count; i++) {
size_t age = skip_newest + count - 1 - i;
xs[i] = -(double)age;
ys[i] = value_of(at_age(age), field);
}
return count;
}
size_t linearize(std::vector<double>& xs,
std::vector<double>& ys,
MetricField field) const {
return linearize(xs, ys, field, m_count);
}
void clear() {
m_count = 0;
}
};
}
+24
View File
@@ -0,0 +1,24 @@
project(tw_metrics_tests)
set(LIBS
tw::metrics
)
file(GLOB FILES
./*.cpp
)
add_executable(${PROJECT_NAME} ${FILES})
target_link_libraries(${PROJECT_NAME}
PRIVATE
${LIBS}
Catch2::Catch2WithMain
)
list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras)
include(CTest)
include(Catch)
catch_discover_tests(${PROJECT_NAME})
+105
View File
@@ -0,0 +1,105 @@
#include "metrics/MetricSample.hpp"
#include "catch2/catch_test_macros.hpp"
using tw::metrics::MetricField;
using tw::metrics::MetricSample;
using tw::metrics::value_of;
TEST_CASE("Empty sample reports zero", "[metric_sample]") {
MetricSample sample;
REQUIRE(sample.is_empty());
REQUIRE(sample.count == 0);
REQUIRE(sample.sum == 0.0);
REQUIRE(sample.min == 0.0);
REQUIRE(sample.max == 0.0);
REQUIRE(sample.avg() == 0.0);
}
TEST_CASE("Sample tracks sum, average and extremes", "[metric_sample]") {
MetricSample sample;
sample.add(4.0);
sample.add(1.0);
sample.add(7.0);
REQUIRE(sample.count == 3);
REQUIRE(sample.sum == 12.0);
REQUIRE(sample.min == 1.0);
REQUIRE(sample.max == 7.0);
REQUIRE(sample.avg() == 4.0);
}
TEST_CASE("First value sets both extremes", "[metric_sample]") {
MetricSample sample;
sample.add(-5.0);
REQUIRE(sample.min == -5.0);
REQUIRE(sample.max == -5.0);
}
TEST_CASE("Merge folds one sample into another", "[metric_sample]") {
MetricSample left;
left.add(2.0);
left.add(4.0);
MetricSample right;
right.add(10.0);
right.add(0.5);
left.merge(right);
REQUIRE(left.count == 4);
REQUIRE(left.sum == 16.5);
REQUIRE(left.min == 0.5);
REQUIRE(left.max == 10.0);
}
TEST_CASE("Merging with an empty sample changes nothing", "[metric_sample]") {
MetricSample sample;
sample.add(3.0);
sample.merge(MetricSample{});
REQUIRE(sample.count == 1);
REQUIRE(sample.min == 3.0);
REQUIRE(sample.max == 3.0);
}
TEST_CASE("Merging into an empty sample adopts the other", "[metric_sample]") {
MetricSample other;
other.add(3.0);
other.add(9.0);
MetricSample sample;
sample.merge(other);
REQUIRE(sample.count == 2);
REQUIRE(sample.sum == 12.0);
REQUIRE(sample.min == 3.0);
REQUIRE(sample.max == 9.0);
}
TEST_CASE("Field selects the statistic to read", "[metric_sample]") {
MetricSample sample;
sample.add(2.0);
sample.add(6.0);
REQUIRE(value_of(sample, MetricField::Avg) == 4.0);
REQUIRE(value_of(sample, MetricField::Min) == 2.0);
REQUIRE(value_of(sample, MetricField::Max) == 6.0);
REQUIRE(value_of(sample, MetricField::Sum) == 8.0);
REQUIRE(value_of(sample, MetricField::Count) == 2.0);
}
TEST_CASE("Reset empties the sample", "[metric_sample]") {
MetricSample sample;
sample.add(5.0);
sample.reset();
REQUIRE(sample.is_empty());
REQUIRE(sample.max == 0.0);
}
+325
View File
@@ -0,0 +1,325 @@
#include "metrics/MetricSeries.hpp"
#include "catch2/catch_test_macros.hpp"
#include <chrono>
using namespace std::chrono_literals;
using tw::metrics::MetricField;
using tw::metrics::MetricSeries;
using Clock = std::chrono::steady_clock;
using Series = MetricSeries<std::chrono::seconds>;
/** Fixed origin so every test drives the series by hand. */
static Clock::time_point at(int64_t seconds) {
return Clock::time_point{} + std::chrono::hours(1) + std::chrono::seconds(seconds);
}
TEST_CASE("Series starts empty", "[metric_series]") {
Series series(8);
REQUIRE(series.capacity() == 8);
REQUIRE(series.size() == 0);
REQUIRE(series.is_empty());
REQUIRE(series.window().is_empty());
}
TEST_CASE("Series rejects a zero capacity", "[metric_series]") {
REQUIRE_THROWS_AS(Series(0), std::invalid_argument);
}
TEST_CASE("Values in the same interval fold into one bucket", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
series.push(3.0, at(0));
REQUIRE(series.size() == 1);
REQUIRE(series.at_age(0).count == 2);
REQUIRE(series.at_age(0).avg() == 2.0);
REQUIRE(series.at_age(0).min == 1.0);
REQUIRE(series.at_age(0).max == 3.0);
}
TEST_CASE("Values in different intervals land in different buckets", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
series.push(5.0, at(1));
REQUIRE(series.size() == 2);
REQUIRE(series.at_age(0).sum == 5.0);
REQUIRE(series.at_age(1).sum == 1.0);
}
TEST_CASE("Intervals without a value become empty buckets", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
series.push(4.0, at(3));
REQUIRE(series.size() == 4);
REQUIRE(series.at_age(0).sum == 4.0);
REQUIRE(series.at_age(1).is_empty());
REQUIRE(series.at_age(2).is_empty());
REQUIRE(series.at_age(3).sum == 1.0);
}
TEST_CASE("Series never holds more than its capacity", "[metric_series]") {
Series series(4);
for(int64_t i = 0; i < 10; i++) {
series.push((double)i, at(i));
}
REQUIRE(series.size() == 4);
REQUIRE(series.at_age(0).sum == 9.0);
REQUIRE(series.at_age(3).sum == 6.0);
}
TEST_CASE("A gap wider than the ring leaves only the newest bucket filled", "[metric_series]") {
Series series(4);
series.push(1.0, at(0));
series.push(2.0, at(100));
REQUIRE(series.size() == 4);
REQUIRE(series.at_age(0).sum == 2.0);
REQUIRE(series.at_age(1).is_empty());
REQUIRE(series.at_age(2).is_empty());
REQUIRE(series.at_age(3).is_empty());
}
TEST_CASE("Buckets dropped by wrapping do not come back", "[metric_series]") {
Series series(4);
series.push(100.0, at(0));
for(int64_t i = 1; i < 5; i++) {
series.push(1.0, at(i));
}
REQUIRE(series.window().max == 1.0);
}
TEST_CASE("Window aggregates across buckets", "[metric_series]") {
Series series(8);
series.push(4.0, at(0));
series.push(1.0, at(1));
series.push(7.0, at(2));
auto window = series.window();
REQUIRE(window.count == 3);
REQUIRE(window.sum == 12.0);
REQUIRE(window.min == 1.0);
REQUIRE(window.max == 7.0);
REQUIRE(window.avg() == 4.0);
}
TEST_CASE("Window can be narrowed to the newest buckets", "[metric_series]") {
Series series(8);
series.push(4.0, at(0));
series.push(1.0, at(1));
series.push(7.0, at(2));
auto window = series.window(2);
REQUIRE(window.count == 2);
REQUIRE(window.min == 1.0);
REQUIRE(window.max == 7.0);
}
TEST_CASE("Empty buckets do not skew the window extremes", "[metric_series]") {
Series series(8);
series.push(5.0, at(0));
series.push(9.0, at(4));
auto window = series.window();
REQUIRE(window.count == 2);
REQUIRE(window.min == 5.0);
REQUIRE(window.max == 9.0);
}
TEST_CASE("A late value folds into the bucket it belongs to", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
series.push(2.0, at(2));
series.push(6.0, at(1));
REQUIRE(series.size() == 3);
REQUIRE(series.at_age(1).sum == 6.0);
REQUIRE(series.at_age(0).sum == 2.0);
}
TEST_CASE("A value older than every bucket held is dropped", "[metric_series]") {
Series series(4);
for(int64_t i = 0; i < 4; i++) {
series.push(1.0, at(i));
}
series.push(99.0, at(-10));
REQUIRE(series.size() == 4);
REQUIRE(series.window().max == 1.0);
REQUIRE(series.window().count == 4);
}
TEST_CASE("Linearize writes buckets oldest first", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
series.push(2.0, at(1));
series.push(3.0, at(2));
std::vector<double> xs;
std::vector<double> ys;
size_t count = series.linearize(xs, ys, MetricField::Sum);
REQUIRE(count == 3);
REQUIRE(xs == std::vector<double>{-2.0, -1.0, 0.0});
REQUIRE(ys == std::vector<double>{1.0, 2.0, 3.0});
}
TEST_CASE("Linearize can be limited to the newest buckets", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
series.push(2.0, at(1));
series.push(3.0, at(2));
std::vector<double> xs;
std::vector<double> ys;
size_t count = series.linearize(xs, ys, MetricField::Sum, 2);
REQUIRE(count == 2);
REQUIRE(xs == std::vector<double>{-1.0, 0.0});
REQUIRE(ys == std::vector<double>{2.0, 3.0});
}
TEST_CASE("Linearize can leave out the newest buckets", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
series.push(2.0, at(1));
series.push(3.0, at(2));
std::vector<double> xs;
std::vector<double> ys;
size_t count = series.linearize(xs, ys, MetricField::Sum, 8, 1);
REQUIRE(count == 2);
REQUIRE(ys == std::vector<double>{1.0, 2.0});
}
TEST_CASE("Skipping the newest bucket keeps the ages of the rest", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
series.push(2.0, at(1));
series.push(3.0, at(2));
std::vector<double> xs;
std::vector<double> ys;
series.linearize(xs, ys, MetricField::Sum, 8, 1);
REQUIRE(xs == std::vector<double>{-2.0, -1.0});
}
TEST_CASE("Skipping more buckets than are held writes nothing", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
std::vector<double> xs;
std::vector<double> ys;
size_t count = series.linearize(xs, ys, MetricField::Sum, 8, 4);
REQUIRE(count == 0);
REQUIRE(xs.empty());
REQUIRE(ys.empty());
}
TEST_CASE("A limit counts buckets that were not skipped", "[metric_series]") {
Series series(8);
for(int64_t i = 0; i < 5; i++) {
series.push((double)i, at(i));
}
std::vector<double> xs;
std::vector<double> ys;
size_t count = series.linearize(xs, ys, MetricField::Sum, 2, 1);
REQUIRE(count == 2);
REQUIRE(ys == std::vector<double>{2.0, 3.0});
}
TEST_CASE("Linearize reports empty buckets as zero", "[metric_series]") {
Series series(8);
series.push(5.0, at(0));
series.push(9.0, at(2));
std::vector<double> xs;
std::vector<double> ys;
series.linearize(xs, ys, MetricField::Max);
REQUIRE(ys == std::vector<double>{5.0, 0.0, 9.0});
}
TEST_CASE("Linearize resizes the vectors it is given", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
std::vector<double> xs(64, 7.0);
std::vector<double> ys(64, 7.0);
series.linearize(xs, ys, MetricField::Avg);
REQUIRE(xs.size() == 1);
REQUIRE(ys.size() == 1);
}
TEST_CASE("Clear empties the series but keeps its capacity", "[metric_series]") {
Series series(8);
series.push(1.0, at(0));
series.clear();
REQUIRE(series.is_empty());
REQUIRE(series.capacity() == 8);
series.push(2.0, at(1));
REQUIRE(series.size() == 1);
REQUIRE(series.at_age(0).sum == 2.0);
}
TEST_CASE("A coarser interval folds more values together", "[metric_series]") {
MetricSeries<std::chrono::minutes> series(4);
series.push(1.0, at(0));
series.push(2.0, at(30));
series.push(3.0, at(90));
REQUIRE(series.size() == 2);
REQUIRE(series.at_age(1).count == 2);
REQUIRE(series.at_age(0).sum == 3.0);
}
+2
View File
@@ -10,7 +10,9 @@ target_link_libraries(${PROJECT_NAME}
PUBLIC
towards
tw::network
tw::quicr
tw::protocol
tw::message_protocol
glm::glm
EnTT::EnTT
Jolt
+37 -16
View File
@@ -1,24 +1,43 @@
#include "Address.hpp"
#include "Login.pb.h"
#include "PlayerMove.pb.h"
#include "TcpStream.hpp"
#include "ProtobufMessages.hpp"
#include "WorldState.pb.h"
#include "messenger/MessageHandler.hpp"
#include "messenger/Messenger.hpp"
#include "message_protocol/MessageEndpoint.hpp"
#include "runtime/LockStep.hpp"
#include <chrono>
#include <cstdlib>
#include <memory>
#include <semaphore>
#include <thread>
#include <vector>
#include <glm/glm.hpp>
tw::net::MessageHandler create_messenger(tw::net::Address& address) {
return tw::net::MessageHandler(address);
static std::unique_ptr<tw::msg::MessageEndpoint> create_endpoint() {
auto endpoint_r = tw::msg::MessageEndpoint::create();
if(!endpoint_r) {
throw std::runtime_error("Failed to create the endpoint: " + endpoint_r.error().message());
}
return std::move(endpoint_r.value());
}
static tw::msg::MessageConnection* connect_to_server(tw::msg::MessageEndpoint* endpoint,
tw::net::Address address) {
auto server_r = endpoint->connect(address.ip_string(), address.port());
if(!server_r) {
throw std::runtime_error("Failed to connect to server: " + server_r.error().message());
}
return server_r.value();
}
class MockClient {
tw::net::MessageHandler m_handler;
std::unique_ptr<tw::msg::MessageEndpoint> m_endpoint;
tw::msg::MessageConnection* m_server;
tw::ProtobufMessages m_messages;
tw::LockStep m_lock_step;
uint32_t m_frame_idx;
@@ -33,28 +52,30 @@ class MockClient {
public:
MockClient(tw::net::Address address, const std::string& name) :
m_handler(create_messenger(address)),
m_endpoint(create_endpoint()),
m_server(connect_to_server(m_endpoint.get(), address)),
m_messages(m_endpoint.get()),
m_lock_step(20),
m_is_running(true),
m_is_connected(false),
m_connected_semaphore(0)
{
m_handler.set_handler<mmo::WorldStateMessage>(
[&](mmo::WorldStateMessage* mesg) { });
m_messages.set_handler<mmo::WorldStateMessage>(
[this](tw::msg::PeerId, const mmo::WorldStateMessage& mesg) { });
m_handler.set_handler<mmo::LoginResponse>(
[&](mmo::LoginResponse* mesg) {
m_messages.set_handler<mmo::LoginResponse>(
[this](tw::msg::PeerId, const mmo::LoginResponse& mesg) {
if(!m_is_connected) {
m_is_connected = true;
m_entity_id = mesg->entity_id();
m_entity_id = mesg.entity_id();
m_connected_semaphore.release();
}
});
}
void run() {
while(!m_handler.is_connected()) {
m_handler.update();
while(!m_server->is_established()) {
m_endpoint->update();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
@@ -67,7 +88,7 @@ public:
continue;
}
m_handler.update();
m_endpoint->update();
value += (float)std::rand() / RAND_MAX;
m_velocity.x = glm::sin(value);
@@ -81,7 +102,7 @@ public:
player_input->set_y(0);
player_input->set_z(m_velocity.z);
player_move_mesg.set_allocated_input(player_input);
auto r = m_handler.send(player_move_mesg);
auto r = m_messages.send(m_server, player_move_mesg, false);
}
}
};
+3 -4
View File
@@ -5,7 +5,6 @@ add_subdirectory(tests)
file(GLOB FILES
src/*.cpp
src/messenger/*.cpp
src/protocol/quicr/*.cpp
src/frames/*.cpp
)
@@ -13,9 +12,6 @@ file(GLOB FILES
file(GLOB HEADERS
include/*.hpp
include/exception/*.hpp
include/io/*.hpp
include/messenger/*.hpp
include/packets/*.hpp
include/metrics/*.hpp
include/protocol/quicr/*.hpp
)
@@ -37,7 +33,10 @@ target_include_directories(${PROJECT_NAME}
target_link_libraries(${PROJECT_NAME}
PUBLIC
spdlog::spdlog
tw::io
tw::protocol
tw::message_protocol
tw::quicr
tl::expected
Tracy::TracyClient
TracyClient
+1 -1
View File
@@ -1,7 +1,7 @@
#pragma once
#include "common.hpp"
#include "protocol/quicr/QuicrFrameType.hpp"
#include "quicr/QuicrFrameType.hpp"
#include <cstddef>
#include <vector>
@@ -1,168 +0,0 @@
#pragma once
#include <concepts>
#include <functional>
#include <google/protobuf/message.h>
#include <span>
#include "Address.hpp"
#include "Messenger.hpp"
#include "NetworkError.hpp"
#include "TcpStream.hpp"
#include "packets/Packet.hpp"
#include "packets/LoginPacket.hpp"
#include "protocol/quicr/QuicrConnection.hpp"
namespace tw::net {
/**
* Contains handlers for each message type. Calls this handler when message comes in.
*/
class MessageHandler {
private:
// std::optional<Messenger<std::byte, quicr::QuicrConnection>> m_quicr_messenger;
// Messenger<std::byte, TcpStream> m_server_messenger;
std::unique_ptr<quicr::QuicrEndpoint> m_quicr_endpoint;
quicr::QuicrConnection* m_quicr_connection;
std::vector<std::function<tl::expected<void, NetworkError>(std::span<std::byte>)>> m_handlers;
std::unique_ptr<quicr::QuicrEndpoint> create_endpoint() {
auto endpoint_r = quicr::QuicrEndpoint::create();
if(!endpoint_r) {
spdlog::error("Failed to create QuicrEndpoint: {}", endpoint_r.error().message());
throw std::runtime_error("Failed to create QuicrEndpoint");
}
return std::make_unique<quicr::QuicrEndpoint>(std::move(endpoint_r.value()));
}
public:
const bool is_connected() const {
return m_quicr_connection->state() == quicr::QuicrConnectionState::Established;
}
MessageHandler(MessageHandler&& m)
// : m_server_messenger{std::move(m.m_server_messenger)},
:
m_handlers(std::move(m.m_handlers)),
m_quicr_endpoint(std::move(m.m_quicr_endpoint)),
m_quicr_connection(m.m_quicr_connection) {
}
MessageHandler(Address address) :
m_quicr_endpoint(create_endpoint()),
m_quicr_connection(m_quicr_endpoint->connect(address).value()),
m_handlers(100) {
spdlog::info("Connected to server at {}", address.to_string());
}
// MessageHandler(Messenger<std::byte, TcpStream>&& server_messenger) :
// // m_server_messenger{std::move(server_messenger)},
// m_quicr_connection(std::move(server_messenger.connection())),
// m_handlers(100) {
// }
template<typename T>
constexpr void set_handler(const std::function<void(T*)> handler) {
PacketType type = Message<T>::value;
m_handlers[type] = [handler, this](std::span<std::byte> data) -> tl::expected<void, NetworkError> {
T result = {};
result.ParseFromArray(data.data(), data.size());
// spdlog::info("Deserialized message [{}]: {}", (int32_t)Message<T>::value, result.DebugString());
handler(&result);
// if(m_server_messenger.peek().has_value() && m_server_messenger.peek().value() == Message<T>::value) {
// tl::expected<T, NetworkError> mesg = m_server_messenger.pop<T>(nullptr);
// if(!mesg.has_value()) {
// return tl::make_unexpected(mesg.error());
// }
// handler(&mesg.value());
// }
return {};
};
}
constexpr void set_raw_handler(uint32_t type, const std::function<tl::expected<void, NetworkError>(std::span<std::byte>)> handler) {
m_handlers[type] = handler;
}
void update() {
m_quicr_endpoint->poll();
while(true) {
std::vector<std::byte> buffer(64 * 1024);
auto read_r = m_quicr_connection->read_into(buffer);
if(!read_r) {
spdlog::error("Failed to read from QUICr stream: {}", read_r.error().message());
break;
}
if(*read_r == 0) {
break;
}
uint32_t type = reinterpret_cast<uint32_t*>(buffer.data())[0];
if(m_handlers[type] == nullptr) {
spdlog::warn("Unknown message type: {}", type);
throw std::runtime_error("Unknown message type: {}");
break;
}
auto handler_r = m_handlers[type](std::span<std::byte>(buffer.data(), *read_r).subspan(sizeof(uint32_t)));
if(!handler_r) {
spdlog::error("Handler error");
break;
}
}
// while(m_server_messenger.peek().has_value() && m_server_messenger.peek().value().has_value()) {
// std::optional<PacketType> type = m_server_messenger.peek().value();
// if(type >= m_handlers.size() || m_handlers[type.value()] == nullptr) {
// spdlog::warn("Unknown message type: {}", (int)type.value());
// break;
// }
// auto r = m_handlers[type.value()]();
// if(!r) {
// spdlog::error("Failed to handle message: {}", r.error().message());
// }
// }
}
template<std::derived_from<google::protobuf::Message> T>
tl::expected<size_t, NetworkError> send(T& mesg) {
std::string payload;
if(!mesg.SerializeToString(&payload)) {
spdlog::error("Failed to serialize message");
return 0;
}
int32_t length = payload.length();
if(length == 0) {
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 send_r = m_quicr_connection->send_message(bytes, false);
if(!send_r) {
spdlog::error("Failed to send message: {}", send_r.error().message());
return 0;
}
return payload_bytes.size();
}
};
}
@@ -1,209 +0,0 @@
#pragma once
#include <immintrin.h>
#include <optional>
#include <span>
#include <google/protobuf/message.h>
#include <google/protobuf/io/zero_copy_stream_impl.h>
#include <tracy/Tracy.hpp>
#include "NetworkError.hpp"
#include "packets/Packet.hpp"
#include "MessageRegistry.hpp"
#include "protocol/quicr/QuicrFrameType.hpp"
#include "tl/expected.hpp"
namespace tw::net {
template<typename TData, std::derived_from<Write<TData>> TOutput>
class Messenger {
private:
const uint32_t MAX_MESG_BODY_SIZE = 65536;
const uint32_t MESG_MAGIC = 0x1DEADBEE;
TOutput m_stream;
std::optional<PacketType> m_next_packet_type;
bool m_is_skipping;
uint32_t m_buffered_size;
size_t m_mesg_size;
size_t m_read_head;
std::vector<std::byte> m_input_buffer;
public:
Messenger(Messenger && m) :
m_stream(std::move(m.m_stream)),
m_next_packet_type(m.m_next_packet_type),
m_input_buffer(std::move(m.m_input_buffer)),
m_buffered_size(m.m_buffered_size),
m_is_skipping(m.m_is_skipping),
m_mesg_size(m.m_mesg_size),
m_read_head(m.m_read_head)
{
// m_stream.set_non_blocking();
}
Messenger(TOutput&& stream) :
m_stream(std::move(stream)),
m_input_buffer(MAX_MESG_BODY_SIZE),
m_buffered_size(0),
m_is_skipping(false),
m_mesg_size(0),
m_read_head(0)
{
// m_stream.set_non_blocking();
}
Messenger<TData, TOutput> operator=(const Messenger<TData, TOutput>&) = delete;
Messenger<TData, TOutput> operator=(Messenger<TData, TOutput>&& m) {
m_stream = std::move(m.m_stream);
m_next_packet_type = m.m_next_packet_type;
m_input_buffer = std::move(m.m_input_buffer);
m_buffered_size = m.m_buffered_size;
m_is_skipping = m.m_is_skipping;
m_mesg_size = m.m_mesg_size;
m_read_head = m.m_read_head;
}
template <std::derived_from<google::protobuf::Message> T>
tl::expected<size_t, NetworkError> send(T &content) {
ZoneScopedN("Messenger::send");
auto id = (int32_t)Message<T>::value;
std::string payload;
if(!content.SerializeToString(&payload)) {
spdlog::error("Failed to serialize message");
throw std::runtime_error("Serialization failed");
}
// spdlog::info("Sending {}: {}", (int)Message<T>::value, content.DebugString());
int32_t length = payload.length();
if(length == 0) {
return 0;
}
// append encoded id & length before payload and write it to the stream
//
const uint32_t HEADER_SIZE = 4 + 4 + 4 + 4;
std::string message;
message.resize(HEADER_SIZE + payload.length());
const uint32_t magic = 0xDEADBEEF;
const uint32_t frame_type = quicr::FrameType::StreamBase;
std::memcpy(message.data(), &magic, sizeof(magic));
std::memcpy(message.data() + sizeof(magic), &frame_type, sizeof(frame_type));
std::memcpy(message.data() + sizeof(frame_type) + sizeof(magic), &length, sizeof(length));
std::memcpy(message.data() + sizeof(frame_type) + sizeof(magic) + sizeof(length), &id, sizeof(id));
// std::memcpy(message.data() + sizeof(id) + sizeof(length), &MESG_MAGIC, sizeof(MESG_MAGIC));
std::memcpy(message.data() + HEADER_SIZE, payload.data(), payload.length());
auto write_result = m_stream.write(std::as_writable_bytes(std::span(message)));
if(!write_result.has_value()) {
return tl::make_unexpected(write_result.error());
}
return write_result.value();
}
int32_t m_packet_peek_size = 0;
tl::expected<std::optional<PacketType>, NetworkError> peek() {
ZoneScopedN("Messenger::peek");
if(m_next_packet_type.has_value()) {
return m_next_packet_type;
}
if(m_read_head < 4) {
auto result = m_stream.read_into(std::as_writable_bytes(std::span{(char*)m_input_buffer.data(), sizeof(PacketType) - m_read_head}));
if(!result.has_value()) {
return tl::make_unexpected(result.error());
}
m_read_head += result.value();
if(m_read_head < 4) {
return {};
}
}
if(m_read_head < 8) {
auto result = m_stream.read_into(std::as_writable_bytes(std::span{(char*)m_input_buffer.data() + m_read_head, 8 - m_read_head}));
if(!result.has_value()) {
return tl::make_unexpected(result.error());
}
m_read_head += result.value();
if(m_read_head < 8) {
return {};
}
m_mesg_size = *reinterpret_cast<uint32_t*>(m_input_buffer.data() + 4);
}
if(m_read_head < m_mesg_size + 8) {
if(m_mesg_size + 8 > m_input_buffer.size()) {
return tl::make_unexpected(NetworkError(NetworkErrorType::NOT_ENOUGH_MEMORY));
}
auto result = m_stream.read_into(std::as_writable_bytes(std::span{(char*)m_input_buffer.data() + m_read_head, m_mesg_size + 8 - m_read_head}));
if(!result.has_value()) {
return tl::make_unexpected(result.error());
}
m_read_head += result.value();
if(m_read_head < m_mesg_size + 8) {
return {};
}
}
m_next_packet_type = (PacketType)(*reinterpret_cast<int32_t*>(m_input_buffer.data()));
return m_next_packet_type;
}
template<typename T>
tl::expected<T, NetworkError> pop(size_t* out_size) {
ZoneScopedN("Messenger::pop");
T result = {};
result.ParseFromArray(m_input_buffer.data() + 8, m_mesg_size);
// spdlog::info("Received {}: {}", (int)m_next_packet_type.value(), result.DebugString());
m_read_head = 0;
m_mesg_size = 0;
m_next_packet_type = {};
return result;
}
void skip() {
}
void clear() {
m_next_packet_type = std::nullopt;
int message_length = 0;
int size = sizeof(message_length);
// m_stream.read_exact(std::as_writable_bytes(std::span{&message_length, 1}));
std::vector<char> data(message_length);
// m_stream.read_exact(std::as_writable_bytes(std::span{data.data(), (size_t)message_length}));
// m_input_buffer.reset();
}
};
}
@@ -1,29 +0,0 @@
#pragma once
#include "MessageRegistry.hpp"
#include <spdlog/spdlog.h>
#include <string>
#include <fstream>
class MessengerDebugLog {
public:
MessengerDebugLog(MessengerDebugLog&& m) :
m_log_file(std::move(m.m_log_file))
{ }
MessengerDebugLog(const std::string& log_file_path);
~MessengerDebugLog();
template<typename T>
void log_send(const T& message) {
spdlog::info("Sending [{}]: {}", (int)tw::Message<T>::value, message.DebugString());
}
template<typename T>
void log_recv(const T& message) {
spdlog::info("Received [{}]: {}", (int)tw::Message<T>::value, message.DebugString());
}
private:
std::ofstream m_log_file;
};
@@ -1,157 +0,0 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <stdexcept>
#include <vector>
#include <print>
namespace tw::net {
template<typename T>
struct AverageOp {
uint32_t count;
T sum;
AverageOp() :
count(0),
sum{} {
}
void add(const T value) {
sum += value;
count++;
}
T result() const {
return count == 0 ? 0 : sum / count;
}
};
template<typename T>
struct SumOp {
T sum;
void add(const T value) {
sum += value;
}
T result() const {
return sum;
}
};
template<typename T, typename Interval,
typename Operation = SumOp<T>,
typename Clock = std::chrono::steady_clock>
class BucketMetric {
T m_min, m_max;
std::vector<T> m_metric;
std::vector<uint32_t> m_bucket_idx;
Operation m_op;
uint32_t m_offset;
uint32_t m_right, m_left;
std::string m_format;
const uint32_t get_bucket(Clock::time_point time_point) const {
return std::chrono::floor<Interval>(time_point).time_since_epoch().count() - m_offset;
}
public:
BucketMetric(std::string format, uint32_t size) :
m_metric(size),
m_bucket_idx(size),
m_right(0), m_left(0),
m_offset(0),
m_format(format)
{
m_offset = get_bucket(Clock::now());
}
const T max() const {
return m_max;
}
const T min() const {
return m_min;
}
const std::string& format() const {
return m_format;
}
size_t max_size() const {
return m_metric.size();
}
void push(T value) {
auto time = Clock::now();
size_t bucket = get_bucket(time) % m_metric.size();
size_t idx = get_bucket(time);
// set result to correct bucket
if(m_right != bucket) {
m_metric[m_right] = m_op.result();
m_min = std::min(m_min, m_op.result());
m_max = std::max(m_max, m_op.result());
m_bucket_idx[m_right] = idx++;
m_right++;
m_op = {};
}
// reset all buckets until the required one
for(; m_right != bucket; m_right = (m_right + 1) % m_metric.size()) {
m_metric[m_right] = {};
m_bucket_idx[m_right] = idx++;
if(m_right == m_left) {
m_left = (m_left + 1) % m_metric.size();
}
}
m_op.add(value);
}
const size_t get_size() const {
return m_right - m_left + (m_left > m_right ? m_metric.size() : 0);
}
const T get(uint32_t idx) const {
if(idx > get_size()) {
throw std::invalid_argument("`idx` cannot be higher than buffer size");
}
return m_metric[m_left + idx].result();
}
std::span<T> get_head() {
return std::span(m_metric).subspan(m_left, (m_right > m_left ? m_right : m_metric.size()));
}
std::span<uint32_t> get_head_timeline() {
return std::span(m_bucket_idx).subspan(m_left, (m_right > m_left ? m_right : m_bucket_idx.size()));
}
std::span<T> get_tail() {
if(m_right > m_left) {
return std::span<T>();
}
return std::span(m_metric).subspan(0, m_right);
}
std::span<uint32_t> get_tail_timeline() {
if(m_right > m_left) {
return std::span<T>();
}
return std::span(m_bucket_idx).subspan(0, m_right);
}
};
}
@@ -42,7 +42,7 @@ public:
}
std::optional<const TValue*> get(TKey key) const {
for(size_t i = m_tail; i != m_head; (i++) % max_size()) {
for(size_t i = m_tail; i != m_head; i = (i + 1) % max_size()) {
if(m_buffer[i].first > key) {
return {};
}
@@ -56,24 +56,25 @@ public:
}
bool set(TKey key, const TValue& value) {
if(key < m_buffer.at(m_tail).first) {
// If buffer has entries and key is older than the oldest, reject it
if(m_tail != m_head && key < m_buffer.at(m_tail).first) {
return false;
}
int i = m_tail + 1;
size_t i = (m_tail + 1) % max_size();
if(m_tail != m_head) {
for(i = m_tail + 1; i != m_head; i++) {
for(i = (m_tail + 1) % max_size(); i != m_head; i = (i + 1) % max_size()) {
if(m_buffer.at(i).first > key) {
m_buffer[(i - 1) % max_size()] = std::make_pair(key, value);
m_head++;
m_buffer[(i - 1 + max_size()) % max_size()] = std::make_pair(key, value);
m_head = (m_head + 1) % max_size();
return true;
} else {
m_buffer[(i - 1) % max_size()] = m_buffer[i];
m_buffer[(i - 1 + max_size()) % max_size()] = m_buffer[i];
}
}
}
m_buffer[(i - 1) % max_size()] = std::make_pair(key, value);
m_buffer[(i - 1 + max_size()) % max_size()] = std::make_pair(key, value);
m_head = (m_head + 1) % max_size();
return true;
}
@@ -1,134 +0,0 @@
#pragma once
#include <cstdint>
#include <chrono>
#include <filesystem>
#include <cassert>
#include "Address.hpp"
#include "BucketMetric.hpp"
#include "packets/Packet.hpp"
#include "MessageRegistry.hpp"
namespace tw::net {
using Clock = std::chrono::steady_clock;
using TimePoint = Clock::time_point;
struct NetworkSendInfo {
PacketType message_type;
bool is_sent_by_us;
Address target;
TimePoint timepoint;
std::span<uint8_t> buffer;
NetworkSendInfo(
PacketType message_type,
bool is_sent_by_us,
const Address& target,
const std::span<uint8_t> buffer
) :
message_type(message_type),
is_sent_by_us(is_sent_by_us),
target(target),
timepoint(std::chrono::steady_clock::now()),
buffer(buffer)
{
}
};
class NetworkStatsLogger {
private:
std::vector<NetworkSendInfo> m_backlog;
std::vector<uint8_t> m_buffer;
size_t m_left, m_right;
std::optional<std::ostream> m_output;
using Interval = std::chrono::seconds;
BucketMetric<uint32_t, Interval, AverageOp<uint32_t>> m_ping_metric;
BucketMetric<uint32_t, Interval, SumOp<uint32_t>> m_outgoing;
BucketMetric<uint32_t, Interval, SumOp<uint32_t>> m_incoming;
public:
NetworkStatsLogger() :
m_backlog(10000, {MESSAGE_PACKET, false, Address({}, 0), {}}),
m_buffer(1000000),
m_left(0), m_right(0),
m_ping_metric("ms", 1000),
m_outgoing("b/s", 1000),
m_incoming("b/s", 1000)
{ }
void set_file_output(std::filesystem::path path);
size_t get_size() {
return m_right - m_left + (m_right < m_left ? m_backlog.size() : 0);
}
NetworkSendInfo& get_item(uint32_t idx) {
return m_backlog[(m_left + idx) % m_backlog.size()];
}
std::span<uint8_t> allocate_memory_for_buffer(size_t size) {
uint32_t start = m_right;
if(m_buffer.size() - m_right < size) {
// throw away packets from the start to make space
for(; m_backlog[m_left].buffer.data() < m_buffer.data() + start + size &&
m_left != m_right; m_left = (m_left + 1) % m_buffer.size()) { }
start = 0;
}
uint32_t end = start + size;
return std::span<uint8_t>(m_buffer.begin() + start, m_buffer.begin() + end);
}
// constexpr void log(PacketType message_type, bool is_sent, const Address& target, const ByteBuffer& content) {
// std::span<uint8_t> span = allocate_memory_for_buffer(content.size());
// memcpy(span.data(), content.data().data(), content.size());
// m_right = (m_right + 1) % m_backlog.size();
// m_backlog[m_right] = NetworkSendInfo(message_type, is_sent, target, span);
// }
// constexpr void log_receive(
// PacketType message_type,
// const Address& from
// ) {
// log(message_type, false, from, content);
// m_incoming.push(content.size());
// }
// constexpr void log_send(
// PacketType message_type,
// const Address& to
// ) {
// log(message_type, true, to, content);
// m_outgoing.push(content.size());
// }
void log_ping(uint32_t ping) {
m_ping_metric.push(ping);
}
BucketMetric<uint32_t, Interval, AverageOp<uint32_t>>& ping(){
return m_ping_metric;
}
BucketMetric<uint32_t, Interval>& outgoing(){
return m_outgoing;
}
BucketMetric<uint32_t, Interval>& incoming(){
return m_incoming;
}
};
}
@@ -1,67 +0,0 @@
#pragma once
#include "Entity.pb.h"
#include "PlayerMove.pb.h"
#include "WorldState.pb.h"
#include "Login.pb.h"
#include "Packet.hpp"
#include "Serialization.hpp"
const int MAX_USERNAME_LENGTH = 128;
struct LoginPacket {
uint32_t username_length;
char username[MAX_USERNAME_LENGTH];
};
// template<>
// class Message<LoginPacket> {
// public:
// static constexpr PacketType value = LOGIN_REQUEST_MSG;
// };
template<>
class tw::net::Serializer<LoginPacket> final {
public:
static bool serialize(Serialization& buffer, LoginPacket& value) {
buffer.serialize(&value.username_length);
buffer.serialize(value.username, value.username_length);
return true;
}
};
// inline void to_json(json& j, const LoginPacket& value) {
// j = json{
// {"username_length", value.username_length},
// {"username", std::string(value.username, value.username_length)}
// };
// }
// inline void from_json(const json& j, LoginPacket& value) {
// j.at("username_length").get_to(value.username_length);
// j.at("username").get_to(value.username);
// }
struct LoginStatusPacket {
bool is_okay;
LoginStatusPacket() {
}
LoginStatusPacket(bool is_okay) :
is_okay(is_okay)
{
}
};
template<>
class tw::net::Serializer<LoginStatusPacket> final {
public:
static bool serialize(Serialization& buffer, LoginStatusPacket& value) {
return buffer.serialize(&value.is_okay);
}
};
@@ -1,26 +0,0 @@
#pragma once
#include "Serializers.hpp"
// #define PACKET(name) struct #name {
// template<>
// class tw::net::Serializer<const PacketType> final {
// public:
// static bool serialize(tw::net::Serialization& buffer, const PacketType& value) {
// uint32_t v = value;
// return buffer.serialize((uint32_t*)&v);
// }
// };
// template<>
// class tw::net::Serializer<PacketType> final {
// public:
// static bool serialize(tw::net::Serialization& buffer, PacketType& value) {
// return buffer.serialize((uint32_t*)&value);
// }
// };
@@ -1,34 +0,0 @@
#pragma once
#include <algorithm>
#include <string>
namespace tw::net::quicr {
enum class QuicrErrorType {
ConnectionClosed
};
struct QuicrError {
public:
QuicrError(QuicrErrorType type) : type_(type), message_(map_quicr_error_type(type)) {}
QuicrError(QuicrErrorType type, std::string message) : type_(type), message_(std::move(message)) {}
QuicrErrorType type() const { return type_; }
std::string message() const { return message_; }
private:
static std::string map_quicr_error_type(QuicrErrorType type) {
switch (type) {
case QuicrErrorType::ConnectionClosed:
return "ConnectionClosed";
default:
return "Unknown";
}
}
std::string message_;
QuicrErrorType type_;
};
}
@@ -1,34 +0,0 @@
#include "NetworkError.hpp"
#include "protocol/quicr/QuicrConnection.hpp"
#include "tl/expected.hpp"
namespace tw::net::quicr {
class QuicrStream : Write<std::byte>, Read<std::byte> {
QuicrConnection* m_connection;
bool m_is_reliable;
public:
QuicrStream(QuicrConnection* connection, bool is_reliable);
tl::expected<size_t, NetworkError> write(std::span<std::byte> data) override {
auto send_r = m_connection->send_message(data, m_is_reliable);
if(!send_r) {
return tl::make_unexpected(NetworkError::from_errno(CONNECTION_RESET));
}
return *send_r;
}
tl::expected<size_t, NetworkError> read_into(std::span<std::byte> target) override {
auto read_r = m_connection->read_into(target);
if(!read_r) {
return tl::make_unexpected(NetworkError::from_errno(CONNECTION_RESET));
}
return *read_r;
}
};
}
@@ -1,6 +0,0 @@
#include "metrics/NetworkStatsLogger.hpp"
namespace tw::net {
}
@@ -1,6 +0,0 @@
#include "messenger/Messenger.hpp"
namespace tw::net {
}
@@ -1,12 +0,0 @@
#include "messenger/MessengerDebugLog.hpp"
#include <nlohmann/json.hpp>
MessengerDebugLog::MessengerDebugLog(const std::string& log_file_path) : m_log_file(log_file_path) {
if (!m_log_file.is_open()) {
throw std::runtime_error("Failed to open log file");
}
}
MessengerDebugLog::~MessengerDebugLog() {
m_log_file.close();
}

Some files were not shown because too many files have changed in this diff Show More