From 1bfa1644c973a190e34da47318b434e6180f60b0 Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Mon, 7 Sep 2026 16:00:41 +0000 Subject: [PATCH] multiplayer: a cab belongs to the peer it is on, and the first one aboard drives Joining a crew did nothing. The cab was being built by a replicated entervehicle that the authority posted only when it had no cab of its own - which is exactly the case that never happens for the second person to board. The host claims a vehicle, the server builds a cab, and from then on every further claim finds one already standing over there, posts nothing, and the joining peer waits forever for a command that is not coming. A cab is not a shared object. Each participant has their own, with their own camera and their own view of it, and nothing about it needs to travel. So it is built locally by whoever needs one: the driver mode does it for the player when their claim is granted, and the authority does it for itself, because it needs a cab to address the AI handover commands to. That also stops a cab being created on every peer for every claimed vehicle, which is what the entervehicle broadcast was doing. Who runs a shared train is no longer decided by car numbering. It is whoever boarded first, and it stays with them: every crew member is stamped with a session-wide counter when they sit down, and the smallest stamp on the whole consist owns the physics. Coupling, uncoupling and renumbering do not move it, and neither does somebody else joining - each handover is a stutter in the ride of the person who has been driving, and they are the one who would notice it. The stamps travel with CREW_UPDATE and in the snapshot, because a client works out ownership for itself and has to arrive at the same answer. Chat: the key that opens it is now read before imgui gets a look at the input. Any window holding the keyboard - the lobby, most likely, which is precisely where a client sits - would swallow it, so on that side the panel could not be opened at all. The exception is text actually being typed, which is the chat box itself; escape closes that. A client also logs what it says, so if a line goes missing it is clear which half of the trip lost it. Protocol version 6, snapshot version 7. --- application/application.cpp | 11 +++++ application/applicationmode.h | 2 + application/drivermode.cpp | 36 ++++++++------ application/drivermode.h | 2 + application/uilayer.cpp | 5 ++ application/uilayer.h | 3 ++ network/manager.cpp | 2 + network/message.cpp | 9 ++++ network/message.h | 3 ++ network/network.cpp | 7 ++- network/session.cpp | 91 +++++++++++++++++++++++++++++------ network/session.h | 19 +++++++- network/snapshot.cpp | 12 ++++- network/snapshot.h | 2 +- 14 files changed, 168 insertions(+), 36 deletions(-) diff --git a/application/application.cpp b/application/application.cpp index 44dfd58d..96679109 100644 --- a/application/application.cpp +++ b/application/application.cpp @@ -894,6 +894,17 @@ eu07_application::get_input_hint( user_command const Command ) const { void eu07_application::on_key(int const Key, int const Scancode, int const Action, int const Mods) { + // the session chat opens on the key left of 1, and it is checked before imgui gets a + // look at the input: a lobby or any other window that happens to have the keyboard + // would otherwise swallow the key and the chat could never be opened at all. the one + // case where imgui does get it is when something is already taking typed text - the + // chat box itself, most of the time, which closes on escape instead + if ((Key == GLFW_KEY_GRAVE_ACCENT) && (Action == GLFW_PRESS) && (Mods == 0) && !m_modestack.empty() && network::is_multiplayer() && + !ui_layer::wants_keyboard()) + { + if (m_modes[m_modestack.top()]->toggle_chat()) + return; + } if (ui_layer::key_callback(Key, Scancode, Action, Mods)) return; diff --git a/application/applicationmode.h b/application/applicationmode.h index feea3bdb..ab2addde 100644 --- a/application/applicationmode.h +++ b/application/applicationmode.h @@ -51,6 +51,8 @@ public: virtual void on_scroll( double Xoffset, double Yoffset ) = 0; virtual void on_window_resize( int w, int h ) = 0; virtual void on_event_poll() = 0; + // opens or closes the session chat; true when this mode has one to open + virtual bool toggle_chat() { return false; } virtual bool is_command_processor() const = 0; protected: diff --git a/application/drivermode.cpp b/application/drivermode.cpp index 9c5f0134..3e1a00e9 100644 --- a/application/drivermode.cpp +++ b/application/drivermode.cpp @@ -31,6 +31,7 @@ http://mozilla.org/MPL/2.0/. #include "utilities/Logs.h" #include "network/entities.h" #include "network/snapshot.h" +#include "network/session.h" /* namespace input { @@ -213,6 +214,17 @@ bool driver_mode::update() if (!change_train.empty()) { TTrain *train = simulation::Trains.find(change_train); + + if ((train == nullptr) && network::is_multiplayer()) + { + // a cab belongs to the peer it is on: each player has their own, with their own + // camera and their own view of it. waiting for a replicated command to build it + // only ever worked for the first person aboard a train - the second finds the + // cab already standing on the peer that got there first, so nothing is posted + // and the lobby button appears to do nothing at all + train = network::ensure_local_cab(simulation::Vehicles.find(change_train)); + } + if (train) { Global.local_start_vehicle = change_train; @@ -540,20 +552,6 @@ void driver_mode::on_key(int const Key, int const Scancode, int const Action, in bool anyModifier = Mods & (GLFW_MOD_SHIFT | GLFW_MOD_CONTROL | GLFW_MOD_ALT); - // the key left of 1 opens and closes the session chat. it is only bound in a session, - // so single player keeps it for whatever else may want it later. while the chat has - // the keyboard this is never reached: imgui swallows the input before it gets here, - // which is exactly what stops typed letters from working the cab - if ((Key == GLFW_KEY_GRAVE_ACCENT) && (Action == GLFW_PRESS) && !anyModifier && network::is_multiplayer()) - { - auto ui = std::dynamic_pointer_cast(m_userinterface); - if (ui != nullptr) - { - ui->toggle_chat(); - return; - } - } - // give the ui first shot at the input processing... if (!anyModifier && true == m_userinterface->on_key(Key, Action)) { @@ -629,6 +627,16 @@ void driver_mode::on_event_poll() m_input.poll(); } +bool driver_mode::toggle_chat() +{ + auto ui = std::dynamic_pointer_cast(m_userinterface); + if (ui == nullptr) + return false; + + ui->toggle_chat(); + return true; +} + bool driver_mode::is_command_processor() const { diff --git a/application/drivermode.h b/application/drivermode.h index 17edc575..679e98d4 100644 --- a/application/drivermode.h +++ b/application/drivermode.h @@ -49,6 +49,8 @@ public: void on_scroll( double Xoffset, double Yoffset ) override; void on_window_resize( int w, int h ) override { ; } void on_event_poll() override; + // opens or closes the session chat + bool toggle_chat() override; bool is_command_processor() const override; private: diff --git a/application/uilayer.cpp b/application/uilayer.cpp index 00ff8273..5a1efd90 100644 --- a/application/uilayer.cpp +++ b/application/uilayer.cpp @@ -112,6 +112,11 @@ bool ui_layer::key_callback(int key, int scancode, int action, int mods) return m_imguiio->WantCaptureKeyboard; } +bool ui_layer::wants_keyboard() +{ + return (m_imguiio != nullptr) && m_imguiio->WantCaptureKeyboard; +} + bool ui_layer::char_callback(unsigned int c) { ImGui_ImplGlfw_CharCallback(m_window, c); diff --git a/application/uilayer.h b/application/uilayer.h index 6d4019c6..61b36121 100644 --- a/application/uilayer.h +++ b/application/uilayer.h @@ -115,6 +115,9 @@ public: // callback functions for imgui input // returns true if input is consumed static bool key_callback(int key, int scancode, int action, int mods); + // true when something on screen is taking typed input and the game must keep its + // hands off the keyboard + static bool wants_keyboard(); static bool char_callback(unsigned int c); static bool scroll_callback(double xoffset, double yoffset); static bool mouse_button_callback(int button, int action, int mods); diff --git a/network/manager.cpp b/network/manager.cpp index 94398a2c..30b92617 100644 --- a/network/manager.cpp +++ b/network/manager.cpp @@ -112,6 +112,7 @@ void network::server_manager::update_crews() crew_update msg; msg.entity_id = id; msg.crew = Crews.crew_of(id); + msg.since = Crews.crew_since_of(id); for (auto srv : servers) srv->push_message(msg); @@ -226,6 +227,7 @@ void network::manager::say(const std::string &text) { // a client waits to hear itself back from the server, so that everybody sees the // conversation in the same order + WriteLog("net: saying \"" + line + "\"", logtype::net); client->send_chat(line); } } diff --git a/network/message.cpp b/network/message.cpp index 0781cabe..675fe3be 100644 --- a/network/message.cpp +++ b/network/message.cpp @@ -220,6 +220,9 @@ void network::crew_update::serialize(std::ostream &stream) const sn_utils::ls_uint32(stream, (uint32_t)crew.size()); for (PeerId const peer : crew) sn_utils::ls_uint32(stream, peer); + sn_utils::ls_uint32(stream, (uint32_t)since.size()); + for (uint64_t const stamp : since) + sn_utils::ls_uint64(stream, stamp); } void network::crew_update::deserialize(std::istream &stream) @@ -231,6 +234,12 @@ void network::crew_update::deserialize(std::istream &stream) crew.reserve(count); for (uint32_t i = 0; i < count; i++) crew.emplace_back(sn_utils::ld_uint32(stream)); + + since.clear(); + uint32_t const stamps = sn_utils::ld_uint32(stream); + since.reserve(stamps); + for (uint32_t i = 0; i < stamps; i++) + since.emplace_back(sn_utils::ld_uint64(stream)); } void ::network::request_command::serialize(std::ostream &stream) const diff --git a/network/message.h b/network/message.h index 2e7f0d8c..522fc0c3 100644 --- a/network/message.h +++ b/network/message.h @@ -222,6 +222,9 @@ struct crew_update : public message uint32_t entity_id{ ENTITY_NONE }; std::vector crew; + // when each of them boarded, on a session-wide counter. it decides which peer runs the + // train's physics, so it has to travel with the crew rather than be guessed at + std::vector since; 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 50ae0248..852086e7 100644 --- a/network/network.cpp +++ b/network/network.cpp @@ -17,7 +17,7 @@ // 3 - handshake carries build identification and an explicit rejection message // 4 - peer identity, vehicle roster and crew handshake, command source, // logical simulation tick and a versioned state digest instead of the position sum -std::uint32_t const EU07_NETWORK_VERSION = 5; +std::uint32_t const EU07_NETWORK_VERSION = 6; namespace network { @@ -668,7 +668,7 @@ void network::client::handle_message(std::shared_ptr conn, const mes if (msg.type == message::CREW_UPDATE) { const auto& cmd = dynamic_cast(msg); - Crews.mirror(cmd.entity_id, cmd.crew); + Crews.mirror(cmd.entity_id, cmd.crew, cmd.since); } if (msg.type == message::CLAIM_GRANTED) { @@ -676,8 +676,7 @@ void network::client::handle_message(std::shared_ptr conn, const mes WriteLog("net: claim of vehicle " + std::to_string(cmd.entity_id) + " granted", logtype::net); Global.network_lobby_message.clear(); - // the cab itself is built by the replicated entervehicle the server posts; - // we only have to walk into it once it shows up + // the driver mode builds this peer's own cab for the vehicle and moves in Global.network_pending_vehicle = Entities.name_of(cmd.entity_id); } diff --git a/network/session.cpp b/network/session.cpp index f22eab61..46c56db3 100644 --- a/network/session.cpp +++ b/network/session.cpp @@ -24,6 +24,19 @@ namespace network crew_registry Crews; +namespace +{ + +// counts up for as long as the session lasts; the number a crew member is stamped with is +// the whole of their claim to running the train +uint64_t next_join_stamp() +{ + static uint64_t stamp{0}; + return ++stamp; +} + +} // namespace + std::string describe(claim_result Result) { switch (Result) @@ -185,21 +198,29 @@ PeerId simulation_owner(NetworkEntityId Entity) if (train == ENTITY_NONE) return PEER_HOST; - // the crew of the lowest numbered car decides, so that every peer works it out the - // same way without having to be told + // whoever boarded this train first runs it. not the lowest numbered car, not the + // newest arrival: the physics has to stay with one person for as long as they are + // aboard, because every handover is a stutter in somebody's ride - and the person + // who has been driving is the one who would notice it PeerId owner{PEER_NONE}; - NetworkEntityId lowest{ENTITY_NONE}; + uint64_t earliest{0}; for (auto const id : Entities.consist_members(train)) { auto const crew = Crews.crew_of(id); - if (crew.empty()) - continue; + auto const since = Crews.crew_since_of(id); - if (lowest == ENTITY_NONE || id < lowest) + for (size_t i = 0; i < crew.size(); ++i) { - lowest = id; - owner = crew.front(); + // a stamp of zero is a crew we were told about by an older peer, or one that + // somehow lost its order; it sorts behind anything stamped properly + uint64_t const stamp = (i < since.size() && since[i] != 0 ? since[i] : ~0ull - crew[i]); + + if (owner == PEER_NONE || stamp < earliest) + { + earliest = stamp; + owner = crew[i]; + } } } @@ -274,6 +295,7 @@ claim_result crew_registry::claim(PeerId Peer, NetworkEntityId Id) } vehicle.crew.emplace_back(Peer); + vehicle.crew_since.emplace_back(next_join_stamp()); m_pending.emplace_back(Id); WriteLog("net: peer " + std::to_string(Peer) + " joined the crew of " + published->name + " (" + std::to_string(vehicle.crew.size()) + "/" + @@ -294,7 +316,10 @@ bool crew_registry::leave(PeerId Peer, NetworkEntityId Id) if (member == crew.end()) return false; + auto const index = (size_t)std::distance(crew.begin(), member); crew.erase(member); + if (index < lookup->second.crew_since.size()) + lookup->second.crew_since.erase(lookup->second.crew_since.begin() + index); m_pending.emplace_back(Id); WriteLog("net: peer " + std::to_string(Peer) + " left the crew of " + Entities.name_of(Id) + " (" + std::to_string(crew.size()) + " left)", logtype::net); @@ -334,6 +359,28 @@ void apply_session_config(int64_t Config) Global.RealisticControlMode = realisticcontrol; } +TTrain *ensure_local_cab(TDynamicObject *Vehicle) +{ + if (Vehicle == nullptr) + return nullptr; + + TTrain *train = simulation::Trains.find(Vehicle->name()); + if (train != nullptr) + return train; + + train = new TTrain(); + if (false == train->Init(Vehicle)) + { + delete train; + ErrorLog("net: could not build a cab for " + Vehicle->name(), logtype::net); + return nullptr; + } + + simulation::Trains.insert(train); + + return train; +} + void enforce_session_settings() { if (Global.trainThreads != 0) @@ -451,9 +498,18 @@ NetworkEntityId crew_registry::vehicle_of(PeerId Peer) const return ENTITY_NONE; } -void crew_registry::mirror(NetworkEntityId Id, std::vector const &Crew) +void crew_registry::mirror(NetworkEntityId Id, std::vector const &Crew, std::vector const &Since) { - entry(Id).crew = Crew; + auto &vehicle = entry(Id); + vehicle.crew = Crew; + vehicle.crew_since = Since; + vehicle.crew_since.resize(Crew.size(), 0); +} + +std::vector crew_registry::crew_since_of(NetworkEntityId Id) const +{ + auto const lookup = m_vehicles.find(Id); + return lookup != m_vehicles.end() ? lookup->second.crew_since : std::vector(); } void crew_registry::take_seat(PeerId Peer, NetworkEntityId Id) @@ -470,6 +526,7 @@ void crew_registry::take_seat(PeerId Peer, NetworkEntityId Id) } vehicle.crew.emplace_back(Peer); + vehicle.crew_since.emplace_back(next_join_stamp()); m_pending.emplace_back(Id); } @@ -550,11 +607,15 @@ void crew_registry::reconcile() { if (train == nullptr) { - // the cab has to exist on every peer, so the authority asks for it with the - // ordinary replicated command instead of building it locally - post_authority_command(user_command::entervehicle, (uint32_t)command_target::simulation, dynamic->GetPosition(), dynamic->name()); - vehicle.action_cooldown = ACTION_COOLDOWN_FRAMES; - continue; + // the authority needs a cab of its own to address the AI commands to. it + // is built here and not replicated: a cab belongs to the peer it is on, + // and every peer that is actually aboard builds its own + train = ensure_local_cab(dynamic); + if (train == nullptr) + { + vehicle.action_cooldown = ACTION_COOLDOWN_FRAMES; + continue; + } } if (dynamic->Mechanik != nullptr && dynamic->Mechanik->AIControllFlag) diff --git a/network/session.h b/network/session.h index 42e5b17a..ca55e711 100644 --- a/network/session.h +++ b/network/session.h @@ -16,6 +16,9 @@ http://mozilla.org/MPL/2.0/. #include #include +class TDynamicObject; +class TTrain; + namespace network { @@ -26,6 +29,11 @@ struct vehicle_crew { NetworkEntityId entity_id{ENTITY_NONE}; std::vector crew; + // when each of them sat down, on a counter that runs across the whole session. this + // is what settles who runs the physics of a shared train: the one who got there first + // keeps it, so their ride stays smooth and nobody's authority changes under them when + // somebody else joins or the consist is renumbered + std::vector crew_since; // whether the AI was actually driving before the first human arrived. a vehicle that // nobody was driving must not suddenly acquire an AI driver when its crew leaves bool ai_before_claim{false}; @@ -76,7 +84,9 @@ public: NetworkEntityId vehicle_of(PeerId Peer) const; // client side: takes over the crew of one vehicle as published by the server - void mirror(NetworkEntityId Id, std::vector const &Crew); + void mirror(NetworkEntityId Id, std::vector const &Crew, std::vector const &Since); + // the join stamps of a crew, in the same order as crew_of() + std::vector crew_since_of(NetworkEntityId Id) const; // puts a peer on a vehicle without asking about capacity; used when somebody walks to // another car of the train they are already working void take_seat(PeerId Peer, NetworkEntityId Id); @@ -171,4 +181,11 @@ claim_result claim_by_name(PeerId Peer, std::string const &Vehicle, NetworkEntit // very same authority layer as input arriving over the wire, only without the transport void filter_commands(PeerId Peer, command_queue::commands_map &Commands); +// makes sure this peer has a cab instance for a vehicle, building one if it has none. +// the cab is a local object - each participant has their own, with their own camera and +// their own view - so it is never replicated: the second person to board a train finds +// the cab already built on the peer that got there first and would otherwise wait for a +// command that is never coming +TTrain *ensure_local_cab(TDynamicObject *Vehicle); + } // namespace network diff --git a/network/snapshot.cpp b/network/snapshot.cpp index fc340045..7b7e4efb 100644 --- a/network/snapshot.cpp +++ b/network/snapshot.cpp @@ -1045,10 +1045,15 @@ std::string write_crews() if (crew.empty()) continue; + auto since = network::Crews.crew_since_of(entry.id); + since.resize(crew.size(), 0); + sn_utils::ls_uint32(entries, entry.id); sn_utils::ls_uint32(entries, (uint32_t)crew.size()); for (network::PeerId const peer : crew) sn_utils::ls_uint32(entries, peer); + for (uint64_t const stamp : since) + sn_utils::ls_uint64(entries, stamp); ++count; } @@ -1075,7 +1080,12 @@ void read_crews(std::istream &Stream) for (uint32_t p = 0; p < size; ++p) crew.emplace_back(sn_utils::ld_uint32(Stream)); - network::Crews.mirror(id, crew); + std::vector since; + since.reserve(size); + for (uint32_t p = 0; p < size; ++p) + since.emplace_back(sn_utils::ld_uint64(Stream)); + + network::Crews.mirror(id, crew, since); } } diff --git a/network/snapshot.h b/network/snapshot.h index 947478d9..5b8950c7 100644 --- a/network/snapshot.h +++ b/network/snapshot.h @@ -18,7 +18,7 @@ namespace network { // layout of the snapshot container. a reader refuses a blob it does not understand -constexpr uint32_t SNAPSHOT_VERSION = 6; +constexpr uint32_t SNAPSHOT_VERSION = 7; // 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