A quiet pod coasts a second, then waits to be told
Build 2's first two items, L4 and L5, picked ahead of the rest of the latency tier because the field data argues for them: a five-pod race on 4.12.233 kept time well - send interval median and p95 both ~32 ms - but every pod saw gaps of one to two seconds, and corrections as large as 2702 metres against a mean under a metre. That is not a timing problem, it is a pod flying most of the way across the map on stale velocity and being yanked back through whatever it passed. L5, the extrapolation clamp. Both dead reckoners carried lastPerformance - lastUpdate into the projection unbounded once past the expected update. They now stop at kMaximumExtrapolationSeconds, one second, so a replicant coasts and then parks. A pod that stops and snaps once reads as the dropped connection it is; a pod sliding confidently through scenery reads as a broken game, and is far more expensive to collide with. It matters more in the accelerated reckoner, which also carries a*t*t/2 and so grows the error as the SQUARE of the silence. RP412NETCOAST sets the seconds, 0 restores the unbounded coast for an A/B on one connection. MUST MATCH across a race, same class as RP412NETPREDICT - it moves replicants, so it decides where they collide. L4, the stale-record guard. Simulation now remembers the sender's stamp on the last record it accepted, and the record walk skips one stamped earlier - such a record winds lastUpdate backwards and has the reckoner extrapolate from a position the sender has already left. A regression larger than five seconds is not a late record but a different stream (a rejoin, a restart, a clock that was set), so that resyncs instead. The counter NetRaceStats::staleCount has been sitting there reading zero with a comment saying "until the stale guard ships"; it is now fed. The guard is asked by the caller rather than done inside ReadUpdateRecord, because that is virtual and not every override chains to the base. Verified: clean Release build; two-pod loopback race green, both pods scoring, and stale 0 on both - which is the reading that says the guard does not fire on an ordered stream. The clamp is NOT demonstrated by that run: the harness parks its pods, so there is no velocity to coast on. Pod B did see a 9.167 s gap in it, which on a moving pod is the shape of the field's kilometre corrections. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+15
-1
@@ -406,7 +406,21 @@ void
|
||||
Check_Pointer(update);
|
||||
Simulation *simulation = GetSimulation((int)update->subsystemID-1);
|
||||
Check(simulation);
|
||||
simulation->ReadUpdateRecord(update);
|
||||
|
||||
//
|
||||
// Skip a record the sender stamped before the one we already
|
||||
// applied - see Simulation::AcceptUpdateStamp. Counted, because
|
||||
// on an ordered stream this should never fire and the number
|
||||
// saying so is the point of it.
|
||||
//
|
||||
if (simulation->AcceptUpdateStamp(update->timeStamp))
|
||||
{
|
||||
simulation->ReadUpdateRecord(update);
|
||||
}
|
||||
else
|
||||
{
|
||||
netRaceStats.staleCount++;
|
||||
}
|
||||
stream.AdvancePointer(update->recordLength);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,29 @@ static const Scalar kMaximumPredictedInterval = 0.25f;
|
||||
static const Scalar kLongGapThreshold = 0.200f;
|
||||
static const Scalar kQueuedGapThreshold = 0.005f;
|
||||
|
||||
//
|
||||
// How far a replicant will coast on a sender that has gone quiet.
|
||||
//
|
||||
// Past the moment the next update was due, velocity times elapsed time
|
||||
// stops being a prediction and becomes a guess that nobody is correcting.
|
||||
// The field logs bear it out: gaps of one to two seconds on every pod in
|
||||
// an eight-minute race, and worst-case corrections of one to two and a
|
||||
// half KILOMETRES against a mean under a metre. That is a pod that flew
|
||||
// most of the way across the map on stale velocity and then got yanked
|
||||
// back - through whatever it passed through on the way.
|
||||
//
|
||||
// So it coasts for a second and then parks, waiting to be told. A pod
|
||||
// that stops and then snaps once reads as a dropped connection, which is
|
||||
// what it is; a pod sliding confidently through scenery reads as a
|
||||
// broken game. Parking is also far cheaper to collide with.
|
||||
//
|
||||
// MUST MATCH on every machine in a race. This moves replicants rather
|
||||
// than merely drawing them, so it decides where they collide - the same
|
||||
// class as RP412NETPREDICT, and the lobby's build guard already keeps a
|
||||
// mismatched build out of the room.
|
||||
//
|
||||
static const Scalar kMaximumExtrapolationSeconds = 1.0f;
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
// RP412NETPREDICT=0 restores the original single-sample prediction, so the
|
||||
@@ -123,6 +146,31 @@ static Logical
|
||||
return cached ? True : False;
|
||||
}
|
||||
|
||||
//
|
||||
// RP412NETCOAST, in seconds; 0 restores the original unbounded coast so
|
||||
// the two can be compared on one build and one connection. See
|
||||
// kMaximumExtrapolationSeconds - MUST MATCH across the race.
|
||||
//
|
||||
static Scalar
|
||||
MaximumExtrapolationSeconds()
|
||||
{
|
||||
static Logical read = False;
|
||||
static Scalar seconds = kMaximumExtrapolationSeconds;
|
||||
|
||||
if (!read)
|
||||
{
|
||||
read = True;
|
||||
|
||||
const char *setting = getenv("RP412NETCOAST");
|
||||
if (setting != NULL && *setting != '\0')
|
||||
{
|
||||
Scalar asked = (Scalar) atof(setting);
|
||||
seconds = (asked > 0.0f) ? asked : 0.0f; // 0 = no limit
|
||||
}
|
||||
}
|
||||
return seconds;
|
||||
}
|
||||
|
||||
//#############################################################################
|
||||
//############################### Mover #################################
|
||||
//#############################################################################
|
||||
@@ -484,8 +532,18 @@ Logical
|
||||
}
|
||||
else
|
||||
{
|
||||
//
|
||||
// Past the expected update: coasting, not interpolating. Bound
|
||||
// it - see kMaximumExtrapolationSeconds.
|
||||
//
|
||||
time_slice = lastPerformance - lastUpdate;
|
||||
lerp_mode = False;
|
||||
|
||||
Scalar coast_limit = MaximumExtrapolationSeconds();
|
||||
if (coast_limit > 0.0f && time_slice > coast_limit)
|
||||
{
|
||||
time_slice = coast_limit;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
@@ -538,8 +596,21 @@ Logical
|
||||
}
|
||||
else
|
||||
{
|
||||
//
|
||||
// Past the expected update: coasting, not interpolating. Bound
|
||||
// it - see kMaximumExtrapolationSeconds. It matters more here
|
||||
// than in the linear reckoner: this branch also carries a*t*t/2,
|
||||
// so an unbounded slice grows the error as the SQUARE of the
|
||||
// silence.
|
||||
//
|
||||
time_slice = lastPerformance - lastUpdate;
|
||||
lerp_mode = False;
|
||||
|
||||
Scalar coast_limit = MaximumExtrapolationSeconds();
|
||||
if (coast_limit > 0.0f && time_slice > coast_limit)
|
||||
{
|
||||
time_slice = coast_limit;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -403,6 +403,48 @@ void NetClock_ReportRaceStats()
|
||||
}
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
// A record older than the one we last applied is a record that would
|
||||
// wind this simulation backwards - the sender has already told us where
|
||||
// it went next. Applying it drags lastUpdate back, and the dead reckoner
|
||||
// then extrapolates from a position the sender has left, which is a
|
||||
// correction the moment the next record lands.
|
||||
//
|
||||
// Today's streams are ordered, so this should read zero; the counter
|
||||
// says so in the race summary and a nonzero reading is itself the
|
||||
// finding. It is here because ordering is a property of the channel, not
|
||||
// of the game, and the queue work has already made the channel something
|
||||
// we change.
|
||||
//
|
||||
// A LARGE regression is not a late record, it is a different stream: a
|
||||
// rejoin, a restart, a machine whose clock was set. Five seconds is well
|
||||
// past any plausible reordering and well short of a session, so beyond
|
||||
// it we take the new stamp as the truth and resync rather than ignoring
|
||||
// the sender forever. Same reasoning as the predictor's own reset.
|
||||
//
|
||||
static const long kStaleRegressionLimitTicks = 5000; // ms; Time is ms here
|
||||
|
||||
Logical
|
||||
Simulation::AcceptUpdateStamp(const Time &stamp)
|
||||
{
|
||||
Check(this);
|
||||
|
||||
if (lastSenderStampValid)
|
||||
{
|
||||
long regression = lastSenderStamp.ticks - stamp.ticks;
|
||||
|
||||
if (regression > 0 && regression < kStaleRegressionLimitTicks)
|
||||
{
|
||||
return False;
|
||||
}
|
||||
}
|
||||
|
||||
lastSenderStampValid = True;
|
||||
lastSenderStamp = stamp;
|
||||
return True;
|
||||
}
|
||||
|
||||
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
//
|
||||
void
|
||||
@@ -575,6 +617,7 @@ Simulation::Simulation(
|
||||
SetSimulationState(DefaultState);
|
||||
lastPerformance = Now();
|
||||
lastUpdate = lastPerformance;
|
||||
lastSenderStampValid = False;
|
||||
activePerformance = &Simulation::DoNothingOnce;
|
||||
updateModel = 0;
|
||||
simulationFlags = 0;
|
||||
|
||||
@@ -209,6 +209,15 @@ public:
|
||||
void
|
||||
RequestEncore(Encore encore);
|
||||
|
||||
//
|
||||
// True if this record should be applied, False if it is an older
|
||||
// one arriving late. Ask BEFORE ReadUpdateRecord: it is the caller
|
||||
// that has to skip the record, because ReadUpdateRecord is virtual
|
||||
// and not every override chains to the base.
|
||||
//
|
||||
Logical
|
||||
AcceptUpdateStamp(const Time &stamp);
|
||||
|
||||
virtual void
|
||||
ReadUpdateRecord(UpdateRecord *message);
|
||||
virtual void
|
||||
@@ -237,6 +246,17 @@ protected:
|
||||
lastPerformance;
|
||||
Time
|
||||
lastUpdate;
|
||||
|
||||
//
|
||||
// The sender's own stamp on the last record we accepted, for the
|
||||
// stale guard in AcceptUpdateStamp. Kept in the sender's clock, not
|
||||
// ours - it is only ever compared against another stamp from the
|
||||
// same sender, so no offset is needed and none is applied.
|
||||
//
|
||||
Time
|
||||
lastSenderStamp;
|
||||
Logical
|
||||
lastSenderStampValid;
|
||||
Word
|
||||
updateModel;
|
||||
|
||||
|
||||
@@ -274,6 +274,22 @@ namespace
|
||||
"# same on every machine in a race.\n"
|
||||
"#RP412NETPREDICT=0\n"
|
||||
"\n"
|
||||
"# How many seconds another player's pod will coast when their updates\n"
|
||||
"# stop arriving, before it parks and waits. Default 1.\n"
|
||||
"#\n"
|
||||
"# Between updates a pod is carried forward on its last known velocity.\n"
|
||||
"# That is a good guess for the length of one gap and a worse one every\n"
|
||||
"# moment after: a race log from four pods showed gaps of one to two\n"
|
||||
"# seconds each, and pods being corrected by as much as two and a half\n"
|
||||
"# kilometres - most of the way across the map, through whatever was in\n"
|
||||
"# the way, then snapped back. Stopping after a second reads as what it\n"
|
||||
"# actually is, somebody's connection going quiet.\n"
|
||||
"#\n"
|
||||
"# 0 restores the old behaviour of coasting indefinitely. Like\n"
|
||||
"# RP412NETPREDICT this changes how other pods MOVE and so where they\n"
|
||||
"# collide - keep it the same on every machine in a race.\n"
|
||||
"#RP412NETCOAST=1\n"
|
||||
"\n"
|
||||
"# How long one background pass may spend drawing cockpit gauges, in\n"
|
||||
"# milliseconds. The gauges and the MFD/map displays are redrawn in the\n"
|
||||
"# time left over after the 3D view; on a big, busy map there is none\n"
|
||||
|
||||
Reference in New Issue
Block a user