Replicants reckon from when an update was sent
Simulation::ReadUpdateRecord threw away the sender's timestamp and
stamped lastUpdate with its own arrival time. The line carried the
original authors' own note: "HACK - should be based upon
message->timeStamp".
The dead reckoner extrapolates a replicant over
(lastPerformance - lastUpdate), so starting that clock at ARRIVAL rather
than at SEND leaves every remote vehicle exactly one network latency
behind where it should be. On the 1 ms LAN inside an arcade that is
nothing. Over Steam Datagram Relay it is 50-150 ms of positional lag on
every other player - a constant bias, not jitter, and the information
needed to remove it was already in the packet.
The timestamp cannot be used as it stands: both machines run
QueryPerformanceCounter since their own boot, so the two clocks share no
epoch. The offset is estimated per peer instead. Each record gives
sample = ourNow - theirStamp = trueOffset + oneWayLatency
and latency is never negative, so the smallest sample seen is the
closest to the truth. A rolling minimum over 128 samples follows crystal
drift and re-adapts when a route gets slower, rather than being pinned
forever by one lucky packet; a shorter path is believed immediately.
Applied with two clamps: never ahead of our own clock, and never further
back than 500 ms. Past that the packet is stale or the estimate is
wrong, and throwing a vehicle half a second forward does more damage
than the lag being corrected.
Entity::UpdateMessageHandler is the only point on the receive path that
knows whose update this is - records carry a timestamp but not an owner -
so it publishes the sender around the loop, and only for entities
somebody else owns. Offsets are forgotten in CreateMission: the hosts in
the next race are not the hosts in the last one and a HostID gets reused.
RP412NETCLOCK=0 restores the arrival-time behaviour, documented in
environ.ini, so a test machine can compare the two without a rebuild.
The estimate is logged per host when it first settles and whenever it
moves more than 50 ms, which is what a three-machine session should be
read against.
WHAT IS AND IS NOT VERIFIED. A full single-player race runs unchanged -
the path is never entered without replicants, which is the regression
risk that reaches everybody. The behaviour this exists for needs real
latency between real machines and is therefore untested: a two-instance
loopback race would only have exercised the zero-latency case, where the
correction is a no-op by construction. Expect remote vehicles to sit
further forward than before, and watch for overshoot when somebody
changes direction sharply - that is the tradeoff this makes, and the
clamp above is what bounds it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1140,6 +1140,12 @@ void
|
||||
Check(this);
|
||||
Check(egg_notation_file);
|
||||
|
||||
//
|
||||
// Forget every peer's clock offset: the hosts in the next race are not
|
||||
// the hosts in the last one, and a HostID gets reused.
|
||||
//
|
||||
NetClock_Reset();
|
||||
|
||||
//
|
||||
//--------------------------------------------------------------------------
|
||||
// Create mission from egg notation file
|
||||
|
||||
@@ -377,8 +377,28 @@ void
|
||||
//------------------------------------------------------------------------
|
||||
// Step through each block until there are no more remaining, and send the
|
||||
// update out the the simulation indicated by the subsystemID
|
||||
//
|
||||
// This is the only point on the receive path that knows WHOSE update
|
||||
// this is - the records themselves carry a timestamp but not an owner -
|
||||
// so the sender is published here for the net clock to align against.
|
||||
// Every record in the message, and the damage zones nested inside them,
|
||||
// came from the same machine in the same frame.
|
||||
//------------------------------------------------------------------------
|
||||
//
|
||||
//
|
||||
// Only for an entity somebody else owns. Our own clock needs no
|
||||
// aligning, and an update we somehow handed ourselves would otherwise
|
||||
// drag lastUpdate back by a frame for no reason.
|
||||
//
|
||||
Check(application);
|
||||
Check(application->GetHostManager());
|
||||
Logical remote_owner =
|
||||
GetOwnerID() != application->GetHostManager()->GetLocalHostID();
|
||||
if (remote_owner)
|
||||
{
|
||||
NetClock_BeginUpdate(GetOwnerID());
|
||||
}
|
||||
|
||||
while (stream.GetBytesRemaining())
|
||||
{
|
||||
Simulation::UpdateRecord *update =
|
||||
@@ -389,6 +409,11 @@ void
|
||||
simulation->ReadUpdateRecord(update);
|
||||
stream.AdvancePointer(update->recordLength);
|
||||
}
|
||||
|
||||
if (remote_owner)
|
||||
{
|
||||
NetClock_EndUpdate();
|
||||
}
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
|
||||
+181
-1
@@ -264,6 +264,109 @@ Simulation::SharedData
|
||||
// Model support
|
||||
//
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
//##########################################################################
|
||||
// Net clock - see SIMULATE.h for why the sender's timestamp is estimated
|
||||
// rather than used as it stands.
|
||||
//##########################################################################
|
||||
|
||||
namespace
|
||||
{
|
||||
enum
|
||||
{
|
||||
netClockMaxPeers = 16,
|
||||
|
||||
// Samples per rolling minimum. A peer sends one record per
|
||||
// simulation per frame, so at eight vehicles and 60 fps this is
|
||||
// well under a second - fast enough to follow a route change,
|
||||
// long enough that the minimum means something.
|
||||
netClockWindow = 128,
|
||||
|
||||
// The furthest back we will believe a timestamp. Beyond this the
|
||||
// packet is stale or the estimate is wrong, and extrapolating a
|
||||
// vehicle half a second forward does more harm than the lag we
|
||||
// are correcting.
|
||||
netClockMaxLagTicks = 500
|
||||
};
|
||||
|
||||
struct PeerClock
|
||||
{
|
||||
HostID host;
|
||||
Logical inUse;
|
||||
Logical settled;
|
||||
long offsetTicks; // our clock - their clock
|
||||
long windowMinTicks;
|
||||
int windowCount;
|
||||
};
|
||||
|
||||
PeerClock gPeerClocks[netClockMaxPeers];
|
||||
HostID gUpdateSender = 0;
|
||||
Logical gUpdateSenderValid = False;
|
||||
|
||||
Logical NetClockEnabled()
|
||||
{
|
||||
static int enabled = -1;
|
||||
if (enabled < 0)
|
||||
{
|
||||
const char *setting = getenv("RP412NETCLOCK");
|
||||
enabled = (setting != NULL && atoi(setting) == 0) ? 0 : 1;
|
||||
if (!enabled)
|
||||
{
|
||||
DEBUG_STREAM << "NetClock: disabled by RP412NETCLOCK=0 - "
|
||||
<< "replicants dead-reckon from arrival time\n" << std::flush;
|
||||
}
|
||||
}
|
||||
return enabled ? True : False;
|
||||
}
|
||||
|
||||
PeerClock *FindPeer(HostID host)
|
||||
{
|
||||
PeerClock *free_slot = NULL;
|
||||
for (int i = 0; i < netClockMaxPeers; ++i)
|
||||
{
|
||||
if (gPeerClocks[i].inUse)
|
||||
{
|
||||
if (gPeerClocks[i].host == host)
|
||||
{
|
||||
return &gPeerClocks[i];
|
||||
}
|
||||
}
|
||||
else if (free_slot == NULL)
|
||||
{
|
||||
free_slot = &gPeerClocks[i];
|
||||
}
|
||||
}
|
||||
if (free_slot != NULL)
|
||||
{
|
||||
free_slot->inUse = True;
|
||||
free_slot->host = host;
|
||||
free_slot->settled = False;
|
||||
free_slot->offsetTicks = 0;
|
||||
free_slot->windowMinTicks = 0;
|
||||
free_slot->windowCount = 0;
|
||||
}
|
||||
return free_slot;
|
||||
}
|
||||
}
|
||||
|
||||
void NetClock_BeginUpdate(HostID sender)
|
||||
{
|
||||
gUpdateSender = sender;
|
||||
gUpdateSenderValid = True;
|
||||
}
|
||||
|
||||
void NetClock_EndUpdate()
|
||||
{
|
||||
gUpdateSenderValid = False;
|
||||
}
|
||||
|
||||
void NetClock_Reset()
|
||||
{
|
||||
memset(gPeerClocks, 0, sizeof(gPeerClocks));
|
||||
gUpdateSenderValid = False;
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
void
|
||||
@@ -272,7 +375,84 @@ void
|
||||
Check(this);
|
||||
Check_Pointer(message);
|
||||
|
||||
lastUpdate = Now(); // HACK - should be based upon message->timeStamp
|
||||
//
|
||||
//------------------------------------------------------------------
|
||||
// When this update arrived is not when it was taken. Put lastUpdate
|
||||
// at the sender's sampling moment, expressed in our clock, so the
|
||||
// dead reckoner extrapolates over the network latency instead of
|
||||
// starting from scratch once it has already elapsed.
|
||||
//------------------------------------------------------------------
|
||||
//
|
||||
long now_ticks = Now().ticks;
|
||||
long local_ticks = now_ticks;
|
||||
|
||||
PeerClock *peer = gUpdateSenderValid && NetClockEnabled()
|
||||
? FindPeer(gUpdateSender) : NULL;
|
||||
if (peer != NULL)
|
||||
{
|
||||
//
|
||||
// sample = trueOffset + oneWayLatency, so the running minimum
|
||||
// converges on the offset from above.
|
||||
//
|
||||
long sample = now_ticks - message->timeStamp.ticks;
|
||||
|
||||
if (!peer->settled)
|
||||
{
|
||||
peer->settled = True;
|
||||
peer->offsetTicks = sample;
|
||||
peer->windowMinTicks = sample;
|
||||
peer->windowCount = 0;
|
||||
DEBUG_STREAM << "NetClock: host " << peer->host
|
||||
<< " first sample, offset " << sample << " ms\n" << std::flush;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (sample < peer->windowMinTicks)
|
||||
{
|
||||
peer->windowMinTicks = sample;
|
||||
}
|
||||
if (sample < peer->offsetTicks)
|
||||
{
|
||||
peer->offsetTicks = sample; // a shorter path: believe it now
|
||||
}
|
||||
if (++peer->windowCount >= netClockWindow)
|
||||
{
|
||||
//
|
||||
// Close the window: adopt its minimum even if it is
|
||||
// LARGER than the running estimate, which is how the
|
||||
// figure follows clock drift and a route that got
|
||||
// slower rather than staying pinned to one old packet.
|
||||
//
|
||||
long moved = peer->windowMinTicks - peer->offsetTicks;
|
||||
if (moved > 50 || moved < -50)
|
||||
{
|
||||
DEBUG_STREAM << "NetClock: host " << peer->host
|
||||
<< " offset " << peer->offsetTicks << " -> "
|
||||
<< peer->windowMinTicks << " ms\n" << std::flush;
|
||||
}
|
||||
peer->offsetTicks = peer->windowMinTicks;
|
||||
peer->windowMinTicks = sample;
|
||||
peer->windowCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
local_ticks = message->timeStamp.ticks + peer->offsetTicks;
|
||||
|
||||
//
|
||||
// Never ahead of our own clock, and never further back than we
|
||||
// are willing to extrapolate.
|
||||
//
|
||||
if (local_ticks > now_ticks)
|
||||
{
|
||||
local_ticks = now_ticks;
|
||||
}
|
||||
else if (now_ticks - local_ticks > netClockMaxLagTicks)
|
||||
{
|
||||
local_ticks = now_ticks - netClockMaxLagTicks;
|
||||
}
|
||||
}
|
||||
|
||||
lastUpdate.ticks = local_ticks;
|
||||
SetSimulationState(message->simulationState);
|
||||
Check_Fpu();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,41 @@
|
||||
#include "receiver.h"
|
||||
#include "time.h"
|
||||
#include "resource.h"
|
||||
#include "hostid.h"
|
||||
|
||||
//##########################################################################
|
||||
//########################### Net clock ##############################
|
||||
//##########################################################################
|
||||
//
|
||||
// Aligning a peer's clock with ours, so a replicant is dead-reckoned from
|
||||
// when its update was SENT rather than when it happened to arrive.
|
||||
//
|
||||
// Every update record carries the sender's own timestamp. The receiver
|
||||
// used to throw it away and stamp lastUpdate with its own Now() - the
|
||||
// original code says so: "HACK - should be based upon message->timeStamp".
|
||||
// The dead reckoner then extrapolates over (lastPerformance - lastUpdate),
|
||||
// so starting that clock at ARRIVAL rather than at SEND leaves every
|
||||
// remote vehicle exactly one network latency behind where it should be.
|
||||
// On the 1 ms arcade LAN that was invisible. Over Steam Datagram Relay it
|
||||
// is a constant 50-150 ms of positional lag - a bias, not jitter.
|
||||
//
|
||||
// The timestamp cannot be used raw: two machines' clocks share no epoch,
|
||||
// both being QueryPerformanceCounter since their own boot. So we estimate
|
||||
// the offset per peer. Each arriving record gives
|
||||
//
|
||||
// sample = ourNow - theirStamp = trueOffset + oneWayLatency
|
||||
//
|
||||
// and since latency is never negative, the SMALLEST sample seen is the
|
||||
// closest to the true offset. Taking a minimum over a short rolling
|
||||
// window tracks crystal drift and re-adapts when the route changes,
|
||||
// instead of being pinned forever by one lucky packet.
|
||||
//
|
||||
// RP412NETCLOCK=0 turns the whole thing off and restores the arrival-time
|
||||
// behaviour, so a test machine can A/B it without a rebuild.
|
||||
//
|
||||
void NetClock_BeginUpdate(HostID sender); // around one message's records
|
||||
void NetClock_EndUpdate();
|
||||
void NetClock_Reset(); // forget every peer (new mission)
|
||||
|
||||
class Simulation__SharedData;
|
||||
class Simulation__IndexData;
|
||||
|
||||
@@ -190,6 +190,14 @@ namespace
|
||||
"# logs the reason and falls back to plain TCP. 0 = TCP only.\n"
|
||||
"RP412STEAM=1\n"
|
||||
"\n"
|
||||
"# Line up each remote player's clock with ours, so their vehicle is\n"
|
||||
"# extrapolated from when its update was SENT rather than when it\n"
|
||||
"# arrived. Without it every remote pod sits one network latency behind\n"
|
||||
"# where it should be - invisible on the 1ms arcade LAN the engine was\n"
|
||||
"# written for, a constant 50-150ms of lag over the internet. 0 restores\n"
|
||||
"# the old arrival-time behaviour if you want to compare.\n"
|
||||
"#RP412NETCLOCK=0\n"
|
||||
"\n"
|
||||
"# ---- Optional ---------------------------------------------------------------\n"
|
||||
"\n"
|
||||
"# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to\n"
|
||||
|
||||
Reference in New Issue
Block a user