#1 - quicr module

This commit is contained in:
Martin Slachta
2026-07-22 17:34:44 +02:00
parent a04f0dc262
commit f4174eb0c7
177 changed files with 5309 additions and 2265 deletions
+119
View File
@@ -0,0 +1,119 @@
#include "ClientArgs.hpp"
#include <arpa/inet.h>
#include <charconv>
#include <algorithm>
#include <cctype>
namespace tw::app {
namespace {
std::string_view trim(std::string_view str) {
// Skip leading whitespace
size_t start = 0;
while(start < str.length() && std::isspace(static_cast<unsigned char>(str[start]))) {
++start;
}
// Skip trailing whitespace
size_t end = str.length();
while(end > start && std::isspace(static_cast<unsigned char>(str[end - 1]))) {
--end;
}
return str.substr(start, end - start);
}
bool is_valid_ipv4(std::string_view ip_str) {
// Use inet_pton to validate IPv4 format
struct in_addr addr;
return inet_pton(AF_INET, std::string(ip_str).c_str(), &addr) == 1;
}
tl::expected<int, std::string> parse_port(std::string_view port_str) {
if(port_str.empty()) {
return 8080; // Default port
}
int port = 0;
const char* end = port_str.data() + port_str.length();
auto result = std::from_chars(port_str.data(), end, port);
// from_chars stops at the first character it cannot use, so a partially
// numeric port like "80x" would otherwise be accepted as 80.
if(result.ec != std::errc() || result.ptr != end) {
return tl::make_unexpected("port must be numeric");
}
if(port < 1 || port > 65535) {
return tl::make_unexpected("port must be between 1 and 65535");
}
return port;
}
} // anonymous namespace
tl::expected<net::Address, std::string> parse_address(std::string_view text) {
// Trim whitespace
text = trim(text);
if(text.empty()) {
return tl::make_unexpected("address cannot be empty");
}
// Find the colon to split host and port
size_t colon_pos = text.rfind(':');
std::string_view host;
std::string_view port_str;
if(colon_pos == std::string_view::npos) {
// No colon found: treat entire string as port or host
// If it's all digits, treat as port; otherwise as host (will fail validation)
bool all_digits = !text.empty() && std::all_of(text.begin(), text.end(),
[](unsigned char c) { return std::isdigit(c); });
if(all_digits) {
host = "127.0.0.1";
port_str = text;
} else {
// Treat as host with no port
host = text;
port_str = "";
}
} else {
host = text.substr(0, colon_pos);
port_str = text.substr(colon_pos + 1);
}
// Validate host
if(host.empty()) {
return tl::make_unexpected("host cannot be empty");
}
if(!is_valid_ipv4(host)) {
return tl::make_unexpected("not a valid IPv4 address");
}
// Parse port
auto port_result = parse_port(port_str);
if(!port_result) {
return tl::make_unexpected(port_result.error());
}
int port = port_result.value();
return net::Address(std::optional<std::string>(std::string(host)), port);
}
std::optional<std::string> server_arg(int argc, char** argv) {
for(int i = 1; i < argc - 1; ++i) {
if(std::string_view(argv[i]) == "--server") {
return std::string(argv[i + 1]);
}
}
return std::nullopt;
}
} // namespace tw::app
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <string_view>
#include <string>
#include <optional>
#include <tl/expected.hpp>
#include "Address.hpp"
namespace tw::app {
/**
* Parse an address string into a network address.
*
* Accepts "host:port" or a bare port number. Bare port uses 127.0.0.1.
* Missing port defaults to 8080. Trims surrounding whitespace.
* Validates the host with inet_pton and returns an error string
* for non-IPv4 addresses or invalid ports.
*/
tl::expected<net::Address, std::string> parse_address(std::string_view text);
/**
* Extract the --server argument value from the command line.
*
* Scans argv for --server and returns the following argument,
* or nothing if the flag is absent or has no value.
*/
std::optional<std::string> server_arg(int argc, char** argv);
} // namespace tw::app
@@ -0,0 +1,31 @@
#include "FavouriteServers.hpp"
#include <algorithm>
namespace tw::app {
FavouriteServers::FavouriteServers()
: FileAddressList("favourite_servers.txt") {
}
const char* FavouriteServers::name() const {
return "Favourites";
}
void FavouriteServers::add(const std::string& entry) {
// Check if already present
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
if(it != m_entries.end()) {
return; // Already present, do nothing
}
// Append at the end
m_entries.push_back(entry);
// Cap at MAX_ENTRIES
if(m_entries.size() > MAX_ENTRIES) {
m_entries.resize(MAX_ENTRIES);
}
}
} // namespace tw::app
@@ -0,0 +1,33 @@
#pragma once
#include "FileAddressList.hpp"
namespace tw::app {
/**
* Manages a list of favourite server addresses.
*
* Persists addresses to a text file, one "ip:port" per line.
* Appended in the order they are added, de-duplicated,
* capped at 32 entries. File path is
* $XDG_CONFIG_HOME/towards/favourite_servers.txt, falling back to
* $HOME/.config/towards/favourite_servers.txt. If both variables are
* unset, keeps the list in memory only.
*/
class FavouriteServers : public FileAddressList {
static constexpr size_t MAX_ENTRIES = 32;
public:
FavouriteServers();
const char* name() const override;
/**
* Add an address to the favourites list.
* Appended at the end if not already present, capped at 32.
* Does not persist to disk; call save() after modifying.
*/
void add(const std::string& entry) override;
};
} // namespace tw::app
+110
View File
@@ -0,0 +1,110 @@
#include "FileAddressList.hpp"
#include <spdlog/spdlog.h>
#include <filesystem>
#include <fstream>
#include <cstdlib>
#include <algorithm>
namespace tw::app {
namespace {
std::string get_config_dir() {
// Try XDG_CONFIG_HOME first
const char* xdg_config_home = std::getenv("XDG_CONFIG_HOME");
if(xdg_config_home && xdg_config_home[0] != '\0') {
return std::string(xdg_config_home) + "/towards";
}
// Fall back to $HOME/.config/towards
const char* home = std::getenv("HOME");
if(home && home[0] != '\0') {
return std::string(home) + "/.config/towards";
}
// Both unset
return "";
}
} // anonymous namespace
FileAddressList::FileAddressList(const std::string& file_name) {
std::string config_dir = get_config_dir();
if(config_dir.empty()) {
spdlog::debug("XDG_CONFIG_HOME and HOME not set; address lists will not be persisted");
m_can_save = false;
return;
}
m_path = config_dir + "/" + file_name;
m_can_save = true;
}
void FileAddressList::load() {
if(m_path.empty()) {
return; // No config path available
}
std::ifstream file(m_path);
if(!file.is_open()) {
// File doesn't exist or can't be read; this is not an error
return;
}
m_entries.clear();
std::string line;
while(std::getline(file, line)) {
// Trim whitespace from the line
size_t start = line.find_first_not_of(" \t\r\n");
size_t end = line.find_last_not_of(" \t\r\n");
if(start != std::string::npos) {
line = line.substr(start, end - start + 1);
if(!line.empty()) {
m_entries.push_back(line);
}
}
}
}
void FileAddressList::save() const {
if(!m_can_save || m_path.empty()) {
return; // Cannot save without config path
}
// Create the directory if needed
std::filesystem::path config_path(m_path);
std::filesystem::path config_dir = config_path.parent_path();
try {
std::filesystem::create_directories(config_dir);
} catch(const std::filesystem::filesystem_error&) {
// If we can't create the directory, silently fail to save
return;
}
// Write entries to file
std::ofstream file(m_path);
if(!file.is_open()) {
return; // Can't open file for writing; silently fail
}
for(const auto& entry : m_entries) {
file << entry << "\n";
}
}
void FileAddressList::remove(const std::string& entry) {
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
if(it != m_entries.end()) {
m_entries.erase(it);
}
}
const std::vector<std::string>& FileAddressList::entries() const {
return m_entries;
}
} // namespace tw::app
@@ -0,0 +1,32 @@
#pragma once
#include "ServerAddressProvider.hpp"
#include <vector>
#include <string>
namespace tw::app {
/**
* Shared behaviour of address lists backed by a file.
*
* One "ip:port" per line. Resolves file path to
* $XDG_CONFIG_HOME/towards/<file_name>, falling back to
* $HOME/.config/towards/<file_name>.
* add() stays pure virtual: subclasses define their own order.
*/
class FileAddressList : public ServerAddressProvider {
protected:
std::vector<std::string> m_entries;
std::string m_path;
bool m_can_save = false;
explicit FileAddressList(const std::string& file_name);
public:
void load() override;
void save() const override;
void remove(const std::string& entry) override;
const std::vector<std::string>& entries() const override;
};
} // namespace tw::app
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "debug/DebugWindowRegistry.hpp"
#include "debug/metrics/NetworkMetrics.hpp"
#include "draw/WorldRenderer.hpp"
#include "io/InputState.hpp"
#include "world/JoltPhysicsWorld.hpp"
#include "world/World.hpp"
namespace tw::app {
/**
* Bundle of runtime-owned subsystems that the game state needs.
*/
struct GameContext {
tw::World* world;
tw::JoltPhysicsWorld* physics_world;
tw::drw::WorldRenderer* renderer;
tw::io::InputManager* input_manager;
tw::dbg::NetworkMetrics* metrics;
tw::dbg::DebugWindowRegistry* debug_windows;
};
} // namespace tw::app
+34
View File
@@ -0,0 +1,34 @@
#include "GameState.hpp"
namespace tw::app {
GameState::GameState(GameContext context, std::unique_ptr<tw::net::ServerConnection> connection)
: m_context(context),
m_connection(std::move(connection)),
m_entity_gui(context.world),
m_network_gui(*context.metrics)
{
m_controller = std::make_unique<tw::ClientWorldController>(
m_context.input_manager,
m_context.world,
m_context.physics_world,
m_context.renderer,
m_connection.get(),
m_context.metrics
);
m_context.debug_windows->add(&m_entity_gui);
m_context.debug_windows->add(&m_network_gui);
}
GameState::~GameState() {
m_context.debug_windows->remove(&m_entity_gui);
m_context.debug_windows->remove(&m_network_gui);
}
void GameState::update(double delta_time) {
m_controller->update(delta_time);
m_context.world->step(delta_time);
}
} // namespace tw::app
+43
View File
@@ -0,0 +1,43 @@
#pragma once
#include <memory>
#include "GameContext.hpp"
#include "debug/tools/EntityManagerGui.hpp"
#include "debug/tools/NetworkStatsGui.hpp"
#include "network/ServerConnection.hpp"
#include "world/ClientWorldController.hpp"
namespace tw::app {
/**
* The game world state. Owns the connection, world controller, and debug GUIs.
* Responsible for updating the game simulation and rendering debug information.
*/
class GameState {
GameContext m_context;
std::unique_ptr<tw::net::ServerConnection> m_connection;
std::unique_ptr<tw::ClientWorldController> m_controller;
tw::dbg::tools::EntityManagerGui m_entity_gui;
tw::dbg::tools::NetworkStatsGui m_network_gui;
public:
/**
* Constructs the game state with the given context and connection.
* The connection must be established before creating the game state.
*/
GameState(GameContext context, std::unique_ptr<tw::net::ServerConnection> connection);
/**
* Takes the debug panels back out of the menu.
*/
~GameState();
/**
* Updates the game state: draws debug GUIs, updates the controller,
* and steps the world physics.
*/
void update(double delta_time);
};
} // namespace tw::app
+182
View File
@@ -0,0 +1,182 @@
#include "LobbyState.hpp"
#include "ClientArgs.hpp"
#include "imgui.h"
#include <cstdio>
namespace tw::app {
namespace {
const ImVec4 FAVOURITES_COLOUR{1.0f, 0.8f, 0.2f, 1.0f};
const ImVec4 RECENT_COLOUR{0.7f, 0.7f, 0.7f, 1.0f};
}
LobbyState::LobbyState(std::optional<tw::net::Address> auto_connect) {
m_recent.load();
m_favourites.load();
set_address_input(m_recent.entries().empty()
? "127.0.0.1:8080"
: m_recent.entries().front());
if(auto_connect) {
begin_connect(*auto_connect);
}
}
void LobbyState::set_address_input(const std::string& address) {
std::snprintf(m_address_input, sizeof(m_address_input), "%s", address.c_str());
}
void LobbyState::begin_connect(tw::net::Address address) {
m_error.clear();
m_connection = std::make_unique<tw::net::ServerConnection>(address);
auto started = m_connection->start();
if(!started) {
m_error = started.error().message();
m_connection.reset();
}
}
void LobbyState::draw_form() {
const float input_width = 200.0f;
ImGui::SetNextItemWidth(input_width);
bool submitted = ImGui::InputText("##address", m_address_input, sizeof(m_address_input),
ImGuiInputTextFlags_EnterReturnsTrue);
ImGui::SameLine();
submitted |= ImGui::Button("Connect");
if(!submitted) {
return;
}
auto parsed = parse_address(m_address_input);
if(parsed) {
begin_connect(*parsed);
} else {
m_error = parsed.error();
}
}
void LobbyState::draw_favourite_toggle(const std::string& entry) {
const char* label = m_favourites.contains(entry) ? "[*]" : "[ ]";
// The label alone would collide between the rows drawn in one frame, so the
// entry it acts on is what identifies the button.
std::string button_id = std::string(label) + "##fav_" + entry;
if(ImGui::SmallButton(button_id.c_str())) {
toggle_favourite(entry);
}
}
void LobbyState::toggle_favourite(const std::string& entry) {
if(m_favourites.contains(entry)) {
m_favourites.remove(entry);
} else {
m_favourites.add(entry);
}
m_favourites.save();
}
void LobbyState::draw_provider(ServerAddressProvider& provider, const ImVec4& header_colour) {
ImGui::PushStyleColor(ImGuiCol_Text, header_colour);
ImGui::SeparatorText(provider.name());
ImGui::PopStyleColor();
// BeginChild is one of the two calls whose End must run even when it
// returns false, so the result only decides whether rows are submitted.
if(ImGui::BeginChild(provider.name(), ImVec2(0, 120), ImGuiChildFlags_Borders)) {
for(const auto& entry : provider.entries()) {
draw_favourite_toggle(entry);
ImGui::SameLine();
if(ImGui::Selectable(entry.c_str(), false)) {
set_address_input(entry);
}
if(ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
auto parsed = parse_address(entry);
if(parsed) {
begin_connect(*parsed);
}
}
}
}
ImGui::EndChild();
}
void LobbyState::draw_status() {
if(m_connection) {
switch(m_connection->status()) {
case tw::net::ConnectionStatus::Idle:
break;
case tw::net::ConnectionStatus::Connecting: {
ImGui::TextUnformatted("Connecting...");
ImGui::SameLine();
draw_favourite_toggle(m_connection->address().to_string());
ImGui::SameLine();
if(ImGui::Button("Cancel")) {
m_connection.reset();
}
break;
}
case tw::net::ConnectionStatus::Connected:
ImGui::TextColored(ImVec4(0, 1, 0, 1), "Connected!");
m_recent.add(m_connection->address().to_string());
m_recent.save();
m_result = LobbyResult{std::move(m_connection)};
break;
case tw::net::ConnectionStatus::Failed: {
std::string failed_msg = "Connection failed: " + m_connection->error();
ImGui::TextColored(ImVec4(1, 0, 0, 1), "%s", failed_msg.c_str());
if(ImGui::Button("Dismiss")) {
m_connection.reset();
}
break;
}
}
} else if(!m_error.empty()) {
std::string error_msg = "Error: " + m_error;
ImGui::TextColored(ImVec4(1, 0, 0, 1), "%s", error_msg.c_str());
}
}
void LobbyState::update(double delta_time) {
if(m_connection) {
m_connection->update();
}
// Center the window
ImGui::SetNextWindowPos(ImGui::GetMainViewport()->GetCenter(), ImGuiCond_FirstUseEver,
ImVec2(0.5f, 0.5f));
ImGui::SetNextWindowSize(ImVec2(400, 0), ImGuiCond_FirstUseEver);
ImGui::Begin("Lobby", nullptr, ImGuiWindowFlags_NoMove);
draw_form();
draw_provider(m_favourites, FAVOURITES_COLOUR);
draw_provider(m_recent, RECENT_COLOUR);
ImGui::Separator();
draw_status();
ImGui::End();
}
std::optional<LobbyResult> LobbyState::take_result() {
// Moving out of an optional leaves it engaged, which would hand the caller
// a second, empty result on the next frame.
auto result = std::move(m_result);
m_result.reset();
return result;
}
} // namespace tw::app
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <memory>
#include <string>
#include <optional>
#include "RecentServers.hpp"
#include "FavouriteServers.hpp"
#include "ServerAddressProvider.hpp"
#include "network/ServerConnection.hpp"
struct ImVec4;
namespace tw::app {
/**
* The edge out of the lobby: a connection that finished its handshake.
*/
struct LobbyResult {
std::unique_ptr<tw::net::ServerConnection> connection;
};
/**
* The lobby screen state. Renders an address input field, recent server list,
* and manages a connection attempt in flight.
*/
class LobbyState {
static constexpr size_t ADDRESS_INPUT_SIZE = 64;
tw::app::RecentServers m_recent;
tw::app::FavouriteServers m_favourites;
/**
* Edited in place by the input field, so it has to outlive the frame that
* draws it rather than being rebuilt from a string every time.
*/
char m_address_input[ADDRESS_INPUT_SIZE];
std::string m_error;
std::unique_ptr<tw::net::ServerConnection> m_connection;
std::optional<LobbyResult> m_result;
void begin_connect(tw::net::Address address);
void set_address_input(const std::string& address);
void draw_form();
/**
* Renders one address list. The colour is what tells the lists apart on
* screen, so it belongs to the lobby rather than to the list itself.
*/
void draw_provider(ServerAddressProvider& provider, const ImVec4& header_colour);
void draw_favourite_toggle(const std::string& entry);
void toggle_favourite(const std::string& entry);
void draw_status();
public:
/**
* Constructs the lobby state, optionally starting an auto-connect if a
* server address is provided.
*/
explicit LobbyState(std::optional<tw::net::Address> auto_connect);
/**
* Updates the lobby: draws the UI, pumps the connection attempt if one
* is in flight.
*/
void update(double delta_time);
/**
* Returns the transition result if the lobby is done. Moves the result
* out, leaving none behind.
*/
std::optional<LobbyResult> take_result();
};
} // namespace tw::app
+31
View File
@@ -0,0 +1,31 @@
#include "RecentServers.hpp"
#include <algorithm>
namespace tw::app {
RecentServers::RecentServers()
: FileAddressList("recent_servers.txt") {
}
const char* RecentServers::name() const {
return "Recent";
}
void RecentServers::add(const std::string& entry) {
// Remove if already in list (de-duplicate)
auto it = std::find(m_entries.begin(), m_entries.end(), entry);
if(it != m_entries.end()) {
m_entries.erase(it);
}
// Add to front (most recent first)
m_entries.insert(m_entries.begin(), entry);
// Cap at MAX_ENTRIES
if(m_entries.size() > MAX_ENTRIES) {
m_entries.resize(MAX_ENTRIES);
}
}
} // namespace tw::app
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "FileAddressList.hpp"
namespace tw::app {
/**
* Manages a list of recently used server addresses.
*
* Persists addresses to a text file, one "ip:port" per line.
* Most recent first, capped at 8 entries. File path is
* $XDG_CONFIG_HOME/towards/recent_servers.txt, falling back to
* $HOME/.config/towards/recent_servers.txt. If both variables are
* unset, keeps the list in memory only.
*/
class RecentServers : public FileAddressList {
static constexpr size_t MAX_ENTRIES = 8;
public:
RecentServers();
const char* name() const override;
/**
* Add an address to the recents list.
* Most recent first, de-duplicated, capped at 8.
* Does not persist to disk; call save() after connecting.
*/
void add(const std::string& entry) override;
};
} // namespace tw::app
@@ -0,0 +1,42 @@
#pragma once
#include <vector>
#include <string>
namespace tw::app {
/**
* Abstract interface for server address lists.
*
* Implementations manage a list of "ip:port" entries, load/save them,
* and provide a human-readable name for the UI.
*/
class ServerAddressProvider {
public:
virtual ~ServerAddressProvider() = default;
/** Human-readable list name, shown as the section header in the lobby. */
virtual const char* name() const = 0;
virtual const std::vector<std::string>& entries() const = 0;
virtual void load() = 0;
virtual void save() const = 0;
virtual void add(const std::string& entry) = 0;
virtual void remove(const std::string& entry) = 0;
/**
* Check whether an entry exists in the list.
* Implemented over entries() — subclasses need not override.
*/
bool contains(const std::string& entry) const {
const auto& vec = entries();
for(const auto& e : vec) {
if(e == entry) {
return true;
}
}
return false;
}
};
} // namespace tw::app