From 9dc78a65dcea5c641272c5cd3dba6d0eb3e9d49d Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Sun, 6 Sep 2026 22:19:28 +0000 Subject: [PATCH] multiplayer: stream the authoritative state, and let a client walk into a cab First live test showed three things wrong at once. A client could not take a vehicle. Walking up to a locomotive and pressing the enter-vehicle key posts entervehicle, which stage 4 classed as a world-wide command and refused outright ("reserved for the session host"), so the client never got a cab and every later control command died locally for want of one. Walking into a cab is a request for a seat, not a world command: the authority now turns it into a crew claim, for the host through the same path as for a remote peer, and answers it the way the lobby button is answered. Nothing but commands was ever replicated. The server sent dt and the commands it ran, and the client simulated the rest for itself - so a pantograph the host raised before the client joined, the contents of the memory cells the signalling reads, brake pressures and switch positions simply never crossed the wire. The server now streams the authoritative state a few times a second: whatever has changed since the last one, and the whole of it every so often so a peer heals from anything it missed. The snapshot carries the vehicle's inputs - line breaker, battery, converter and compressor permits, pantograph valves, cab, lights, doors - and lets the receiving peer derive the rest, plus the memory cells and the switches. Desync detection demanded that two state digests be equal, on a design where the client deliberately runs its own physics. They never can be, so it reported a desync permanently and asked for a resync every five seconds forever, which fixed nothing because the next step disagreed again. The digest is a log line now; what is measured instead is how far out of place the routine correction found us, and a full resync is only asked for when even that is not catching up. Also: a consist is put back on the rails as a whole or not at all. Correcting one vehicle while its neighbour stayed put is what was stretching couplers until they tore - the last test moved six vehicles out of eight, every time. And the lobby window collapsed to nothing, because its size_max was left at the -1 default while size_min was set, so imgui was handed a maximum smaller than the minimum. --- application/application.cpp | 47 +-- application/application.h | 5 +- application/driveruilayer.cpp | 8 +- input/command.cpp | 30 +- network/manager.cpp | 26 ++ network/manager.h | 10 + network/message.cpp | 2 + network/message.h | 3 + network/network.cpp | 62 ++- network/network.h | 6 + network/session.cpp | 39 ++ network/session.h | 5 + network/snapshot.cpp | 723 ++++++++++++++++++++++++++++------ network/snapshot.h | 47 ++- utilities/Globals.h | 2 + widgets/multiplayer_lobby.cpp | 29 +- 16 files changed, 859 insertions(+), 185 deletions(-) diff --git a/application/application.cpp b/application/application.cpp index ddc2c4a9..90ae44d0 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -463,10 +463,9 @@ void eu07_application::request_vehicle_leave(network::NetworkEntityId const Enti namespace { -// how many steps in a row may disagree with the authority before we ask to be corrected, -// and how long to leave the correction alone before asking again -uint64_t const NETWORK_RESYNC_THRESHOLD = 120; -uint64_t const NETWORK_RESYNC_COOLDOWN = 600; +// the state digest is logged when it starts differing and then only this often, so that +// a persistent difference does not bury the rest of the log +uint64_t const NETWORK_DIGEST_LOG_INTERVAL = 1800; } // namespace @@ -581,46 +580,26 @@ int eu07_application::run() // if we're slave if (m_network && m_network->client) { - if (authoritative.valid) + // the digest is a diagnostic, not a control input: a client runs its + // own physics, so the two will never agree bit for bit and demanding + // that they do only produces noise. what actually keeps a client in + // line is the authoritative state the server streams to it + if (authoritative.valid && authoritative.state_hash_version == network::STATE_HASH_VERSION) { - if (authoritative.state_hash_version != network::STATE_HASH_VERSION) - { - // nothing sensible to compare; say so once and stop pretending - if (m_statemismatches == 0) - { - ErrorLog("net: state digest version mismatch, desync detection is off", logtype::net); - m_statemismatches = 1; - } - } - else if (statehash != authoritative.state_hash) + if (statehash != authoritative.state_hash) { ++m_statemismatches; - // a drift usually persists, so this is reported when it starts - // and then only now and then, instead of every single step - if (m_statemismatches == 1 || (m_statemismatches % 300) == 0) + if (m_statemismatches == 1 || (m_statemismatches % NETWORK_DIGEST_LOG_INTERVAL) == 0) { - WriteLog("net: state mismatch at tick " + std::to_string(authoritative.tick) + " (" + std::to_string(m_statemismatches) + + WriteLog("net: state digest differs at tick " + std::to_string(authoritative.tick) + " (" + std::to_string(m_statemismatches) + " steps): local " + std::to_string(statehash) + ", authoritative " + std::to_string(authoritative.state_hash), logtype::net); } - Global.desync = (float)m_statemismatches; - - // a short disagreement rides itself out; a lasting one will not, - // and calls for the authoritative state rather than a restart - if ((m_statemismatches >= NETWORK_RESYNC_THRESHOLD) && - (m_lastresynctick == 0 || authoritative.tick > m_lastresynctick + NETWORK_RESYNC_COOLDOWN)) - { - m_lastresynctick = authoritative.tick; - WriteLog("net: resync requested at tick " + std::to_string(authoritative.tick), logtype::net); - m_network->request_resync(authoritative.tick, statehash); - } } - else + else if (m_statemismatches != 0) { - if (m_statemismatches != 0) - WriteLog("net: state back in step with the authority at tick " + std::to_string(authoritative.tick), logtype::net); + WriteLog("net: state digest back in step at tick " + std::to_string(authoritative.tick), logtype::net); m_statemismatches = 0; - Global.desync = 0.0f; } } diff --git a/application/application.h b/application/application.h index bf6d69ad..98c76bf6 100644 --- a/application/application.h +++ b/application/application.h @@ -123,10 +123,9 @@ private: // members bool m_screenshot_queued = false; - // consecutive authoritative steps whose state digest did not match ours + // consecutive authoritative steps whose state digest did not match ours. purely a + // diagnostic: a client runs its own physics, so it is never expected to match exactly std::uint64_t m_statemismatches { 0 }; - // tick at which we last asked the server to put us back in line - std::uint64_t m_lastresynctick { 0 }; modeptr_array m_modes { nullptr }; // collection of available application behaviour modes mode_stack m_modestack; // current behaviour mode diff --git a/application/driveruilayer.cpp b/application/driveruilayer.cpp index 48d7d871..70de30ee 100644 --- a/application/driveruilayer.cpp +++ b/application/driveruilayer.cpp @@ -54,6 +54,10 @@ driver_ui::driver_ui() m_transcriptspanel.size_min = {435, 85}; m_transcriptspanel.size_max = {Global.fb_size.x * 0.95, Global.fb_size.y * 0.95}; + m_multiplayerlobby.title = STR("Multiplayer lobby"); + m_multiplayerlobby.size_min = {560, 320}; + m_multiplayerlobby.size_max = {Global.fb_size.x * 0.95f, Global.fb_size.y * 0.95f}; + if (Global.gui_defaultwindows) { m_aidpanel.is_open = true; @@ -278,11 +282,11 @@ void driver_ui::render_() ImGui::OpenPopup(popupheader); } - if (Global.desync != 0.0f) + if (Global.network_position_error > 1.0f) { ImGui::SetNextWindowSize(ImVec2(-1, -1)); if (ImGui::Begin("network", nullptr, ImGuiWindowFlags_NoCollapse)) - ImGui::Text("out of step with the server for %d ticks (at %llu)", (int)Global.desync, (unsigned long long)Global.simulation_tick); + ImGui::Text("%.1f m behind the server (tick %llu)", Global.network_position_error, (unsigned long long)Global.simulation_tick); ImGui::End(); } } diff --git a/input/command.cpp b/input/command.cpp index 9fbf72f9..103b5710 100644 --- a/input/command.cpp +++ b/input/command.cpp @@ -806,17 +806,37 @@ bool is_vehicle_recipient(uint32_t const Recipient) { return (command_target)(Recipient & ~0xffff) == command_target::vehicle; } +// says a thing once instead of once per frame; a command that cannot be addressed keeps +// failing for as long as the player holds the key +bool report_once(std::string const &Message) { + static std::unordered_set reported; + if (!reported.emplace(Message).second) + return false; + ErrorLog(Message, logtype::net); + return true; +} + uint32_t to_network_recipient(uint32_t const Recipient) { - if (!is_vehicle_recipient(Recipient) || network::Entities.empty()) + if (!is_vehicle_recipient(Recipient)) return Recipient; + if (network::Entities.empty()) { + if (network::is_multiplayer()) + report_once("net: no vehicle roster yet, vehicle commands cannot be addressed"); + return Recipient; + } + TTrain *train = simulation::Trains.find_id((uint16_t)(Recipient & 0xffff)); - if (train == nullptr || train->Dynamic() == nullptr) + if (train == nullptr || train->Dynamic() == nullptr) { + report_once("net: no cab behind local id " + std::to_string(Recipient & 0xffff) + ", its commands cannot be addressed"); return Recipient; + } auto const id = network::Entities.id_of(train->Dynamic()->name()); - if (id == network::ENTITY_NONE || id > 0xffff) + if (id == network::ENTITY_NONE || id > 0xffff) { + report_once("net: \"" + train->Dynamic()->name() + "\" has no network id, its commands cannot leave this peer"); return Recipient; + } return (uint32_t)command_target::vehicle | id; } @@ -833,8 +853,10 @@ bool from_network_recipient(uint32_t const Recipient, uint32_t &Local) { return true; TTrain *train = simulation::Trains.find(name); - if (train == nullptr) + if (train == nullptr) { + report_once("net: \"" + name + "\" has no cab here yet, commands for it are dropped"); return false; + } Local = (uint32_t)command_target::vehicle | train->id(); return true; diff --git a/network/manager.cpp b/network/manager.cpp index f4da38a9..89c50c38 100644 --- a/network/manager.cpp +++ b/network/manager.cpp @@ -13,6 +13,7 @@ http://mozilla.org/MPL/2.0/. #include "utilities/Logs.h" #include "utilities/Globals.h" #include "network/statehash.h" +#include "network/snapshot.h" network::server_manager::server_manager() { @@ -158,6 +159,30 @@ void network::manager::notify_scenario_loaded() client->send_ready(); } +void network::server_manager::publish_state() +{ + if (--state_countdown > 0) + return; + + state_countdown = STATE_INTERVAL_FRAMES; + + // most of the time only what changed goes out; every so often the whole thing does, + // so that anything a peer missed heals by itself + bool const full = ((state_updates++ % STATE_FULL_EVERY) == 0); + + auto const blob = take_snapshot(full); + if (blob.empty()) + return; + + snapshot msg; + msg.tick = Global.simulation_tick; + msg.mode = 1; // correction on top of a running simulation + msg.blob = blob; + + for (auto srv : servers) + srv->push_message(msg); +} + void network::manager::request_resync(uint64_t tick, uint64_t state_hash) { if (client) @@ -172,6 +197,7 @@ void network::manager::update() if (servers && simulation::is_ready) { servers->update_crews(); servers->publish_vehicle_list(); + servers->publish_state(); } if (client) diff --git a/network/manager.h b/network/manager.h index 414153bb..4ce057ed 100644 --- a/network/manager.h +++ b/network/manager.h @@ -21,6 +21,9 @@ namespace network void publish_vehicle_list(); // brings the world in line with the crew roster and announces every change void update_crews(); + // sends the peers what the world actually looks like. this is what keeps a client + // in line - the command stream alone cannot, because a client runs its own physics + void publish_state(); // crew request coming from the local participant, which needs no transport void apply_local_claim(NetworkEntityId entity_id); void apply_local_leave(NetworkEntityId entity_id); @@ -29,9 +32,16 @@ namespace network // how many frames may pass between two unconditional broadcasts of the roster static const int PUBLISH_INTERVAL_FRAMES = 120; + // how often the authoritative state goes out, and how often it goes out whole + // rather than as what changed since the last one + static const int STATE_INTERVAL_FRAMES = 10; + static const int STATE_FULL_EVERY = 60; + std::vector last_published_list; int publish_countdown = 0; int crew_publish_countdown = 0; + int state_countdown = 0; + int state_updates = 0; }; class manager diff --git a/network/message.cpp b/network/message.cpp index 98c78170..b525b80d 100644 --- a/network/message.cpp +++ b/network/message.cpp @@ -97,6 +97,7 @@ void network::client_ready::deserialize(std::istream &stream) void network::snapshot::serialize(std::ostream &stream) const { sn_utils::ls_uint64(stream, tick); + sn_utils::s_uint8(stream, mode); sn_utils::ls_uint32(stream, (uint32_t)blob.size()); stream.write(blob.data(), blob.size()); } @@ -104,6 +105,7 @@ void network::snapshot::serialize(std::ostream &stream) const void network::snapshot::deserialize(std::istream &stream) { tick = sn_utils::ld_uint64(stream); + mode = sn_utils::d_uint8(stream); uint32_t const size = sn_utils::ld_uint32(stream); blob.assign(size, '\0'); diff --git a/network/message.h b/network/message.h index 315218f4..97ee58ed 100644 --- a/network/message.h +++ b/network/message.h @@ -113,6 +113,9 @@ struct snapshot : public message snapshot() : message(SNAPSHOT) {} uint64_t tick{ 0 }; + // 0 - the peer is joining and takes the world as given + // 1 - routine correction on top of a simulation that is already running + uint8_t mode{ 0 }; std::string blob; virtual void serialize(std::ostream &stream) const override; diff --git a/network/network.cpp b/network/network.cpp index e6bcbf7a..266c7be5 100644 --- a/network/network.cpp +++ b/network/network.cpp @@ -205,7 +205,8 @@ void network::server::handle_message(std::shared_ptr conn, const mes snapshot reply; reply.tick = Global.simulation_tick; - reply.blob = take_snapshot(); + reply.mode = 0; // joining: take the world as it is + reply.blob = take_snapshot(true); conn->send_message(reply); // from here on the peer receives the live stream; there is no backlog to catch up on @@ -225,7 +226,8 @@ void network::server::handle_message(std::shared_ptr conn, const mes snapshot reply; reply.tick = Global.simulation_tick; - reply.blob = take_snapshot(); + reply.mode = 0; + reply.blob = take_snapshot(true); conn->send_message(reply); } else if (msg.type == message::CLAIM_VEHICLE) { @@ -256,6 +258,29 @@ void network::server::handle_message(std::shared_ptr conn, const mes for (auto const &kv : cmd.commands) { for (command_data const &data : kv.second) { + if (data.command == user_command::entervehicle) { + // the peer walked into a cab; that is a request for a seat, exactly + // like the lobby button, and the crew registry is what decides on it + NetworkEntityId entity { ENTITY_NONE }; + auto const result = claim_by_name(conn->peer_id, data.payload, entity); + + if (result == claim_result::granted || result == claim_result::already_member) { + claim_granted reply; + reply.entity_id = entity; + conn->send_message(reply); + } + else { + WriteLog("net: peer " + std::to_string(conn->peer_id) + " cannot take " + + data.payload + ": " + describe(result), logtype::net); + + claim_denied reply; + reply.entity_id = entity; + reply.reason = describe(result); + conn->send_message(reply); + } + continue; + } + auto const verdict = validate_command(conn->peer_id, data.command, kv.first); if (verdict != command_verdict::accepted) { report_rejected_command(conn->peer_id, data.command, verdict); @@ -393,6 +418,8 @@ void network::client::send_claim(NetworkEntityId entity_id) claim_vehicle msg; msg.entity_id = entity_id; conn->send_message(msg); + + WriteLog("net: asking for a seat on vehicle " + std::to_string(entity_id) + " (" + Entities.name_of(entity_id) + ")", logtype::net); } void network::client::send_leave(NetworkEntityId entity_id) @@ -471,8 +498,16 @@ void network::client::handle_message(std::shared_ptr conn, const mes if (msg.type == message::SNAPSHOT) { const auto& cmd = dynamic_cast(msg); - if (apply_snapshot(cmd.blob)) { + auto const mode = (cmd.mode == 0 ? snapshot_mode::join : snapshot_mode::correction); + auto const result = apply_snapshot(cmd.blob, mode); + + if (!result.ok) { + ErrorLog("net: could not apply the state handed to us", logtype::net); + Global.network_status = "The server sent a snapshot this build cannot read"; + } + else if (mode == snapshot_mode::join) { Global.network_snapshot_applied = true; + Global.network_position_error = (float)result.worst_position_error; // the roster may say we are on a crew already - after a reconnect, or because // the claim was granted while we were still loading. walk into that cab @@ -484,13 +519,30 @@ void network::client::handle_message(std::shared_ptr conn, const mes } } else { - ErrorLog("net: could not apply the snapshot handed to us", logtype::net); - Global.network_status = "The server sent a snapshot this build cannot read"; + Global.network_position_error = (float)result.worst_position_error; + + // the routine correction is what keeps us in line; a full resync is only worth + // asking for when even that is not catching up + if (result.worst_position_error > RESYNC_POSITION_ERROR) { + if (++bad_corrections >= RESYNC_BAD_CORRECTIONS) { + bad_corrections = 0; + WriteLog("net: " + std::to_string((int)result.worst_position_error) + + " m out of place after a correction, asking for a full resync", logtype::net); + send_resync_request(Global.simulation_tick, 0); + } + } + else { + bad_corrections = 0; + } } } if (msg.type == message::VEHICLE_LIST) { const auto& cmd = dynamic_cast(msg); + + if (Entities.empty() && !cmd.vehicles.empty()) + WriteLog("net: vehicle roster received, " + std::to_string(cmd.vehicles.size()) + " vehicles to choose from", logtype::net); + Entities.adopt(cmd.vehicles); } diff --git a/network/network.h b/network/network.h index 1d3603dc..e29c9360 100644 --- a/network/network.h +++ b/network/network.h @@ -110,6 +110,12 @@ namespace network std::queue delta_queue; + // how far out of place a correction found us, in a row. a full resync is only + // worth asking for when the routine stream is not catching up on its own + int bad_corrections = 0; + static constexpr double RESYNC_POSITION_ERROR = 25.0; + static constexpr int RESYNC_BAD_CORRECTIONS = 5; + float last_target = 20.0f; float jitteriness = 1.0f; float consume_counter = 0.0f; diff --git a/network/session.cpp b/network/session.cpp index 98e89d85..1fe15c82 100644 --- a/network/session.cpp +++ b/network/session.cpp @@ -86,6 +86,24 @@ command_verdict validate_command(PeerId Peer, user_command Command, uint32_t Rec return command_verdict::unsupported_target; } +claim_result claim_by_name(PeerId Peer, std::string const &Vehicle, NetworkEntityId &Entity) +{ + Entity = Entities.id_of(Vehicle); + if (Entity == ENTITY_NONE) + return claim_result::unknown_vehicle; + + auto const previous = Crews.vehicle_of(Peer); + auto const result = Crews.claim(Peer, Entity); + + if ((result == claim_result::granted) && (previous != ENTITY_NONE) && (previous != Entity)) + { + // nobody works two vehicles at once + Crews.leave(Peer, previous); + } + + return result; +} + void filter_commands(PeerId Peer, command_queue::commands_map &Commands) { for (auto it = Commands.begin(); it != Commands.end();) @@ -94,6 +112,27 @@ void filter_commands(PeerId Peer, command_queue::commands_map &Commands) for (auto command = sequence.begin(); command != sequence.end();) { + if (command->source == PEER_NONE) + { + // posted by the authority itself rather than by a person; it is already + // the decision, not a request for one + ++command; + continue; + } + + if (command->command == user_command::entervehicle) + { + // the local player walked into a cab. that is a crew request; the cab + // itself is built by the authority once the seat is granted + NetworkEntityId entity{ENTITY_NONE}; + auto const result = claim_by_name(Peer, command->payload, entity); + if (result != claim_result::granted && result != claim_result::already_member) + WriteLog("net: cannot take " + command->payload + ": " + describe(result), logtype::net); + + command = sequence.erase(command); + continue; + } + if (validate_command(Peer, command->command, it->first) != command_verdict::accepted) { command = sequence.erase(command); diff --git a/network/session.h b/network/session.h index 6b5ebee8..937aeb33 100644 --- a/network/session.h +++ b/network/session.h @@ -138,6 +138,11 @@ std::string describe(command_verdict Verdict); // simply holds every right - there is no separate path for the local participant command_verdict validate_command(PeerId Peer, user_command Command, uint32_t Recipient); +// walking into a cab with the in-game key is the same request as pressing the button in +// the lobby, so it goes through the crew registry rather than being treated as a command +// that reaches the whole world. returns the vehicle it resolved to, if any +claim_result claim_by_name(PeerId Peer, std::string const &Vehicle, NetworkEntityId &Entity); + // drops everything the peer is not allowed to ask for and stamps the rest with its // identity. used for the local participant as well, so that host input goes through the // very same authority layer as input arriving over the wire, only without the transport diff --git a/network/snapshot.cpp b/network/snapshot.cpp index d3268520..dbc4d65a 100644 --- a/network/snapshot.cpp +++ b/network/snapshot.cpp @@ -20,6 +20,8 @@ http://mozilla.org/MPL/2.0/. #include "utilities/Logs.h" #include "vehicle/Driver.h" #include "vehicle/DynObj.h" +#include "world/Event.h" +#include "world/MemCell.h" #include "world/Track.h" namespace @@ -27,9 +29,29 @@ namespace uint32_t const SNAPSHOT_MAGIC{0x4e535545}; // 'EUSN' -// a vehicle is only put back on the rails when it really is somewhere else; nudging one -// that is already in place would only shake it about -double const REPOSITION_THRESHOLD{0.5}; +// how far a vehicle may sit from where the authority says it is before it gets put back. +// a joining peer is placed exactly; a running one is left to its own physics for small +// errors, because nudging a vehicle every fraction of a second is worse than the error +double const REPOSITION_TOLERANCE_JOIN{0.5}; +double const REPOSITION_TOLERANCE_CORRECTION{2.5}; + +// --------------------------------------------------------------------------- +// what the authority last sent, so that a routine correction only carries changes + +std::unordered_map g_lastvehicles; +std::unordered_map g_lastmemcells; +std::unordered_map g_lastswitches; + +uint64_t digest_of(std::string const &Text) +{ + uint64_t hash{14695981039346656037ull}; + for (char const character : Text) + { + hash ^= (uint64_t)(unsigned char)character; + hash *= 1099511628211ull; + } + return hash; +} void write_chunk(std::ostream &Stream, network::snapshot_chunk const Id, std::string const &Body) { @@ -41,7 +63,7 @@ void write_chunk(std::ostream &Stream, network::snapshot_chunk const Id, std::st // --------------------------------------------------------------------------- // session -std::string write_session() +std::string write_session(bool const Full) { std::ostringstream body; @@ -57,15 +79,20 @@ std::string write_session() sn_utils::ls_float64(body, Global.Overcast); sn_utils::ls_float64(body, Global.AirTemperature); - // the engine drives gameplay randomness, so a joining peer has to pick it up as it is - std::ostringstream engine; - engine << Global.random_engine; - sn_utils::s_str(body, engine.str()); + // the engine drives gameplay randomness, so a joining peer has to pick it up as it is. + // it is several kilobytes though, far too much to repeat in every routine correction + sn_utils::s_bool(body, Full); + if (Full) + { + std::ostringstream engine; + engine << Global.random_engine; + sn_utils::s_str(body, engine.str()); + } return body.str(); } -void read_session(std::istream &Stream) +void read_session(std::istream &Stream, network::snapshot_mode const Mode) { auto const tick = sn_utils::ld_uint64(Stream); auto const timestamp = sn_utils::ld_int64(Stream); @@ -78,7 +105,10 @@ void read_session(std::istream &Stream) auto const overcast = sn_utils::ld_float64(Stream); auto const temperature = sn_utils::ld_float64(Stream); - auto const enginestate = sn_utils::d_str(Stream); + bool const hasengine = sn_utils::d_bool(Stream); + std::string enginestate; + if (hasengine) + enginestate = sn_utils::d_str(Stream); Global.simulation_tick = tick; Global.starting_timestamp = timestamp; @@ -96,14 +126,282 @@ void read_session(std::istream &Stream) std::istringstream engine(enginestate); engine >> Global.random_engine; } + + (void)Mode; } // --------------------------------------------------------------------------- // vehicles -std::string write_vehicles() +// everything about one vehicle the rest of the session needs to agree on +struct vehicle_state +{ + std::string name; + std::string track; + double translation{0.0}; + uint8_t axlefirst{0}; + uint8_t direction{0}; + glm::dvec3 position{}; + + double velocity{0.0}; + double velocitykmh{0.0}; + int32_t diractive{0}; + int32_t mainctrl{0}; + int32_t scndctrl{0}; + int32_t brakectrl{0}; + double localbrake{0.0}; + + double pipepress{0.0}; + double brakepress{0.0}; + double scndpipepress{0.0}; + double eqvtpipepress{0.0}; + double compressor{0.0}; + double compressedvolume{0.0}; + + // switch positions and permissions - the inputs of the vehicle, not its outputs. + // getting these right lets the receiving peer derive the rest for itself + bool mains{false}; + bool battery{false}; + bool converterallow{false}; + bool compressorallow{false}; + int32_t cabactive{0}; + int32_t caboccupied{0}; + std::array pantenabled{{false, false}}; + std::array pantdisabled{{false, false}}; + std::array pantactive{{false, false}}; + int32_t lights[2]{0, 0}; + // door open/close intent, one entry per side; the local model derives the rest + std::array dooropen{{false, false}}; + std::array doorpermit{{false, false}}; + bool aiactive{false}; +}; + +void serialize_vehicle(std::ostream &Stream, vehicle_state const &State) +{ + sn_utils::s_str(Stream, State.name); + sn_utils::s_str(Stream, State.track); + sn_utils::ls_float64(Stream, State.translation); + sn_utils::s_uint8(Stream, State.axlefirst); + sn_utils::s_uint8(Stream, State.direction); + sn_utils::s_dvec3(Stream, State.position); + + sn_utils::ls_float64(Stream, State.velocity); + sn_utils::ls_float64(Stream, State.velocitykmh); + sn_utils::ls_int32(Stream, State.diractive); + sn_utils::ls_int32(Stream, State.mainctrl); + sn_utils::ls_int32(Stream, State.scndctrl); + sn_utils::ls_int32(Stream, State.brakectrl); + sn_utils::ls_float64(Stream, State.localbrake); + + sn_utils::ls_float64(Stream, State.pipepress); + sn_utils::ls_float64(Stream, State.brakepress); + sn_utils::ls_float64(Stream, State.scndpipepress); + sn_utils::ls_float64(Stream, State.eqvtpipepress); + sn_utils::ls_float64(Stream, State.compressor); + sn_utils::ls_float64(Stream, State.compressedvolume); + + sn_utils::s_bool(Stream, State.mains); + sn_utils::s_bool(Stream, State.battery); + sn_utils::s_bool(Stream, State.converterallow); + sn_utils::s_bool(Stream, State.compressorallow); + sn_utils::ls_int32(Stream, State.cabactive); + sn_utils::ls_int32(Stream, State.caboccupied); + for (int i = 0; i < 2; ++i) + { + sn_utils::s_bool(Stream, State.pantenabled[i]); + sn_utils::s_bool(Stream, State.pantdisabled[i]); + sn_utils::s_bool(Stream, State.pantactive[i]); + } + sn_utils::ls_int32(Stream, State.lights[0]); + sn_utils::ls_int32(Stream, State.lights[1]); + for (int i = 0; i < 2; ++i) + { + sn_utils::s_bool(Stream, State.dooropen[i]); + sn_utils::s_bool(Stream, State.doorpermit[i]); + } + sn_utils::s_bool(Stream, State.aiactive); +} + +vehicle_state deserialize_vehicle(std::istream &Stream) +{ + vehicle_state state; + + state.name = sn_utils::d_str(Stream); + state.track = sn_utils::d_str(Stream); + state.translation = sn_utils::ld_float64(Stream); + state.axlefirst = sn_utils::d_uint8(Stream); + state.direction = sn_utils::d_uint8(Stream); + state.position = sn_utils::d_dvec3(Stream); + + state.velocity = sn_utils::ld_float64(Stream); + state.velocitykmh = sn_utils::ld_float64(Stream); + state.diractive = sn_utils::ld_int32(Stream); + state.mainctrl = sn_utils::ld_int32(Stream); + state.scndctrl = sn_utils::ld_int32(Stream); + state.brakectrl = sn_utils::ld_int32(Stream); + state.localbrake = sn_utils::ld_float64(Stream); + + state.pipepress = sn_utils::ld_float64(Stream); + state.brakepress = sn_utils::ld_float64(Stream); + state.scndpipepress = sn_utils::ld_float64(Stream); + state.eqvtpipepress = sn_utils::ld_float64(Stream); + state.compressor = sn_utils::ld_float64(Stream); + state.compressedvolume = sn_utils::ld_float64(Stream); + + state.mains = sn_utils::d_bool(Stream); + state.battery = sn_utils::d_bool(Stream); + state.converterallow = sn_utils::d_bool(Stream); + state.compressorallow = sn_utils::d_bool(Stream); + state.cabactive = sn_utils::ld_int32(Stream); + state.caboccupied = sn_utils::ld_int32(Stream); + for (int i = 0; i < 2; ++i) + { + state.pantenabled[i] = sn_utils::d_bool(Stream); + state.pantdisabled[i] = sn_utils::d_bool(Stream); + state.pantactive[i] = sn_utils::d_bool(Stream); + } + state.lights[0] = sn_utils::ld_int32(Stream); + state.lights[1] = sn_utils::ld_int32(Stream); + for (int i = 0; i < 2; ++i) + { + state.dooropen[i] = sn_utils::d_bool(Stream); + state.doorpermit[i] = sn_utils::d_bool(Stream); + } + state.aiactive = sn_utils::d_bool(Stream); + + return state; +} + +vehicle_state read_from(TDynamicObject const &Vehicle) +{ + auto const &mover = *Vehicle.MoverParameters; + auto const *track = Vehicle.RaTrackGet(); + + vehicle_state state; + state.name = Vehicle.name(); + state.track = (track != nullptr ? track->name() : std::string()); + state.translation = Vehicle.RaTranslationGet(); + state.axlefirst = (uint8_t)(Vehicle.iAxleFirst ? 1 : 0); + state.direction = (uint8_t)(Vehicle.iDirection ? 1 : 0); + state.position = Vehicle.GetPosition(); + + state.velocity = mover.V; + state.velocitykmh = mover.Vel; + state.diractive = mover.DirActive; + state.mainctrl = mover.MainCtrlPos; + state.scndctrl = mover.ScndCtrlPos; + state.brakectrl = mover.BrakeCtrlPos; + state.localbrake = mover.LocalBrakePosA; + + state.pipepress = mover.PipePress; + state.brakepress = mover.BrakePress; + state.scndpipepress = mover.ScndPipePress; + state.eqvtpipepress = mover.EqvtPipePress; + state.compressor = mover.Compressor; + state.compressedvolume = mover.CompressedVolume; + + state.mains = mover.Mains; + state.battery = mover.Battery; + state.converterallow = mover.ConverterAllow; + state.compressorallow = mover.CompressorAllow; + state.cabactive = mover.CabActive; + state.caboccupied = mover.CabOccupied; + for (int i = 0; i < 2; ++i) + { + state.pantenabled[i] = mover.Pantographs[i].valve.is_enabled; + state.pantdisabled[i] = mover.Pantographs[i].valve.is_disabled; + state.pantactive[i] = mover.Pantographs[i].is_active; + } + state.lights[0] = mover.iLights[0]; + state.lights[1] = mover.iLights[1]; + for (int i = 0; i < 2; ++i) + { + state.dooropen[i] = mover.Doors.instances[i].is_open; + state.doorpermit[i] = mover.Doors.instances[i].open_permit; + } + state.aiactive = (Vehicle.Mechanik != nullptr && Vehicle.Mechanik->AIControllFlag); + + return state; +} + +// applies everything except where the vehicle is; position is dealt with per consist, +// because moving one vehicle of a coupled set on its own tears the couplers apart +void apply_controls(TDynamicObject &Vehicle, vehicle_state const &State) +{ + auto &mover = *Vehicle.MoverParameters; + + mover.V = State.velocity; + mover.Vel = State.velocitykmh; + mover.DirActive = State.diractive; + mover.MainCtrlPos = State.mainctrl; + mover.ScndCtrlPos = State.scndctrl; + mover.BrakeCtrlPos = State.brakectrl; + mover.LocalBrakePosA = State.localbrake; + + mover.PipePress = State.pipepress; + mover.BrakePress = State.brakepress; + mover.ScndPipePress = State.scndpipepress; + mover.EqvtPipePress = State.eqvtpipepress; + mover.Compressor = State.compressor; + mover.CompressedVolume = State.compressedvolume; + + // the appliances go through the vehicle's own switches wherever it has them, so that + // whatever they drag along stays consistent. range local: every vehicle carries its + // own state in the update, there is no need to push it down the consist twice + if (mover.Battery != State.battery) + mover.BatterySwitch(State.battery, range_t::local); + if (mover.Mains != State.mains) + mover.MainSwitch(State.mains, range_t::local); + if (mover.ConverterAllow != State.converterallow) + mover.ConverterSwitch(State.converterallow, range_t::local); + if (mover.CompressorAllow != State.compressorallow) + mover.CompressorSwitch(State.compressorallow, range_t::local); + + mover.CabActive = State.cabactive; + mover.CabOccupied = State.caboccupied; + + for (int i = 0; i < 2; ++i) + { + mover.Pantographs[i].valve.is_enabled = State.pantenabled[i]; + mover.Pantographs[i].valve.is_disabled = State.pantdisabled[i]; + mover.Pantographs[i].is_active = State.pantactive[i]; + } + + mover.iLights[0] = State.lights[0]; + mover.iLights[1] = State.lights[1]; + + for (int i = 0; i < 2; ++i) + { + auto const side = (i == 0 ? side::right : side::left); + if (mover.Doors.instances[i].open_permit != State.doorpermit[i]) + mover.PermitDoors(side, State.doorpermit[i], range_t::local); + if (mover.Doors.instances[i].is_open != State.dooropen[i]) + mover.OperateDoors(side, State.dooropen[i], range_t::local); + } + + if (Vehicle.Mechanik != nullptr && Vehicle.Mechanik->AIControllFlag != State.aiactive) + Vehicle.Mechanik->TakeControl(State.aiactive); +} + +// puts a vehicle back where the authority says it is. place_on_track() wants the distance +// of the vehicle's nose along the track, while what travels in the update is the offset of +// its leading bogie; this inverts the arithmetic that function itself does +void reposition(TDynamicObject &Vehicle, vehicle_state const &State) +{ + TTrack *track = (State.track.empty() ? nullptr : simulation::Paths.find(State.track)); + if (track == nullptr) + return; + + double const half = Vehicle.fAxleDist * 0.5; + double const axleoffset = (State.axlefirst ? -half : half); + double const sign = (State.direction ? 1.0 : -1.0); + double const nose = (State.translation - axleoffset) * sign + 0.5 * Vehicle.MoverParameters->Dim.L; + + Vehicle.place_on_track(track, nose, false); +} + +std::string write_vehicles(bool const Full) { - std::ostringstream body; std::ostringstream entries; uint32_t count{0}; @@ -112,33 +410,35 @@ std::string write_vehicles() if (vehicle == nullptr || vehicle->MoverParameters == nullptr) continue; - auto const &mover = *vehicle->MoverParameters; - auto const *track = vehicle->RaTrackGet(); + auto const state = read_from(*vehicle); - sn_utils::s_str(entries, vehicle->name()); - sn_utils::s_str(entries, track != nullptr ? track->name() : std::string()); - sn_utils::ls_float64(entries, vehicle->RaTranslationGet()); - sn_utils::s_uint8(entries, (uint8_t)(vehicle->iAxleFirst ? 1 : 0)); - sn_utils::s_uint8(entries, (uint8_t)(vehicle->iDirection ? 1 : 0)); - sn_utils::s_dvec3(entries, vehicle->GetPosition()); + std::ostringstream packed; + serialize_vehicle(packed, state); + auto const record = packed.str(); - sn_utils::ls_float64(entries, mover.V); - sn_utils::ls_float64(entries, mover.Vel); - sn_utils::ls_int32(entries, mover.DirActive); - sn_utils::ls_int32(entries, mover.MainCtrlPos); - sn_utils::ls_int32(entries, mover.ScndCtrlPos); - sn_utils::ls_int32(entries, mover.BrakeCtrlPos); - sn_utils::ls_float64(entries, mover.LocalBrakePosA); - sn_utils::ls_float64(entries, mover.PipePress); - sn_utils::ls_float64(entries, mover.BrakePress); - sn_utils::ls_float64(entries, mover.ScndPipePress); - sn_utils::ls_float64(entries, mover.Compressor); - sn_utils::ls_float64(entries, mover.CompressedVolume); - sn_utils::s_bool(entries, vehicle->Mechanik != nullptr && vehicle->Mechanik->AIControllFlag); + if (!Full) + { + // a vehicle that is standing still and has not been touched needs no update + auto const digest = digest_of(record); + auto const previous = g_lastvehicles.find(state.name); + if (previous != g_lastvehicles.end() && previous->second == digest) + continue; + g_lastvehicles[state.name] = digest; + } + else + { + g_lastvehicles[state.name] = digest_of(record); + } + + entries.write(record.data(), record.size()); ++count; } + if (count == 0) + return std::string(); + + std::ostringstream body; sn_utils::ls_uint32(body, count); auto const packed = entries.str(); body.write(packed.data(), packed.size()); @@ -146,87 +446,98 @@ std::string write_vehicles() return body.str(); } -void read_vehicles(std::istream &Stream) +void read_vehicles(std::istream &Stream, network::snapshot_mode const Mode, network::snapshot_result &Result) { auto const count = sn_utils::ld_uint32(Stream); - uint32_t repositioned{0}; - uint32_t missing{0}; + double const tolerance = (Mode == network::snapshot_mode::join ? REPOSITION_TOLERANCE_JOIN : REPOSITION_TOLERANCE_CORRECTION); + + std::unordered_map states; + states.reserve(count); for (uint32_t i = 0; i < count; ++i) { - auto const name = sn_utils::d_str(Stream); - auto const trackname = sn_utils::d_str(Stream); - auto const translation = sn_utils::ld_float64(Stream); - auto const axlefirst = sn_utils::d_uint8(Stream); - auto const direction = sn_utils::d_uint8(Stream); - auto const position = sn_utils::d_dvec3(Stream); + auto const state = deserialize_vehicle(Stream); - auto const velocity = sn_utils::ld_float64(Stream); - auto const velocitykmh = sn_utils::ld_float64(Stream); - auto const diractive = sn_utils::ld_int32(Stream); - auto const mainctrl = sn_utils::ld_int32(Stream); - auto const scndctrl = sn_utils::ld_int32(Stream); - auto const brakectrl = sn_utils::ld_int32(Stream); - auto const localbrake = sn_utils::ld_float64(Stream); - auto const pipepress = sn_utils::ld_float64(Stream); - auto const brakepress = sn_utils::ld_float64(Stream); - auto const scndpipepress = sn_utils::ld_float64(Stream); - auto const compressor = sn_utils::ld_float64(Stream); - auto const compressedvolume = sn_utils::ld_float64(Stream); - auto const aiactive = sn_utils::d_bool(Stream); - - TDynamicObject *vehicle = simulation::Vehicles.find(name); + TDynamicObject *vehicle = simulation::Vehicles.find(state.name); if (vehicle == nullptr || vehicle->MoverParameters == nullptr) - { - ++missing; - continue; - } - - auto &mover = *vehicle->MoverParameters; - - mover.V = velocity; - mover.Vel = velocitykmh; - mover.DirActive = diractive; - mover.MainCtrlPos = mainctrl; - mover.ScndCtrlPos = scndctrl; - mover.BrakeCtrlPos = brakectrl; - mover.LocalBrakePosA = localbrake; - mover.PipePress = pipepress; - mover.BrakePress = brakepress; - mover.ScndPipePress = scndpipepress; - mover.Compressor = compressor; - mover.CompressedVolume = compressedvolume; - - if (vehicle->Mechanik != nullptr && vehicle->Mechanik->AIControllFlag != aiactive) - { - vehicle->Mechanik->TakeControl(aiactive); - } - - if (glm::length(vehicle->GetPosition() - position) <= REPOSITION_THRESHOLD) - { - // close enough already; leaving it alone avoids shaking a vehicle that is fine - continue; - } - - TTrack *track = (trackname.empty() ? nullptr : simulation::Paths.find(trackname)); - if (track == nullptr) continue; - // place_on_track() takes the distance of the vehicle's nose, while what we recorded - // is the offset of its leading bogie. this inverts the arithmetic the placement - // itself does, so the two stay in step if that code ever changes - double const half = vehicle->fAxleDist * 0.5; - double const axleoffset = (axlefirst ? -half : half); - double const sign = (direction ? 1.0 : -1.0); - double const nose = (translation - axleoffset) * sign + 0.5 * mover.Dim.L; + apply_controls(*vehicle, state); + states.emplace(vehicle, state); - vehicle->place_on_track(track, nose, false); - ++repositioned; + Result.worst_position_error = std::max(Result.worst_position_error, glm::length(vehicle->GetPosition() - state.position)); } - WriteLog("net: snapshot restored " + std::to_string(count) + " vehicles, " + std::to_string(repositioned) + " of them put back on the rails" + - (missing > 0 ? ", " + std::to_string(missing) + " unknown here" : ""), - logtype::net); + Result.vehicles = (uint32_t)states.size(); + + // a coupled set is put back as a whole or not at all: correcting one vehicle of a + // consist while its neighbour stays put is what stretches a coupler until it breaks + std::unordered_set visited; + + for (auto const &pair : states) + { + if (visited.count(pair.first) > 0) + continue; + + // the cap is only there so that a malformed consist cannot spin us forever + int const CONSIST_LIMIT{256}; + + std::vector group; + TDynamicObject *front = pair.first; + for (int step = 0; step < CONSIST_LIMIT; ++step) + { + TDynamicObject *previous = front->Prev(); + if (previous == nullptr || previous == pair.first || visited.count(previous) > 0) + break; + front = previous; + } + + TDynamicObject *member = front; + for (int step = 0; step < CONSIST_LIMIT && member != nullptr; ++step, member = member->Next()) + { + if (visited.count(member) > 0) + break; + visited.emplace(member); + group.emplace_back(member); + } + + bool wanted{false}; + for (TDynamicObject *member : group) + { + auto const known = states.find(member); + if (known == states.end()) + continue; + if (glm::length(member->GetPosition() - known->second.position) > tolerance) + { + wanted = true; + break; + } + } + + if (!wanted) + continue; + + for (TDynamicObject *member : group) + { + auto const known = states.find(member); + if (known == states.end()) + { + // part of the consist is not in this update, so the set cannot be placed + // as a whole; leaving it alone beats tearing it in half + wanted = false; + break; + } + } + + if (!wanted) + continue; + + for (TDynamicObject *member : group) + { + reposition(*member, states.at(member)); + ++Result.repositioned; + } + } } // --------------------------------------------------------------------------- @@ -234,7 +545,6 @@ void read_vehicles(std::istream &Stream) std::string write_crews() { - std::ostringstream body; std::ostringstream entries; uint32_t count{0}; @@ -252,6 +562,7 @@ std::string write_crews() ++count; } + std::ostringstream body; sn_utils::ls_uint32(body, count); auto const packed = entries.str(); body.write(packed.data(), packed.size()); @@ -277,42 +588,204 @@ void read_crews(std::istream &Stream) } } +// --------------------------------------------------------------------------- +// memory cells - this is what the signalling reads, so without them a client sees +// semaphores frozen at whatever the scenario started with + +std::string write_memcells(bool const Full) +{ + std::ostringstream entries; + uint32_t count{0}; + + for (TMemCell const *cell : simulation::Memory.sequence()) + { + if (cell == nullptr || cell->name().empty()) + continue; + + std::ostringstream packed; + sn_utils::s_str(packed, cell->name()); + sn_utils::s_str(packed, cell->Text()); + sn_utils::ls_float64(packed, cell->Value1()); + sn_utils::ls_float64(packed, cell->Value2()); + auto const record = packed.str(); + + auto const digest = digest_of(record); + if (!Full) + { + auto const previous = g_lastmemcells.find(cell->name()); + if (previous != g_lastmemcells.end() && previous->second == digest) + continue; + } + g_lastmemcells[cell->name()] = digest; + + entries.write(record.data(), record.size()); + ++count; + } + + if (count == 0) + return std::string(); + + std::ostringstream body; + sn_utils::ls_uint32(body, count); + auto const packed = entries.str(); + body.write(packed.data(), packed.size()); + + return body.str(); +} + +void read_memcells(std::istream &Stream) +{ + auto const count = sn_utils::ld_uint32(Stream); + + for (uint32_t i = 0; i < count; ++i) + { + auto const name = sn_utils::d_str(Stream); + auto const text = sn_utils::d_str(Stream); + auto const value1 = sn_utils::ld_float64(Stream); + auto const value2 = sn_utils::ld_float64(Stream); + + TMemCell *cell = simulation::Memory.find(name); + if (cell == nullptr) + continue; + + if (cell->Text() == text && cell->Value1() == value1 && cell->Value2() == value2) + continue; + + cell->UpdateValues(text, value1, value2, basic_event::flags::text | basic_event::flags::value1 | basic_event::flags::value2); + } +} + +// --------------------------------------------------------------------------- +// switches + +std::string write_switches(bool const Full) +{ + std::ostringstream entries; + uint32_t count{0}; + + for (TTrack *track : simulation::Paths.sequence()) + { + if (track == nullptr || track->name().empty()) + continue; + + int const state = track->GetSwitchState(); + if (state < 0) + continue; + + if (!Full) + { + auto const previous = g_lastswitches.find(track->name()); + if (previous != g_lastswitches.end() && previous->second == state) + continue; + } + g_lastswitches[track->name()] = state; + + sn_utils::s_str(entries, track->name()); + sn_utils::ls_int32(entries, state); + ++count; + } + + if (count == 0) + return std::string(); + + std::ostringstream body; + sn_utils::ls_uint32(body, count); + auto const packed = entries.str(); + body.write(packed.data(), packed.size()); + + return body.str(); +} + +void read_switches(std::istream &Stream) +{ + auto const count = sn_utils::ld_uint32(Stream); + + for (uint32_t i = 0; i < count; ++i) + { + auto const name = sn_utils::d_str(Stream); + auto const state = sn_utils::ld_int32(Stream); + + TTrack *track = simulation::Paths.find(name); + if (track == nullptr) + continue; + + if (track->GetSwitchState() != state) + track->Switch(state); + } +} + } // namespace -std::string network::take_snapshot() +void network::reset_snapshot_history() { - std::ostringstream stream; + g_lastvehicles.clear(); + g_lastmemcells.clear(); + g_lastswitches.clear(); +} +std::string network::take_snapshot(bool const Full) +{ + auto const session = write_session(Full); + auto const vehicles = write_vehicles(Full); + auto const crews = write_crews(); + auto const memcells = write_memcells(Full); + auto const switches = write_switches(Full); + + if (!Full && vehicles.empty() && memcells.empty() && switches.empty()) + { + // nothing moved and nobody touched anything; the clock alone is not worth a packet + return std::string(); + } + + uint32_t chunks{1}; // the session section always goes along + if (!vehicles.empty()) + ++chunks; + ++chunks; // crews + if (!memcells.empty()) + ++chunks; + if (!switches.empty()) + ++chunks; + + std::ostringstream stream; sn_utils::ls_uint32(stream, SNAPSHOT_MAGIC); sn_utils::ls_uint32(stream, SNAPSHOT_VERSION); sn_utils::ls_uint64(stream, Global.simulation_tick); - sn_utils::ls_uint32(stream, 3); // number of chunks that follow + sn_utils::ls_uint32(stream, chunks); - write_chunk(stream, SNAPSHOT_SESSION, write_session()); - write_chunk(stream, SNAPSHOT_VEHICLES, write_vehicles()); - write_chunk(stream, SNAPSHOT_CREWS, write_crews()); + write_chunk(stream, SNAPSHOT_SESSION, session); + if (!vehicles.empty()) + write_chunk(stream, SNAPSHOT_VEHICLES, vehicles); + write_chunk(stream, SNAPSHOT_CREWS, crews); + if (!memcells.empty()) + write_chunk(stream, SNAPSHOT_MEMCELLS, memcells); + if (!switches.empty()) + write_chunk(stream, SNAPSHOT_SWITCHES, switches); auto const blob = stream.str(); - WriteLog("net: snapshot taken at tick " + std::to_string(Global.simulation_tick) + ", " + std::to_string(blob.size()) + " bytes", logtype::net); + + if (Full) + WriteLog("net: snapshot taken at tick " + std::to_string(Global.simulation_tick) + ", " + std::to_string(blob.size()) + " bytes", logtype::net); return blob; } -bool network::apply_snapshot(std::string const &Blob) +network::snapshot_result network::apply_snapshot(std::string const &Blob, snapshot_mode const Mode) { + snapshot_result result; + std::istringstream stream(Blob); if (sn_utils::ld_uint32(stream) != SNAPSHOT_MAGIC) { ErrorLog("net: snapshot rejected, not a snapshot", logtype::net); - return false; + return result; } auto const version = sn_utils::ld_uint32(stream); if (version != SNAPSHOT_VERSION) { ErrorLog("net: snapshot rejected, version " + std::to_string(version) + " but this build speaks " + std::to_string(SNAPSHOT_VERSION), logtype::net); - return false; + return result; } auto const tick = sn_utils::ld_uint64(stream); @@ -323,7 +796,7 @@ bool network::apply_snapshot(std::string const &Blob) if (!stream.good()) { ErrorLog("net: snapshot truncated", logtype::net); - return false; + return result; } auto const id = sn_utils::ld_uint16(stream); @@ -337,23 +810,35 @@ bool network::apply_snapshot(std::string const &Blob) switch (id) { case SNAPSHOT_SESSION: - read_session(chunk); + read_session(chunk, Mode); break; case SNAPSHOT_VEHICLES: - read_vehicles(chunk); + read_vehicles(chunk, Mode, result); break; case SNAPSHOT_CREWS: read_crews(chunk); break; + case SNAPSHOT_MEMCELLS: + read_memcells(chunk); + break; + case SNAPSHOT_SWITCHES: + read_switches(chunk); + break; default: // a section this build knows nothing about; its length is right there, so it // costs nothing to walk past it - WriteLog("net: snapshot section " + std::to_string(id) + " skipped, unknown to this build", logtype::net); break; } } - WriteLog("net: snapshot applied, now at tick " + std::to_string(tick), logtype::net); + result.ok = true; - return true; + if (Mode == snapshot_mode::join) + { + WriteLog("net: snapshot applied at tick " + std::to_string(tick) + ", " + std::to_string(result.vehicles) + " vehicles, " + + std::to_string(result.repositioned) + " put back on the rails", + logtype::net); + } + + return result; } diff --git a/network/snapshot.h b/network/snapshot.h index 07776a4f..1d7b0f3e 100644 --- a/network/snapshot.h +++ b/network/snapshot.h @@ -16,27 +16,54 @@ namespace network { // layout of the snapshot container. a reader refuses a blob it does not understand -constexpr uint32_t SNAPSHOT_VERSION = 1; +constexpr uint32_t SNAPSHOT_VERSION = 2; // the sections a snapshot is built from. a reader skips over any id it does not know, // which is what makes the format extensible: a peer built before a section existed can // still read a newer snapshot and simply ignores what it has no use for enum snapshot_chunk : uint16_t { - // tick, clock, weather and the state of the random engine + // tick, clock, weather and (in a full snapshot) the state of the random engine SNAPSHOT_SESSION = 1, - // where every vehicle is and what its controls and brakes are doing + // where every vehicle is, what its controls and appliances are set to, and what its + // brakes are doing SNAPSHOT_VEHICLES = 2, // who is working which vehicle - SNAPSHOT_CREWS = 3 - // candidates for the next versions: switches, memcells, the event queue, signals + SNAPSHOT_CREWS = 3, + // contents of the memory cells - this is what signalling reads + SNAPSHOT_MEMCELLS = 4, + // which way the switches are thrown + SNAPSHOT_SWITCHES = 5 }; -// packs the current state of the world. only ever called on the authority -std::string take_snapshot(); +// what a peer is meant to do with the blob it received +enum class snapshot_mode +{ + // the peer is coming in cold and takes the world as it is given + join, + // routine correction on top of a simulation that is already running + correction +}; -// restores a snapshot handed to us by the authority. returns false when the blob cannot -// be used at all - a version we do not know, or damaged content -bool apply_snapshot(std::string const &Blob); +struct snapshot_result +{ + bool ok{false}; + // how far the worst vehicle was from where the authority says it is + double worst_position_error{0.0}; + uint32_t vehicles{0}; + uint32_t repositioned{0}; +}; + +// packs the state of the world. only ever called on the authority. +// a full snapshot carries everything and is what a joining peer gets; otherwise only what +// has changed since the last one goes out, which is what keeps the routine correction +// stream small +std::string take_snapshot(bool Full); + +// restores state handed to us by the authority +snapshot_result apply_snapshot(std::string const &Blob, snapshot_mode Mode); + +// forgets what was last sent, so that the next delta comes out complete +void reset_snapshot_history(); } // namespace network diff --git a/utilities/Globals.h b/utilities/Globals.h index 6e8927fe..1da6f56b 100644 --- a/utilities/Globals.h +++ b/utilities/Globals.h @@ -370,6 +370,8 @@ struct global_settings { bool network_snapshot_applied = false; // identity handed out by the server, presented again when reconnecting uint64_t network_session_token = 0; + // how far the worst vehicle was out of place when the last correction arrived, metres + float network_position_error = 0.0f; std::unordered_map trainset_overrides; diff --git a/widgets/multiplayer_lobby.cpp b/widgets/multiplayer_lobby.cpp index 2feac4a6..08ae6fc2 100644 --- a/widgets/multiplayer_lobby.cpp +++ b/widgets/multiplayer_lobby.cpp @@ -24,7 +24,10 @@ http://mozilla.org/MPL/2.0/. ui::multiplayer_lobby_panel::multiplayer_lobby_panel() : ui_panel(STR_C("Multiplayer lobby"), false) { - size_min = {520, 280}; + // both bounds have to be set: ui_panel feeds them straight to imgui, and a maximum + // left at its -1 default is smaller than the minimum, which collapses the window + size_min = {560, 320}; + size_max = {1920, 1080}; } std::string ui::multiplayer_lobby_panel::describe_crew(network::NetworkEntityId const Entity) const @@ -62,7 +65,8 @@ void ui::multiplayer_lobby_panel::render_contents() } ImGui::Text("%s: %s, peer %u", STR_C("Session"), host ? (client ? "host + client" : "host") : "client", Global.network_peer_id); - ImGui::TextUnformatted(Global.SceneryFile.c_str()); + if (!Global.SceneryFile.empty()) + ImGui::TextDisabled("%s", Global.SceneryFile.c_str()); if (!Global.network_lobby_message.empty()) ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.3f, 1.0f), "%s", Global.network_lobby_message.c_str()); @@ -72,16 +76,22 @@ void ui::multiplayer_lobby_panel::render_contents() auto const &entries = network::Entities.entries(); if (entries.empty()) { - ImGui::TextUnformatted(STR_C("Waiting for the vehicle list from the server...")); + ImGui::TextUnformatted(client ? STR_C("Waiting for the vehicle list from the server...") : STR_C("This scenario has no vehicles anybody could drive.")); return; } network::NetworkEntityId const ownvehicle{network::Crews.vehicle_of(Global.network_peer_id)}; + float const scale = std::max(1.0f, Global.ui_scale); + float const crewwidth = 60.0f * scale; + float const statewidth = 150.0f * scale; + float const actionwidth = 110.0f * scale; + ImGui::Columns(4, "mp_vehicles", false); - ImGui::SetColumnWidth(1, 70.0f * Global.ui_scale); - ImGui::SetColumnWidth(2, 200.0f * Global.ui_scale); - ImGui::SetColumnWidth(3, 130.0f * Global.ui_scale); + ImGui::SetColumnWidth(0, std::max(120.0f * scale, ImGui::GetWindowWidth() - crewwidth - statewidth - actionwidth - 30.0f * scale)); + ImGui::SetColumnWidth(1, crewwidth); + ImGui::SetColumnWidth(2, statewidth); + ImGui::SetColumnWidth(3, actionwidth); ImGui::TextDisabled("%s", STR_C("Vehicle")); ImGui::NextColumn(); @@ -111,14 +121,14 @@ void ui::multiplayer_lobby_panel::render_contents() if (entry.id == ownvehicle) { - if (ImGui::Button(STR_C("Leave"), ImVec2(-1, 0))) + if (ImGui::Button(STR_C("Leave"))) Application.request_vehicle_leave(entry.id); } else if (entry.claimable) { // a vehicle somebody else is already working on can still be joined, that is // what the crew capacity is for - if (ImGui::Button(entry.crew_count > 0 ? STR_C("Join crew") : STR_C("Enter"), ImVec2(-1, 0))) + if (ImGui::Button(entry.crew_count > 0 ? STR_C("Join crew") : STR_C("Enter"))) claim(entry.id, ownvehicle); } else @@ -131,6 +141,9 @@ void ui::multiplayer_lobby_panel::render_contents() } ImGui::Columns(1); + + ImGui::Separator(); + ImGui::TextDisabled("%s", STR_C("You can also walk up to a vehicle and use the enter-vehicle key.")); } void ui::multiplayer_lobby_panel::claim(network::NetworkEntityId const Entity, network::NetworkEntityId const Current)