One late packet is not the new rate

Another player's pod is moved between updates by dead reckoning, which
advances it toward the projected position by a fraction of the remaining
gap each step:

    percent = time_slice / ((nextUpdate - lastPerformance) + time_slice)

That fraction is decided by nextUpdate, so predicting when the next
packet lands is not cosmetic - it sets how far every step moves. The
prediction was labelled HACK in the original source and deserved it: the
next gap was assumed to equal the previous gap. On a LAN the gaps were
all alike and it held. Over Steam a single late packet doubles the
expected gap, percent collapses, the pod barely advances for one step and
then catches up on the next - a visible tick.

Measured on a live connection, in the replicant's own step grid: 7 spikes
in 5 seconds, about 1.4 a second, matching the reported symptom, with
percent bottoming out at 0.014 against a normal range of 0.27 to 0.95.

Predict from the median of the last eight gaps instead. A median has a
breakdown point of half its samples, so one straggler - or three - moves
it not at all, while a genuine change in the send rate still carries it
within a few updates. Gaps that are non-positive (duplicate or reordered)
or multi-second (a join or a stall) never enter the window, and the
window is reset outright when the stream is interrupted.

Against the measured jitter pattern the blend fraction holds 0.282..0.286
where it previously swung 0.095..0.294 - a spread fifty times smaller.

This changes how remote pods MOVE, not merely how they are drawn, so it
feeds collisions with them as well. RP412NETPREDICT=0 restores the old
prediction for comparison on the same build, and the environ.ini entry
says to keep the setting alike on every machine in a race.

