diff --git a/.dockerignore b/.dockerignore index 83f354e..d86fa73 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,19 @@ -build/ +**/build/ +target/ +Release/ +Debug/ +Testing/ +cmake-build-debug/ .git/ .zed/ .clangd/ +.idea/ +.cache/ compile_commands.json *.md + +# cloned by FetchContent at configure time +external/glm/ +external/entt/ +external/jolt/ +external/tracy/ diff --git a/.gitignore b/.gitignore index 398fd4c..d88dfbf 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ imgui.ini cmake-build-debug/ .opencode/ compile_commands.json + +# conan (machine-specific, generated by `conan install`) +CMakeUserPresets.json diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 11e199d..0000000 --- a/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# ── build stage ────────────────────────────────────────────────────────────── -FROM ubuntu:24.04 AS builder - -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - gcc-13 g++-13 \ - cmake \ - ninja-build \ - git \ - mold \ - libvulkan-dev \ - libsdl2-dev \ - libprotobuf-dev protobuf-compiler \ - libpq-dev \ - libpqxx-dev \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /src -COPY . . - -RUN cmake -B build -G Ninja \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_C_COMPILER=gcc-13 \ - -DCMAKE_CXX_COMPILER=g++-13 \ - -DCMAKE_PREFIX_PATH=/usr/lib/x86_64-linux-gnu \ - && cmake --build build --target tw_server -j$(nproc) - -# ── runtime stage ───────────────────────────────────────────────────────────── -FROM ubuntu:24.04 - -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y --no-install-recommends \ - libvulkan1 \ - libsdl2-2.0-0 \ - libprotobuf32t64 \ - libpq5 \ - libpqxx-dev \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=builder /src/build/modules/server/tw_server /usr/local/bin/tw_server - -EXPOSE 8101/udp 8102/udp - -ENTRYPOINT ["tw_server"] diff --git a/README.md b/README.md index 4a07746..a56b6d8 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,39 @@ -# mmo +# Towards -Game engine demo with built-in networking for multiplayer. +Game engine built as a modular monolith. ## Building Dependencies: -- SDL2 +- Conan 2 (`pipx install conan`, or `pip install conan`) - Vulkan SDK -Both need to be downloaded before building. Do not forget to set PATH on Windows. +The Vulkan SDK must be installed before building. Do not forget to set PATH on Windows. +On Linux, Conan builds SDL2 from source and still needs the system OpenGL/X11/Wayland/ALSA development headers present. -This command will initialize the build directory: `build/` +First time only, create a Conan profile describing your compiler: ```bash -$ cmake -S . -B ./build/ +$ conan profile detect ``` -This will build the entire project. +Install the pinned dependencies and generate the CMake toolchain + presets: ```bash -$ cmake --build ./build +$ conan install . --output-folder=build --build=missing -s build_type=Debug ``` +Configure and build using the generated preset: + +```bash +$ cmake --preset conan-debug +$ cmake --build --preset conan-debug +``` + +For a release build, repeat both steps with `-s build_type=Release` and the +`conan-release` preset. + There are now two executables: Server is at `build/modules/client/mmo_server` @@ -30,4 +41,3 @@ Server is at `build/modules/client/mmo_server` Client is at `build/modules/client/mmo_client` First run the server, so that client can connect. The server will try to use port 8080, but if it is already occupied, it will try use the next free higher one. - diff --git a/cmake/Modules.cmake b/cmake/Modules.cmake index d8407ff..4b732dd 100644 --- a/cmake/Modules.cmake +++ b/cmake/Modules.cmake @@ -1,6 +1,7 @@ +add_subdirectory(${CMAKE_SOURCE_DIR}/modules/io/) +add_subdirectory(${CMAKE_SOURCE_DIR}/modules/metrics/) add_subdirectory(${CMAKE_SOURCE_DIR}/modules/network/) add_subdirectory(${CMAKE_SOURCE_DIR}/modules/protocol/) -add_subdirectory(${CMAKE_SOURCE_DIR}/modules/messaging/) add_subdirectory(${CMAKE_SOURCE_DIR}/modules/chat_service/) add_subdirectory(${CMAKE_SOURCE_DIR}/modules/server/) add_subdirectory(${CMAKE_SOURCE_DIR}/modules/client/) @@ -9,3 +10,5 @@ add_subdirectory(${CMAKE_SOURCE_DIR}/modules/gui/) add_subdirectory(${CMAKE_SOURCE_DIR}/modules/server_gui/) add_subdirectory(${CMAKE_SOURCE_DIR}/modules/serialization/) add_subdirectory(${CMAKE_SOURCE_DIR}/modules/peer_to_peer/) +add_subdirectory(${CMAKE_SOURCE_DIR}/modules/quicr/) +add_subdirectory(${CMAKE_SOURCE_DIR}/modules/message_protocol/) diff --git a/conanfile.txt b/conanfile.txt new file mode 100644 index 0000000..05dada6 --- /dev/null +++ b/conanfile.txt @@ -0,0 +1,14 @@ +[requires] +protobuf/7.35.0 +sdl/2.32.10 + +[tool_requires] +protobuf/7.35.0 + +[options] +sdl/*:pulse=False +libffi/*:shared=True + +[generators] +CMakeDeps +CMakeToolchain diff --git a/modules/chat_service/chat_client/CMakeLists.txt b/modules/chat_service/chat_client/CMakeLists.txt index 62e574a..30f411c 100644 --- a/modules/chat_service/chat_client/CMakeLists.txt +++ b/modules/chat_service/chat_client/CMakeLists.txt @@ -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 diff --git a/modules/chat_service/chat_client/include/ChatClient.hpp b/modules/chat_service/chat_client/include/ChatClient.hpp index cb685e3..a56f14a 100644 --- a/modules/chat_service/chat_client/include/ChatClient.hpp +++ b/modules/chat_service/chat_client/include/ChatClient.hpp @@ -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 +#include #include namespace tw::chat { class ChatClient { - tw::MessageSession m_session; + std::unique_ptr m_endpoint; + msg::MessageConnection* m_server; + ProtobufMessages m_messages; std::function m_on_message; std::function)> m_on_send_response; diff --git a/modules/chat_service/chat_client/src/ChatClient.cpp b/modules/chat_service/chat_client/src/ChatClient.cpp index c441bcd..3a23800 100644 --- a/modules/chat_service/chat_client/src/ChatClient.cpp +++ b/modules/chat_service/chat_client/src/ChatClient.cpp @@ -22,45 +22,68 @@ tl::expected from_error_code(mmo::chat::ChatErr namespace tw::chat { +namespace { + +std::unique_ptr 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 data) { - if (!m_on_message) return; - mmo::chat::ChatMessageBroadcastRequest proto; - if (!proto.ParseFromArray(data.data(), static_cast(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( + [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 data) { - if (!m_on_send_response) return; - mmo::chat::SendChatMessageResponse proto; - if (!proto.ParseFromArray(data.data(), static_cast(data.size()))) return; - m_on_send_response(from_error_code(proto.error())); - }); + m_messages.set_handler( + [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 data) { - if (!m_on_join_response) return; - mmo::chat::JoinChannelResponse proto; - if (!proto.ParseFromArray(data.data(), static_cast(data.size()))) return; - m_on_join_response(static_cast(proto.channel_id()), from_error_code(proto.error())); - }); + m_messages.set_handler( + [this](msg::PeerId, const mmo::chat::JoinChannelResponse& proto) { + if (!m_on_join_response) return; + m_on_join_response(static_cast(proto.channel_id()), from_error_code(proto.error())); + }); - m_session.set_handler(CHAT_LEAVE_CHANNEL_RESPONSE, [this](std::span data) { - if (!m_on_leave_response) return; - mmo::chat::LeaveChannelResponse proto; - if (!proto.ParseFromArray(data.data(), static_cast(data.size()))) return; - m_on_leave_response(static_cast(proto.channel_id()), from_error_code(proto.error())); - }); + m_messages.set_handler( + [this](msg::PeerId, const mmo::chat::LeaveChannelResponse& proto) { + if (!m_on_leave_response) return; + m_on_leave_response(static_cast(proto.channel_id()), from_error_code(proto.error())); + }); } void ChatClient::update() { - m_session.update(); + m_endpoint->update(); } tl::expected ChatClient::send_mesg(SendChatMessage message) { @@ -68,11 +91,7 @@ tl::expected ChatClient::send_mesg(SendChatMessage messag mesg.set_channel_id(message.channel_id); mesg.set_message(message.message); - std::vector buf(mesg.ByteSizeLong()); - (void)mesg.SerializeToArray(buf.data(), static_cast(buf.size())); - - auto send_r = m_session.send(Message::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 {}; diff --git a/modules/chat_service/chat_server_exe/CMakeLists.txt b/modules/chat_service/chat_server_exe/CMakeLists.txt index 394a618..3e15f26 100644 --- a/modules/chat_service/chat_server_exe/CMakeLists.txt +++ b/modules/chat_service/chat_server_exe/CMakeLists.txt @@ -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 ) diff --git a/modules/chat_service/chat_server_exe/src/ChatServerController.cpp b/modules/chat_service/chat_server_exe/src/ChatServerController.cpp index 4326e82..45b8d75 100644 --- a/modules/chat_service/chat_server_exe/src/ChatServerController.cpp +++ b/modules/chat_service/chat_server_exe/src/ChatServerController.cpp @@ -3,7 +3,7 @@ #include "Chat.pb.h" #include "MessageRegistry.hpp" #include -#include +#include namespace tw::chat { @@ -16,108 +16,68 @@ static mmo::chat::ChatErrorCode to_error_code(tl::expected -static std::vector serialize(const T& msg) { - std::vector buf(msg.ByteSizeLong()); - (void)msg.SerializeToArray(buf.data(), static_cast(buf.size())); - return buf; +static std::unique_ptr 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::value] = - [this](uint64_t client_id, std::span data) { - mmo::chat::SendChatMessageRequest msg; - msg.ParseFromArray(data.data(), static_cast(data.size())); + m_messages.set_handler( + [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::value, serialize(r)); - }; + (void)m_messages.send_to(client_id, r, false); + }); - m_handlers[Message::value] = - [this](uint64_t client_id, std::span data) { - mmo::chat::JoinChannelRequest msg; - msg.ParseFromArray(data.data(), static_cast(data.size())); + m_messages.set_handler( + [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::value, serialize(r)); - }; + (void)m_messages.send_to(client_id, r, false); + }); - m_handlers[Message::value] = - [this](uint64_t client_id, std::span data) { - mmo::chat::LeaveChannelRequest msg; - msg.ParseFromArray(data.data(), static_cast(data.size())); + m_messages.set_handler( + [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::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 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 payload, bool reliable) { - auto it = m_connections.find(client_id); - if (it == m_connections.end()) return; - - constexpr uint32_t SEQ_NONE = 0; - std::vector 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::value, - serialize(bcast), true); + + (void)m_messages.send_to(client_id, bcast, true); } } // namespace tw::chat diff --git a/modules/chat_service/chat_server_exe/src/ChatServerController.hpp b/modules/chat_service/chat_server_exe/src/ChatServerController.hpp index 6d373dc..e1ed998 100644 --- a/modules/chat_service/chat_server_exe/src/ChatServerController.hpp +++ b/modules/chat_service/chat_server_exe/src/ChatServerController.hpp @@ -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 #include -#include -#include -#include -#include +#include namespace tw::chat { class ChatServerController { - static constexpr size_t MAX_TYPES = 32; - - std::unique_ptr m_endpoint; - std::unique_ptr m_listener; - std::unordered_map m_connections; - std::vector m_recv_buf{64 * 1024}; - ChatService m_service; - - std::array)>, MAX_TYPES> m_handlers{}; + std::unique_ptr 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 data); - void send_to(uint64_t client_id, uint32_t type, std::span 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 diff --git a/modules/chat_service/chat_service/tests/chat_mock_client/CMakeLists.txt b/modules/chat_service/chat_service/tests/chat_mock_client/CMakeLists.txt index 9a05384..8ee6a3c 100644 --- a/modules/chat_service/chat_service/tests/chat_mock_client/CMakeLists.txt +++ b/modules/chat_service/chat_service/tests/chat_mock_client/CMakeLists.txt @@ -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 ) diff --git a/modules/chat_service/chat_service/tests/chat_mock_client/ChatMockClient.cpp b/modules/chat_service/chat_service/tests/chat_mock_client/ChatMockClient.cpp index 37dd9cc..e90cdb2 100644 --- a/modules/chat_service/chat_service/tests/chat_mock_client/ChatMockClient.cpp +++ b/modules/chat_service/chat_service/tests/chat_mock_client/ChatMockClient.cpp @@ -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 #include @@ -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::value, - [](std::span data) { - mmo::chat::ChatMessageBroadcastRequest bcast; - bcast.ParseFromArray(data.data(), static_cast(data.size())); + messages.set_handler( + [](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::value, serialize(join), [channel_id](std::span 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::value, serialize(msg), [channel_id](std::span data) { @@ -113,7 +126,7 @@ int main(int argc, char* argv[]) { } } - session.update(); + endpoint->update(); } return 0; diff --git a/modules/client/CMakeLists.txt b/modules/client/CMakeLists.txt index 8317e11..0ee2c71 100644 --- a/modules/client/CMakeLists.txt +++ b/modules/client/CMakeLists.txt @@ -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 diff --git a/modules/client/src/app/ClientArgs.cpp b/modules/client/src/app/ClientArgs.cpp new file mode 100644 index 0000000..0c21edf --- /dev/null +++ b/modules/client/src/app/ClientArgs.cpp @@ -0,0 +1,119 @@ +#include "ClientArgs.hpp" + +#include +#include +#include +#include + +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(str[start]))) { + ++start; + } + + // Skip trailing whitespace + size_t end = str.length(); + while(end > start && std::isspace(static_cast(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 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 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(host)), port); +} + +std::optional 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 diff --git a/modules/client/src/app/ClientArgs.hpp b/modules/client/src/app/ClientArgs.hpp new file mode 100644 index 0000000..f8989d8 --- /dev/null +++ b/modules/client/src/app/ClientArgs.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include + +#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 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 server_arg(int argc, char** argv); + +} // namespace tw::app diff --git a/modules/client/src/app/FavouriteServers.cpp b/modules/client/src/app/FavouriteServers.cpp new file mode 100644 index 0000000..9a960e2 --- /dev/null +++ b/modules/client/src/app/FavouriteServers.cpp @@ -0,0 +1,31 @@ +#include "FavouriteServers.hpp" + +#include + +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 diff --git a/modules/client/src/app/FavouriteServers.hpp b/modules/client/src/app/FavouriteServers.hpp new file mode 100644 index 0000000..77524f0 --- /dev/null +++ b/modules/client/src/app/FavouriteServers.hpp @@ -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 diff --git a/modules/client/src/app/FileAddressList.cpp b/modules/client/src/app/FileAddressList.cpp new file mode 100644 index 0000000..26d2c0b --- /dev/null +++ b/modules/client/src/app/FileAddressList.cpp @@ -0,0 +1,110 @@ +#include "FileAddressList.hpp" + +#include +#include +#include +#include +#include + +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& FileAddressList::entries() const { + return m_entries; +} + +} // namespace tw::app diff --git a/modules/client/src/app/FileAddressList.hpp b/modules/client/src/app/FileAddressList.hpp new file mode 100644 index 0000000..f9dd18d --- /dev/null +++ b/modules/client/src/app/FileAddressList.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "ServerAddressProvider.hpp" +#include +#include + +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/, falling back to + * $HOME/.config/towards/. + * add() stays pure virtual: subclasses define their own order. + */ +class FileAddressList : public ServerAddressProvider { +protected: + std::vector 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& entries() const override; +}; + +} // namespace tw::app diff --git a/modules/client/src/app/GameContext.hpp b/modules/client/src/app/GameContext.hpp new file mode 100644 index 0000000..f4dbbd8 --- /dev/null +++ b/modules/client/src/app/GameContext.hpp @@ -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 diff --git a/modules/client/src/app/GameState.cpp b/modules/client/src/app/GameState.cpp new file mode 100644 index 0000000..94176d7 --- /dev/null +++ b/modules/client/src/app/GameState.cpp @@ -0,0 +1,34 @@ +#include "GameState.hpp" + +namespace tw::app { + +GameState::GameState(GameContext context, std::unique_ptr connection) + : m_context(context), + m_connection(std::move(connection)), + m_entity_gui(context.world), + m_network_gui(*context.metrics) +{ + m_controller = std::make_unique( + 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 diff --git a/modules/client/src/app/GameState.hpp b/modules/client/src/app/GameState.hpp new file mode 100644 index 0000000..24097d5 --- /dev/null +++ b/modules/client/src/app/GameState.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include + +#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 m_connection; + std::unique_ptr 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 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 diff --git a/modules/client/src/app/LobbyState.cpp b/modules/client/src/app/LobbyState.cpp new file mode 100644 index 0000000..5c64c40 --- /dev/null +++ b/modules/client/src/app/LobbyState.cpp @@ -0,0 +1,182 @@ +#include "LobbyState.hpp" + +#include "ClientArgs.hpp" +#include "imgui.h" + +#include + +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 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(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 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 diff --git a/modules/client/src/app/LobbyState.hpp b/modules/client/src/app/LobbyState.hpp new file mode 100644 index 0000000..cc54eb6 --- /dev/null +++ b/modules/client/src/app/LobbyState.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include + +#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 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 m_connection; + std::optional 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 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 take_result(); +}; + +} // namespace tw::app diff --git a/modules/client/src/app/RecentServers.cpp b/modules/client/src/app/RecentServers.cpp new file mode 100644 index 0000000..3ad26da --- /dev/null +++ b/modules/client/src/app/RecentServers.cpp @@ -0,0 +1,31 @@ +#include "RecentServers.hpp" + +#include + +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 diff --git a/modules/client/src/app/RecentServers.hpp b/modules/client/src/app/RecentServers.hpp new file mode 100644 index 0000000..cbb2e60 --- /dev/null +++ b/modules/client/src/app/RecentServers.hpp @@ -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 diff --git a/modules/client/src/app/ServerAddressProvider.hpp b/modules/client/src/app/ServerAddressProvider.hpp new file mode 100644 index 0000000..565321d --- /dev/null +++ b/modules/client/src/app/ServerAddressProvider.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include +#include + +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& 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 diff --git a/modules/client/src/debug/DebugUI.cpp b/modules/client/src/debug/DebugUI.cpp new file mode 100644 index 0000000..efc6573 --- /dev/null +++ b/modules/client/src/debug/DebugUI.cpp @@ -0,0 +1,44 @@ +#include "DebugUI.hpp" +#include + +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(); +} + +} diff --git a/modules/client/src/debug/DebugUI.hpp b/modules/client/src/debug/DebugUI.hpp new file mode 100644 index 0000000..28c2c54 --- /dev/null +++ b/modules/client/src/debug/DebugUI.hpp @@ -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(); +}; + +} diff --git a/modules/client/src/debug/DebugWindow.cpp b/modules/client/src/debug/DebugWindow.cpp new file mode 100644 index 0000000..a6cf722 --- /dev/null +++ b/modules/client/src/debug/DebugWindow.cpp @@ -0,0 +1,26 @@ +#include "DebugWindow.hpp" +#include +#include + +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(); +} + +} diff --git a/modules/client/src/debug/DebugWindow.hpp b/modules/client/src/debug/DebugWindow.hpp new file mode 100644 index 0000000..c1097fb --- /dev/null +++ b/modules/client/src/debug/DebugWindow.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include + +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(); +}; + +} diff --git a/modules/client/src/debug/DebugWindowRegistry.cpp b/modules/client/src/debug/DebugWindowRegistry.cpp new file mode 100644 index 0000000..6418f4a --- /dev/null +++ b/modules/client/src/debug/DebugWindowRegistry.cpp @@ -0,0 +1,59 @@ +#include "DebugWindowRegistry.hpp" +#include "DebugWindow.hpp" +#include +#include + +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 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(); + } +} + +} diff --git a/modules/client/src/debug/DebugWindowRegistry.hpp b/modules/client/src/debug/DebugWindowRegistry.hpp new file mode 100644 index 0000000..16cc3d1 --- /dev/null +++ b/modules/client/src/debug/DebugWindowRegistry.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include + +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 m_windows; + + /** + * Keyed by panel id, remembered across the panels themselves. + */ + std::unordered_map 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(); +}; + +} diff --git a/modules/client/src/debug/metrics/NetworkMetrics.hpp b/modules/client/src/debug/metrics/NetworkMetrics.hpp new file mode 100644 index 0000000..a553839 --- /dev/null +++ b/modules/client/src/debug/metrics/NetworkMetrics.hpp @@ -0,0 +1,159 @@ +#pragma once + +#include "metrics/MetricSeries.hpp" + +#include +#include + +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; + + /** 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(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; + } +}; + +} diff --git a/modules/client/src/debug/tools/EntityManagerGui.cpp b/modules/client/src/debug/tools/EntityManagerGui.cpp index dcee6d7..15975f5 100644 --- a/modules/client/src/debug/tools/EntityManagerGui.cpp +++ b/modules/client/src/debug/tools/EntityManagerGui.cpp @@ -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(); } } diff --git a/modules/client/src/debug/tools/EntityManagerGui.hpp b/modules/client/src/debug/tools/EntityManagerGui.hpp index 5eed462..7d719c7 100644 --- a/modules/client/src/debug/tools/EntityManagerGui.hpp +++ b/modules/client/src/debug/tools/EntityManagerGui.hpp @@ -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(); }; } diff --git a/modules/client/src/debug/tools/MetricWidget.hpp b/modules/client/src/debug/tools/MetricWidget.hpp index 27cffca..5077181 100644 --- a/modules/client/src/debug/tools/MetricWidget.hpp +++ b/modules/client/src/debug/tools/MetricWidget.hpp @@ -2,95 +2,109 @@ #include #include -#include -#include "metrics/BucketMetric.hpp" +#include "metrics/MetricSeries.hpp" + +#include +#include +#include +#include namespace tw::dbg::tools { -template> +/** + * 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& m_metric; - - using Self = MetricWidget; - - 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& metric - ) : - m_name(name), - m_metric(metric) - { - y_axis = { - .constraint_from = 0, - .constraint_to = 500, - .from = 0, - .to = 250 - }; + using Series = metrics::MetricSeries; + +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 m_ages; + std::vector m_values; + std::vector m_lows; + std::vector 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(); } diff --git a/modules/client/src/debug/tools/NetworkStatsGui.hpp b/modules/client/src/debug/tools/NetworkStatsGui.hpp index 6af52ad..280ef96 100644 --- a/modules/client/src/debug/tools/NetworkStatsGui.hpp +++ b/modules/client/src/debug/tools/NetworkStatsGui.hpp @@ -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 -#include +#include namespace tw::dbg::tools { -class NetworkStatsGui { -private: - // MetricWidget> m_ping_widget; - // MetricWidget m_outgoing_widget; - // MetricWidget 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) + { } }; } diff --git a/modules/client/src/debug/tools/PacketBacklogGui.hpp b/modules/client/src/debug/tools/PacketBacklogGui.hpp deleted file mode 100644 index 13600e5..0000000 --- a/modules/client/src/debug/tools/PacketBacklogGui.hpp +++ /dev/null @@ -1,57 +0,0 @@ -#pragma once - -#include -#include - -#include "imgui.h" - -#include "metrics/NetworkStatsLogger.hpp" - -namespace tw::dbg::tools { - -class PacketBacklogGui { -private: - std::vector 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(); - // } - } -}; - -} diff --git a/modules/client/src/debug/tools/PacketLogger.hpp b/modules/client/src/debug/tools/PacketLogger.hpp index a3d368c..31b19fe 100644 --- a/modules/client/src/debug/tools/PacketLogger.hpp +++ b/modules/client/src/debug/tools/PacketLogger.hpp @@ -2,7 +2,6 @@ #include "Address.hpp" #include "ByteBuffer.hpp" -#include "packets/Packet.hpp" #include namespace tw::dbg::tools { diff --git a/modules/client/src/debug/tools/PerformanceStatsGui.cpp b/modules/client/src/debug/tools/PerformanceStatsGui.cpp index 23f95b6..b0cfdf1 100644 --- a/modules/client/src/debug/tools/PerformanceStatsGui.cpp +++ b/modules/client/src/debug/tools/PerformanceStatsGui.cpp @@ -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(); } } diff --git a/modules/client/src/debug/tools/PerformanceStatsGui.hpp b/modules/client/src/debug/tools/PerformanceStatsGui.hpp index e31f316..ce0ab56 100644 --- a/modules/client/src/debug/tools/PerformanceStatsGui.hpp +++ b/modules/client/src/debug/tools/PerformanceStatsGui.hpp @@ -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(); }; } diff --git a/modules/client/src/network/PlayerReconciler.cpp b/modules/client/src/network/PlayerReconciler.cpp new file mode 100644 index 0000000..d47bd1c --- /dev/null +++ b/modules/client/src/network/PlayerReconciler.cpp @@ -0,0 +1,122 @@ +#include "PlayerReconciler.hpp" + +#include "world/JoltPhysicsWorld.hpp" +#include "world/CharacterBody.hpp" +#include "world/Transform.hpp" +#include +#include +#include + +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(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(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; + } +} + +} diff --git a/modules/client/src/network/PlayerReconciler.hpp b/modules/client/src/network/PlayerReconciler.hpp new file mode 100644 index 0000000..214db50 --- /dev/null +++ b/modules/client/src/network/PlayerReconciler.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include + +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 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; } +}; + +} diff --git a/modules/client/src/network/ServerConnection.cpp b/modules/client/src/network/ServerConnection.cpp new file mode 100644 index 0000000..8cb1984 --- /dev/null +++ b/modules/client/src/network/ServerConnection.cpp @@ -0,0 +1,84 @@ +#include "ServerConnection.hpp" + +#include + +namespace tw::net { + +ServerConnection::ServerConnection(Address address) : + m_address(address), + m_status(ConnectionStatus::Idle), + m_started_at(Clock::now()) { +} + +tl::expected 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; +} + +} diff --git a/modules/client/src/network/ServerConnection.hpp b/modules/client/src/network/ServerConnection.hpp new file mode 100644 index 0000000..38af43d --- /dev/null +++ b/modules/client/src/network/ServerConnection.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +#include "Address.hpp" +#include "message_protocol/MessageEndpoint.hpp" +#include "message_protocol/MessageConnection.hpp" +#include "message_protocol/MessageError.hpp" + +#include + +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 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 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; +}; + +} diff --git a/modules/client/src/runtime.cpp b/modules/client/src/runtime.cpp index 2a6af15..25bb549 100644 --- a/modules/client/src/runtime.cpp +++ b/modules/client/src/runtime.cpp @@ -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 #include #include @@ -32,17 +32,23 @@ std::unique_ptr 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 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, 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(&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(context(), std::move(result->connection)); + } + return; + } -// m_world.registry().emplace(entity, Transform(mesg->player_states[i].position)); -// m_world.registry().emplace(entity, -// PlayerInfoComponent( -// mesg->player_states[i].id, -// mesg->player_states[i].name)); -// m_world.registry().emplace(entity, mesh); - -// } else { -// auto entity = (entt::entity)mesg->player_states[i].id; -// auto entity_ts = m_world.registry() -// .try_get(entity); - -// if(entity_ts) { -// entity_ts->transform = glm::translate(glm::mat4(1.0f), mesg->player_states[i].position); -// } -// } -// } - -// return true; -// } + std::get(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().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; diff --git a/modules/client/src/runtime.hpp b/modules/client/src/runtime.hpp index c168d17..652702c 100644 --- a/modules/client/src/runtime.hpp +++ b/modules/client/src/runtime.hpp @@ -1,9 +1,16 @@ #pragma once +#include + +#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 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 m_state; + bool m_is_running; std::unordered_map 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(); }; diff --git a/modules/client/src/world/Camera.hpp b/modules/client/src/world/Camera.hpp index 5c65302..7112a99 100644 --- a/modules/client/src/world/Camera.hpp +++ b/modules/client/src/world/Camera.hpp @@ -14,8 +14,7 @@ struct CameraData { CameraData(glm::mat4 projection, Transform view) : projection(projection), view(view) - { - } + { } }; class Camera { diff --git a/modules/client/src/world/ClientWorldController.cpp b/modules/client/src/world/ClientWorldController.cpp index 4a7ed89..3d23819 100644 --- a/modules/client/src/world/ClientWorldController.cpp +++ b/modules/client/src/world/ClientWorldController.cpp @@ -4,7 +4,7 @@ #include #include -#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 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* mesg) { - if(!m_is_connected) { - spdlog::info("Joined the game!"); - } + m_messages.set_handler( + [this](msg::PeerId, const mmo::LoginResponse& mesg) { + spdlog::info("Logged in!"); }); - m_messenger->set_raw_handler(Message::value, - [&](std::span data) -> tl::expected { + m_messages.set_handler( + [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::value, + [this](msg::PeerId, std::span 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(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(entity.value()); @@ -193,33 +247,111 @@ ClientWorldController::ClientWorldController( } // apply_entity_positions(); - - - return {}; }); - m_messenger->set_handler( - [&](mmo::EntitySpawnMessage* mesg) { - auto entity = create_entity(mesg->name(), glm::vec3()); + m_messages.set_handler( + [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(entity); + glm::vec3 position = transform ? transform->position() : glm::vec3(0.0f); + + m_world->registry().emplace(entity, 20.0f); + m_world->registry().emplace(entity, m_physics_world->create_character( + new JPH::BoxShape(JPH::Vec3Arg(0.5f, 0.5f, 0.5f)), + position + )); + + if(m_world->registry().all_of(entity)) { + m_world->registry().remove(entity); + } +} + +void ClientWorldController::snap_player_to(entt::entity entity, glm::vec3 position) { + CharacterBody* body = m_world->registry().try_get(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(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(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(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(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(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(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(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(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); + } + } } } diff --git a/modules/client/src/world/ClientWorldController.hpp b/modules/client/src/world/ClientWorldController.hpp index 7f8bf2c..81da5fe 100644 --- a/modules/client/src/world/ClientWorldController.hpp +++ b/modules/client/src/world/ClientWorldController.hpp @@ -3,9 +3,8 @@ #include #include -#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 m_player_entity; + std::optional m_controlled_server_id; ThirdPersonPlayerController m_player_controller; - std::optional 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 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 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 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 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(); diff --git a/modules/io/CMakeLists.txt b/modules/io/CMakeLists.txt new file mode 100644 index 0000000..7a43a16 --- /dev/null +++ b/modules/io/CMakeLists.txt @@ -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 +) diff --git a/modules/io/README.md b/modules/io/README.md new file mode 100644 index 0000000..1e05fde --- /dev/null +++ b/modules/io/README.md @@ -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` / `Write` interfaces every stream implements (`read_into`, `write`, `flush`, plus `read_exact` helpers) | +| `io/BufferReader.hpp`, `io/BufferWriter.hpp` | Buffering decorators that wrap another `Read` / `Write` | +| `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` 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. diff --git a/modules/network/include/NetworkError.hpp b/modules/io/include/NetworkError.hpp similarity index 100% rename from modules/network/include/NetworkError.hpp rename to modules/io/include/NetworkError.hpp diff --git a/modules/network/include/bytebuffer/ByteBuffer.hpp b/modules/io/include/bytebuffer/ByteBuffer.hpp similarity index 100% rename from modules/network/include/bytebuffer/ByteBuffer.hpp rename to modules/io/include/bytebuffer/ByteBuffer.hpp diff --git a/modules/network/include/bytebuffer/ByteBufferCodec.hpp b/modules/io/include/bytebuffer/ByteBufferCodec.hpp similarity index 100% rename from modules/network/include/bytebuffer/ByteBufferCodec.hpp rename to modules/io/include/bytebuffer/ByteBufferCodec.hpp diff --git a/modules/network/include/bytebuffer/ByteBufferDecoder.hpp b/modules/io/include/bytebuffer/ByteBufferDecoder.hpp similarity index 100% rename from modules/network/include/bytebuffer/ByteBufferDecoder.hpp rename to modules/io/include/bytebuffer/ByteBufferDecoder.hpp diff --git a/modules/network/include/bytebuffer/ByteBufferEncoder.hpp b/modules/io/include/bytebuffer/ByteBufferEncoder.hpp similarity index 100% rename from modules/network/include/bytebuffer/ByteBufferEncoder.hpp rename to modules/io/include/bytebuffer/ByteBufferEncoder.hpp diff --git a/modules/network/include/bytebuffer/ByteBufferReader.hpp b/modules/io/include/bytebuffer/ByteBufferReader.hpp similarity index 100% rename from modules/network/include/bytebuffer/ByteBufferReader.hpp rename to modules/io/include/bytebuffer/ByteBufferReader.hpp diff --git a/modules/network/include/bytebuffer/ByteBufferStreamReader.hpp b/modules/io/include/bytebuffer/ByteBufferStreamReader.hpp similarity index 100% rename from modules/network/include/bytebuffer/ByteBufferStreamReader.hpp rename to modules/io/include/bytebuffer/ByteBufferStreamReader.hpp diff --git a/modules/network/include/bytebuffer/ByteBufferWriter.hpp b/modules/io/include/bytebuffer/ByteBufferWriter.hpp similarity index 100% rename from modules/network/include/bytebuffer/ByteBufferWriter.hpp rename to modules/io/include/bytebuffer/ByteBufferWriter.hpp diff --git a/modules/network/include/exception/ByteBufferOverflowException.hpp b/modules/io/include/exception/ByteBufferOverflowException.hpp similarity index 100% rename from modules/network/include/exception/ByteBufferOverflowException.hpp rename to modules/io/include/exception/ByteBufferOverflowException.hpp diff --git a/modules/network/include/io/BufferReader.hpp b/modules/io/include/io/BufferReader.hpp similarity index 100% rename from modules/network/include/io/BufferReader.hpp rename to modules/io/include/io/BufferReader.hpp diff --git a/modules/network/include/io/BufferWriter.hpp b/modules/io/include/io/BufferWriter.hpp similarity index 100% rename from modules/network/include/io/BufferWriter.hpp rename to modules/io/include/io/BufferWriter.hpp diff --git a/modules/network/include/io/Read.hpp b/modules/io/include/io/Read.hpp similarity index 100% rename from modules/network/include/io/Read.hpp rename to modules/io/include/io/Read.hpp diff --git a/modules/network/include/io/Write.hpp b/modules/io/include/io/Write.hpp similarity index 100% rename from modules/network/include/io/Write.hpp rename to modules/io/include/io/Write.hpp diff --git a/modules/message_protocol/CMakeLists.txt b/modules/message_protocol/CMakeLists.txt new file mode 100644 index 0000000..6cf4a41 --- /dev/null +++ b/modules/message_protocol/CMakeLists.txt @@ -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) diff --git a/modules/message_protocol/include/message_protocol/MessageConnection.hpp b/modules/message_protocol/include/message_protocol/MessageConnection.hpp new file mode 100644 index 0000000..3795e90 --- /dev/null +++ b/modules/message_protocol/include/message_protocol/MessageConnection.hpp @@ -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 + +#include +#include +#include +#include +#include +#include + +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)>; + +private: + struct PendingRequest { + ReplyHandler on_reply; + std::function on_timeout; + std::chrono::steady_clock::time_point expires_at; + }; + + net::quicr::QuicrConnection* m_connection; + MessageDispatcher* m_dispatcher; + PeerId m_peer_id; + + std::vector m_send_buffer; + std::unordered_map 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 send_impl(MessageType type, + std::span 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 send(MessageType type, + std::span 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 send_framed(std::span 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 request( + MessageType type, + std::span body, + ReplyHandler on_reply, + std::chrono::milliseconds timeout = std::chrono::seconds(5), + std::function 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 scratch); + + /** Routes one received message to its reply handler, or to the dispatcher. */ + void on_message(std::span message); + + /** Fails every request whose reply did not arrive before `now`. */ + void expire_requests(std::chrono::steady_clock::time_point now); +}; + +} diff --git a/modules/message_protocol/include/message_protocol/MessageDispatcher.hpp b/modules/message_protocol/include/message_protocol/MessageDispatcher.hpp new file mode 100644 index 0000000..1f627ac --- /dev/null +++ b/modules/message_protocol/include/message_protocol/MessageDispatcher.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "message_protocol/MessageType.hpp" +#include "message_protocol/PeerId.hpp" + +#include +#include +#include + +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)>; + +private: + std::unordered_map 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 body) { + auto handler = m_handlers.find(type); + if(handler == m_handlers.end()) { + return false; + } + + handler->second(peer, body); + return true; + } +}; + +} diff --git a/modules/message_protocol/include/message_protocol/MessageEndpoint.hpp b/modules/message_protocol/include/message_protocol/MessageEndpoint.hpp new file mode 100644 index 0000000..7a98f91 --- /dev/null +++ b/modules/message_protocol/include/message_protocol/MessageEndpoint.hpp @@ -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 + +#include +#include +#include +#include +#include +#include +#include + +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 m_endpoint; + std::unique_ptr m_listener; + + MessageDispatcher m_dispatcher; + + std::unordered_map> m_peers; + std::function m_on_peer_connected; + PeerId m_next_peer_id = 1; + + std::vector m_receive_buffer; + uint64_t m_bytes_received = 0; + + explicit MessageEndpoint(std::unique_ptr 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, MessageError> create(); + + /** Creates an endpoint that also accepts peers on `port`. */ + static tl::expected, MessageError> bind(int port); + + tl::expected 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 handler) { + m_on_peer_connected = std::move(handler); + } + + std::vector peers() const; + + void broadcast(MessageType type, std::span 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; +}; + +} diff --git a/modules/message_protocol/include/message_protocol/MessageError.hpp b/modules/message_protocol/include/message_protocol/MessageError.hpp new file mode 100644 index 0000000..e7d05de --- /dev/null +++ b/modules/message_protocol/include/message_protocol/MessageError.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +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; + } +}; + +} diff --git a/modules/message_protocol/include/message_protocol/MessageHeader.hpp b/modules/message_protocol/include/message_protocol/MessageHeader.hpp new file mode 100644 index 0000000..9c6c042 --- /dev/null +++ b/modules/message_protocol/include/message_protocol/MessageHeader.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "message_protocol/MessageType.hpp" + +#include +#include +#include +#include + +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 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 decode(std::span 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; + } +}; + +} diff --git a/modules/message_protocol/include/message_protocol/MessageType.hpp b/modules/message_protocol/include/message_protocol/MessageType.hpp new file mode 100644 index 0000000..d0503e6 --- /dev/null +++ b/modules/message_protocol/include/message_protocol/MessageType.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace tw::msg { + +/** + * Address a message is delivered to. Concrete values are assigned by the + * application. + */ +using MessageType = uint32_t; + +} diff --git a/modules/message_protocol/include/message_protocol/PeerId.hpp b/modules/message_protocol/include/message_protocol/PeerId.hpp new file mode 100644 index 0000000..d33799d --- /dev/null +++ b/modules/message_protocol/include/message_protocol/PeerId.hpp @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace tw::msg { + +/** + * Identifies a remote peer. Assigned when the peer connects or is accepted and + * stable until it disconnects. + */ +using PeerId = uint64_t; + +} diff --git a/modules/message_protocol/src/MessageConnection.cpp b/modules/message_protocol/src/MessageConnection.cpp new file mode 100644 index 0000000..2a29162 --- /dev/null +++ b/modules/message_protocol/src/MessageConnection.cpp @@ -0,0 +1,176 @@ +#include "message_protocol/MessageConnection.hpp" + +#include "message_protocol/MessageDispatcher.hpp" +#include "quicr/QuicrConnection.hpp" + +#include + +#include + +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 MessageConnection::send_impl(MessageType type, + std::span 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 MessageConnection::send(MessageType type, + std::span body, + bool reliable) { + return send_impl(type, body, MessageHeader::SEQ_NONE, reliable); +} + +tl::expected MessageConnection::send_framed(std::span 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 MessageConnection::request(MessageType type, + std::span body, + ReplyHandler on_reply, + std::chrono::milliseconds timeout, + std::function 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 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 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; + }); +} + +} diff --git a/modules/message_protocol/src/MessageEndpoint.cpp b/modules/message_protocol/src/MessageEndpoint.cpp new file mode 100644 index 0000000..eafbc32 --- /dev/null +++ b/modules/message_protocol/src/MessageEndpoint.cpp @@ -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 + +namespace tw::msg { + +namespace { +constexpr size_t RECEIVE_BUFFER_SIZE = 64 * 1024; +} + +MessageEndpoint::MessageEndpoint(std::unique_ptr endpoint) : + m_endpoint(std::move(endpoint)), + m_receive_buffer(RECEIVE_BUFFER_SIZE) { +} + +MessageEndpoint::~MessageEndpoint() = default; + +tl::expected, 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(new MessageEndpoint(std::move(endpoint_r.value()))); +} + +tl::expected, 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(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 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 MessageEndpoint::peers() const { + std::vector 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 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; +} + +} diff --git a/modules/message_protocol/tests/CMakeLists.txt b/modules/message_protocol/tests/CMakeLists.txt new file mode 100644 index 0000000..259af73 --- /dev/null +++ b/modules/message_protocol/tests/CMakeLists.txt @@ -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}) diff --git a/modules/message_protocol/tests/MessageDispatcherTests.cpp b/modules/message_protocol/tests/MessageDispatcherTests.cpp new file mode 100644 index 0000000..1df7b2d --- /dev/null +++ b/modules/message_protocol/tests/MessageDispatcherTests.cpp @@ -0,0 +1,73 @@ +#include "message_protocol/MessageDispatcher.hpp" + +#include + +#include +#include + +using namespace tw::msg; + +namespace { + +std::span as_bytes(const std::array& 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 body) { + seen_peer = peer; + seen_size = body.size(); + }); + + std::array 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 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) { called = "first"; }); + dispatcher.set_handler(2, [&](PeerId, std::span) { called = "second"; }); + + std::array 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) { called = "first"; }); + dispatcher.set_handler(7, [&](PeerId, std::span) { called = "second"; }); + + std::array body{}; + dispatcher.dispatch(1, 7, as_bytes(body)); + + REQUIRE(called == "second"); +} diff --git a/modules/message_protocol/tests/MessageHeaderTests.cpp b/modules/message_protocol/tests/MessageHeaderTests.cpp new file mode 100644 index 0000000..c4db707 --- /dev/null +++ b/modules/message_protocol/tests/MessageHeaderTests.cpp @@ -0,0 +1,45 @@ +#include "message_protocol/MessageHeader.hpp" + +#include + +#include + +using namespace tw::msg; + +TEST_CASE("Header survives a round trip", "[message_header]") { + std::array 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 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 buffer{}; + + REQUIRE_FALSE(MessageHeader::decode(buffer).has_value()); +} + +TEST_CASE("Encoding only writes the header", "[message_header]") { + std::array buffer{}; + buffer[MessageHeader::SIZE] = std::byte{ 0xAB }; + + MessageHeader{ 1, 2 }.encode(buffer); + + REQUIRE(buffer[MessageHeader::SIZE] == std::byte{ 0xAB }); +} diff --git a/modules/messaging/CMakeLists.txt b/modules/messaging/CMakeLists.txt deleted file mode 100644 index 818f593..0000000 --- a/modules/messaging/CMakeLists.txt +++ /dev/null @@ -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 -) diff --git a/modules/messaging/include/MessageSession.hpp b/modules/messaging/include/MessageSession.hpp deleted file mode 100644 index 2e3e2b6..0000000 --- a/modules/messaging/include/MessageSession.hpp +++ /dev/null @@ -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 -#include - -#include -#include -#include -#include -#include -#include - -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)> handler; - std::chrono::steady_clock::time_point expires_at; - std::function on_timeout; - }; - - std::unique_ptr m_endpoint; - net::quicr::QuicrConnection* m_conn; - std::vector m_recv_buf{64 * 1024}; - std::vector)>> m_handlers{MAX_TYPES}; - std::unordered_map 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::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)> 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 request( - uint32_t type, - std::span payload, - std::function)> on_response, - std::chrono::milliseconds timeout = std::chrono::seconds(5), - std::function 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 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 send(uint32_t type, - std::span 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 send_impl(uint32_t type, - std::span payload, - uint32_t seq, - bool reliable) { - std::vector 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 diff --git a/modules/metrics/CMakeLists.txt b/modules/metrics/CMakeLists.txt new file mode 100644 index 0000000..6886b61 --- /dev/null +++ b/modules/metrics/CMakeLists.txt @@ -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/ +) diff --git a/modules/metrics/include/metrics/MetricSample.hpp b/modules/metrics/include/metrics/MetricSample.hpp new file mode 100644 index 0000000..20caef8 --- /dev/null +++ b/modules/metrics/include/metrics/MetricSample.hpp @@ -0,0 +1,86 @@ +#pragma once + +#include +#include + +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; +} + +} diff --git a/modules/metrics/include/metrics/MetricSeries.hpp b/modules/metrics/include/metrics/MetricSeries.hpp new file mode 100644 index 0000000..99d1e6d --- /dev/null +++ b/modules/metrics/include/metrics/MetricSeries.hpp @@ -0,0 +1,193 @@ +#pragma once + +#include "metrics/MetricSample.hpp" + +#include +#include +#include +#include + +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 +class MetricSeries { +public: + using TimePoint = typename Clock::time_point; + +private: + std::vector 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(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& xs, + std::vector& 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& xs, + std::vector& ys, + MetricField field) const { + return linearize(xs, ys, field, m_count); + } + + void clear() { + m_count = 0; + } +}; + +} diff --git a/modules/metrics/tests/CMakeLists.txt b/modules/metrics/tests/CMakeLists.txt new file mode 100644 index 0000000..115bce1 --- /dev/null +++ b/modules/metrics/tests/CMakeLists.txt @@ -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}) diff --git a/modules/metrics/tests/MetricSampleTests.cpp b/modules/metrics/tests/MetricSampleTests.cpp new file mode 100644 index 0000000..7218dea --- /dev/null +++ b/modules/metrics/tests/MetricSampleTests.cpp @@ -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); +} diff --git a/modules/metrics/tests/MetricSeriesTests.cpp b/modules/metrics/tests/MetricSeriesTests.cpp new file mode 100644 index 0000000..f8aa968 --- /dev/null +++ b/modules/metrics/tests/MetricSeriesTests.cpp @@ -0,0 +1,325 @@ +#include "metrics/MetricSeries.hpp" + +#include "catch2/catch_test_macros.hpp" + +#include + +using namespace std::chrono_literals; + +using tw::metrics::MetricField; +using tw::metrics::MetricSeries; + +using Clock = std::chrono::steady_clock; +using Series = MetricSeries; + +/** 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 xs; + std::vector ys; + + size_t count = series.linearize(xs, ys, MetricField::Sum); + + REQUIRE(count == 3); + REQUIRE(xs == std::vector{-2.0, -1.0, 0.0}); + REQUIRE(ys == std::vector{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 xs; + std::vector ys; + + size_t count = series.linearize(xs, ys, MetricField::Sum, 2); + + REQUIRE(count == 2); + REQUIRE(xs == std::vector{-1.0, 0.0}); + REQUIRE(ys == std::vector{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 xs; + std::vector ys; + + size_t count = series.linearize(xs, ys, MetricField::Sum, 8, 1); + + REQUIRE(count == 2); + REQUIRE(ys == std::vector{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 xs; + std::vector ys; + + series.linearize(xs, ys, MetricField::Sum, 8, 1); + + REQUIRE(xs == std::vector{-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 xs; + std::vector 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 xs; + std::vector ys; + + size_t count = series.linearize(xs, ys, MetricField::Sum, 2, 1); + + REQUIRE(count == 2); + REQUIRE(ys == std::vector{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 xs; + std::vector ys; + + series.linearize(xs, ys, MetricField::Max); + + REQUIRE(ys == std::vector{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 xs(64, 7.0); + std::vector 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 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); +} diff --git a/modules/mock_client/CMakeLists.txt b/modules/mock_client/CMakeLists.txt index b3deb5b..5995a40 100644 --- a/modules/mock_client/CMakeLists.txt +++ b/modules/mock_client/CMakeLists.txt @@ -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 diff --git a/modules/mock_client/src/main.cpp b/modules/mock_client/src/main.cpp index bd70445..ebcfd6f 100644 --- a/modules/mock_client/src/main.cpp +++ b/modules/mock_client/src/main.cpp @@ -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 #include +#include #include #include +#include #include -tw::net::MessageHandler create_messenger(tw::net::Address& address) { - return tw::net::MessageHandler(address); +static std::unique_ptr 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 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* mesg) { }); + m_messages.set_handler( + [this](tw::msg::PeerId, const mmo::WorldStateMessage& mesg) { }); - m_handler.set_handler( - [&](mmo::LoginResponse* mesg) { + m_messages.set_handler( + [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); } } }; diff --git a/modules/network/CMakeLists.txt b/modules/network/CMakeLists.txt index 281442a..eff181d 100644 --- a/modules/network/CMakeLists.txt +++ b/modules/network/CMakeLists.txt @@ -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 diff --git a/modules/network/include/frames/Frame.hpp b/modules/network/include/frames/Frame.hpp index 5a34c8a..86d0f3f 100644 --- a/modules/network/include/frames/Frame.hpp +++ b/modules/network/include/frames/Frame.hpp @@ -1,7 +1,7 @@ #pragma once #include "common.hpp" -#include "protocol/quicr/QuicrFrameType.hpp" +#include "quicr/QuicrFrameType.hpp" #include #include diff --git a/modules/network/include/messenger/MessageHandler.hpp b/modules/network/include/messenger/MessageHandler.hpp deleted file mode 100644 index 4f2b9b4..0000000 --- a/modules/network/include/messenger/MessageHandler.hpp +++ /dev/null @@ -1,168 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#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> m_quicr_messenger; - // Messenger m_server_messenger; - std::unique_ptr m_quicr_endpoint; - quicr::QuicrConnection* m_quicr_connection; - - std::vector(std::span)>> m_handlers; - - std::unique_ptr 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(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&& server_messenger) : - // // m_server_messenger{std::move(server_messenger)}, - // m_quicr_connection(std::move(server_messenger.connection())), - // m_handlers(100) { - - // } - - template - constexpr void set_handler(const std::function handler) { - PacketType type = Message::value; - m_handlers[type] = [handler, this](std::span data) -> tl::expected { - T result = {}; - - result.ParseFromArray(data.data(), data.size()); - // spdlog::info("Deserialized message [{}]: {}", (int32_t)Message::value, result.DebugString()); - - handler(&result); - // if(m_server_messenger.peek().has_value() && m_server_messenger.peek().value() == Message::value) { - // tl::expected mesg = m_server_messenger.pop(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(std::span)> handler) { - m_handlers[type] = handler; - } - - void update() { - m_quicr_endpoint->poll(); - while(true) { - std::vector 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(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(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 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 T> - tl::expected 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 bytes(length + sizeof(uint32_t)); - - uint32_t type = Message::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(); - } -}; - -} diff --git a/modules/network/include/messenger/Messenger.hpp b/modules/network/include/messenger/Messenger.hpp deleted file mode 100644 index 33173b7..0000000 --- a/modules/network/include/messenger/Messenger.hpp +++ /dev/null @@ -1,209 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include - -#include "NetworkError.hpp" -#include "packets/Packet.hpp" -#include "MessageRegistry.hpp" -#include "protocol/quicr/QuicrFrameType.hpp" -#include "tl/expected.hpp" - -namespace tw::net { - -template> TOutput> -class Messenger { -private: - const uint32_t MAX_MESG_BODY_SIZE = 65536; - const uint32_t MESG_MAGIC = 0x1DEADBEE; - - TOutput m_stream; - - std::optional 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 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 operator=(const Messenger&) = delete; - - Messenger operator=(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; - } - - template T> - tl::expected send(T &content) { - ZoneScopedN("Messenger::send"); - auto id = (int32_t)Message::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::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, 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(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(m_input_buffer.data())); - return m_next_packet_type; - } - - template - tl::expected 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 data(message_length); - // m_stream.read_exact(std::as_writable_bytes(std::span{data.data(), (size_t)message_length})); - - // m_input_buffer.reset(); - } -}; - -} diff --git a/modules/network/include/messenger/MessengerDebugLog.hpp b/modules/network/include/messenger/MessengerDebugLog.hpp deleted file mode 100644 index 6e7ad75..0000000 --- a/modules/network/include/messenger/MessengerDebugLog.hpp +++ /dev/null @@ -1,29 +0,0 @@ -#pragma once - -#include "MessageRegistry.hpp" -#include -#include -#include - -class MessengerDebugLog { -public: - MessengerDebugLog(MessengerDebugLog&& m) : - m_log_file(std::move(m.m_log_file)) - { } - - MessengerDebugLog(const std::string& log_file_path); - ~MessengerDebugLog(); - - template - void log_send(const T& message) { - spdlog::info("Sending [{}]: {}", (int)tw::Message::value, message.DebugString()); - } - - template - void log_recv(const T& message) { - spdlog::info("Received [{}]: {}", (int)tw::Message::value, message.DebugString()); - } - -private: - std::ofstream m_log_file; -}; diff --git a/modules/network/include/metrics/BucketMetric.hpp b/modules/network/include/metrics/BucketMetric.hpp deleted file mode 100644 index dacd4b4..0000000 --- a/modules/network/include/metrics/BucketMetric.hpp +++ /dev/null @@ -1,157 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include - -namespace tw::net { - -template -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 -struct SumOp { - T sum; - - void add(const T value) { - sum += value; - } - - T result() const { - return sum; - } -}; - -template, - typename Clock = std::chrono::steady_clock> -class BucketMetric { - - T m_min, m_max; - - std::vector m_metric; - std::vector 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(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 get_head() { - return std::span(m_metric).subspan(m_left, (m_right > m_left ? m_right : m_metric.size())); - } - - std::span get_head_timeline() { - return std::span(m_bucket_idx).subspan(m_left, (m_right > m_left ? m_right : m_bucket_idx.size())); - } - - std::span get_tail() { - if(m_right > m_left) { - return std::span(); - } - - return std::span(m_metric).subspan(0, m_right); - } - - std::span get_tail_timeline() { - if(m_right > m_left) { - return std::span(); - } - - return std::span(m_bucket_idx).subspan(0, m_right); - } -}; - -} diff --git a/modules/network/include/metrics/HistoryBuffer.hpp b/modules/network/include/metrics/HistoryBuffer.hpp index 62b7d32..f688dc5 100644 --- a/modules/network/include/metrics/HistoryBuffer.hpp +++ b/modules/network/include/metrics/HistoryBuffer.hpp @@ -42,7 +42,7 @@ public: } std::optional 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; } diff --git a/modules/network/include/metrics/NetworkStatsLogger.hpp b/modules/network/include/metrics/NetworkStatsLogger.hpp deleted file mode 100644 index d853c92..0000000 --- a/modules/network/include/metrics/NetworkStatsLogger.hpp +++ /dev/null @@ -1,134 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#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 buffer; - - NetworkSendInfo( - PacketType message_type, - bool is_sent_by_us, - const Address& target, - const std::span 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 m_backlog; - - std::vector m_buffer; - size_t m_left, m_right; - - std::optional m_output; - - using Interval = std::chrono::seconds; - - BucketMetric> m_ping_metric; - BucketMetric> m_outgoing; - BucketMetric> 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 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(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 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>& ping(){ - return m_ping_metric; - } - - BucketMetric& outgoing(){ - return m_outgoing; - } - - BucketMetric& incoming(){ - return m_incoming; - } -}; - -} diff --git a/modules/network/include/packets/LoginPacket.hpp b/modules/network/include/packets/LoginPacket.hpp deleted file mode 100644 index 91a0bd2..0000000 --- a/modules/network/include/packets/LoginPacket.hpp +++ /dev/null @@ -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 { -// public: -// static constexpr PacketType value = LOGIN_REQUEST_MSG; -// }; - - - - -template<> -class tw::net::Serializer 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 final { -public: - static bool serialize(Serialization& buffer, LoginStatusPacket& value) { - return buffer.serialize(&value.is_okay); - } -}; diff --git a/modules/network/include/packets/Packet.hpp b/modules/network/include/packets/Packet.hpp deleted file mode 100644 index 4e70fd9..0000000 --- a/modules/network/include/packets/Packet.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "Serializers.hpp" - -// #define PACKET(name) struct #name { - - - - -// template<> -// class tw::net::Serializer 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 final { -// public: -// static bool serialize(tw::net::Serialization& buffer, PacketType& value) { -// return buffer.serialize((uint32_t*)&value); -// } -// }; diff --git a/modules/network/include/protocol/quicr/QuicrError.hpp b/modules/network/include/protocol/quicr/QuicrError.hpp deleted file mode 100644 index 934f7d0..0000000 --- a/modules/network/include/protocol/quicr/QuicrError.hpp +++ /dev/null @@ -1,34 +0,0 @@ -#pragma once - -#include -#include -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_; -}; - -} diff --git a/modules/network/include/protocol/quicr/QuicrStream.hpp b/modules/network/include/protocol/quicr/QuicrStream.hpp deleted file mode 100644 index ac197af..0000000 --- a/modules/network/include/protocol/quicr/QuicrStream.hpp +++ /dev/null @@ -1,34 +0,0 @@ - -#include "NetworkError.hpp" -#include "protocol/quicr/QuicrConnection.hpp" -#include "tl/expected.hpp" - -namespace tw::net::quicr { - -class QuicrStream : Write, Read { - QuicrConnection* m_connection; - bool m_is_reliable; - -public: - QuicrStream(QuicrConnection* connection, bool is_reliable); - - tl::expected write(std::span 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 read_into(std::span 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; - } -}; - -} diff --git a/modules/network/src/NetworkStatsLogger.cpp b/modules/network/src/NetworkStatsLogger.cpp deleted file mode 100644 index 462a49b..0000000 --- a/modules/network/src/NetworkStatsLogger.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#include "metrics/NetworkStatsLogger.hpp" - -namespace tw::net { - - -} diff --git a/modules/network/src/messenger/Messenger.cpp b/modules/network/src/messenger/Messenger.cpp deleted file mode 100644 index d1291ba..0000000 --- a/modules/network/src/messenger/Messenger.cpp +++ /dev/null @@ -1,6 +0,0 @@ -#include "messenger/Messenger.hpp" - -namespace tw::net { - - -} diff --git a/modules/network/src/messenger/MessengerDebugLog.cpp b/modules/network/src/messenger/MessengerDebugLog.cpp deleted file mode 100644 index ad8f539..0000000 --- a/modules/network/src/messenger/MessengerDebugLog.cpp +++ /dev/null @@ -1,12 +0,0 @@ -#include "messenger/MessengerDebugLog.hpp" -#include - -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(); -} diff --git a/modules/network/tests/CMakeLists.txt b/modules/network/tests/CMakeLists.txt index a18a90b..ce240bb 100644 --- a/modules/network/tests/CMakeLists.txt +++ b/modules/network/tests/CMakeLists.txt @@ -18,29 +18,6 @@ target_link_libraries(${PROJECT_NAME}_sources ) add_executable(${PROJECT_NAME}) -add_executable(QuicrOverloadTest ./quicr/QuicrOverloadTests.cpp) -add_executable(QuicrBenchmarks ./quicr/QuicrBenchmarks.cpp) - -target_link_libraries(QuicrBenchmarks - PRIVATE - ${LIBS} - ${PROJECT_NAME}_sources - Tracy::TracyClient - Catch2::Catch2WithMain - tl::expected - EnTT::EnTT -) - -target_link_libraries(QuicrOverloadTest - PRIVATE - ${LIBS} - ${PROJECT_NAME}_sources - Tracy::TracyClient - TracyClient - Catch2::Catch2WithMain - tl::expected - EnTT::EnTT -) target_link_libraries(${PROJECT_NAME} PRIVATE diff --git a/modules/network/tests/FrameEncoderTests.cpp b/modules/network/tests/FrameEncoderTests.cpp index 66ccab2..bc0bd5c 100644 --- a/modules/network/tests/FrameEncoderTests.cpp +++ b/modules/network/tests/FrameEncoderTests.cpp @@ -2,7 +2,7 @@ #include "bytebuffer/ByteBufferDecoder.hpp" #include "catch2/catch_test_macros.hpp" #include "frames/FrameCodec.hpp" -#include "protocol/quicr/QuicrFrameType.hpp" +#include "quicr/QuicrFrameType.hpp" using namespace tw::net; diff --git a/modules/network/tests/MessengerTests.cpp b/modules/network/tests/MessengerTests.cpp deleted file mode 100644 index a6c1b9d..0000000 --- a/modules/network/tests/MessengerTests.cpp +++ /dev/null @@ -1,46 +0,0 @@ -#include - -// #include "io/Read.hpp" -// #include "messenger/Messenger.hpp" - -// class MockReader : public Read { -// size_t m_cursor; -// std::string m_content; - -// public: -// MockReader(const std::string& content) : -// m_cursor(0), -// m_content(content) { - -// } - -// size_t read(std::span data) override { -// size_t read_len = std::min(data.size(), m_content.size() - m_cursor); -// if(read_len == 0) { -// return 0; -// } - -// std::copy(m_content.begin() + m_cursor, m_content.begin() + m_cursor + read_len, data.begin()); -// m_cursor += read_len; -// return read_len; -// } -// }; - -// class MockWriter : public Write { - -// public: -// MockWriter() { - -// } - -// size_t write(std::span data) override { -// } -// }; - -// TEST_CASE("Test01", "[Messenger_Test]") { -// MockReader reader("0Hello, World!"); -// MockWriter writer; -// tw::net::Messenger messenger(&writer, &reader); - -// REQUIRE(messenger.peek() == '0'); -// } diff --git a/modules/network/tests/UdpStreamTests.cpp b/modules/network/tests/UdpStreamTests.cpp index 134b853..aee467f 100644 --- a/modules/network/tests/UdpStreamTests.cpp +++ b/modules/network/tests/UdpStreamTests.cpp @@ -5,9 +5,9 @@ #include "UdpStream.hpp" #include "Address.hpp" -#include "protocol/quicr/QuicrConnection.hpp" -#include "protocol/quicr/QuicrConnectionListener.hpp" -#include "protocol/quicr/QuicrEndpoint.hpp" +#include "quicr/QuicrConnection.hpp" +#include "quicr/QuicrConnectionListener.hpp" +#include "quicr/QuicrEndpoint.hpp" TEST_CASE("Start two sockets and send message", "[udp]") { std::barrier create_sync_point(2); diff --git a/modules/peer_to_peer/CMakeLists.txt b/modules/peer_to_peer/CMakeLists.txt index beb6b43..9a0b5b4 100644 --- a/modules/peer_to_peer/CMakeLists.txt +++ b/modules/peer_to_peer/CMakeLists.txt @@ -24,7 +24,9 @@ target_link_libraries(tw_peer_to_peer_lib PUBLIC towards tw::network + tw::quicr tw::protocol + tw::message_protocol glm::glm EnTT::EnTT spdlog::spdlog diff --git a/modules/peer_to_peer/src/PeerWorldController.cpp b/modules/peer_to_peer/src/PeerWorldController.cpp index cff467f..cc5bc3b 100644 --- a/modules/peer_to_peer/src/PeerWorldController.cpp +++ b/modules/peer_to_peer/src/PeerWorldController.cpp @@ -36,8 +36,6 @@ PeerWorldController::PeerWorldController(uint32_t self_id, uint16_t port, }); } -// ── registry & connection ───────────────────────────────────────────────────── - void PeerWorldController::register_self() { PeerRegistry reg(m_registry_path); reg.register_self(m_self_id, "127.0.0.1:" + std::to_string(m_port)); @@ -86,8 +84,6 @@ void PeerWorldController::wait_for_connections(uint32_t expected_count, m_self_id, m_peer_ids.size(), expected_count); } -// ── tick ────────────────────────────────────────────────────────────────────── - bool PeerWorldController::tick() { m_link->poll(); @@ -122,8 +118,6 @@ bool PeerWorldController::tick() { return has_advanced; } -// ── helpers ─────────────────────────────────────────────────────────────────── - mmo::peer::PeerAction PeerWorldController::build_local_action(uint32_t frame) const { float t = static_cast(frame) * static_cast(WorldStepProcessor::FIXED_DELTA_S); diff --git a/modules/peer_to_peer/src/PeerWorldController.hpp b/modules/peer_to_peer/src/PeerWorldController.hpp index f62bd58..6d9babf 100644 --- a/modules/peer_to_peer/src/PeerWorldController.hpp +++ b/modules/peer_to_peer/src/PeerWorldController.hpp @@ -56,6 +56,9 @@ public: const std::vector& peer_ids() const { return m_peer_ids; } + uint32_t get_peer_latest_snapshot_frame(uint32_t peer_id) { + } + private: uint32_t m_self_id; uint16_t m_port; diff --git a/modules/peer_to_peer/src/QuicrPeerLink.cpp b/modules/peer_to_peer/src/QuicrPeerLink.cpp index 1f13d8b..ab32dd6 100644 --- a/modules/peer_to_peer/src/QuicrPeerLink.cpp +++ b/modules/peer_to_peer/src/QuicrPeerLink.cpp @@ -1,6 +1,6 @@ #include "QuicrPeerLink.hpp" -#include "protocol/quicr/QuicrConnectionListener.hpp" -#include "protocol/quicr/QuicrEndpoint.hpp" +#include "quicr/QuicrConnectionListener.hpp" +#include "quicr/QuicrEndpoint.hpp" #include #include @@ -15,7 +15,7 @@ QuicrPeerLink::QuicrPeerLink(uint32_t self_id, uint16_t port) {} void QuicrPeerLink::connect_to(uint32_t peer_id, const tw::net::Address& addr) { - auto r = m_endpoint->connect(addr); + auto r = m_endpoint->connect(net::quicr::QuicrAddress(addr.ip_string(), addr.port())); if (!r) { spdlog::warn("QuicrPeerLink[{}]: connect to peer {} failed", m_self_id, peer_id); return; diff --git a/modules/peer_to_peer/src/QuicrPeerLink.hpp b/modules/peer_to_peer/src/QuicrPeerLink.hpp index 42432d3..e73c573 100644 --- a/modules/peer_to_peer/src/QuicrPeerLink.hpp +++ b/modules/peer_to_peer/src/QuicrPeerLink.hpp @@ -1,9 +1,9 @@ #pragma once #include "PeerLink.hpp" -#include "protocol/quicr/QuicrConnection.hpp" -#include "protocol/quicr/QuicrConnectionListener.hpp" -#include "protocol/quicr/QuicrEndpoint.hpp" +#include "quicr/QuicrConnection.hpp" +#include "quicr/QuicrConnectionListener.hpp" +#include "quicr/QuicrEndpoint.hpp" #include #include diff --git a/modules/protocol/CMakeLists.txt b/modules/protocol/CMakeLists.txt index da6c3ee..99ab2c8 100644 --- a/modules/protocol/CMakeLists.txt +++ b/modules/protocol/CMakeLists.txt @@ -24,6 +24,7 @@ target_link_libraries(${PROJECT_NAME} PUBLIC glm::glm protobuf::libprotobuf + tw::message_protocol ) target_include_directories(${PROJECT_NAME} diff --git a/modules/protocol/proto/Entity.proto b/modules/protocol/proto/Entity.proto index 6e2f032..22fed36 100644 --- a/modules/protocol/proto/Entity.proto +++ b/modules/protocol/proto/Entity.proto @@ -11,3 +11,7 @@ message EntitySpawnMessage { message EntityDespawnMessage { uint32 entity_id = 1; } + +message SetControlledEntity { + uint32 entity_id = 1; +} diff --git a/modules/protocol/src/MessageRegistry.hpp b/modules/protocol/src/MessageRegistry.hpp index 6e80ecc..7919db9 100644 --- a/modules/protocol/src/MessageRegistry.hpp +++ b/modules/protocol/src/MessageRegistry.hpp @@ -7,10 +7,17 @@ #include "PlayerMove.pb.h" #include "Entity.pb.h" +#include "message_protocol/MessageType.hpp" + #include #include -enum PacketType { +namespace tw { + +/** + * Every address this application assigns, and the message that travels to it. + */ +enum MessageType : msg::MessageType { MESSAGE_PACKET, LOGIN_REQUEST_MSG, LOGIN_RESPONSE_PACKET, @@ -33,9 +40,11 @@ enum PacketType { CLUSTER_ZONE_HELLO, CLUSTER_ZONE_BYE, + + SET_CONTROLLED_ENTITY_MSG, }; -namespace tw { + template class Message; @@ -43,99 +52,107 @@ class Message; template<> class Message { public: - static constexpr PacketType value = LOGIN_REQUEST_MSG; + static constexpr MessageType value = LOGIN_REQUEST_MSG; }; template<> class Message { public: - static constexpr PacketType value = LOGIN_RESPONSE_PACKET; + static constexpr MessageType value = LOGIN_RESPONSE_PACKET; }; template<> class Message { public: - static constexpr PacketType value = WORLD_STATE_PACKET; + static constexpr MessageType value = WORLD_STATE_PACKET; }; template<> class Message { public: - static constexpr PacketType value = PLAYER_UPDATE_MSG; + static constexpr MessageType value = PLAYER_UPDATE_MSG; }; template<> class Message { public: - static constexpr PacketType value = ENTITY_SPAWN_MSG; + static constexpr MessageType value = ENTITY_SPAWN_MSG; }; template<> class Message { public: - static constexpr PacketType value = ENTITY_DESPAWN_MSG; + static constexpr MessageType value = ENTITY_DESPAWN_MSG; +}; + +template<> +class Message { +public: + static constexpr MessageType value = SET_CONTROLLED_ENTITY_MSG; }; template<> class Message { public: - static constexpr PacketType value = CHAT_SEND_MESSAGE_REQUEST; + static constexpr MessageType value = CHAT_SEND_MESSAGE_REQUEST; }; template<> class Message { public: - static constexpr PacketType value = CHAT_SEND_MESSAGE_RESPONSE; + static constexpr MessageType value = CHAT_SEND_MESSAGE_RESPONSE; }; template<> class Message { public: - static constexpr PacketType value = CHAT_JOIN_CHANNEL_REQUEST; + static constexpr MessageType value = CHAT_JOIN_CHANNEL_REQUEST; }; template<> class Message { public: - static constexpr PacketType value = CHAT_JOIN_CHANNEL_RESPONSE; + static constexpr MessageType value = CHAT_JOIN_CHANNEL_RESPONSE; }; template<> class Message { public: - static constexpr PacketType value = CHAT_LEAVE_CHANNEL_REQUEST; + static constexpr MessageType value = CHAT_LEAVE_CHANNEL_REQUEST; }; template<> class Message { public: - static constexpr PacketType value = CHAT_LEAVE_CHANNEL_RESPONSE; + static constexpr MessageType value = CHAT_LEAVE_CHANNEL_RESPONSE; }; template<> class Message { public: - static constexpr PacketType value = CHAT_MESSAGE_BROADCAST_REQUEST; + static constexpr MessageType value = CHAT_MESSAGE_BROADCAST_REQUEST; }; template<> class Message { public: - static constexpr PacketType value = CLUSTER_ZONE_HELLO; + static constexpr MessageType value = CLUSTER_ZONE_HELLO; }; template<> class Message { public: - static constexpr PacketType value = CLUSTER_ZONE_BYE; + static constexpr MessageType value = CLUSTER_ZONE_BYE; }; } template<> -struct fmt::formatter : fmt::formatter { - auto format(PacketType type, fmt::format_context& ctx) const { +struct fmt::formatter : fmt::formatter { + auto format(tw::MessageType type, fmt::format_context& ctx) const { + using enum tw::MessageType; + std::string_view name; switch (type) { case MESSAGE_PACKET: name = "MESSAGE_PACKET"; break; diff --git a/modules/protocol/src/ProtobufMessages.hpp b/modules/protocol/src/ProtobufMessages.hpp new file mode 100644 index 0000000..80da1b1 --- /dev/null +++ b/modules/protocol/src/ProtobufMessages.hpp @@ -0,0 +1,94 @@ +#pragma once + +#include "MessageRegistry.hpp" + +#include "message_protocol/MessageConnection.hpp" +#include "message_protocol/MessageEndpoint.hpp" + +#include + +#include +#include +#include +#include + +namespace tw { + +/** + * Sends and receives protobuf messages over an endpoint. + * + * A view rather than an owner: several of these may share one endpoint, and + * messages encoded some other way can travel over it at the same time. + */ +class ProtobufMessages { + msg::MessageEndpoint* m_endpoint; + + // Reused between sends so a steady stream of messages does not allocate. + std::vector m_buffer; + + template + bool serialize(const T& message) { + m_buffer.resize(message.ByteSizeLong()); + return message.SerializeToArray(m_buffer.data(), static_cast(m_buffer.size())); + } + +public: + explicit ProtobufMessages(msg::MessageEndpoint* endpoint) : + m_endpoint(endpoint) { + } + + /** Calls `handler` for every T that arrives from any peer. */ + template + void set_handler(std::function handler) { + m_endpoint->set_handler( + Message::value, + [handler = std::move(handler), message = T{}](msg::PeerId peer, + std::span body) mutable { + if(!message.ParseFromArray(body.data(), static_cast(body.size()))) { + spdlog::warn("Failed to parse a {} of {} bytes", Message::value, body.size()); + return; + } + + handler(peer, message); + }); + } + + template + tl::expected send(msg::MessageConnection* peer, + const T& message, + bool reliable = true) { + if(!serialize(message)) { + return tl::make_unexpected( + msg::MessageError(msg::MessageErrorType::SendFailed, "failed to serialize the message")); + } + + return peer->send(Message::value, m_buffer, reliable); + } + + template + tl::expected send_to(msg::PeerId id, const T& message, bool reliable = true) { + auto* peer = m_endpoint->peer(id); + if(peer == nullptr) { + return tl::make_unexpected(msg::MessageError(msg::MessageErrorType::NotConnected)); + } + + return send(peer, message, reliable); + } + + template + void broadcast(const T& message, bool reliable = false) { + if(!serialize(message)) { + spdlog::error("Failed to serialize a {} for broadcast", Message::value); + return; + } + + for(auto* peer : m_endpoint->peers()) { + auto send_r = peer->send(Message::value, m_buffer, reliable); + if(!send_r) { + spdlog::error("Failed to send to peer {}: {}", peer->peer_id(), send_r.error().message()); + } + } + } +}; + +} diff --git a/modules/protocol/src/messages/PlayerMoveMessage.hpp b/modules/protocol/src/messages/PlayerMoveMessage.hpp index 8f638e9..d618b03 100644 --- a/modules/protocol/src/messages/PlayerMoveMessage.hpp +++ b/modules/protocol/src/messages/PlayerMoveMessage.hpp @@ -3,7 +3,6 @@ #include #include "Serialization.hpp" -#include "packets/Packet.hpp" #include "Serializers.hpp" #include "GlmSerializers.hpp" #include diff --git a/modules/protocol/src/messages/PlayerUpdateMessage.hpp b/modules/protocol/src/messages/PlayerUpdateMessage.hpp index ce8d0c5..5b248c5 100644 --- a/modules/protocol/src/messages/PlayerUpdateMessage.hpp +++ b/modules/protocol/src/messages/PlayerUpdateMessage.hpp @@ -4,7 +4,6 @@ #include #include "Serializers.hpp" -#include "packets/Packet.hpp" struct PlayerUpdate { uint32_t id; diff --git a/modules/quicr/CMakeLists.txt b/modules/quicr/CMakeLists.txt new file mode 100644 index 0000000..2816b51 --- /dev/null +++ b/modules/quicr/CMakeLists.txt @@ -0,0 +1,34 @@ +project(tw_quicr) + +# set(CMAKE_CXX_CLANG_TIDY "/usr/bin/clang-tidy;-checks=*") + +file(GLOB FILES + src/*.cpp +) + +file(GLOB HEADERS + include/*.hpp +) + +add_library(${PROJECT_NAME} OBJECT ${FILES}) +add_library(tw::quicr ALIAS ${PROJECT_NAME}) +target_sources(${PROJECT_NAME} + PUBLIC FILE_SET HEADERS + BASE_DIRS include + FILES ${HEADERS}) + + +target_include_directories(${PROJECT_NAME} + PUBLIC + ${PROJECT_SOURCE_DIR}/include/ +) + +target_link_libraries(${PROJECT_NAME} + PUBLIC + tw::io + tl::expected + Tracy::TracyClient + TracyClient +) + +# add_subdirectory(./tests/) diff --git a/modules/quicr/include/quicr/QuicrAddress.hpp b/modules/quicr/include/quicr/QuicrAddress.hpp new file mode 100644 index 0000000..babb764 --- /dev/null +++ b/modules/quicr/include/quicr/QuicrAddress.hpp @@ -0,0 +1,182 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace tw::net::quicr { + +/** + * IP Address + */ +struct QuicrAddress { +private: + sockaddr_storage m_storage {}; + +public: + QuicrAddress(const std::optional& address, int port) { + std::memset((char*)&this->m_storage, 0, sizeof(this->m_storage)); + + auto& addr = reinterpret_cast(m_storage); + addr.sin_family = AF_INET; + addr.sin_port = htons(static_cast(port)); + addr.sin_addr.s_addr = address.has_value() + ? inet_addr(address->c_str()) + : INADDR_ANY; + } + + + QuicrAddress(sockaddr_storage& storage) + : m_storage(storage) + { } + + QuicrAddress(sockaddr_storage&& storage) + : m_storage(storage) + { } + + /** Return a const pointer suitable for connect / sendto / bind. */ + const struct sockaddr* sockaddr() const { + return reinterpret_cast(&m_storage); + } + + /** Return a mutable pointer suitable for recvfrom / accept. */ + struct sockaddr* sockaddr_mut() { + return reinterpret_cast(&m_storage); + } + + /** Return the size of the active address (depends on family). */ + socklen_t socklen() const { + switch (m_storage.ss_family) { + case AF_INET: return sizeof(sockaddr_in); + case AF_INET6: return sizeof(sockaddr_in6); + default: return sizeof(sockaddr_storage); + } + } + + /** Mutable reference to the raw storage — useful when you need to pass + * a sockaddr_storage* to recvfrom together with a socklen_t. */ + sockaddr_storage& storage() { return m_storage; } + const sockaddr_storage& storage() const { return m_storage; } + + sa_family_t family() const { return m_storage.ss_family; } + + /** Returns the raw network-order IPv4 address, or 0 if not AF_INET. */ + uint32_t ipv4_addr_raw() const { + if (m_storage.ss_family == AF_INET) { + return reinterpret_cast(m_storage).sin_addr.s_addr; + } + return 0; + } + + /** Returns the raw network-order port (any family). */ + uint16_t port_raw() const { + switch (m_storage.ss_family) { + case AF_INET: + return reinterpret_cast(m_storage).sin_port; + case AF_INET6: + return reinterpret_cast(m_storage).sin6_port; + default: + return 0; + } + } + + uint16_t port() const { + switch (m_storage.ss_family) { + case AF_INET: + return ntohs(reinterpret_cast(m_storage).sin_port); + case AF_INET6: + return ntohs(reinterpret_cast(m_storage).sin6_port); + default: + return 0; + } + } + + /** Return the IP portion only (no port). */ + std::string ip_string() const { + char buf[INET6_ADDRSTRLEN]{}; + switch (m_storage.ss_family) { + case AF_INET: { + const auto& v4 = reinterpret_cast(m_storage); + inet_ntop(AF_INET, &v4.sin_addr, buf, sizeof(buf)); + break; + } + case AF_INET6: { + const auto& v6 = reinterpret_cast(m_storage); + inet_ntop(AF_INET6, &v6.sin6_addr, buf, sizeof(buf)); + break; + } + default: + return ""; + } + return std::string(buf); + } + + /** Human-readable "ip:port" (or "[ip]:port" for IPv6). */ + std::string to_string() const { + if (m_storage.ss_family == AF_INET6) { + return std::format("[{}]:{}", ip_string(), port()); + } + return std::format("{}:{}", ip_string(), port()); + } + + bool operator==(const QuicrAddress& other) const { + if (m_storage.ss_family != other.m_storage.ss_family) return false; + + switch (m_storage.ss_family) { + case AF_INET: { + const auto& a = reinterpret_cast(m_storage); + const auto& b = reinterpret_cast(other.m_storage); + return a.sin_port == b.sin_port + && a.sin_addr.s_addr == b.sin_addr.s_addr; + } + case AF_INET6: { + const auto& a = reinterpret_cast(m_storage); + const auto& b = reinterpret_cast(other.m_storage); + return a.sin6_port == b.sin6_port + && std::memcmp(&a.sin6_addr, &b.sin6_addr, sizeof(in6_addr)) == 0; + } + default: + return std::memcmp(&m_storage, &other.m_storage, sizeof(m_storage)) == 0; + } + } + + bool operator!=(const QuicrAddress& other) const { return !(*this == other); } + + /** + * Retained for source compatibility. Prefer operator==. + */ + bool equals(const QuicrAddress& other) const { return *this == other; } +}; + + + +} +template<> +struct std::hash { + std::size_t operator()(const tw::net::quicr::QuicrAddress& addr) const noexcept { + // FNV-style combine of family + port + address bytes + std::size_t h = std::hash{}(addr.family()); + h ^= std::hash{}(addr.port_raw()) + 0x9e3779b9 + (h << 6) + (h >> 2); + + switch (addr.family()) { + case AF_INET: + h ^= std::hash{}(addr.ipv4_addr_raw()) + 0x9e3779b9 + (h << 6) + (h >> 2); + break; + case AF_INET6: { + const auto& s = reinterpret_cast(addr.storage()); + const auto* bytes = reinterpret_cast(&s.sin6_addr); + for (int i = 0; i < 16; ++i) { + h ^= std::hash{}(bytes[i]) + 0x9e3779b9 + (h << 6) + (h >> 2); + } + break; + } + default: + break; + } + return h; + } +}; diff --git a/modules/network/include/protocol/quicr/QuicrConnection.hpp b/modules/quicr/include/quicr/QuicrConnection.hpp similarity index 85% rename from modules/network/include/protocol/quicr/QuicrConnection.hpp rename to modules/quicr/include/quicr/QuicrConnection.hpp index 63a64da..dd0664b 100644 --- a/modules/network/include/protocol/quicr/QuicrConnection.hpp +++ b/modules/quicr/include/quicr/QuicrConnection.hpp @@ -1,20 +1,20 @@ #pragma once -#include "Address.hpp" -#include "NetworkError.hpp" -#include "bytebuffer/ByteBuffer.hpp" -#include "io/Read.hpp" -#include "protocol/quicr/QuicrConnectionIdGenerator.hpp" -#include "protocol/quicr/QuicrEndpoint.hpp" -#include "protocol/quicr/QuicrError.hpp" -#include "protocol/quicr/QuicrPacket.hpp" -#include "protocol/quicr/QuicrReliability.hpp" #include #include #include #include #include +#include "quicr/QuicrAddress.hpp" +#include "quicr/QuicrError.hpp" +#include "bytebuffer/ByteBuffer.hpp" +#include "quicr/QuicrConnectionIdGenerator.hpp" +#include "quicr/QuicrEndpoint.hpp" +#include "quicr/QuicrPacket.hpp" +#include "quicr/QuicrReliability.hpp" + + namespace tw::net::quicr { const int TW_NET_HEARTBEAT_INTERVAL_IN_MILLIS = 5000; @@ -74,14 +74,14 @@ class QuicrEndpoint; /** * Established QUICr connection. */ -class QuicrConnection : Read { +class QuicrConnection { static constexpr int PROTOCOL_VERSION = 1; static constexpr int MAX_HELLO_RETRIES = 5; static constexpr int HELLO_RETRY_INTERVAL_MS = 200; using Clock = std::chrono::steady_clock; - Address m_peer_address; + QuicrAddress m_peer_address; QuicrEndpoint* m_endpoint; QuicrReliabilityUnit* m_reliability_unit; @@ -109,10 +109,10 @@ class QuicrConnection : Read { /** * Builds and writes next datagram. */ - tl::expected write_datagram(std::span data); + tl::expected write_datagram(std::span data); public: - QuicrConnection(uint64_t self_id, uint64_t peer_id, Address peer_address, QuicrEndpoint* endpoint) : + QuicrConnection(uint64_t self_id, uint64_t peer_id, QuicrAddress peer_address, QuicrEndpoint* endpoint) : m_peer_address{peer_address}, m_endpoint{endpoint}, m_self_id{generate_id()}, @@ -125,7 +125,7 @@ public: // static tl::expected connect(const Address& address); - constexpr Address address() { + constexpr QuicrAddress address() { return m_peer_address; } @@ -149,7 +149,7 @@ public: return m_last_heartbeat_received < std::chrono::steady_clock::now() - std::chrono::milliseconds(TW_NET_HEARTBEAT_INTERVAL_IN_MILLIS * 2); } - tl::expected send_keep_alive(); + tl::expected send_keep_alive(); void send_initial_hello(); @@ -184,6 +184,7 @@ public: void send_hello_ack_frame(); bool process_hello_ack_frame(std::span dgram, size_t& off); + /* * Handshake Done Frame * - Protocol version @@ -199,11 +200,7 @@ public: void process_datagram(std::span dgram); - // void update(); - - // void drain_socket(); - - tl::expected read_into(std::span target) override; + tl::expected read_into(std::span target); void on_tick(std::chrono::steady_clock::time_point now); diff --git a/modules/network/include/protocol/quicr/QuicrConnectionIdGenerator.hpp b/modules/quicr/include/quicr/QuicrConnectionIdGenerator.hpp similarity index 100% rename from modules/network/include/protocol/quicr/QuicrConnectionIdGenerator.hpp rename to modules/quicr/include/quicr/QuicrConnectionIdGenerator.hpp diff --git a/modules/network/include/protocol/quicr/QuicrConnectionListener.hpp b/modules/quicr/include/quicr/QuicrConnectionListener.hpp similarity index 92% rename from modules/network/include/protocol/quicr/QuicrConnectionListener.hpp rename to modules/quicr/include/quicr/QuicrConnectionListener.hpp index 0b39f3f..80c881a 100644 --- a/modules/network/include/protocol/quicr/QuicrConnectionListener.hpp +++ b/modules/quicr/include/quicr/QuicrConnectionListener.hpp @@ -1,6 +1,6 @@ #pragma once -#include "NetworkError.hpp" +#include "quicr/QuicrError.hpp" #include "tl/expected.hpp" #include @@ -23,7 +23,7 @@ public: QuicrConnectionListener(QuicrConnectionListener&&) = delete; QuicrConnectionListener& operator=(QuicrConnectionListener&&) = delete; - static tl::expected, NetworkError> + static tl::expected, QuicrError> listen(QuicrEndpoint* endpoint); QuicrConnection* listen(); @@ -35,7 +35,7 @@ public: /** * Receives single datagram. */ - // tl::expected recv_into(std::span buffer, Address* from) { + // tl::expected recv_into(std::span buffer, Address* from) { // struct sockaddr_storage sockaddr_from; // socklen_t from_length = sizeof( sockaddr_from ); diff --git a/modules/network/include/protocol/quicr/QuicrConnectionState.hpp b/modules/quicr/include/quicr/QuicrConnectionState.hpp similarity index 100% rename from modules/network/include/protocol/quicr/QuicrConnectionState.hpp rename to modules/quicr/include/quicr/QuicrConnectionState.hpp diff --git a/modules/network/include/protocol/quicr/QuicrEncoder.hpp b/modules/quicr/include/quicr/QuicrEncoder.hpp similarity index 95% rename from modules/network/include/protocol/quicr/QuicrEncoder.hpp rename to modules/quicr/include/quicr/QuicrEncoder.hpp index ecc9f30..908e79e 100644 --- a/modules/network/include/protocol/quicr/QuicrEncoder.hpp +++ b/modules/quicr/include/quicr/QuicrEncoder.hpp @@ -3,8 +3,8 @@ #include "bytebuffer/ByteBuffer.hpp" #include "bytebuffer/ByteBufferReader.hpp" #include "bytebuffer/ByteBufferWriter.hpp" -#include "protocol/quicr/QuicrFrame.hpp" -#include "protocol/quicr/QuicrPacket.hpp" +#include "quicr/QuicrFrame.hpp" +#include "quicr/QuicrPacket.hpp" #include diff --git a/modules/network/include/protocol/quicr/QuicrEndpoint.hpp b/modules/quicr/include/quicr/QuicrEndpoint.hpp similarity index 65% rename from modules/network/include/protocol/quicr/QuicrEndpoint.hpp rename to modules/quicr/include/quicr/QuicrEndpoint.hpp index 4c4f5fb..1b0432e 100644 --- a/modules/network/include/protocol/quicr/QuicrEndpoint.hpp +++ b/modules/quicr/include/quicr/QuicrEndpoint.hpp @@ -1,14 +1,16 @@ #pragma once -#include "Address.hpp" -#include "NetworkError.hpp" -#include "protocol/quicr/QuicrConnection.hpp" - -#include #include #include #include +#include + +#include "quicr/QuicrAddress.hpp" +#include "quicr/QuicrError.hpp" +#include "quicr/QuicrConnection.hpp" + + namespace tw::net::quicr { class QuicrConnection; @@ -22,7 +24,7 @@ class QuicrEndpoint { QuicrConnectionListener* m_new_connection_handler; - void process_datagram(std::span datagram, Address from); + void process_datagram(std::span datagram, QuicrAddress from); QuicrEndpoint(int socket_fd); @@ -45,24 +47,24 @@ public: return result; } - static tl::expected, NetworkError> create(); + static tl::expected, QuicrError> create(); /** * Creates the QUICr endpoint and binds it to a port. */ - static tl::expected, NetworkError> create_and_bind(int16_t port); + static tl::expected, QuicrError> create_and_bind(int16_t port); void assign_listener(QuicrConnectionListener* listener) { m_new_connection_handler = listener; } - tl::expected bind(int port); + tl::expected bind(int port); - tl::expected connect(Address address); + tl::expected connect(QuicrAddress address); - tl::expected send_to(std::span data, Address to); + tl::expected send_to(std::span data, QuicrAddress to); - tl::expected read_from_into(std::span data, Address* out_from); + tl::expected read_from_into(std::span data, QuicrAddress* out_from); void poll(); }; diff --git a/modules/quicr/include/quicr/QuicrError.hpp b/modules/quicr/include/quicr/QuicrError.hpp new file mode 100644 index 0000000..10cf120 --- /dev/null +++ b/modules/quicr/include/quicr/QuicrError.hpp @@ -0,0 +1,109 @@ +#pragma once + +#include +#include +#include +#include + +namespace tw::net::quicr { + +/** + * Error categories surfaced by the QUICr stack. + * + * The socket-level values are taken straight from so a QuicrError can + * be built directly from `errno` after a failed UDP syscall (see from_errno). + * The protocol-level values use a negative range to stay clear of the errno + * space; they describe QUICr conditions that have no errno equivalent. + */ +enum class QuicrErrorType : int32_t { + // Protocol-level errors (no errno equivalent). + ConnectionClosed = -1, + Unknown = -2, + + // Socket / errno-derived errors raised by UDP operations. + MessageTooLong = EMSGSIZE, + AddressFamilyNotSupported = EAFNOSUPPORT, + BadFileDescriptor = EBADF, + ConnectionReset = ECONNRESET, + WouldBlock = EWOULDBLOCK, + Interrupted = EINTR, + InvalidArgument = EINVAL, + NotConnected = ENOTCONN, + NotSocket = ENOTSOCK, + OperationNotSupported = EOPNOTSUPP, + TimedOut = ETIMEDOUT, + IoError = EIO, + NoBufferSpace = ENOBUFS, + NotEnoughMemory = ENOMEM, + DestinationAddressRequired = EDESTADDRREQ, + BrokenPipe = EPIPE, +}; + +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_; } + + /** + * Builds a QuicrError from a raw errno value (as returned by UDP socket + * syscalls). Unknown codes still carry the errno through and fall back to + * strerror() for their message. + */ + static QuicrError from_errno(int32_t err) { + return QuicrError(static_cast(err)); + } + +private: + static std::string map_quicr_error_type(QuicrErrorType type) { + switch (type) { + case QuicrErrorType::ConnectionClosed: + return "The QUICr connection has been closed."; + case QuicrErrorType::MessageTooLong: + return "The message is larger than the maximum supported datagram size."; + case QuicrErrorType::AddressFamilyNotSupported: + return "The address family is not supported."; + case QuicrErrorType::BadFileDescriptor: + return "The socket is not a valid file descriptor."; + case QuicrErrorType::ConnectionReset: + return "The connection was forcibly closed by the peer."; + case QuicrErrorType::WouldBlock: + return "The operation would block on a non-blocking socket."; + case QuicrErrorType::Interrupted: + return "The operation was interrupted by a signal before any data was transferred."; + case QuicrErrorType::InvalidArgument: + return "An invalid argument was supplied."; + case QuicrErrorType::NotConnected: + return "The socket is not connected."; + case QuicrErrorType::NotSocket: + return "The operation was attempted on a non-socket."; + case QuicrErrorType::OperationNotSupported: + return "The operation is not supported for this socket type or protocol."; + case QuicrErrorType::TimedOut: + return "The operation timed out."; + case QuicrErrorType::IoError: + return "An I/O error occurred."; + case QuicrErrorType::NoBufferSpace: + return "Insufficient buffer space was available to complete the operation."; + case QuicrErrorType::NotEnoughMemory: + return "Insufficient memory was available to complete the operation."; + case QuicrErrorType::DestinationAddressRequired: + return "A destination address is required for this operation."; + case QuicrErrorType::BrokenPipe: + return "The write end of the socket has been closed."; + case QuicrErrorType::Unknown: + return "Unknown QUICr error."; + default: + return std::string(std::strerror(static_cast(type))); + } + } + + QuicrErrorType type_; + std::string message_; +}; + +} diff --git a/modules/network/include/protocol/quicr/QuicrFrame.hpp b/modules/quicr/include/quicr/QuicrFrame.hpp similarity index 96% rename from modules/network/include/protocol/quicr/QuicrFrame.hpp rename to modules/quicr/include/quicr/QuicrFrame.hpp index 38c2e26..326afe3 100644 --- a/modules/network/include/protocol/quicr/QuicrFrame.hpp +++ b/modules/quicr/include/quicr/QuicrFrame.hpp @@ -1,6 +1,6 @@ #pragma once -#include "protocol/quicr/QuicrFrameType.hpp" +#include "quicr/QuicrFrameType.hpp" #include #include diff --git a/modules/network/include/protocol/quicr/QuicrFrameType.hpp b/modules/quicr/include/quicr/QuicrFrameType.hpp similarity index 100% rename from modules/network/include/protocol/quicr/QuicrFrameType.hpp rename to modules/quicr/include/quicr/QuicrFrameType.hpp diff --git a/modules/network/include/protocol/quicr/QuicrPacket.hpp b/modules/quicr/include/quicr/QuicrPacket.hpp similarity index 91% rename from modules/network/include/protocol/quicr/QuicrPacket.hpp rename to modules/quicr/include/quicr/QuicrPacket.hpp index 5cc4e1f..122ce46 100644 --- a/modules/network/include/protocol/quicr/QuicrPacket.hpp +++ b/modules/quicr/include/quicr/QuicrPacket.hpp @@ -1,7 +1,7 @@ #pragma once #include "QuicrFrame.hpp" -#include "protocol/quicr/QuicrPacketType.hpp" +#include "quicr/QuicrPacketType.hpp" #include diff --git a/modules/network/include/protocol/quicr/QuicrPacketType.hpp b/modules/quicr/include/quicr/QuicrPacketType.hpp similarity index 100% rename from modules/network/include/protocol/quicr/QuicrPacketType.hpp rename to modules/quicr/include/quicr/QuicrPacketType.hpp diff --git a/modules/network/include/protocol/quicr/QuicrReliability.hpp b/modules/quicr/include/quicr/QuicrReliability.hpp similarity index 98% rename from modules/network/include/protocol/quicr/QuicrReliability.hpp rename to modules/quicr/include/quicr/QuicrReliability.hpp index 424e231..a22b91c 100644 --- a/modules/network/include/protocol/quicr/QuicrReliability.hpp +++ b/modules/quicr/include/quicr/QuicrReliability.hpp @@ -1,7 +1,7 @@ #pragma once #include "bytebuffer/ByteBuffer.hpp" -#include "protocol/quicr/QuicrFrame.hpp" +#include "quicr/QuicrFrame.hpp" #include #include #include diff --git a/modules/quicr/include/quicr/QuicrStream.hpp b/modules/quicr/include/quicr/QuicrStream.hpp new file mode 100644 index 0000000..7df3804 --- /dev/null +++ b/modules/quicr/include/quicr/QuicrStream.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "quicr/QuicrConnection.hpp" +#include "quicr/QuicrError.hpp" +#include "tl/expected.hpp" + +namespace tw::net::quicr { + +class QuicrStream { + QuicrConnection* m_connection; + bool m_is_reliable; + +public: + QuicrStream(QuicrConnection* connection, bool is_reliable); + + tl::expected write(std::span data) { + auto send_r = m_connection->send_message(data, m_is_reliable); + if(!send_r) { + return tl::make_unexpected(send_r.error()); + } + + return data.size(); + } + + tl::expected read_into(std::span target) { + auto read_r = m_connection->read_into(target); + if(!read_r) { + return tl::make_unexpected(read_r.error()); + } + + return *read_r; + } +}; + +} diff --git a/modules/network/include/protocol/quicr/VarInt.hpp b/modules/quicr/include/quicr/VarInt.hpp similarity index 100% rename from modules/network/include/protocol/quicr/VarInt.hpp rename to modules/quicr/include/quicr/VarInt.hpp diff --git a/modules/network/include/protocol/quicr/frames/QuicrAckFrame.hpp b/modules/quicr/include/quicr/frames/QuicrAckFrame.hpp similarity index 88% rename from modules/network/include/protocol/quicr/frames/QuicrAckFrame.hpp rename to modules/quicr/include/quicr/frames/QuicrAckFrame.hpp index bfb738b..1de3160 100644 --- a/modules/network/include/protocol/quicr/frames/QuicrAckFrame.hpp +++ b/modules/quicr/include/quicr/frames/QuicrAckFrame.hpp @@ -1,6 +1,6 @@ #pragma once -#include "protocol/quicr/QuicrEncoder.hpp" +#include "quicr/QuicrEncoder.hpp" namespace tw::net::quicr { diff --git a/modules/network/src/protocol/quicr/QuicrConnection.cpp b/modules/quicr/src/QuicrConnection.cpp similarity index 93% rename from modules/network/src/protocol/quicr/QuicrConnection.cpp rename to modules/quicr/src/QuicrConnection.cpp index ec21735..5138ad0 100644 --- a/modules/network/src/protocol/quicr/QuicrConnection.cpp +++ b/modules/quicr/src/QuicrConnection.cpp @@ -1,29 +1,27 @@ -#include "protocol/quicr/QuicrConnection.hpp" +#include "quicr/QuicrConnection.hpp" #include "bytebuffer/ByteBuffer.hpp" #include "bytebuffer/ByteBufferReader.hpp" -#include "protocol/quicr/QuicrConnectionIdGenerator.hpp" -#include "protocol/quicr/QuicrEncoder.hpp" -#include "protocol/quicr/QuicrFrame.hpp" -#include "protocol/quicr/QuicrPacket.hpp" -#include "protocol/quicr/QuicrPacketType.hpp" -#include "protocol/quicr/VarInt.hpp" -#include "protocol/quicr/QuicrFrameType.hpp" -#include - +#include "quicr/QuicrConnectionIdGenerator.hpp" +#include "quicr/QuicrEncoder.hpp" +#include "quicr/QuicrFrame.hpp" +#include "quicr/QuicrPacket.hpp" +#include "quicr/QuicrPacketType.hpp" +#include "quicr/VarInt.hpp" +#include "quicr/QuicrFrameType.hpp" namespace tw::net::quicr { -tl::expected QuicrConnection::write_datagram(std::span data) { +tl::expected QuicrConnection::write_datagram(std::span data) { if(m_state == QuicrConnectionState::Closed) { spdlog::warn("Attempted to write in Closed state"); - return tl::make_unexpected(NetworkError::from_errno(ENOTCONN)); + return tl::make_unexpected(QuicrError::from_errno(ENOTCONN)); } if(m_last_heartbeat_received < Clock::now() - std::chrono::milliseconds(TW_NET_HEARTBEAT_INTERVAL_IN_MILLIS * 2)) { m_state = QuicrConnectionState::Closed; - return tl::make_unexpected(NetworkError::from_errno(CONNECTION_RESET)); + return tl::make_unexpected(QuicrError::from_errno(ECONNRESET)); } std::vector dgram; @@ -228,23 +226,6 @@ QuicrConnection::send_message(std::span data, bool is_reliable) { m_outbound_messages.emplace_back(data.begin(), data.end()); return {}; - - // std::vector dgram; - - // VarInt(peer_id()).encode(dgram); - // VarInt(self_id()).encode(dgram); - // VarInt(FrameType::StreamBase).encode(dgram); - // VarInt(data.size()).encode(dgram); - - // dgram.insert(dgram.end(), data.begin(), data.end()); - - // auto r = write_datagram(dgram); - // if (!r) { - // spdlog::error("Failed to send stream frame: {}", r.error().message()); - // return false; - // } - - // return true; } bool QuicrConnection::process_stream_frame(uint64_t type, std::span dgram, size_t& offset) { @@ -283,7 +264,7 @@ bool QuicrConnection::process_stream_frame(uint64_t type, std::span QuicrConnection::send_keep_alive() { +tl::expected QuicrConnection::send_keep_alive() { std::vector dgram; VarInt(FrameType::KeepAlive).encode(dgram); VarInt(m_self_id).encode(dgram); @@ -459,7 +440,7 @@ void QuicrConnection::process_datagram(std::span dgram) { // } -tl::expected QuicrConnection::read_into(std::span target) { +tl::expected QuicrConnection::read_into(std::span target) { if(m_messages.empty()) { return 0; } diff --git a/modules/network/src/protocol/quicr/QuicrConnectionListener.cpp b/modules/quicr/src/QuicrConnectionListener.cpp similarity index 71% rename from modules/network/src/protocol/quicr/QuicrConnectionListener.cpp rename to modules/quicr/src/QuicrConnectionListener.cpp index 48b51cb..dda4b40 100644 --- a/modules/network/src/protocol/quicr/QuicrConnectionListener.cpp +++ b/modules/quicr/src/QuicrConnectionListener.cpp @@ -1,5 +1,5 @@ -#include "protocol/quicr/QuicrConnectionListener.hpp" -#include "protocol/quicr/QuicrEndpoint.hpp" +#include "quicr/QuicrConnectionListener.hpp" +#include "quicr/QuicrEndpoint.hpp" #include @@ -11,7 +11,7 @@ QuicrConnectionListener::QuicrConnectionListener(QuicrEndpoint* endpoint) endpoint->assign_listener(this); } -tl::expected, NetworkError> QuicrConnectionListener::listen(QuicrEndpoint* endpoint) { +tl::expected, QuicrError> QuicrConnectionListener::listen(QuicrEndpoint* endpoint) { return std::unique_ptr(new QuicrConnectionListener(endpoint)); }; diff --git a/modules/network/src/protocol/quicr/QuicrEncoder.cpp b/modules/quicr/src/QuicrEncoder.cpp similarity index 97% rename from modules/network/src/protocol/quicr/QuicrEncoder.cpp rename to modules/quicr/src/QuicrEncoder.cpp index ef9d06d..6a6c396 100644 --- a/modules/network/src/protocol/quicr/QuicrEncoder.cpp +++ b/modules/quicr/src/QuicrEncoder.cpp @@ -1,8 +1,7 @@ -#include "protocol/quicr/QuicrEncoder.hpp" -#include "protocol/quicr/QuicrConnection.hpp" +#include "quicr/QuicrEncoder.hpp" +#include "quicr/QuicrConnection.hpp" #include "bytebuffer/ByteBufferReader.hpp" -#include "frames/Frame.hpp" -#include "protocol/quicr/QuicrFrameType.hpp" +#include "quicr/QuicrFrameType.hpp" #include namespace tw::net::quicr { diff --git a/modules/network/src/protocol/quicr/QuicrEndpoint.cpp b/modules/quicr/src/QuicrEndpoint.cpp similarity index 78% rename from modules/network/src/protocol/quicr/QuicrEndpoint.cpp rename to modules/quicr/src/QuicrEndpoint.cpp index 8e5596e..aded158 100644 --- a/modules/network/src/protocol/quicr/QuicrEndpoint.cpp +++ b/modules/quicr/src/QuicrEndpoint.cpp @@ -1,7 +1,7 @@ -#include "protocol/quicr/QuicrEndpoint.hpp" -#include "protocol/quicr/QuicrConnection.hpp" -#include "protocol/quicr/QuicrConnectionListener.hpp" -#include "protocol/quicr/QuicrEncoder.hpp" +#include "quicr/QuicrEndpoint.hpp" +#include "quicr/QuicrConnection.hpp" +#include "quicr/QuicrConnectionListener.hpp" +#include "quicr/QuicrEncoder.hpp" #include "tl/expected.hpp" #include #include @@ -16,7 +16,7 @@ QuicrEndpoint::QuicrEndpoint(int socket_fd) } -tl::expected, NetworkError> QuicrEndpoint::create_and_bind(int16_t port) { +tl::expected, QuicrError> QuicrEndpoint::create_and_bind(int16_t port) { auto endpoint = QuicrEndpoint::create(); if (!endpoint.has_value()) { return tl::make_unexpected(endpoint.error()); @@ -30,23 +30,23 @@ tl::expected, NetworkError> QuicrEndpoint::create return std::move(*endpoint); } -tl::expected, NetworkError> QuicrEndpoint::create() { +tl::expected, QuicrError> QuicrEndpoint::create() { const int domain = AF_INET; int socket_fd = socket(domain, SOCK_DGRAM, IPPROTO_UDP); if(socket_fd < 0) { spdlog::error("Failed to create socket: {}", strerror(errno)); - return tl::make_unexpected(NetworkError::from_errno(errno)); + return tl::make_unexpected(QuicrError::from_errno(errno)); } if(fcntl(socket_fd, F_SETFL, fcntl(socket_fd, F_GETFL, 0) | O_NONBLOCK, 1) == -1) { spdlog::error("Failed to set non-blocking mode: {}", strerror(errno)); - return tl::make_unexpected(NetworkError::from_errno(errno)); + return tl::make_unexpected(QuicrError::from_errno(errno)); } return std::unique_ptr(new QuicrEndpoint(socket_fd)); } -tl::expected QuicrEndpoint::bind(int port) { +tl::expected QuicrEndpoint::bind(int port) { const int domain = AF_INET; struct sockaddr_in addr = {}; addr.sin_family = domain; @@ -55,12 +55,12 @@ tl::expected QuicrEndpoint::bind(int port) { if(::bind(m_socket_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { spdlog::error("Failed to bind socket: {}", strerror(errno)); - return tl::make_unexpected(NetworkError::from_errno(errno)); + return tl::make_unexpected(QuicrError::from_errno(errno)); } if(fcntl(m_socket_fd, F_SETFL, fcntl(m_socket_fd, F_GETFL, 0) | O_NONBLOCK, 1) == -1) { spdlog::error("Failed to set non-blocking mode: {}", strerror(errno)); - return tl::make_unexpected(NetworkError::from_errno(errno)); + return tl::make_unexpected(QuicrError::from_errno(errno)); } return {}; @@ -69,7 +69,7 @@ tl::expected QuicrEndpoint::bind(int port) { /** * Creates new connection from current socket to the address. */ -tl::expected QuicrEndpoint::connect(Address address) { +tl::expected QuicrEndpoint::connect(QuicrAddress address) { auto connection = std::make_shared(0, 0, address, this); auto inserted_r = m_connections.emplace(connection->self_id(), connection); @@ -82,7 +82,7 @@ tl::expected QuicrEndpoint::connect(Address addr return inserted_r.first->second.get(); } -void QuicrEndpoint::process_datagram(std::span datagram, Address from) { +void QuicrEndpoint::process_datagram(std::span datagram, QuicrAddress from) { ZoneScopedN("Process Datagram"); // parse first byte as packet type @@ -118,7 +118,7 @@ void QuicrEndpoint::process_datagram(std::span datagram, Address from } } -tl::expected QuicrEndpoint::send_to(std::span data, Address to) { +tl::expected QuicrEndpoint::send_to(std::span data, QuicrAddress to) { size_t total = 0; while(total < data.size_bytes()) { @@ -128,7 +128,7 @@ tl::expected QuicrEndpoint::send_to(std::span d continue; } - return tl::make_unexpected(NetworkError::from_errno(errno)); + return tl::make_unexpected(QuicrError::from_errno(errno)); } total += t; } @@ -136,7 +136,7 @@ tl::expected QuicrEndpoint::send_to(std::span d return total; } -tl::expected QuicrEndpoint::read_from_into(std::span data, Address* out_from) { +tl::expected QuicrEndpoint::read_from_into(std::span data, QuicrAddress* out_from) { struct sockaddr_storage sockaddr_from; socklen_t from_length = sizeof( sockaddr_from ); @@ -146,10 +146,10 @@ tl::expected QuicrEndpoint::read_from_into(std::span QuicrEndpoint::read_from_into(std::span #include diff --git a/modules/quicr/tests/CMakeLists.txt b/modules/quicr/tests/CMakeLists.txt new file mode 100644 index 0000000..93f1495 --- /dev/null +++ b/modules/quicr/tests/CMakeLists.txt @@ -0,0 +1,45 @@ +project(tw_quicr_tests) + +# set(CMAKE_CXX_CLANG_TIDY "/usr/bin/clang-tidy;-checks=*") + +file(GLOB FILES + src/*.cpp +) + +add_library(tw_quicr_lib STATIC ${FILES}) + +target_include_directories(tw_quicr_lib + PUBLIC + ${PROJECT_SOURCE_DIR}/src/ +) + +target_link_libraries(tw_server_lib + PUBLIC + tl::expected +) + +# add_executable(QuicrOverloadTest ./quicr/QuicrOverloadTests.cpp) +# add_executable(QuicrBenchmarks ./quicr/QuicrBenchmarks.cpp) + +target_link_libraries(QuicrBenchmarks + PRIVATE + ${LIBS} + ${PROJECT_NAME}_sources + Tracy::TracyClient + Catch2::Catch2WithMain + tl::expected + EnTT::EnTT +) + +target_link_libraries(QuicrOverloadTest + PRIVATE + ${LIBS} + ${PROJECT_NAME}_sources + Tracy::TracyClient + TracyClient + Catch2::Catch2WithMain + tl::expected + EnTT::EnTT +) + + add_subdirectory(./tests/) diff --git a/modules/network/tests/quicr/QuicrBasicTests.cpp b/modules/quicr/tests/QuicrBasicTests.cpp similarity index 100% rename from modules/network/tests/quicr/QuicrBasicTests.cpp rename to modules/quicr/tests/QuicrBasicTests.cpp diff --git a/modules/network/tests/quicr/QuicrBenchmarks.cpp b/modules/quicr/tests/QuicrBenchmarks.cpp similarity index 100% rename from modules/network/tests/quicr/QuicrBenchmarks.cpp rename to modules/quicr/tests/QuicrBenchmarks.cpp diff --git a/modules/network/tests/quicr/QuicrEndpointTests.cpp b/modules/quicr/tests/QuicrEndpointTests.cpp similarity index 100% rename from modules/network/tests/quicr/QuicrEndpointTests.cpp rename to modules/quicr/tests/QuicrEndpointTests.cpp diff --git a/modules/network/tests/quicr/QuicrOverloadTests.cpp b/modules/quicr/tests/QuicrOverloadTests.cpp similarity index 100% rename from modules/network/tests/quicr/QuicrOverloadTests.cpp rename to modules/quicr/tests/QuicrOverloadTests.cpp diff --git a/modules/serialization/include/tw/serial/WorldStateWriter.hpp b/modules/serialization/include/tw/serial/WorldStateWriter.hpp index 3dd08cc..3029e40 100644 --- a/modules/serialization/include/tw/serial/WorldStateWriter.hpp +++ b/modules/serialization/include/tw/serial/WorldStateWriter.hpp @@ -9,8 +9,9 @@ * Wire format (all values little-endian): * * ┌──────────────────────────────────────────────────────────┐ - * │ Header (12 bytes) │ - * │ packet_type : uint32 (PacketType::WORLD_STATE = 3) │ + * │ Header (16 bytes) │ + * │ message_type : uint32 (supplied by the caller) │ + * │ sequence : uint32 (always 0, no reply expected) │ * │ frame_idx : uint32 │ * │ entity_count : uint32 (number of position records) │ * ├──────────────────────────────────────────────────────────┤ @@ -66,12 +67,6 @@ namespace tw::serial { -// ── Packet type tag ─────────────────────────────────────────────────────── -// Mirrors PacketType::WORLD_STATE_PACKET (value 3) in packets/Packet.hpp. -// Hardcoded here so the serialisation module does not depend on the network -// module — the numerical value must stay in sync if the enum changes. -inline constexpr uint32_t kWorldStatePacketType = 3; // WORLD_STATE_PACKET - // ────────────────────────────────────────────────────────────────────────── // WorldStateWriter // ────────────────────────────────────────────────────────────────────────── @@ -87,14 +82,20 @@ public: explicit WorldStateWriter(BinaryBuffer& buf) noexcept : m_w(buf) {} /** - * Write the packet header. Call once per frame, before everything else. + * Write the header. Call once per frame, before everything else. * entity_count is patched in end(). + * + * message_type is supplied by the caller so that this module stays free of + * the address the message is delivered to. */ - void begin(uint32_t frame_idx) noexcept { + void begin(uint32_t frame_idx, uint32_t message_type) noexcept { m_entity_count = 0; - // packet_type — lets the receiver dispatch without peeking further - m_w.encode(kWorldStatePacketType); + // message_type — lets the receiver dispatch without peeking further + m_w.encode(message_type); + + // sequence — this message is never a reply to a request + m_w.encode(0); // frame_idx m_w.encode(frame_idx); diff --git a/modules/serialization/tests/SerializationBenchmarks.cpp b/modules/serialization/tests/SerializationBenchmarks.cpp index 4d952bf..68208b7 100644 --- a/modules/serialization/tests/SerializationBenchmarks.cpp +++ b/modules/serialization/tests/SerializationBenchmarks.cpp @@ -117,8 +117,10 @@ void tw_serial_benchmark(std::vector& positions) { buf.reset(); tw::serial::BinaryWriter w(buf); - // packet_type - w.encode(tw::serial::kWorldStatePacketType); + // message_type — any value; only the size matters to the benchmark + w.encode(0u); + // sequence — this message is never a reply to a request + w.encode(0u); // frame_idx w.encode(static_cast(frame)); // entity_count placeholder diff --git a/modules/server/CMakeLists.txt b/modules/server/CMakeLists.txt index 1902957..3f03ddd 100644 --- a/modules/server/CMakeLists.txt +++ b/modules/server/CMakeLists.txt @@ -24,9 +24,12 @@ target_include_directories(tw_server_lib target_link_libraries(tw_server_lib PUBLIC towards + tw::io tw::network tw::protocol + tw::message_protocol tw::serialization + tw::quicr glm::glm EnTT::EnTT Jolt diff --git a/modules/server/Dockerfile b/modules/server/Dockerfile new file mode 100644 index 0000000..694037d --- /dev/null +++ b/modules/server/Dockerfile @@ -0,0 +1,58 @@ +# Build the toolchain stage on its own and pass it back in to skip the apt step: +# docker build -f modules/server/Dockerfile --target toolchain -t tw_toolchain . +# docker build -f modules/server/Dockerfile --build-arg TOOLCHAIN_IMAGE=tw_toolchain . +ARG TOOLCHAIN_IMAGE=toolchain + +# ── toolchain stage ────────────────────────────────────────────────────────── +FROM ubuntu:24.04 AS toolchain + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + clang \ + libstdc++-14-dev \ + cmake \ + ninja-build \ + mold \ + git \ + ca-certificates \ + pkg-config \ + glslang-tools \ + libvulkan-dev \ + libsdl2-dev \ + libprotobuf-dev protobuf-compiler \ + libpq-dev \ + libpqxx-dev \ + && rm -rf /var/lib/apt/lists/* + +ENV CC=clang +ENV CXX=clang++ + +# ── build stage ────────────────────────────────────────────────────────────── +FROM ${TOOLCHAIN_IMAGE} AS builder + +WORKDIR /src +COPY . . + +# glm, entt, Jolt, spdlog, expected, Catch2 and tracy are cloned at configure time +RUN cmake -B /build -G Ninja -DCMAKE_BUILD_TYPE=Release \ + && cmake --build /build --target tw_server + +# Stage the shared libraries the binary was linked against; the runtime image has +# no package manager. glibc and its loader stay behind, they come with that image. +RUN mkdir -p /rootfs/usr/lib/x86_64-linux-gnu \ + && ldd /build/modules/server/tw_server \ + | awk '/=> \//{ print $3 }' \ + | grep -vE '/(libc|libm|libdl|libpthread|librt|libresolv|libanl)\.so' \ + | xargs -I{} cp -L {} /rootfs/usr/lib/x86_64-linux-gnu/ + +# ── runtime stage ───────────────────────────────────────────────────────────── +FROM gcr.io/distroless/cc-debian13:nonroot + +COPY --from=builder /rootfs/ / +COPY --from=builder /build/modules/server/tw_server /usr/local/bin/tw_server + +# player connections, zone-server peering +EXPOSE 8101/udp 8102/udp + +ENTRYPOINT ["/usr/local/bin/tw_server"] diff --git a/modules/server/README.md b/modules/server/README.md index 14eba5b..be1fdd1 100644 --- a/modules/server/README.md +++ b/modules/server/README.md @@ -1,3 +1,101 @@ # Server -The authoritative server executable source code. +The authoritative server executable. + +## Running + +``` +Usage: tw_server [--quicr-port ] [--cluster-port ] + --quicr-port UDP port for player connections (default: 8101) + --cluster-port UDP port for zone-server peering (default: 8102) +``` + +Metrics reporting to TimescaleDB stays off until `TIMESCALEDB_HOST` is set: + +| Variable | Default | +|------------------------|----------------| +| `TIMESCALEDB_HOST` | unset (off) | +| `TIMESCALEDB_PORT` | `5432` | +| `TIMESCALEDB_DB` | `mmo` | +| `TIMESCALEDB_USER` | `mmo` | +| `TIMESCALEDB_PASSWORD` | empty | +| `TIMESCALEDB_TABLE` | `zone_metrics` | + +## Building natively + +See the root `README.md`. The target is `tw_server` and the binary lands in +`/modules/server/tw_server`. + +## Building the image + +`Dockerfile` has three stages: a toolchain stage holding the C++ build +environment, a build stage that compiles `tw_server` with clang, and a runtime +stage that carries only the binary and its shared libraries. The build context is +the repository root, so run it from there: + +```bash +$ docker build -f modules/server/Dockerfile -t tw_server . +``` + +The toolchain stage installs the compiler and tools (clang, cmake, ninja, mold, +glslangValidator) and the development packages CMake looks for (Vulkan, SDL2, +protobuf, libpq, libpqxx) from apt. It touches no source, so it only rebuilds +when that package list changes. + +The build stage configures a Release build on top of it with `CC=clang` / +`CXX=clang++`. glm, entt, Jolt, spdlog, expected, Catch2 and tracy are cloned +while configuring, so the build needs network access. `.dockerignore` keeps the +local build directories and those cloned sources out of the context. + +## Reusing the toolchain + +Layer caching already keeps apt out of a rebuild, but the cache is local and dies +with `docker builder prune`. To pin the toolchain down, build that stage on its +own and tag it: + +```bash +$ docker build -f modules/server/Dockerfile --target toolchain -t tw_toolchain . +``` + +`TOOLCHAIN_IMAGE` then points the build stage at it, and the apt step is skipped +outright rather than cache-hit: + +```bash +$ docker build -f modules/server/Dockerfile \ + --build-arg TOOLCHAIN_IMAGE=tw_toolchain -t tw_server . +``` + +The default is the in-file `toolchain` stage, so a plain build still works +standalone. Any registry tag works too, which is the useful form on CI. To carry +it between machines by hand: + +```bash +$ docker save tw_toolchain | zstd -o tw_toolchain.tar.zst +$ zstd -dc tw_toolchain.tar.zst | docker load +``` + +The runtime stage is `gcr.io/distroless/cc-debian13:nonroot` — glibc, libstdc++ +and a `nonroot` user, no shell and no package manager. Since nothing can be +installed there, the build stage walks `ldd` over the binary and stages every +shared library it resolved into `/rootfs`, which the runtime stage copies in +whole. glibc and the loader are filtered out of that list: the binary is built +against 2.39 and the runtime image ships 2.41, which runs it, but the two must +not be mixed. + +Nothing in the image can be executed except the server, so `docker exec` and +`docker run --entrypoint` are of no use for poking around. Swap the base for +`gcr.io/distroless/cc-debian13:debug-nonroot` when a busybox shell is needed. + +Run it, publishing both UDP ports: + +```bash +$ docker run --rm -p 8101:8101/udp -p 8102:8102/udp tw_server +``` + +Arguments after the image name reach the binary, and environment variables are +passed as usual: + +```bash +$ docker run --rm -p 9101:9101/udp -e TIMESCALEDB_HOST=timescale \ + tw_server --quicr-port 9101 +``` diff --git a/modules/server/src/PlayerSession.hpp b/modules/server/src/PlayerSession.hpp index 04d0a6d..600a801 100644 --- a/modules/server/src/PlayerSession.hpp +++ b/modules/server/src/PlayerSession.hpp @@ -1,6 +1,8 @@ #pragma once -#include "protocol/quicr/QuicrConnection.hpp" +#include "network/SessionId.hpp" + +#include "message_protocol/MessageConnection.hpp" #include @@ -8,16 +10,18 @@ namespace tw::net { struct PlayerSession { public: - uint32_t session_id; + SessionId session_id; - quicr::QuicrConnection* quicr_connection; + msg::MessageConnection* connection; uint32_t last_frame; + uint32_t acked_frame; - PlayerSession(uint32_t session_id, quicr::QuicrConnection* quicr_connection) : + PlayerSession(SessionId session_id, msg::MessageConnection* connection) : session_id(session_id), - quicr_connection(std::move(quicr_connection)), - last_frame(0) + connection(connection), + last_frame(0), + acked_frame(0) { } }; diff --git a/modules/server/src/ZoneClusterLink.cpp b/modules/server/src/ZoneClusterLink.cpp index 56fef08..54dd557 100644 --- a/modules/server/src/ZoneClusterLink.cpp +++ b/modules/server/src/ZoneClusterLink.cpp @@ -1,42 +1,48 @@ #include "ZoneClusterLink.hpp" -#include "Address.hpp" - #include #include namespace tw::net { -ZoneClusterLink::ZoneClusterLink(int port) - : m_endpoint(quicr::QuicrEndpoint::create().value()), - m_listener(quicr::QuicrConnectionListener::listen(m_endpoint.get()).value()) -{ - if (auto r = m_endpoint->bind(port); !r) { - throw std::runtime_error("ZoneClusterLink: failed to bind to port " + std::to_string(port)); +namespace { + +std::unique_ptr bind_endpoint(int port) { + auto endpoint_r = msg::MessageEndpoint::bind(port); + if(!endpoint_r) { + throw std::runtime_error("ZoneClusterLink: failed to bind to port " + std::to_string(port) + + ": " + endpoint_r.error().message()); } + + return std::move(endpoint_r.value()); +} + +} + +ZoneClusterLink::ZoneClusterLink(int port) : + m_endpoint(bind_endpoint(port)), + m_messages(m_endpoint.get()) { + m_endpoint->set_on_peer_connected([](msg::PeerId peer) { + spdlog::info("Zone peer {} connected", peer); + }); + spdlog::info("ZoneClusterLink listening on port {}", port); } void ZoneClusterLink::update() { - m_endpoint->poll(); - - quicr::QuicrConnection* connection; - while ((connection = m_listener->listen())) { - spdlog::info("Zone peer connected from {}", connection->address().to_string()); - m_peers.push_back(connection); - } + m_endpoint->update(); } -quicr::QuicrConnection* ZoneClusterLink::connect_to_peer(const std::string& host, int port) { - auto result = m_endpoint->connect(Address(host, port)); - if (!result) { - spdlog::error("ZoneClusterLink: failed to connect to peer {}:{}", host, port); +msg::MessageConnection* ZoneClusterLink::connect_to_peer(const std::string& host, int port) { + auto peer_r = m_endpoint->connect(host, port); + if (!peer_r) { + spdlog::error("ZoneClusterLink: failed to connect to peer {}:{}: {}", + host, port, peer_r.error().message()); return nullptr; } - quicr::QuicrConnection* conn = result.value(); - m_peers.push_back(conn); + spdlog::info("ZoneClusterLink: connected to peer {}:{}", host, port); - return conn; + return peer_r.value(); } } // namespace tw::net diff --git a/modules/server/src/ZoneClusterLink.hpp b/modules/server/src/ZoneClusterLink.hpp index 5815011..c488ed8 100644 --- a/modules/server/src/ZoneClusterLink.hpp +++ b/modules/server/src/ZoneClusterLink.hpp @@ -1,12 +1,10 @@ #pragma once -#include "MessageRegistry.hpp" -#include "protocol/quicr/QuicrConnection.hpp" -#include "protocol/quicr/QuicrConnectionListener.hpp" -#include "protocol/quicr/QuicrEndpoint.hpp" +#include "ProtobufMessages.hpp" +#include "message_protocol/MessageConnection.hpp" +#include "message_protocol/MessageEndpoint.hpp" #include -#include #include #include @@ -14,51 +12,47 @@ namespace tw::net { -// Listens on a dedicated QUICr port for incoming zone-server peer connections -// and opens outgoing connections to known peers. +// Listens on a dedicated port for incoming zone-server peer connections and +// opens outgoing connections to known peers. // -// All connections (incoming and outgoing) are collected in m_peers so that -// broadcast messages (ZoneHello, ZoneBye) reach every peer uniformly. -// -// Accepted QuicrConnection* pointers are owned by the endpoint and remain valid -// for the lifetime of this object. +// Incoming and outgoing peers are held together so that broadcast messages +// (ZoneHello, ZoneBye) reach every peer uniformly. class ZoneClusterLink { - std::unique_ptr m_endpoint; - std::unique_ptr m_listener; - std::vector m_peers; + std::unique_ptr m_endpoint; + ProtobufMessages m_messages; public: explicit ZoneClusterLink(int port); - // Poll for datagrams and accept any pending peer connections. + // Poll for messages and accept any pending peer connections. void update(); // Connect to a remote zone peer and register it in the peer list. - quicr::QuicrConnection* connect_to_peer(const std::string& host, int port); + msg::MessageConnection* connect_to_peer(const std::string& host, int port); - // Serialize and send a protobuf message to a single peer. + // Send a protobuf message to a single peer. template - void send_mesg(quicr::QuicrConnection* conn, const T& msg) { - std::string payload; - if (!msg.SerializeToString(&payload)) { - spdlog::error("ZoneClusterLink: failed to serialize message"); - return; + void send_mesg(msg::MessageConnection* peer, const T& msg) { + auto send_r = m_messages.send(peer, msg, false); + if(!send_r) { + spdlog::error("ZoneClusterLink: failed to send to peer {}: {}", + peer->peer_id(), send_r.error().message()); } - std::vector bytes(payload.size() + sizeof(uint32_t)); - uint32_t type = tw::Message::value; - memcpy(bytes.data(), &type, sizeof(type)); - memcpy(bytes.data() + sizeof(uint32_t), payload.data(), payload.size()); - conn->send_message(bytes, false); } - // Broadcast a protobuf message to all connected peers. + // Send a protobuf message to all connected peers. template void broadcast(const T& msg) { - for (auto* peer : m_peers) - send_mesg(peer, msg); + m_messages.broadcast(msg); } - std::span peers() const { return m_peers; } + std::vector peers() const { + return m_endpoint->peers(); + } + + msg::MessageEndpoint& endpoint() { + return *m_endpoint; + } }; } // namespace tw::net diff --git a/modules/server/src/ZoneManager.cpp b/modules/server/src/ZoneManager.cpp index f92c976..f44dcbb 100644 --- a/modules/server/src/ZoneManager.cpp +++ b/modules/server/src/ZoneManager.cpp @@ -68,6 +68,8 @@ void ZoneManager::on_player_move(SessionId session_id, mmo::PlayerMoveMessage&& entt::entity entity = client_entity(session_id); if (entity == entt::null) return; + m_session_input_frame[session_id] = message.frame_idx(); + auto* controller = m_world->registry().try_get(entity); if (controller) { controller->set_input( @@ -97,6 +99,11 @@ const im::Interest* ZoneManager::get_interest(im::InterestId id) const { return m_interest_system->get_interest(id); } +uint32_t ZoneManager::acked_input_frame(im::InterestId interest_id) const { + auto it = m_session_acked_frame.find(interest_id); + return it != m_session_acked_frame.end() ? it->second : 0; +} + // ── Private helpers ─────────────────────────────────────────────────────────── void ZoneManager::check_neighbor_transfers() { @@ -169,9 +176,25 @@ void ZoneManager::tick(uint32_t frame_idx, float delta_time) { m_world->step(delta_time); FrameMarkEnd("World step"); + // Re-stamp each client entity's newest input onto the server's frame index. + // The input ring is keyed by client frame numbers (independent counter), + // but physics lookup uses the server's frame counter. By re-stamping onto + // the server frame, input(server_frame_idx) resolves by exact match rather + // than fallback, ensuring deterministic and correct input consumption. + for (const auto& [interest_id, entity] : m_client_entities) { + auto* controller = m_world->registry().try_get(entity); + if (!controller) continue; + controller->set_input(frame_idx, controller->input()); + } + FrameMarkStart("Physics step"); - m_physics_world.step(frame_idx, delta_time); + // Fixed step, not the measured interval: the simulation has to advance by the + // same amount every frame for a replay of the same inputs to land in the same + // place. + m_physics_world.step(frame_idx, JoltPhysicsWorld::FIXED_DELTA_TIME); FrameMarkEnd("Physics step"); + + m_session_acked_frame = m_session_input_frame; } } // namespace tw::net diff --git a/modules/server/src/ZoneManager.hpp b/modules/server/src/ZoneManager.hpp index f66b121..af230bd 100644 --- a/modules/server/src/ZoneManager.hpp +++ b/modules/server/src/ZoneManager.hpp @@ -40,6 +40,8 @@ class ZoneManager : public ZoneProxy { std::unordered_map m_client_entities; std::unordered_map m_entity_sessions; // reverse map + std::unordered_map m_session_input_frame; // most recent input frame received + std::unordered_map m_session_acked_frame; // input frame consumed by last tick std::vector m_neighbors; uint32_t m_next_neighbor_id = 0x80000000u; @@ -83,6 +85,9 @@ public: // Returns the current interest state for any registered id, or nullptr. const im::Interest* get_interest(im::InterestId id) const; + // Returns the input frame that this session's entity was last simulated with, or 0 if unknown. + uint32_t acked_input_frame(im::InterestId interest_id) const; + // Runs one tick: interest queries, neighbour transfer checks, world step, physics step. void tick(uint32_t frame_idx, float delta_time); }; diff --git a/modules/server/src/ZoneServer.cpp b/modules/server/src/ZoneServer.cpp index c054eda..6c8050e 100644 --- a/modules/server/src/ZoneServer.cpp +++ b/modules/server/src/ZoneServer.cpp @@ -25,7 +25,6 @@ ZoneServer::ZoneServer(ZoneServerConfiguration config) m_network_receiver(std::make_unique( m_player_session_registry.get(), config.quicr_port, m_metrics_reporter.get() )), - m_message_dispatcher(std::make_unique(m_network_receiver.get())), m_replicator(std::make_unique>( m_player_session_registry.get(), m_network_receiver.get() @@ -58,9 +57,16 @@ ZoneServer::ZoneServer(ZoneServerConfiguration config) } } -void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMessage&& message) { +void ZoneServer::player_update_handler(SessionId session_id, mmo::PlayerMoveMessage message) { auto it = m_session_zone.find(session_id); if (it == m_session_zone.end()) return; + + // Echoed back in the next snapshot, so the client can tell how long its + // input took to come back. + if (auto* session = m_player_session_registry->session(session_id)) { + session->last_frame = message.frame_idx(); + } + it->second->on_player_move(session_id, std::move(message)); } @@ -80,7 +86,6 @@ static void register_signal_handler() { void ZoneServer::update_clients(uint32_t frame_idx) { m_network_receiver->update(); - m_message_dispatcher->drain_queue(); while (m_network_receiver->peek_new_session()) { auto session_id = m_network_receiver->pop_new_session(); @@ -96,6 +101,10 @@ void ZoneServer::update_clients(uint32_t frame_idx) { zone->add_client(session_id, entity); m_session_zone[session_id] = zone; + mmo::SetControlledEntity mesg = {}; + mesg.set_entity_id((uint32_t)entity); + m_network_receiver->send_mesg(session_id, mesg); + spdlog::info("Client {} connected", session_id); } } @@ -106,13 +115,13 @@ void ZoneServer::run() { uint32_t frame_idx = 1; - m_message_dispatcher->set_handler( - [&](uint64_t session_id, mmo::PlayerMoveMessage mesg) { - player_update_handler(static_cast(session_id), std::move(mesg)); + m_network_receiver->set_handler( + [this](SessionId session_id, const mmo::PlayerMoveMessage& mesg) { + player_update_handler(session_id, mesg); }); - m_message_dispatcher->set_handler( - [&](uint64_t session_id, mmo::chat::SendChatMessageRequest mesg) { + m_network_receiver->set_handler( + [this](SessionId session_id, const mmo::chat::SendChatMessageRequest& mesg) { }); while (!quit.load()) { @@ -127,6 +136,16 @@ void ZoneServer::run() { for (auto& zone : m_zones) { zone->tick(frame_idx, lock_step.delta_time()); + // Copy acked frames from zone to sessions before replication. + // This ensures snapshots carry the input frame actually consumed by this tick, + // not frames that arrive in the trailing network update. + for (const auto& [session_id, session_zone] : m_session_zone) { + if (session_zone != zone.get()) continue; + if (auto* session = m_player_session_registry->session(session_id)) { + session->acked_frame = zone->acked_input_frame(session_id); + } + } + m_replicator->replicate(zone->registry(), zone->interest()); } diff --git a/modules/server/src/ZoneServer.hpp b/modules/server/src/ZoneServer.hpp index 0288237..547a0d8 100644 --- a/modules/server/src/ZoneServer.hpp +++ b/modules/server/src/ZoneServer.hpp @@ -10,7 +10,6 @@ #include "ZoneManager.hpp" #include "ZoneServerConfiguration.hpp" #include "monitoring/MetricsReporter.hpp" -#include "network/MessageDispatcher.hpp" #include "network/NetworkReceiver.hpp" #include "network/PlayerSessionRegistry.hpp" #include "replication/StateReplicator.hpp" @@ -21,7 +20,6 @@ class ZoneServer { std::unique_ptr m_metrics_reporter; std::unique_ptr m_player_session_registry; std::unique_ptr m_network_receiver; - std::unique_ptr m_message_dispatcher; std::unique_ptr> m_replicator; ZoneClusterLink m_cluster_link; ZoneCoordinator m_coordinator; @@ -30,7 +28,7 @@ class ZoneServer { std::unordered_map m_session_zone; uint32_t m_own_zone_id = 0; - void player_update_handler(SessionId session_id, mmo::PlayerMoveMessage&& message); + void player_update_handler(SessionId session_id, mmo::PlayerMoveMessage message); void update_clients(uint32_t frame_idx); public: diff --git a/modules/server/src/network/InboundMessage.hpp b/modules/server/src/network/InboundMessage.hpp deleted file mode 100644 index 211112d..0000000 --- a/modules/server/src/network/InboundMessage.hpp +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include -#include - -namespace tw::net { - -class InboundMessage { -public: - uint32_t session_id; - std::vector payload; - - InboundMessage(uint32_t session_id, std::vector payload) - : session_id(session_id), payload(std::move(payload)) {} -}; - -} diff --git a/modules/server/src/network/MessageDeserializer.hpp b/modules/server/src/network/MessageDeserializer.hpp deleted file mode 100644 index 80113e8..0000000 --- a/modules/server/src/network/MessageDeserializer.hpp +++ /dev/null @@ -1,21 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace tw::net { - -class MessageDeserializer { -public: - template - static std::optional deserialize(std::span data) { - T result = {}; - - result.ParseFromArray(data.data(), data.size()); - - return result; - } -}; - -} diff --git a/modules/server/src/network/MessageDispatcher.hpp b/modules/server/src/network/MessageDispatcher.hpp deleted file mode 100644 index c53cd9a..0000000 --- a/modules/server/src/network/MessageDispatcher.hpp +++ /dev/null @@ -1,67 +0,0 @@ -#pragma once - -#include "InboundMessage.hpp" -#include "MessageQueue.hpp" -#include "NetworkError.hpp" -#include "MessageDeserializer.hpp" -#include "monitoring/TimescaleDbMetricsReporter.hpp" -#include "network/NetworkReceiver.hpp" -#include "packets/Packet.hpp" - -#include - -#include -#include - -namespace tw::net { - -typedef std::function(uint32_t, std::span)> MessageHandler; - -class MessageDispatcher { - NetworkReceiver* m_network; - - std::unordered_map m_handlers; - -public: - MessageDispatcher(NetworkReceiver* network) : - m_network(network) { - } - - template - void set_handler(std::function handler) { - m_handlers[static_cast(Message::value)] = - [handler, this](uint32_t session_id, std::span data) -> tl::expected { - ZoneScopedN("Handling message"); - - size_t size = 0; - auto r = MessageDeserializer::deserialize(data); - if(!r) { - return {}; - } - - handler(session_id, *r); - return {}; - }; - } - - void drain_queue() { - while(!m_network->inbound_queue()->is_empty()) { - auto msg = m_network->inbound_queue()->pop(); - uint32_t message_type = *(uint32_t*)msg->payload.data(); - - if(m_handlers.find(message_type) == m_handlers.end()) { - spdlog::error("No handler for message type {}", message_type); - continue; - } - - auto r = m_handlers[message_type](msg->session_id, std::span(msg->payload).subspan(sizeof(uint32_t))); - if(!r) { - spdlog::error("Failed to handle message of type {}: {}", message_type, r.error().message()); - // TODO: Add session failure - continue; - } - } - } -}; - -} diff --git a/modules/server/src/network/MessageQueue.hpp b/modules/server/src/network/MessageQueue.hpp deleted file mode 100644 index 5bde9d9..0000000 --- a/modules/server/src/network/MessageQueue.hpp +++ /dev/null @@ -1,32 +0,0 @@ -#pragma once - -#include -#include - -namespace tw::net { - -template -class MessageQueue { - std::queue m_queue; - -public: - MessageQueue() : m_queue() { - - } - - bool is_empty() { - return m_queue.empty(); - } - - void push(T mesg) { - m_queue.push(mesg); - } - - T pop() { - auto mesg = m_queue.front(); - m_queue.pop(); - return mesg; - } -}; - -} diff --git a/modules/server/src/network/NetworkReceiver.cpp b/modules/server/src/network/NetworkReceiver.cpp index 3e0cdff..b50119a 100644 --- a/modules/server/src/network/NetworkReceiver.cpp +++ b/modules/server/src/network/NetworkReceiver.cpp @@ -1,72 +1,77 @@ #include "NetworkReceiver.hpp" -#include "network/InboundMessage.hpp" -#include "network/MessageQueue.hpp" -#include "network/PlayerSessionRegistry.hpp" -#include "protocol/quicr/QuicrConnectionListener.hpp" + +#include namespace tw::net { +namespace { + +std::unique_ptr bind_endpoint(int32_t port) { + auto endpoint_r = msg::MessageEndpoint::bind(port); + if(!endpoint_r) { + throw std::runtime_error("NetworkReceiver: failed to bind to port " + std::to_string(port) + + ": " + endpoint_r.error().message()); + } + + return std::move(endpoint_r.value()); +} + +} + NetworkReceiver::NetworkReceiver( PlayerSessionRegistry* session_registry, int32_t udp_port, NetworkMetricsReporter* metrics_reporter ) : m_session_registry(session_registry), - m_inbound_queue(new MessageQueue), - m_quicr_endpoint(quicr::QuicrEndpoint::create().value()), - m_quicr_listener(quicr::QuicrConnectionListener::listen(m_quicr_endpoint.get()).value()), + m_endpoint(bind_endpoint(udp_port)), + m_messages(m_endpoint.get()), m_metrics_reporter(metrics_reporter) { - m_quicr_endpoint->bind(udp_port); + // Registering the session here rather than after update() means a peer + // already has one by the time its first message is dispatched. + m_endpoint->set_on_peer_connected([this](msg::PeerId peer) { + auto session_id = m_session_registry->register_session(peer, m_endpoint->peer(peer)); + m_new_sessions.push_back(session_id); + }); spdlog::info("Running on port: {}", udp_port); } -bool NetworkReceiver::listen_quicr() { - quicr::QuicrConnection* connection = nullptr; - while((connection = m_quicr_listener->listen())) { - spdlog::info("Quicr client tries to connect"); +void NetworkReceiver::report_traffic() { + const uint64_t received = m_endpoint->bytes_received(); + const uint64_t sent = m_endpoint->bytes_sent(); - auto session_id = m_session_registry->register_session(connection); + m_metrics_reporter->add_inbound(received - m_reported_bytes_received); + m_metrics_reporter->add_outbound(sent - m_reported_bytes_sent); - m_new_sessions.push_back(session_id); - } - - return true; -} - -void NetworkReceiver::listen() { - listen_quicr(); -} - -void NetworkReceiver::process_streams() { - m_quicr_endpoint->poll(); - - std::vector buffer(64 * 1024); - for(auto& client : m_session_registry->sessions()) { - auto read_r = client->quicr_connection->read_into(buffer); - if(!read_r) { - spdlog::error("Failed to read from QUICr stream"); - continue; - } - - if(*read_r == 0) { - continue; - } - - m_metrics_reporter->add_inbound(*read_r); - - auto data = std::vector(buffer.begin(), buffer.begin() + *read_r); - m_inbound_queue->push(new InboundMessage(client->session_id, data)); - } + m_reported_bytes_received = received; + m_reported_bytes_sent = sent; } void NetworkReceiver::update() { - m_quicr_endpoint->poll(); + m_endpoint->update(); - listen(); + report_traffic(); +} - process_streams(); +size_t NetworkReceiver::send_framed(SessionId session_id, std::span message) { + if(message.empty()) { + return 0; + } + + auto* session = m_session_registry->session(session_id); + if(session == nullptr) { + return 0; + } + + auto send_r = session->connection->send_framed(message, false); + if(!send_r) { + spdlog::error("Failed to send to session {}: {}", session_id, send_r.error().message()); + return 0; + } + + return message.size(); } } diff --git a/modules/server/src/network/NetworkReceiver.hpp b/modules/server/src/network/NetworkReceiver.hpp index c8d57db..1f14713 100644 --- a/modules/server/src/network/NetworkReceiver.hpp +++ b/modules/server/src/network/NetworkReceiver.hpp @@ -1,35 +1,40 @@ #pragma once -#include "MessageQueue.hpp" -#include "InboundMessage.hpp" -#include "MessageRegistry.hpp" +#include "ProtobufMessages.hpp" +#include "SessionId.hpp" #include "monitoring/MetricsReporter.hpp" -#include "monitoring/TimescaleDbMetricsReporter.hpp" #include "network/PlayerSessionRegistry.hpp" -#include "protocol/quicr/QuicrConnectionListener.hpp" -#include "protocol/quicr/QuicrEndpoint.hpp" +#include "message_protocol/MessageEndpoint.hpp" + +#include + +#include +#include +#include +#include #include namespace tw::net { +/** + * Accepts player connections and routes their messages to the handlers the + * zone server registers, translating peers into sessions on the way. + */ class NetworkReceiver { - PlayerSessionRegistry *m_session_registry; + PlayerSessionRegistry* m_session_registry; - std::unique_ptr m_quicr_endpoint; - std::unique_ptr m_quicr_listener; - - MessageQueue* m_inbound_queue; + std::unique_ptr m_endpoint; + ProtobufMessages m_messages; std::deque m_new_sessions; NetworkMetricsReporter* m_metrics_reporter; - bool listen_quicr(); + uint64_t m_reported_bytes_received = 0; + uint64_t m_reported_bytes_sent = 0; - void listen(); - - void process_streams(); + void report_traffic(); public: NetworkReceiver( @@ -38,8 +43,22 @@ public: NetworkMetricsReporter* metrics_reporter ); - MessageQueue* inbound_queue() { - return m_inbound_queue; + /** + * Calls `handler` for every T that arrives, along with the session that + * sent it. + */ + template + void set_handler(std::function handler) { + m_messages.set_handler( + [this, handler = std::move(handler)](msg::PeerId peer, const T& message) { + const SessionId session_id = m_session_registry->session_for_peer(peer); + if(session_id == 0) { + spdlog::warn("Dropped a message from peer {}, which has no session", peer); + return; + } + + handler(session_id, message); + }); } bool peek_new_session() const { @@ -47,67 +66,35 @@ public: } SessionId pop_new_session() { - auto session = m_new_sessions.front(); + auto session_id = m_new_sessions.front(); m_new_sessions.pop_front(); - return session; + return session_id; } void update(); template size_t send_mesg(SessionId session_id, const T& mesg) { - std::string payload; - if(!mesg.SerializeToString(&payload)) { - spdlog::error("Failed to serialize message"); + auto* session = m_session_registry->session(session_id); + if(session == nullptr) { + spdlog::warn("Cannot send to session {}, it is not registered", session_id); return 0; } - int32_t length = payload.length(); - if(length == 0) { + auto send_r = m_messages.send(session->connection, mesg, true); + if(!send_r) { + spdlog::error("Failed to send to session {}: {}", session_id, send_r.error().message()); return 0; } - std::vector bytes(length + sizeof(uint32_t)); - - uint32_t type = Message::value; - auto payload_bytes = std::as_writable_bytes(std::span(payload)); - memcpy(bytes.data(), &type, sizeof(type)); - memcpy(bytes.data() + sizeof(uint32_t), payload_bytes.data(), payload_bytes.size()); - - auto session = m_session_registry->session(session_id); - m_metrics_reporter->add_outbound(bytes.size()); - - session->quicr_connection->send_message(bytes, false); - return bytes.size(); + return msg::MessageHeader::SIZE + mesg.ByteSizeLong(); } /** - * Send a pre-serialised raw byte payload directly, with no additional - * framing. The caller is responsible for including any type tag and - * length prefix in the payload (as tw::serial::WorldStateWriter does). - * - * This avoids the Protobuf SerializeToString heap allocation entirely — - * the span points into the caller's BinaryBuffer, which is reused every - * frame. + * Sends a message the caller has already framed, so that a writer holding + * its own buffer does not have to be copied through an encoder. */ - size_t send_raw(SessionId session_id, std::span payload) { - if(payload.empty()) { - return 0; - } - - auto* session = m_session_registry->session(session_id); - if(!session) { - return 0; - } - - // QuicrConnection::send_message takes a std::vector. - // We copy the span here. If the QUICr layer is ever refactored to - // accept a span we can remove this copy entirely. - std::vector bytes(payload.begin(), payload.end()); - m_metrics_reporter->add_outbound(bytes.size()); - session->quicr_connection->send_message(bytes, false); - return bytes.size(); - } + size_t send_framed(SessionId session_id, std::span message); }; } diff --git a/modules/server/src/network/OutboundMessage.hpp b/modules/server/src/network/OutboundMessage.hpp deleted file mode 100644 index 153b2e8..0000000 --- a/modules/server/src/network/OutboundMessage.hpp +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include - -namespace tw::net { - -struct OutboundMessage { - std::vector payload; - - OutboundMessage(std::vector payload) - : payload(std::move(payload)) {} -}; - -} // namespace tw::net diff --git a/modules/server/src/network/PlayerSessionRegistry.cpp b/modules/server/src/network/PlayerSessionRegistry.cpp index cdd147b..63d0380 100644 --- a/modules/server/src/network/PlayerSessionRegistry.cpp +++ b/modules/server/src/network/PlayerSessionRegistry.cpp @@ -1,6 +1,5 @@ #include "PlayerSessionRegistry.hpp" #include "PlayerSession.hpp" -#include "protocol/quicr/QuicrConnection.hpp" namespace tw::net { @@ -9,13 +8,16 @@ SessionId PlayerSessionRegistry::generate_session_id() { return next_id++; } -SessionId PlayerSessionRegistry::register_session(quicr::QuicrConnection* quicr_connection) { - auto session = new PlayerSession(quicr_connection->self_id(), quicr_connection); - m_session_vec.emplace_back(session); +SessionId PlayerSessionRegistry::register_session(msg::PeerId peer, msg::MessageConnection* connection) { + const SessionId session_id = generate_session_id(); - m_session_map.emplace(quicr_connection->self_id(), session); + auto session = std::make_unique(session_id, connection); + m_session_vec.emplace_back(session.get()); - return quicr_connection->self_id(); + m_session_map.emplace(session_id, std::move(session)); + m_peer_sessions.emplace(peer, session_id); + + return session_id; } } diff --git a/modules/server/src/network/PlayerSessionRegistry.hpp b/modules/server/src/network/PlayerSessionRegistry.hpp index 5d171d1..3f13123 100644 --- a/modules/server/src/network/PlayerSessionRegistry.hpp +++ b/modules/server/src/network/PlayerSessionRegistry.hpp @@ -2,7 +2,10 @@ #include "PlayerSession.hpp" #include "SessionId.hpp" -#include "protocol/quicr/QuicrConnection.hpp" + +#include "message_protocol/MessageConnection.hpp" +#include "message_protocol/PeerId.hpp" + #include #include #include @@ -14,6 +17,10 @@ class PlayerSessionRegistry { std::unordered_map> m_session_map; + // Sessions are identified by a dense id of their own, so the wider peer id + // the network layer assigns stays at the boundary. + std::unordered_map m_peer_sessions; + SessionId generate_session_id(); public: @@ -26,7 +33,13 @@ public: return session != m_session_map.end() ? session->second.get() : nullptr; } - SessionId register_session(quicr::QuicrConnection* quicr_connection); + /** The session belonging to a peer, or zero if the peer has none. */ + SessionId session_for_peer(msg::PeerId peer) const { + auto session = m_peer_sessions.find(peer); + return session != m_peer_sessions.end() ? session->second : 0; + } + + SessionId register_session(msg::PeerId peer, msg::MessageConnection* connection); void unregister_session(SessionId); }; diff --git a/modules/server/src/replication/StateReplicator.hpp b/modules/server/src/replication/StateReplicator.hpp index 0162beb..e2b3f77 100644 --- a/modules/server/src/replication/StateReplicator.hpp +++ b/modules/server/src/replication/StateReplicator.hpp @@ -1,5 +1,6 @@ #pragma once +#include "MessageRegistry.hpp" #include "network/NetworkReceiver.hpp" #include "network/PlayerSessionRegistry.hpp" #include "systems/Interest.hpp" @@ -34,8 +35,9 @@ class StateReplicator { // Per-client backing buffers reused every frame. std::vector m_frames; - // Header(12) + spawn_hdr(4) + despawn_hdr(4) + 512 entities × 16 bytes - static constexpr std::size_t kInitialCapacity = 20 + 512 * 16; + // Header(16) + spawn_hdr(4) + despawn_hdr(4) + 512 entities × 16 bytes + static constexpr std::size_t kHeaderCapacity = 24; + static constexpr std::size_t kInitialCapacity = kHeaderCapacity + 512 * 16; public: StateReplicator( @@ -87,14 +89,14 @@ public: if (!state) continue; const std::size_t needed = - 20 + kHeaderCapacity + state->spawn().size() * 4 + state->despawn().size() * 4 + state->interest().size() * 16; m_frames[i].reserve(needed); writers[i].reset(); - writers[i].begin(session->last_frame); + writers[i].begin(session->acked_frame, Message::value); writers[i].write_spawns(state->spawn()); writers[i].write_despawns(state->despawn()); } @@ -118,10 +120,9 @@ public: ZoneScopedN("Sending messages"); for (std::size_t i = 0; i < session_count; ++i) { - spdlog::info("Sending"); if (!client_states[i]) continue; writers[i].end(); - m_network->send_raw(sessions[i]->session_id, writers[i].view()); + m_network->send_framed(sessions[i]->session_id, writers[i].view()); } } } diff --git a/modules/server_gui/CMakeLists.txt b/modules/server_gui/CMakeLists.txt index 6f2d09b..15590c8 100644 --- a/modules/server_gui/CMakeLists.txt +++ b/modules/server_gui/CMakeLists.txt @@ -22,6 +22,8 @@ target_link_libraries( loft_window loft::render_graph tw::protocol + tw::message_protocol + tw::quicr towards tw::gui imgui::imgui diff --git a/modules/template/CMakeLists.txt b/modules/template/CMakeLists.txt new file mode 100644 index 0000000..55ff869 --- /dev/null +++ b/modules/template/CMakeLists.txt @@ -0,0 +1,47 @@ +project(tw_template) + +# set(CMAKE_CXX_CLANG_TIDY "/usr/bin/clang-tidy;-checks=*") + +file(GLOB FILES + src/ZoneServer.cpp + src/ZoneCoordinator.cpp + src/ZoneClusterLink.cpp + src/ZoneManager.cpp + src/interest_management/*.cpp + src/replication/*.cpp + src/systems/*.cpp + src/monitoring/*.cpp + src/network/*.cpp +) + +add_library(tw_server_lib STATIC ${FILES}) + +target_include_directories(tw_server_lib + PUBLIC + ${PROJECT_SOURCE_DIR}/src/ +) + +target_link_libraries(tw_server_lib + PUBLIC + towards + tw::network + tw::protocol + tw::serialization + glm::glm + EnTT::EnTT + Jolt + protobuf::libprotobuf + ${Boost_LIBRARIES} + Tracy::TracyClient + pqxx + pq +) + +add_executable(${PROJECT_NAME} src/server.cpp) + +target_link_libraries(tw_server + PUBLIC + tw_server_lib +) + +ADD_SUBDIRECTORY(./tests/) diff --git a/modules/template/README.md b/modules/template/README.md new file mode 100644 index 0000000..9104910 --- /dev/null +++ b/modules/template/README.md @@ -0,0 +1,3 @@ +# Template + +This is a template module for boosting start. diff --git a/src/world/CharacterBody.hpp b/src/world/CharacterBody.hpp index bdfa0ab..cf4a653 100644 --- a/src/world/CharacterBody.hpp +++ b/src/world/CharacterBody.hpp @@ -14,8 +14,12 @@ public: JPH::Vec3 m_desired_velocity; + // JPH::Vec3's default constructor leaves the value uninitialised, and the + // desired velocity is accumulated across steps, so it has to start at zero + // or the first step feeds garbage into the character's velocity. CharacterBody(JPH::CharacterVirtual* character) : - m_character(character) + m_character(character), + m_desired_velocity(JPH::Vec3::sZero()) { } }; diff --git a/src/world/CharacterController.hpp b/src/world/CharacterController.hpp index 23bcb67..2440a6e 100644 --- a/src/world/CharacterController.hpp +++ b/src/world/CharacterController.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -11,6 +12,7 @@ namespace tw { * Controls the character's body */ class CharacterController { +private: float m_speed; using Clock = std::chrono::steady_clock; @@ -18,7 +20,16 @@ class CharacterController { HistoryBuffer m_history; HistoryBuffer m_position_history; - glm::vec3 m_input; + // Frame-indexed input ring, capacity 64 + struct InputSlot { + uint32_t frame; + glm::vec3 input; + bool valid; + }; + std::array m_input_ring; + + uint32_t m_last_input_frame; + glm::vec3 m_last_input; uint32_t m_frame_idx; @@ -31,12 +42,26 @@ public: m_speed(speed), m_history(Clock::now(), glm::vec3(), 10 * 20), m_position_history(Clock::now(), glm::vec3(), 10 * 20), + m_last_input_frame(0), + m_last_input(0.0f), m_frame_idx(0) - { } + { + // Initialize input ring + for(auto& slot : m_input_ring) { + slot.frame = 0; + slot.input = glm::vec3(0.0f); + slot.valid = false; + } + } void set_input(uint32_t frame_idx, glm::vec3 input) { - // m_history.set(frame_idx, input); - m_input = input; + size_t idx = frame_idx % m_input_ring.size(); + m_input_ring[idx].frame = frame_idx; + m_input_ring[idx].input = input; + m_input_ring[idx].valid = true; + + m_last_input_frame = frame_idx; + m_last_input = input; } void set_frame_idx(uint32_t idx) { @@ -44,18 +69,23 @@ public: } glm::vec3 input() const { - return m_input; - // return m_history.values()[m_history.values().size() - 1]; + return m_last_input; } + // Returns the input for the specified frame, or falls back to the most recently set input + // if the frame slot has been overwritten or never written glm::vec3 input(uint32_t frame_idx) const { - return m_input; - // auto value = m_history.get(frame_idx); - // if(value.has_value()) { - // return *value.value(); - // } + size_t idx = frame_idx % m_input_ring.size(); + const auto& slot = m_input_ring[idx]; - // return glm::vec3(); + // If slot contains the exact frame we're looking for, return it + if(slot.valid && slot.frame == frame_idx) { + return slot.input; + } + + // Otherwise, fall back to the most recent input + // This handles dropped packets (slot never written) or wraparound (slot overwritten) + return m_last_input; } }; diff --git a/src/world/JoltPhysicsWorld.cpp b/src/world/JoltPhysicsWorld.cpp index db7d3f1..5415eec 100644 --- a/src/world/JoltPhysicsWorld.cpp +++ b/src/world/JoltPhysicsWorld.cpp @@ -40,7 +40,6 @@ JoltPhysicsWorld::JoltPhysicsWorld(World* world) : temp_allocator(std::make_unique(10 * 1024 * 1024)), job_system(JPH::cMaxPhysicsJobs, JPH::cMaxPhysicsBarriers, std::thread::hardware_concurrency() - 1), m_world(world), - m_history(0, std::move(std::make_unique()), 10), m_latest_frame(0), broad_phase_layer_interface(), object_vs_broadphase_layer_filter(), @@ -50,6 +49,11 @@ JoltPhysicsWorld::JoltPhysicsWorld(World* world) : contact_listener(), m_character_vs_character_collision() { + // Initialize snapshot ring + for(auto& snapshot : m_snapshot_ring) { + snapshot.frame = 0; + snapshot.valid = false; + } // for(int i = 0; i < m_thread_pool.size(); i++) { // temp_allocator.emplace_back(std::make_unique(10 * 1024 * 1024)); // } @@ -92,6 +96,27 @@ JoltPhysicsWorld::JoltPhysicsWorld(World* world) : // body_interface().SetLinearVelocity(sphere_id, JPH::Vec3(0.0f, -5.0f, 0.0f)); } +void JoltPhysicsWorld::save_snapshot(uint32_t frame) { + size_t idx = frame % m_snapshot_ring.size(); + auto& snapshot = m_snapshot_ring[idx]; + + // Clear and set up the slot + snapshot.recorder.Clear(); + snapshot.frame = frame; + snapshot.valid = true; + snapshot.desired_velocities.clear(); + + // Save the physics world state + physics_system.SaveState(snapshot.recorder); + + // Save character states in deterministic order + auto view = m_world->registry().view(); + view.each([&](const CharacterController& controller, CharacterBody& rb) { + rb.m_character->SaveState(snapshot.recorder); + snapshot.desired_velocities.push_back(rb.m_desired_velocity); + }); +} + void JoltPhysicsWorld::update(uint32_t frame_idx, double delta_time) { auto view = m_world->registry().view(); @@ -117,7 +142,9 @@ void JoltPhysicsWorld::update(uint32_t frame_idx, double delta_time) { : vel; // True if the player intended to move - mAllowSliding = input.length() < 0.01f; // allow sliding when idle, prevent when moving + // glm::vec3::length() is the static component count (3), not the + // magnitude, so this has to go through glm::length. + mAllowSliding = glm::length(input) < 0.01f; // allow sliding when idle, prevent when moving } JPH::Vec3 current_vertical_velocity = character->GetLinearVelocity().Dot(character->GetUp()) * character->GetUp(); JPH::Vec3 ground_velocity = character->GetGroundVelocity(); @@ -181,16 +208,15 @@ void JoltPhysicsWorld::update(uint32_t frame_idx, double delta_time) { }); physics_system.Update(delta_time, 1, temp_allocator.get(), &job_system); - - auto stateRecorder = std::make_shared(); - physics_system.SaveState(*stateRecorder.get()); - - m_history.set(frame_idx, stateRecorder); } -void JoltPhysicsWorld::step(uint32_t frame_idx, double delta_time) { +void JoltPhysicsWorld::step(uint32_t frame_idx, double delta_time, bool record_snapshot) { update(frame_idx, delta_time); + if(record_snapshot) { + save_snapshot(frame_idx); + } + m_world->registry().view() .each([&](Transform& ts, CharacterBody& rb) { auto character = rb.m_character; @@ -199,22 +225,52 @@ void JoltPhysicsWorld::step(uint32_t frame_idx, double delta_time) { memcpy(&ts.transform, &transform, sizeof(glm::mat4)); }); - // copy values - // m_world->registry().view() - // .each([&](Transform& ts, RigidBody& rb) { - // auto transform = body_interface().GetWorldTransform(rb.id); - // memcpy(&ts.transform, &transform, sizeof(glm::mat4)); - // }); - // - // m_world->registry().view() - // .each([&](Transform& ts, CharacterBody& rb) { - // auto character = rb.m_character; - // - // auto transform = character->GetWorldTransform(); - // memcpy(&ts.transform, &transform, sizeof(glm::mat4)); - // }); - m_latest_frame = std::max(m_latest_frame, frame_idx); } +bool JoltPhysicsWorld::rollback(uint32_t frame) { + if(frame == 0) return true; + + size_t idx = frame % m_snapshot_ring.size(); + auto& snapshot = m_snapshot_ring[idx]; + + // Check if snapshot exists and matches the requested frame + if(!snapshot.valid || snapshot.frame != frame) { + return false; + } + + // Restore the physics world state + snapshot.recorder.Rewind(); + physics_system.RestoreState(snapshot.recorder); + + // Restore character states in the same deterministic order + size_t char_idx = 0; + auto view = m_world->registry().view(); + view.each([&](const CharacterController& controller, CharacterBody& rb) { + rb.m_character->RestoreState(snapshot.recorder); + if(char_idx < snapshot.desired_velocities.size()) { + rb.m_desired_velocity = snapshot.desired_velocities[char_idx]; + } + char_idx++; + }); + + return true; +} + +void JoltPhysicsWorld::apply_rollback(uint32_t frame) { + // Replay frames from frame+1 through m_latest_frame inclusive + for(uint32_t i = frame + 1; i <= m_latest_frame; i++) { + update(i, FIXED_DELTA_TIME); + save_snapshot(i); + } + + // Sync transforms from characters to match the current state + m_world->registry().view() + .each([&](Transform& ts, CharacterBody& rb) { + auto character = rb.m_character; + auto transform = character->GetWorldTransform(); + memcpy(&ts.transform, &transform, sizeof(glm::mat4)); + }); +} + } diff --git a/src/world/JoltPhysicsWorld.hpp b/src/world/JoltPhysicsWorld.hpp index 7f7785b..165a0cc 100644 --- a/src/world/JoltPhysicsWorld.hpp +++ b/src/world/JoltPhysicsWorld.hpp @@ -5,6 +5,7 @@ #include #include "metrics/HistoryBuffer.hpp" +#include #include #include @@ -189,7 +190,14 @@ private: JPH::CharacterVsCharacterCollisionSimple m_character_vs_character_collision; - HistoryBuffer> m_history; + struct Snapshot { + uint32_t frame; + bool valid; + JPH::StateRecorderImpl recorder; + std::vector desired_velocities; + }; + + std::array m_snapshot_ring; inline JPH::BodyInterface &body_interface() { return physics_system.GetBodyInterface(); @@ -203,7 +211,11 @@ private: void update(uint32_t frame_idx, double delta_time); + void save_snapshot(uint32_t frame); + public: + static constexpr double FIXED_DELTA_TIME = 1.0 / 20.0; + JoltPhysicsWorld(World* world); RigidBody create_dynamic_rigid_body(JPH::Shape* shape, glm::vec3 position) { @@ -242,24 +254,17 @@ public: body_interface().AddForce(rigidbody.id, JPH::RVec3(force.x, force.y, force.z)); } - void step(uint32_t frame_idx, double delta_time); + void step(uint32_t frame_idx, double delta_time, bool record_snapshot = false); - void rollback(uint32_t frame) { - if(frame == 0) return; - auto snapshot = m_history.get(frame); + bool rollback(uint32_t frame); - if(!snapshot.has_value()) { - throw std::runtime_error("Failed to restore history"); - } + void apply_rollback(uint32_t frame); - auto snapshot_value = snapshot.value()->get(); - physics_system.RestoreState(*snapshot_value); - } + uint32_t latest_frame() const { return m_latest_frame; } - void apply_rollback(uint32_t frame) { - for(uint32_t i = frame; i < m_latest_frame; i++) { - update(i, 1000.0f / 20.0f); - } + bool has_snapshot(uint32_t frame) const { + size_t idx = frame % m_snapshot_ring.size(); + return m_snapshot_ring[idx].valid && m_snapshot_ring[idx].frame == frame; } };