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:
Cyd
2026-08-13 21:35:22 -05:00
co-authored by Claude Opus 5
parent 835580ab91
commit d9149a6a8c
5 changed files with 165 additions and 1 deletions
+15 -1
View File
@@ -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);
}
+71
View File
@@ -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;
}
}
//
+43
View File
@@ -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;
+20
View File
@@ -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;
+16
View File
@@ -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"