The RP412CAMLOG trace now also reports the interval being predicted and
the worst one-step-ahead miss, scored per entity rather than through the
file-scope statics the percent readings use - those are written by
whichever mover ran last, which is exactly the crossed frame of reference
this whole investigation kept tripping over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-11 08:30:01 -05:00
co-authored by Claude Opus 5
parent e029113ade
commit ec815b6216
3 changed files with 228 additions and 5 deletions
+182 -5
View File
@@ -21,6 +21,40 @@
static Logical gLastLerpUsed = False;
static Scalar gLastPercent = 0.0f;
//
// Bounds on the replication interval estimate, in seconds.
//
// The first pair decide what is allowed into the sample window at all: a
// non-positive gap is a duplicate or a reordered packet, and a multi-second
// one is a join, a pause or a stall. Neither says anything about the rate
// the sender is actually keeping.
//
// The second pair are a backstop on the answer, set deliberately wide so
// that in every sane case the median decides it and these never bind.
//
static const Scalar kMinimumUpdateInterval = 0.001f;
static const Scalar kOutlierUpdateInterval = 2.0f;
static const Scalar kMinimumPredictedInterval = 0.010f;
static const Scalar kMaximumPredictedInterval = 1.0f;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// RP412NETPREDICT=0 restores the original single-sample prediction, so the
// two can be compared on the same build and the same connection.
//
static Logical
UseMedianPrediction()
{
static int cached = -1;
if (cached < 0)
{
const char *setting = getenv("RP412NETPREDICT");
cached = (setting && *setting == '0') ? 0 : 1;
}
return cached ? True : False;
}
//#############################################################################
//############################### Mover #################################
//#############################################################################
@@ -640,6 +674,7 @@ void
static Scalar mean_step = 0.0f;
static Scalar min_percent = 1.0f;
static Scalar max_percent = 0.0f;
static Scalar worst_error = 0.0f;
++steps;
if (have_last)
@@ -676,7 +711,9 @@ void
<< stalls << " stall(s), " << lerped
<< " lerped, percent " << min_percent << ".."
<< max_percent << ", mean step " << mean_step
<< "m\n" << std::flush;
<< "m, predicting " << predictedInterval
<< "s worst miss " << worst_error << "s\n"
<< std::flush;
}
next_say = ((Scalar) Now()) + 5.0f;
steps = 0;
@@ -685,6 +722,13 @@ void
lerped = 0;
min_percent = 1.0f;
max_percent = 0.0f;
worst_error = 0.0f;
}
{
Scalar missed =
(predictionError < 0.0f) ? -predictionError : predictionError;
if (missed > worst_error) { worst_error = missed; }
}
if (gLastLerpUsed)
{
@@ -820,6 +864,105 @@ void
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
Mover::ResetUpdateIntervals()
{
Check(this);
updateIntervalCount = 0;
updateIntervalWrite = 0;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Estimate how long until the next update for this entity arrives.
//
// This is not a cosmetic guess. DeadReckon blends toward the projected
// origin by
//
// percent = time_slice / ((nextUpdate - lastPerformance) + time_slice)
//
// so the prediction sets how far every single step moves. The original code
// predicted the next gap from the one previous gap. On a LAN that was fine,
// because the gaps were all alike. Over the internet a late packet doubles
// the prediction, percent collapses toward zero, the entity barely advances
// for a step and then catches up on the following ones - which is a visible
// tick. Measured on a live Steam connection at about 1.4 a second, with
// percent bottoming out at 0.014 against a normal range of 0.27 to 0.95.
//
// A median has a breakdown point of half its samples, so one straggler - or
// three - moves it not at all, while a real change in the send rate still
// carries it within a few updates. That is the whole trick: ignore the
// outlier, follow the trend.
//
Scalar
Mover::PredictUpdateInterval(Scalar latest)
{
Check(this);
//
// Only plausible gaps go into the window. Letting a join or a stall in
// would poison the estimate for the next eight updates - precisely when
// the entity is most conspicuous, just after it appears.
//
if (latest > kMinimumUpdateInterval && latest < kOutlierUpdateInterval)
{
updateIntervals[updateIntervalWrite] = latest;
updateIntervalWrite = (updateIntervalWrite + 1) % UpdateIntervalSamples;
if (updateIntervalCount < UpdateIntervalSamples)
{
updateIntervalCount++;
}
}
//
// Too few samples to hold an opinion. Fall back to the old behaviour
// rather than inventing a rate we have no evidence for.
//
if (updateIntervalCount < 3)
{
return latest;
}
//
// Insertion sort - the window is eight samples, and this runs once per
// arriving packet per entity.
//
Scalar sorted[UpdateIntervalSamples];
int i;
for (i = 0; i < updateIntervalCount; i++)
{
sorted[i] = updateIntervals[i];
}
for (i = 1; i < updateIntervalCount; i++)
{
Scalar value = sorted[i];
int j = i - 1;
while (j >= 0 && sorted[j] > value)
{
sorted[j + 1] = sorted[j];
j--;
}
sorted[j + 1] = value;
}
Scalar median = sorted[updateIntervalCount / 2];
if (median < kMinimumPredictedInterval)
{
median = kMinimumPredictedInterval;
}
if (median > kMaximumPredictedInterval)
{
median = kMaximumPredictedInterval;
}
return median;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
@@ -833,15 +976,45 @@ void
{
//
//-------------------------------------------
// HACK - precalculation for next update time
//-------------------------------------------
//---------------------------------------
// Precalculation for next update time
//---------------------------------------
//
nextUpdate = Now();
Scalar diff = nextUpdate - lastUpdate;
if (diff < 10.0f)
{
nextUpdate.ticks += nextUpdate.ticks - lastUpdate.ticks;
if (UseMedianPrediction())
{
Scalar predicted = PredictUpdateInterval(diff);
//
// Score the previous prediction against the gap that
// actually just elapsed - a true one-step-ahead error,
// kept per entity so a trace reads the entity it is
// watching and not whichever one updated last.
//
if (predictedInterval > 0.0f)
{
predictionError = predictedInterval - diff;
}
predictedInterval = predicted;
nextUpdate += predicted;
}
else
{
nextUpdate.ticks += nextUpdate.ticks - lastUpdate.ticks;
}
}
else
{
//
// The stream was interrupted - a join, a pause, a long
// stall. Nothing recorded before it describes the rate
// now, so start the window over.
//
ResetUpdateIntervals();
}
//
@@ -1860,6 +2033,10 @@ Mover::Mover(
updateAcceleration.angularMotion = localAcceleration.linearMotion;
nextUpdate = lastUpdate;
ResetUpdateIntervals();
predictedInterval = 0.0f;
predictionError = 0.0f;
normalizeCount = 0;
if (IsInitialStasis())
{
+27
View File
@@ -289,6 +289,33 @@ protected:
Time
nextUpdate;
//
// Recent gaps between replication updates for this entity, as a ring,
// and the running estimate drawn from them. The original code predicted
// the next gap from the single previous gap; see PredictUpdateInterval
// for why that stalls a step every time a packet runs late.
//
enum {UpdateIntervalSamples = 8};
Scalar
updateIntervals[UpdateIntervalSamples];
int
updateIntervalCount,
updateIntervalWrite;
//
// The interval last predicted, and how wrong the prediction before it
// proved to be once the gap it described actually elapsed. Per entity,
// so a trace reads the entity it is watching.
//
Scalar
predictedInterval,
predictionError;
Scalar
PredictUpdateInterval(Scalar latest);
void
ResetUpdateIntervals();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Collision support
//
+19
View File
@@ -255,6 +255,25 @@ namespace
"# would rather have those 20 ms than smooth motion.\n"
"#RP412INTERP=0\n"
"\n"
"# Set 0 to go back to the original guess at when the next update for\n"
"# another player's pod will arrive. On by default.\n"
"#\n"
"# Between updates, another player's pod is moved by dead reckoning: it\n"
"# advances toward where it is projected to be by a fraction of the gap\n"
"# each step, and that fraction is decided by when the next update is\n"
"# expected. The original code expected the next gap to match the one\n"
"# before it, which held on a LAN where the gaps were all alike. Over the\n"
"# internet one late packet doubles the expected gap, the fraction\n"
"# collapses, the pod barely moves for a step and then catches up - a\n"
"# tick roughly once a second on a live connection.\n"
"#\n"
"# The default instead takes the middle value of the last eight gaps, so\n"
"# one straggler is ignored while a real change in the rate is still\n"
"# followed. This changes how other players' pods MOVE, not merely how\n"
"# they are drawn, so it affects collisions with them too - keep it the\n"
"# same on every machine in a race.\n"
"#RP412NETPREDICT=0\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"