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.
Three things that turn out to be one thing: a session with people in it needs
to be able to say who they are, and a machine that only referees one should not
have to open a window to do it.
Names. A peer id is what the protocol addresses; "player 4" is no use to anybody
reading a lobby. CLIENT_HELLO carries a requested nickname now, and the server
decides what it actually is: trimmed of control characters and runs of spaces,
capped at 24, and made unique - two people called Marcin are two people and the
lobby has to say which is which. The result goes out as PEER_ROSTER whenever
somebody joins or leaves, and to a joining peer just before its snapshot, so the
crew section resolves to names rather than numbers. The name comes from
multiplayer.nickname in the ini or --nick on the command line, falling back to
the account name and, failing that, to the old numbering. The host is a peer
like any other here too, and registers its own name at startup.
Chat, on the key left of 1. Going up a line carries only its text: who said it
is decided by the connection it arrived on, never by the field in the packet,
which is what stops anybody putting words in anybody's mouth. The server tidies
and caps it, says it, and passes it on - a client waits to hear itself back, so
that everybody sees the conversation in the same order. While the box has the
keyboard imgui swallows the input before the driver mode sees it, so typing "d"
types "d" instead of opening the doors of whatever you are driving; that also
means the key that opened the panel cannot close it, hence escape.
And --nogui. No glfw, no window, no imgui, the null renderer, sound off, log to
the console it was started from, and nobody in a cab - the scenario is left to
its own drivers and this process is a referee rather than a participant. Every
window-dependent path is guarded on an empty window list rather than on a flag
scattered through the code, and the loop is paced at fifty steps a second
because nothing is waiting on a swapchain to slow it down. Chat lines go to the
log, which is what a machine with no window has instead of a chat panel.
eu07.exe -s scenario.scn --host 0.0.0.0:7420 --nogui --nick Dispatcher
Two hardcoded constants decided how far a train could drift before it was put
back, and there was no way to change them short of a rebuild. They are settings
now:
multiplayer.sync.running 1.5 // margin while the train is rolling
multiplayer.sync.stop 0.2 // one precise correction once it stands
The split is what the two numbers are for. A moving train is left to its own
physics unless it is well out, because nudging it several times a second is
worse than the error; a standing one is worth getting right to the centimetre,
and it is done once and then left alone until it moves again - a consist that
has had its correction is marked settled, and clears that mark the moment it
rolls. Both travel in SERVER_HELLO: whatever a player has in their own ini, a
session runs to the host's margins, which is the only way the peers agree on
what "out of place" means.
And the trainsets. Every peer loads its own copy of the scenery - that is the
only reason joining takes seconds instead of minutes - but the copy on disk is
not the session: the host may have shunted a set apart, put one together, or be
running a scenery whose consists were edited since. So the composition travels
in a full snapshot, ahead of the positions: which coupler of which vehicle is
joined to which coupler of which other, and with what hoses and cables. A
joining peer breaks what should not be coupled, makes what should, and only
then gets told where everything is - so the per-consist repositioning works on
the session's trains rather than on the ones its own scenery file described.
The vehicles themselves do not travel, and cannot: a peer missing one gets a
line in the log saying so, not a model over the wire.
Uncoupling has to be forced here. TDynamicObject::Dettach() leaves the
mechanical coupler alone unless the buffers are pressed together, which is
right for a player pulling a lever and wrong for putting a peer's world in
order.
Protocol version 5, snapshot version 6.
Second live test. Four things, three of them the same mistake seen from
different angles: the session was reasoning about single vehicles where it
should have been reasoning about trains.
- a peer may now operate anything coupled into the set it is sitting in. This
is what "cabchangebackward rejected: not a member of the crew of that
vehicle" was: the crew was recorded against one car of a four-car unit, and
the cab switch addressed another
- every vehicle gets a network id, not only the ones worth driving. Commands
are addressed by that id, and a player has to be able to walk into any car
of their train. "peer 3 cannot take en71-017rb_4: no such vehicle in this
session" was the driving trailer of an EN71 - a full desk, no engine, and
nobody assigned to it, so the old test missed it. Having a driving position
is what counts now, engine or not
- the roster groups vehicles into the trains they are coupled into and the
lobby lists one row per train. Coupling is worked out afresh every time, so
a train that comes apart becomes two, and because a crew entry sits on the
car its player occupies, each half keeps the people who were riding in it
- walking to another car of your own train is not a new claim and asks nothing
of the crew capacity, which counts people per train
Positional correction never actually did anything: it identified a track by
name, and most track in a scenery has none, so the lookup returned nothing and
place_on_track() was never reached. The client sat 178 m out of place for
minutes, reporting four vehicles "put back on the rails" every time while
moving none of them. Tracks now travel as their index in the path table, which
comes from the scenery file and is therefore the same on every peer.
Also from the test:
- the camera jumped in and out of the cab on the client whenever the host
reversed, because the correction was forcing the server's CabActive and
CabOccupied onto it. Which cab somebody sits in is theirs alone - two people
sharing a vehicle each have their own - so it is no longer replicated
- the correction no longer touches the control positions of the train the local
player is working. Their crew's commands reach every peer on their own, and
snapping a lever back a fraction of a second after it was moved is what made
the reverser look dead
- the frame buffer was sized for the old lockstep, up to sixty frames deep.
Every frame in it is input lag, so it is down to twelve with a target of one
- asking the AI of a vehicle to step aside is given five tries and then said
once, instead of every thirty frames forever; and a resync request now has a
real cooldown
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.
Two things were still missing once a session ran for a while: a peer that had
drifted apart from the authority had no way back short of restarting, and a
connection that blinked cost the player their seat and handed their vehicle to
the AI while the other driver was still sitting in it.
- a client that disagrees with the authoritative digest for 120 steps in a row
asks to be put back in line, and the server answers with a fresh snapshot.
There is a cooldown between requests, so a correction that needs a moment to
take hold does not turn into a stream of them
- the server hands out a session token with its hello and remembers it. Present
it again on the next connection and you come back as the same peer, with the
same id
- a lost connection no longer removes the peer from its crews: the seat is held
for a minute. Come back in time and nothing changed; stay away and the seat
is given up properly, handing the vehicle back to the AI only when that
leaves the crew empty and the AI was driving it in the first place
- after applying a snapshot a peer that finds itself on a crew walks into that
cab, which is what makes a reconnect land the player back where they were
- the vehicle roster is sent to a joining peer right before its snapshot, so
the crew section resolves to names instead of waiting for the next broadcast
Joining meant replaying backbuffer.bin from the very first frame of the
session, so the longer a session ran, the longer it took to get in - and the
whole history had to be kept for it.
- network/snapshot: a chunked container with a version of its own. Every
section carries its length, so a peer built before a section existed reads
a newer snapshot and simply walks past what it does not know. Version 1
carries the session (tick, clock, weather, the state of the random engine),
the vehicles (position on the track, speed, direction, controls, brake
pressures, whether the AI is driving) and the crew roster. Switches,
memcells, signals and the event queue are the next sections
- joining is now: handshake -> load the scenario -> CLIENT_READY -> the server
takes a snapshot on the spot and sends it -> the peer goes live. Because the
snapshot is taken at the moment the peer becomes active there is no backlog
to replay at all; the frame by frame catch-up is gone
- the server refuses a peer that loaded a different scenario, with a readable
reason instead of a silent desync
- reconnecting into a session that is already loaded asks for a fresh snapshot
straight away rather than waiting for a loader that will not run again
- a vehicle is only put back on the rails when it really is somewhere else; the
arithmetic that turns the recorded bogie offset back into the nose distance
place_on_track() expects mirrors what that function itself does
- the session log is still written out - it is useful when picking a desync
apart - but nothing depends on replaying it any more
The protocol hung off whatever frame the renderer happened to be drawing, and
the only thing standing in for synchronisation was the sum of every vehicle's
coordinates with a random number added on top - a value that cannot be equal on
two machines and therefore reported a desync on every single step.
- Global.simulation_tick: the authority counts the steps, a client takes the
number from the authoritative frame it is executing. FRAME_INFO and the
command requests carry it, so the network timeline has one unambiguous order
independent of frame rates
- network/statehash: a versioned FNV digest of the gameplay critical state -
vehicle position, speed, direction, brake pipe and cylinder pressure, plus the
clock. Values are quantized so that float noise is not mistaken for drift, and
vehicles are folded in under their own identity and summed, so the digest does
not depend on the order the scenario created them in. Switches, memcells and
the event queue are the next candidates; they go in when stage 7 pins down
what a snapshot contains
- a client compares its own digest against the authoritative one and reports the
drift when it starts and then only now and then, rather than every step; the
overlay says how long it has been out of step and at which tick
- refusing to compare at all when the two builds disagree on the digest layout
EU07_NETWORK_VERSION is now 4.
Three things stood in the way of two people sharing a cab and of a client not
being able to reach past its own vehicle.
Vehicle commands were addressed by TTrain::id(), which every peer hands out
locally from its own counter, so the same number means a different vehicle on
a different peer. Commands now travel with the session wide NetworkEntityId
and are translated back to the local cab on arrival; outside of multiplayer
the registry is empty and both directions are a no-op, so singleplayer is
untouched.
A client could send any command it liked, including setweather, queueevent or
spawntrainset. The server now validates every incoming command: a player may
work the controls of the vehicle whose crew they are on and nothing else, and
world wide commands stay with the host until dispatcher roles exist. The
source is stamped by the server, never taken from what the peer sent, and
rejections are logged once per peer/command instead of every frame. Host input
runs through the very same validation, just without the transport.
Continuous commands were tracked per command and recipient only, so with two
people in one cab the release sent by one of them cancelled the other one's
hold. command_data now carries the source peer and the active set is keyed by
{source, recipient, command}.
Also: a client asking to quit no longer replicates quitsimulation, which would
have shut down everybody else's session.
A vehicle is not owned by one player: two people share a cab, so the session
needs a crew relation rather than an owner field. The server keeps it and is
the only one allowed to change it.
- network/session: vehicle_crew { entity_id, crew, ai_before_claim } and a
crew_registry holding the roster. the host goes through exactly the same
path as a remote peer (claim -> crew -> grant), only without transport
- CLAIM_VEHICLE / CLAIM_GRANTED / CLAIM_DENIED / LEAVE_VEHICLE / CREW_UPDATE;
the server validates the vehicle exists, is present in the scenario and has
room in the crew, and answers a refusal with a reason the lobby shows
- the cab of a claimed vehicle is created by the authority posting the
ordinary replicated entervehicle, so every peer builds it and the vehicle is
never moved. the claiming peer only walks in once the cab appears locally
- AI transitions follow the crew size: 0 -> 1 remembers whether an AI was
really driving and takes the vehicle over, 1 -> 2 and 2 -> 1 change nothing,
1 -> 0 hands the vehicle back but only if there was an AI driver to begin
with. driver mode no longer fires aidriverdisable of its own in multiplayer
- losing a connection removes that peer from every crew it was on; a vehicle
with somebody still on board carries on untouched
- the lobby lists who is on board, offers "join crew" for a vehicle another
player already occupies, and lets a player step off
The AI and cab handling runs as a reconciliation pass rather than as edge
triggered actions, so a request that has not taken effect yet (the cab needs a
frame to appear) is simply repeated, with a cooldown to keep it off the wire.
TTrain::id() numbers cabs with a local counter, in the order each peer happens
to create them, so it cannot address a vehicle across the session. Neither was
there any way for a player to see what is available to drive.
- network/entities: PeerId (the host is peer 1, remote peers are numbered from
2, so it is a participant rather than a special case) and NetworkEntityId,
plus a registry that numbers the drivable vehicles of the loaded scenario in
name order and resolves ids back to TDynamicObject
- the server owns the numbering and publishes it as VEHICLE_LIST - name, crew
occupancy, crew capacity, AI state, availability - on every change and at
least every 120 frames, out of band so it stays out of the replay backbuffer;
a client binds the received ids to its own copy of the scenario by name
- SERVER_HELLO hands the joining peer its PeerId
- a multiplayer lobby listing that roster, with vehicles taken by other players
shown as such rather than hidden; entering one goes through the existing
entervehicle command, the vehicle is never moved
The lobby lives inside the driver mode instead of a mode of its own: the world
has to keep running while somebody is choosing (a client stops consuming the
server's frames the moment its mode is not a command processor), and the same
screen is what a player needs to change vehicles later on.
Crew occupancy is still derived from the presence of a cab instance; the real
crew model, claim handshake and AI takeover follow in stage 3.
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