From 8460fc531fa3d82e68b9af0a673366eaa2fe1a2d Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Sun, 6 Sep 2026 17:28:44 +0000 Subject: [PATCH] multiplayer stage 1: --host/--connect startup, client without a local scenario Networking used to be reachable only through the eu07.ini network.server / network.client entries, and init_modes() refused to start at all unless a scenery file was already known. That made it impossible to join a session without knowing (and typing) the host's scenario up front, even though SERVER_HELLO already carries the scenario name. - add --host [address:port] and --connect address:port to the command line (default port 7420, --host defaults to 0.0.0.0), plus --scenario/--vehicle aliases and -h/--help; the existing ini settings keep working - drop the init_modes() restriction: a client goes straight to the scenario loader and waits there for the handshake, a host without -s still gets the launcher and simply refuses joins until a scenario is up - the loader screen reports the network state instead of sitting on a blank progress bar - a client that did not ask for a vehicle with -v starts as an observer rather than failing to find the default EU07-424; the lobby of stage 2 will take over the choice - handshake carries build identification and the server answers an impossible join with SERVER_REJECT and a readable reason instead of dropping the socket silently; protocol version bumped to 3 - deserialize_message() no longer dereferences a null message on an unknown message type --- application/application.cpp | 110 +++++++++++++++++++++++++---- application/scenarioloadermode.cpp | 20 ++++-- network/message.cpp | 21 ++++++ network/message.h | 17 +++++ network/network.cpp | 46 ++++++++++-- utilities/Globals.h | 7 ++ 6 files changed, 200 insertions(+), 21 deletions(-) diff --git a/application/application.cpp b/application/application.cpp index e5d0b6ba..d5cd118c 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -1105,6 +1105,49 @@ void eu07_application::init_files() } namespace fs = std::filesystem; +namespace { + +// port used when the user did not spell one out in --host / --connect +uint32_t const EU07_DEFAULT_NETWORK_PORT = 7420; + +// completes a user supplied endpoint into the "address:port" form expected by the tcp backend. +// note: only the plain ipv4/hostname form is split, anything with more colons is passed through +std::string network_endpoint(std::string const &Argument, std::string const &Defaultaddress) +{ + auto address{Argument}; + std::string port; + + if (std::count(address.begin(), address.end(), ':') == 1) + { + auto const separator{address.find(':')}; + port = address.substr(separator + 1); + address = address.substr(0, separator); + } + + if (address.empty()) + address = Defaultaddress; + if (port.empty()) + port = std::to_string(EU07_DEFAULT_NETWORK_PORT); + + return address + ":" + port; +} + +void print_usage(std::string const &Executable) +{ + std::cout + << "usage: " << Executable << " [options]\n" + << " -s, --scenario scenario file to load\n" + << " -v, --vehicle vehicle to start in\n" + << " --host [address:port] host a multiplayer session (default 0.0.0.0:" + << EU07_DEFAULT_NETWORK_PORT << ")\n" + << " --connect join a multiplayer session (default port " + << EU07_DEFAULT_NETWORK_PORT << ")\n" + << " -h, --help this message" + << std::endl; +} + +} // namespace + int eu07_application::init_settings(int Argc, char *Argv[]) { Global.asVersion = VERSION_INFO; @@ -1126,24 +1169,48 @@ int eu07_application::init_settings(int Argc, char *Argv[]) std::string token{Argv[i]}; - if (token == "-s") + if (token == "-s" || token == "--scenario") { if (i + 1 < Argc) { Global.SceneryFile = ToLower(Argv[++i]); } } - else if (token == "-v") + else if (token == "-v" || token == "--vehicle") { if (i + 1 < Argc) { Global.local_start_vehicle = ToLower(Argv[++i]); + Global.local_start_vehicle_override = true; } } + else if (token == "--host") + { + // the address is optional, so that a bare --host just listens on every interface + std::string endpoint{std::string("0.0.0.0:") + std::to_string(EU07_DEFAULT_NETWORK_PORT)}; + if (i + 1 < Argc && Argv[i + 1][0] != '-') + { + endpoint = network_endpoint(Argv[++i], "0.0.0.0"); + } + Global.network_servers.emplace_back("tcp", endpoint); + } + else if (token == "--connect") + { + if (i + 1 >= Argc) + { + std::cout << "--connect requires a server address" << std::endl; + return -1; + } + Global.network_client.emplace("tcp", network_endpoint(Argv[++i], "127.0.0.1")); + } + else if (token == "-h" || token == "--help") + { + print_usage(Argv[0]); + return -1; + } else { - std::cout << "usage: " << std::string(Argv[0]) << " [-s sceneryfilepath]" - << " [-v vehiclename]" << std::endl; + print_usage(Argv[0]); return -1; } } @@ -1423,17 +1490,23 @@ int eu07_application::init_modes() { Global.local_random_engine.seed(std::random_device{}()); - if ((!Global.network_servers.empty() || Global.network_client) && Global.SceneryFile.empty()) - { - ErrorLog("launcher mode is currently not supported in network mode"); - return -1; - } - // activate the default mode - if (Global.SceneryFile.empty()) - push_mode(mode::launcher); - else + if (Global.network_client) + { + // a multiplayer client learns the scenario name from the server handshake, + // so it goes straight to the loader and waits there for Global.ready_to_load push_mode(mode::scenarioloader); + } + else if (Global.SceneryFile.empty()) + { + // no scenario given: let the user pick one (a listening server simply + // refuses joins until the scenario is up) + push_mode(mode::launcher); + } + else + { + push_mode(mode::scenarioloader); + } return 0; } @@ -1449,11 +1522,22 @@ bool eu07_application::init_network() for (auto const &pair : Global.network_servers) { // create all servers + WriteLog("net: hosting session on " + pair.second + " (" + pair.first + ")", logtype::net); m_network->create_server(pair.first, pair.second); } if (Global.network_client) { + if (!Global.local_start_vehicle_override) + { + // in a multiplayer session the vehicle is assigned by the server; + // until the lobby exists the client simply starts out as an observer + Global.local_start_vehicle = "ghostview"; + } + + Global.network_status = "Connecting to " + Global.network_client->second + "..."; + WriteLog("net: connecting to " + Global.network_client->second + " (" + Global.network_client->first + ")", logtype::net); + // create client m_network->connect(Global.network_client->first, Global.network_client->second); } diff --git a/application/scenarioloadermode.cpp b/application/scenarioloadermode.cpp index f05c3869..c514c5d3 100644 --- a/application/scenarioloadermode.cpp +++ b/application/scenarioloadermode.cpp @@ -32,9 +32,14 @@ bool scenarioloader_mode::init() { // mode-specific update of simulation data. returns: false on error, true otherwise bool scenarioloader_mode::update() { - if (!Global.ready_to_load) - // waiting for network connection + if (!Global.ready_to_load) { + // waiting for the network handshake to tell us which scenario to load + m_userinterface->set_progress( + Global.network_status.empty() + ? STR("Connecting to server") + : Global.network_status); return true; + } if (!state) { WriteLog("using simulation seed: " + std::to_string(Global.random_seed), logtype::generic); @@ -76,8 +81,15 @@ void scenarioloader_mode::enter() { simulation::is_ready = false; - Application.set_title( Global.AppName + " (" + Global.SceneryFile + ")" ); - m_userinterface->set_progress(STR("Loading scenery")); + if( Global.SceneryFile.empty() ) { + // multiplayer client: the scenario is not known until the server answers + Application.set_title( Global.AppName ); + m_userinterface->set_progress( STR( "Connecting to server" ) ); + } + else { + Application.set_title( Global.AppName + " (" + Global.SceneryFile + ")" ); + m_userinterface->set_progress(STR("Loading scenery")); + } } // maintenance method, called when the mode is deactivated diff --git a/network/message.cpp b/network/message.cpp index 12609905..aa93b046 100644 --- a/network/message.cpp +++ b/network/message.cpp @@ -6,12 +6,24 @@ void network::client_hello::serialize(std::ostream &stream) const { sn_utils::ls_int32(stream, version); sn_utils::ls_uint32(stream, start_packet); + sn_utils::s_str(stream, app_version); } void network::client_hello::deserialize(std::istream &stream) { version = sn_utils::ld_int32(stream); start_packet = sn_utils::ld_uint32(stream); + app_version = sn_utils::d_str(stream); +} + +void network::server_reject::serialize(std::ostream &stream) const +{ + sn_utils::s_str(stream, reason); +} + +void network::server_reject::deserialize(std::istream &stream) +{ + reason = sn_utils::d_str(stream); } void network::server_hello::serialize(std::ostream &stream) const @@ -20,6 +32,7 @@ void network::server_hello::serialize(std::ostream &stream) const sn_utils::ls_int64(stream, timestamp); sn_utils::ls_int64(stream, config); sn_utils::s_str(stream, scenario); + sn_utils::s_str(stream, app_version); } void network::server_hello::deserialize(std::istream &stream) @@ -28,6 +41,7 @@ void network::server_hello::deserialize(std::istream &stream) timestamp = sn_utils::ld_int64(stream); config = sn_utils::ld_int64(stream); scenario = sn_utils::d_str(stream); + app_version = sn_utils::d_str(stream); } void ::network::request_command::serialize(std::ostream &stream) const @@ -115,6 +129,13 @@ std::shared_ptr network::deserialize_message(std::istream &str msg = std::make_shared(); else if (type == message::REQUEST_COMMAND) msg = std::make_shared(); + else if (type == message::SERVER_REJECT) + msg = std::make_shared(); + + if (!msg) { + // unknown message type; hand back a marker the peer handlers treat as a protocol error + return std::make_shared(message::TYPE_MAX); + } msg->deserialize(stream); diff --git a/network/message.h b/network/message.h index 43ad13ec..ff198ded 100644 --- a/network/message.h +++ b/network/message.h @@ -13,6 +13,7 @@ struct message SERVER_HELLO, FRAME_INFO, REQUEST_COMMAND, + SERVER_REJECT, TYPE_MAX }; @@ -32,6 +33,20 @@ struct client_hello : public message int32_t version; uint32_t start_packet; + // build identification of the connecting simulator, informational only + std::string app_version; +}; + +// sent by the server instead of SERVER_HELLO when the client cannot join; +// carries a human readable reason so the player learns what went wrong +struct server_reject : public message +{ + server_reject() : message(SERVER_REJECT) {} + + std::string reason; + + virtual void serialize(std::ostream &stream) const override; + virtual void deserialize(std::istream &stream) override; }; struct server_hello : public message @@ -42,6 +57,8 @@ struct server_hello : public message int64_t timestamp; int64_t config; std::string scenario; + // build identification of the server, informational only + std::string app_version; virtual void serialize(std::ostream &stream) const override; virtual void deserialize(std::istream &stream) override; diff --git a/network/network.cpp b/network/network.cpp index ae83bf78..fe022b10 100644 --- a/network/network.cpp +++ b/network/network.cpp @@ -7,7 +7,9 @@ #include "application/application.h" #include "utilities/Globals.h" -std::uint32_t const EU07_NETWORK_VERSION = 2; +// 2 - legacy lockstep protocol +// 3 - handshake carries build identification and an explicit rejection message +std::uint32_t const EU07_NETWORK_VERSION = 3; namespace network { @@ -44,6 +46,7 @@ void network::connection::connected() client_hello msg; msg.version = EU07_NETWORK_VERSION; msg.start_packet = packet_counter; + msg.app_version = Global.asVersion; send_message(msg); } } @@ -127,8 +130,23 @@ void network::server::handle_message(std::shared_ptr conn, const mes if (msg.type == message::CLIENT_HELLO) { const auto& cmd = dynamic_cast(msg); - if (cmd.version != EU07_NETWORK_VERSION // wrong version - || !Global.ready_to_load) { // not ready yet + std::string rejection; + if (cmd.version != (int32_t)EU07_NETWORK_VERSION) { + rejection = "incompatible protocol version: server speaks " + + std::to_string(EU07_NETWORK_VERSION) + + ", client speaks " + std::to_string(cmd.version); + } + else if (!Global.ready_to_load || Global.SceneryFile.empty()) { + // the host has not picked a scenario yet, there is nothing to join + rejection = "the server has no scenario running yet"; + } + + if (!rejection.empty()) { + WriteLog("net: rejecting peer: " + rejection, logtype::net); + + server_reject reply; + reply.reason = rejection; + conn->send_message(reply); conn->disconnect(); return; } @@ -138,6 +156,7 @@ void network::server::handle_message(std::shared_ptr conn, const mes reply.timestamp = Global.starting_timestamp; reply.config = 0; // TODO: pass bitfield with state of relevant setting switches reply.scenario = Global.SceneryFile; + reply.app_version = Global.asVersion; conn->state = connection::CATCHING_UP; conn->backbuffer = backbuffer; conn->backbuffer_pos = 0; @@ -145,7 +164,7 @@ void network::server::handle_message(std::shared_ptr conn, const mes conn->send_message(reply); - WriteLog("net: client accepted", logtype::net); + WriteLog("net: peer connected, build \"" + cmd.app_version + "\", scenario \"" + Global.SceneryFile + "\"", logtype::net); } else if (msg.type == message::REQUEST_COMMAND) { const auto& cmd = dynamic_cast(msg); @@ -163,6 +182,11 @@ void network::client::update() conn.reset(); } + if (!Global.network_reject_reason.empty()) { + // the server explicitly refused us; retrying would only spam it + return; + } + if (!conn) { if (!reconnect_delay) { connect(); @@ -240,6 +264,16 @@ void network::client::handle_message(std::shared_ptr conn, const mes return; } + if (msg.type == message::SERVER_REJECT) { + const auto& cmd = dynamic_cast(msg); + + ErrorLog("net: connection refused by the server: " + cmd.reason, logtype::net); + Global.network_reject_reason = cmd.reason; + Global.network_status = "Connection refused: " + cmd.reason; + conn->disconnect(); + return; + } + if (msg.type == message::SERVER_HELLO) { const auto& cmd = dynamic_cast(msg); conn->state = connection::ACTIVE; @@ -251,12 +285,16 @@ void network::client::handle_message(std::shared_ptr conn, const mes // TODO: configure simulation settings according to received cmd.config Global.SceneryFile = cmd.scenario; Global.ready_to_load = true; + + WriteLog("net: scenario handshake: \"" + cmd.scenario + "\", server build \"" + cmd.app_version + "\"", logtype::net); } else if (Global.random_seed != cmd.seed) { ErrorLog("net: seed mismatch", logtype::net); conn->disconnect(); return; } + Global.network_status.clear(); + WriteLog("net: accept received", logtype::net); } diff --git a/utilities/Globals.h b/utilities/Globals.h index 0df9f818..c0745126 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -81,6 +81,9 @@ struct global_settings { std::string szDefaultExt{ szTexturesDDS }; std::string SceneryFile; std::string local_start_vehicle{ "EU07-424" }; + // set when the starting vehicle was explicitly requested (command line -v), + // so multiplayer clients can tell an intentional choice from the built-in default + bool local_start_vehicle_override{ false }; int iConvertModels{ 0 }; // tworzenie plików binarnych int iConvertIndexRange{ 1000 }; // range of duplicate vertex scan bool file_binary_terrain{ true }; // enable binary terrain (de)serialization @@ -347,6 +350,10 @@ struct global_settings { std::vector> network_servers; std::optional> network_client; float desync = 0.0f; + // human readable state of the network session, presented on the connecting/loading screen + std::string network_status; + // set when the server refused us; keeps the reason for the ui and stops reconnect attempts + std::string network_reject_reason; std::unordered_map trainset_overrides;