diff --git a/code/_event.cpp b/code/_event.cpp
index 26403dd6..66e95718 100644
--- a/code/_event.cpp
+++ b/code/_event.cpp
@@ -57,6 +57,7 @@ unsigned char EventClass::EventLength[EventClass::LAST_EVENT] = {
0, // PAGEUSER
size_of(EventClass, Data.General), // REMOVEPLAYER
size_of(EventClass, Data.General), // LATENCYFUDGE
+ size_of(EventClass, Data.NetworkReport), // NETWORK_REPORT
};
char const * EventClass::EventNames[EventClass::LAST_EVENT] = {
@@ -96,4 +97,5 @@ char const * EventClass::EventNames[EventClass::LAST_EVENT] = {
"PAGEUSER",
"REMOVEPLAYER",
"LATENCYFUDGE",
+ "NETWORK_REPORT",
};
diff --git a/code/connect.cpp b/code/connect.cpp
index ccde7c01..639d325f 100644
--- a/code/connect.cpp
+++ b/code/connect.cpp
@@ -997,6 +997,17 @@ unsigned int ConnectionClass::Time (void)
} /* end of Time */
+/// Reports this link's last measured round trip.
+/// Returns the smoothed round trip, or nothing until a clean acknowledgement has been measured.
+std::optional ConnectionClass::Smoothed_Round_Trip_MS(void) const
+{
+ if (!RoundTripEstimator.Has_Sample() || RoundTripEstimator.Is_Provisional()) {
+ return(std::nullopt);
+ }
+ return(RoundTripEstimator.Smoothed_Rtt());
+}
+
+
/***************************************************************************
* ConnectionClass::Command_Name -- returns name for given packet command *
* *
diff --git a/code/connect.h b/code/connect.h
index d7fd47c4..9b59d028 100644
--- a/code/connect.h
+++ b/code/connect.h
@@ -100,6 +100,8 @@
#include "netadmit.h"
#include "nettiming.h"
+#include
+
/*
********************************** Defines **********************************
*/
@@ -186,6 +188,7 @@ class ConnectionClass
void Set_TimeOut (unsigned int t) { Timeout = t;}
unsigned int Max_Packet_Len (void) { return(MaxPacketLen); }
void Reset_Round_Trip_Time(void) {RoundTripEstimator.Reset();}
+ std::optional Smoothed_Round_Trip_MS(void) const;
static const char * Command_Name(int command);
int Num_Resends(void) const { return(NumResends); }
diff --git a/code/connmgr.h b/code/connmgr.h
index 82ef04e2..fe8734b7 100644
--- a/code/connmgr.h
+++ b/code/connmgr.h
@@ -60,6 +60,10 @@
* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */
#pragma once
+#include "nettime.h"
+
+#include
+
/*
***************************** Class Declaration *****************************
@@ -120,6 +124,7 @@ class ConnManClass
.....................................................................*/
virtual void Reset_Response_Time(bool zero) = 0;
virtual unsigned int Response_Time(void) = 0;
+ virtual std::optional Worst_Local_Round_Trip_MS(void) const = 0;
virtual void Set_Timing (unsigned int retrydelta,
unsigned int maxretries, unsigned int timeout, bool set_external = true) = 0;
virtual void Set_External_Timing (unsigned int retrydelta,
diff --git a/code/event.cpp b/code/event.cpp
index 19679101..027a3575 100644
--- a/code/event.cpp
+++ b/code/event.cpp
@@ -76,6 +76,9 @@
#include "ramp.hh"
#include "special.hh"
+#include
+#include
+
namespace {
enum class EventRejectReason : unsigned int {
@@ -93,7 +96,10 @@ namespace {
InvalidLatencyFudge,
UnauthorizedSubject,
UnauthorizedTiming,
+ InvalidTimingArithmetic,
InvalidTimingValues,
+ UnschedulableTiming,
+ InvalidNetworkReport,
Count,
};
@@ -112,7 +118,10 @@ namespace {
"invalid latency fudge",
"unauthorized subject",
"unauthorized timing",
+ "invalid timing arithmetic",
"invalid timing values",
+ "unschedulable timing",
+ "invalid network report",
};
static_assert(ARRAY_SIZE(EventRejectReasonNames) == (int)EventRejectReason::Count);
@@ -707,7 +716,6 @@ void EventClass::Execute(void)
// bool formation = false;
int i;
int index;
- unsigned int ul;
// RTTIType rt;
//if (Debug_Print_Events) {
@@ -1209,7 +1217,7 @@ void EventClass::Execute(void)
Log_Event_Rejection(EventRejectReason::InvalidTimingValues, Type, ID, Data.FrameInfo.Delay);
break;
}
- Session.MaxAhead = Data.FrameInfo.Delay;
+ Session.Apply_Network_Response_Time(Data.FrameInfo.Delay, Frame >= 0 ? static_cast(Frame) : 0u);
break;
}
@@ -1250,6 +1258,7 @@ void EventClass::Execute(void)
DebugString("Executing REMOVEPLAYER event. Frame is %d\n", ::Frame);
Disable_Multiplayer_Saving();
+ Session.Remove_Network_Timing_Player(index, Frame >= 0 ? static_cast(Frame) : 0u);
house = Houses[index];
if (house->IsObserver) {
break;
@@ -1295,17 +1304,48 @@ void EventClass::Execute(void)
break;
}
- unsigned int const padding = Scen->Special.IsFogOfWar ? 10 : 0;
- if (Data.Timing.MaxAhead < padding) {
+ if (Session.CommProtocol != COMM_PROTOCOL_MULTI_E_COMP || Frame < 0) {
+ Log_Event_Rejection(EventRejectReason::InvalidTimingArithmetic, Type, ID, Frame);
+ break;
+ }
+ if (!NetSemantic::Timing_Values_Are_Valid(Data.Timing.DesiredFrameRate, Data.Timing.MaxAhead, Data.Timing.FrameSendRate)) {
Log_Event_Rejection(EventRejectReason::InvalidTimingValues, Type, ID, Data.Timing.MaxAhead);
break;
}
- unsigned int const max_ahead = Data.Timing.MaxAhead - padding;
- if (!NetSemantic::Timing_Values_Are_Valid(Data.Timing.DesiredFrameRate, max_ahead, Data.Timing.FrameSendRate)) {
- Log_Event_Rejection(EventRejectReason::InvalidTimingValues, Type, ID, max_ahead);
+
+ NetTiming::TimingSettings const settings{Data.Timing.FrameSendRate, Data.Timing.MaxAhead};
+ NetTiming::ConnectionQuality const old_quality = NetTiming::Connection_Quality_For_Settings(Session.Network_Timing_Target());
+ unsigned int const old_frame_send_rate = Session.FrameSendRate;
+ unsigned int const old_max_ahead = Session.MaxAhead;
+
+ if (settings.MaxAhead > old_max_ahead || settings.FrameSendRate > old_frame_send_rate) {
+ std::uint64_t const boundary = settings.FrameSendRate * ((static_cast(Frame) + NetTiming::MAXIMUM_MAX_AHEAD
+ + settings.FrameSendRate - 1) / settings.FrameSendRate);
+ if (boundary > static_cast((std::numeric_limits::max)())) {
+ Log_Event_Rejection(EventRejectReason::InvalidTimingArithmetic, Type, ID, Frame);
+ break;
+ }
+ }
+
+ NetTiming::ScheduleResult const result = Session.Schedule_Network_Timing(settings, Data.Timing.DesiredFrameRate, static_cast(Frame));
+ if (result == NetTiming::ScheduleResult::Rejected) {
+ Log_Event_Rejection(EventRejectReason::UnschedulableTiming, Type, ID, static_cast(settings.MaxAhead));
break;
}
+ DebugString("Network timing event at frame %d from player %d: %u/%u at %u fps %s\n", Frame, ID, settings.FrameSendRate, settings.MaxAhead,
+ Data.Timing.DesiredFrameRate, result == NetTiming::ScheduleResult::Applied ? "applied" : "staged");
+ NetTiming::ConnectionQuality const quality = NetTiming::Connection_Quality_For_Settings(settings);
+ if (quality != old_quality) {
+ char const * format = Fetch_String(TXT_CONNECTION_QUALITY_STATUS);
+ char const * quality_name = Fetch_String(Network_Quality_Text_ID(quality));
+ if (format != NULL && quality_name != NULL && format[0] != '\0' && quality_name[0] != '\0') {
+ snprintf(msg, sizeof(msg), format, quality_name);
+ Session.Messages.Add_Message(NULL, 0, msg, house->Scheme,
+ TextPrintType(TPF_6PT_GRAD|TPF_USE_GRAD_PAL|TPF_FULLSHADOW), Rule->MessageDelay * TICKS_PER_MINUTE);
+ }
+ }
+
#if (TIMING_FIX)
//
// If MaxAhead is about to increase, we're vulnerable to a Packet-
@@ -1314,26 +1354,16 @@ void EventClass::Execute(void)
// period of vulnerability's frame start & end values, so we
// can reschedule these events to execute after it's over.
//
- if (max_ahead > Session.MaxAhead || Data.Timing.FrameSendRate > Session.FrameSendRate) {
+ if (result == NetTiming::ScheduleResult::Applied && (Session.MaxAhead > old_max_ahead || Session.FrameSendRate > old_frame_send_rate)) {
+ std::uint64_t const boundary = Session.FrameSendRate * ((static_cast(Frame) + Session.MaxAhead
+ + Session.FrameSendRate - 1) / Session.FrameSendRate);
NewMaxAheadFrame1 = Frame;
- NewMaxAheadFrame2 = Data.Timing.FrameSendRate * ((Data.Timing.FrameSendRate + max_ahead + Frame - 1) / Data.Timing.FrameSendRate);
+ NewMaxAheadFrame2 = static_cast(boundary);
} else {
NewMaxAheadFrame1 = 0;
NewMaxAheadFrame2 = 0;
}
#endif
-
- ul = Session.MaxMaxAhead;
-
- Session.DesiredFrameRate = Data.Timing.DesiredFrameRate;
- Session.MaxAhead = max_ahead;
-
- if (ul <= Session.MaxAhead) {
- Session.MaxMaxAhead = Session.MaxAhead;
- }
-
- Session.FrameSendRate = Data.Timing.FrameSendRate;
-
break;
}
@@ -1351,6 +1381,21 @@ void EventClass::Execute(void)
}
break;
+ case NETWORK_REPORT:
+ // A recording started without a roster has nobody to attribute reports to.
+ if ((Session.CommProtocol != COMM_PROTOCOL_MULTI_E_COMP || Frame < 0
+ || !Session.Record_Network_Report(ID, Data.NetworkReport.AverageProcessMilliseconds,
+ Data.NetworkReport.WorstRoundTripMilliseconds, Data.NetworkReport.StallMilliseconds, static_cast(Frame)))
+ && !Session.Play) {
+ Log_Event_Rejection(EventRejectReason::InvalidNetworkReport, Type, ID, Data.NetworkReport.WorstRoundTripMilliseconds);
+ } else if (!Session.Play) {
+ DebugString("Network report at frame %d from player %d: process %u ms, RTT %d ms, longest wait %u ms\n", Frame, ID,
+ (unsigned int)Data.NetworkReport.AverageProcessMilliseconds,
+ Data.NetworkReport.WorstRoundTripMilliseconds == NETWORK_RTT_UNAVAILABLE ? -1 : (int)Data.NetworkReport.WorstRoundTripMilliseconds,
+ (unsigned int)Data.NetworkReport.StallMilliseconds);
+ }
+ break;
+
/*
** Default: do nothing.
*/
diff --git a/code/event.h b/code/event.h
index 5a3aaf57..ce45385f 100644
--- a/code/event.h
+++ b/code/event.h
@@ -42,6 +42,7 @@
#include "mph.hh"
#include "speed.hh"
+#include
#include
/*
@@ -108,10 +109,13 @@ class EventClass
REMOVEPLAYER,
LATENCYFUDGE,
+ NETWORK_REPORT,
LAST_EVENT, // one past the last event
};
+ static constexpr std::uint16_t NETWORK_RTT_UNAVAILABLE = UINT16_MAX;
+
unsigned char Type; // Type of queue command object.
/*
@@ -236,6 +240,12 @@ class EventClass
unsigned short AverageTicks;
} ProcessTime;
+ struct {
+ std::uint16_t AverageProcessMilliseconds;
+ std::uint16_t WorstRoundTripMilliseconds;
+ std::uint16_t StallMilliseconds;
+ } NetworkReport;
+
} Data;
//-------------- Constructors ---------------------
diff --git a/code/goptions.cpp b/code/goptions.cpp
index c22beb60..4d150d19 100644
--- a/code/goptions.cpp
+++ b/code/goptions.cpp
@@ -115,24 +115,30 @@ void Game_Options_Dialog(void)
}
+/// Returns the localized label for a synchronized connection-quality tier.
+int Network_Quality_Text_ID(NetTiming::ConnectionQuality quality)
+{
+ switch (quality) {
+ case NetTiming::ConnectionQuality::Fast: return(TXT_BEST_CONNECTION);
+ case NetTiming::ConnectionQuality::Normal: return(TXT_GOOD_CONNECTION);
+ case NetTiming::ConnectionQuality::Poor: return(TXT_POOR_CONNECTION);
+ case NetTiming::ConnectionQuality::Bad: return(TXT_WORST_CONNECTION);
+ }
+ return(TXT_WORST_CONNECTION);
+}
+
+
///
/// Handles messages for the in game options dialog.
/// This routine offers every message to the owner draw system first. What is left it uses
/// to service the option buttons -- save, load, delete, briefing, resume, abort and
/// settings -- either acting on them directly or noting the player's choice for
-/// Game_Options_Dialog to deal with once the dialog comes down. Dragging the game speed or
-/// connection quality slider updates the label beside it.
+/// Game_Options_Dialog to deal with once the dialog comes down. Dragging the game speed
+/// slider updates the label beside it.
///
/// Returns with TRUE if the owner draw system consumed the message.
BOOL CALLBACK Game_Options_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam)
{
- static int GameConnectionQualityNames[] = {
- TXT_WORST_CONNECTION,
- TXT_POOR_CONNECTION,
- TXT_GOOD_CONNECTION,
- TXT_BEST_CONNECTION
- };
-
BOOL rc = OwnerDraw::Default_Dialog_Proc(window, message, wparam, lparam);
HWND handle;
@@ -204,14 +210,6 @@ BOOL CALLBACK Game_Options_Dialog_Proc(HWND window, UINT message, WPARAM wparam,
case IDC_RESUME_MISSION:
if (!code) {
if (Session.Type == GAME_INTERNET) {
- handle = GetDlgItem(window, IDC_CTRLWOL_CONNECTION);
- if (handle) {
- int fudge = 3 - SendMessage(handle, TBM_GETPOS, 0, 0);
- if (fudge != Session.LatencyFudge) {
- OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::LATENCYFUDGE, fudge));
- DebugString("LATENCYFUDGE event created - %d\n", fudge);
- }
- }
handle = GetDlgItem(window, IDC_GAME_SPEED_SLIDER);
if (handle) {
int speed = (OptionsClass::MAX_SPEED_SETTING-1) - SendMessage(handle, TBM_GETPOS, 0, 0);
@@ -254,20 +252,11 @@ BOOL CALLBACK Game_Options_Dialog_Proc(HWND window, UINT message, WPARAM wparam,
case WM_HSCROLL: {
if (LOWORD(wparam) == SB_THUMBTRACK) {
int pos = HIWORD(wparam);
- int textid;
-
if ((HWND)lparam == GetDlgItem(window, IDC_GAME_SPEED_SLIDER)) {
- textid = GameSpeedNames[pos];
handle = GetDlgItem(window, IDC_GAME_SPEED_LABEL);
- } else if ((HWND)lparam == GetDlgItem(window, IDC_CTRLWOL_CONNECTION)) {
- textid = GameConnectionQualityNames[pos];
- handle = GetDlgItem(window, IDC_SCROLL_SPEED_LABEL);
- } else {
- break;
- }
-
- if (handle) {
- Static_SetText(handle, Fetch_String(textid));
+ if (handle) {
+ Static_SetText(handle, Fetch_String(GameSpeedNames[pos]));
+ }
}
}
break;
@@ -285,7 +274,7 @@ BOOL CALLBACK Game_Options_Dialog_Proc(HWND window, UINT message, WPARAM wparam,
/// Prepares the controls of the game options dialog.
/// This routine is called when the dialog is created, and again whenever a save or delete
/// has changed what is on disk. It decides which buttons the current game type allows the
-/// player to use and primes the game speed and connection quality sliders.
+/// player to use and primes the game speed and connection-quality controls.
///
void Game_Options_On_INITDIALOG(HWND window)
{
@@ -313,10 +302,23 @@ void Game_Options_On_INITDIALOG(HWND window)
}
if (Session.Type == GAME_INTERNET) {
+ NetTiming::TimingSettings const settings{Session.FrameSendRate, Session.MaxAhead};
+ NetTiming::ConnectionQuality const quality = NetTiming::Connection_Quality_For_Settings(settings);
handle = GetDlgItem(window, IDC_CTRLWOL_CONNECTION);
if (handle) {
- SetSliderRangeAndPos(handle, 0, 3, 3 - Session.LatencyFudge);
+ unsigned int const displayed_rung = settings.FrameSendRate >= NetTiming::MINIMUM_TIMING_RUNG
+ && settings.FrameSendRate <= NetTiming::MAXIMUM_TIMING_RUNG ? settings.FrameSendRate : NetTiming::MAXIMUM_TIMING_RUNG;
+ int const mirrored_rung = NetTiming::MINIMUM_TIMING_RUNG + NetTiming::MAXIMUM_TIMING_RUNG - displayed_rung;
+ SetSliderRangeAndPos(handle, NetTiming::MINIMUM_TIMING_RUNG, NetTiming::MAXIMUM_TIMING_RUNG, mirrored_rung);
+ EnableWindow(handle, FALSE);
+ }
+
+ handle = GetDlgItem(window, IDC_SCROLL_SPEED_LABEL);
+ if (handle) {
+ char label[64];
+ snprintf(label, sizeof(label), Fetch_String(TXT_CONNECTION_QUALITY_RUNG), Fetch_String(Network_Quality_Text_ID(quality)), settings.FrameSendRate);
+ Static_SetText(handle, label);
}
handle = GetDlgItem(window, IDC_GAME_SPEED_SLIDER);
diff --git a/code/goptions.h b/code/goptions.h
index c090f7ad..a2c69c11 100644
--- a/code/goptions.h
+++ b/code/goptions.h
@@ -33,6 +33,7 @@
#pragma once
#include "gadget.h"
+#include "nettiming.h"
#include "options.h"
@@ -42,4 +43,5 @@ class GameOptionsClass : public OptionsClass {
};
int Abort_Dialog(void);
+int Network_Quality_Text_ID(NetTiming::ConnectionQuality quality);
void Game_Options_Dialog(void);
diff --git a/code/gscreen.cpp b/code/gscreen.cpp
index 30316d80..98123c23 100644
--- a/code/gscreen.cpp
+++ b/code/gscreen.cpp
@@ -69,6 +69,8 @@
#include
+void Multiplayer_Debug_Print(void);
+
GadgetClass * GScreenClass::Buttons = NULL;
@@ -413,6 +415,9 @@ void GScreenClass::Render(void)
** This way, they'll Blit along with the rest of the map.
*/
Session.Messages.Draw();
+ if (Session.ShowInternetDebug) {
+ Multiplayer_Debug_Print();
+ }
if (ToolTips != NULL) {
ToolTips->Draw_Current();
diff --git a/code/init.cpp b/code/init.cpp
index 7bc8abe2..3b0aaf3c 100644
--- a/code/init.cpp
+++ b/code/init.cpp
@@ -1037,6 +1037,8 @@ bool Select_Game(bool )
Session.ProcessTicks = 0;
Session.ProcessFrames = 0;
+ Session.WorstStallTicks = 0;
+ Session.PreviousWorstStallTicks = 0;
Session.DesiredFrameRate = 30;
NewMaxAheadFrame1 = 0;
NewMaxAheadFrame2 = 0;
@@ -1457,6 +1459,8 @@ bool Select_Game(bool )
Ipx.Set_Timing(std::max(TIMER_SECOND, Ipx.Global_Response_Time() + 2), (unsigned int) -1, 10 * TIMER_SECOND);
}
}
+ } else if (Session.Play && (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET)) {
+ Session.Reset_Network_Timing(Frame >= 0 ? static_cast(Frame) : 0u);
}
/*
diff --git a/code/ipxmgr.cpp b/code/ipxmgr.cpp
index 57fb524a..430f3b2f 100644
--- a/code/ipxmgr.cpp
+++ b/code/ipxmgr.cpp
@@ -1310,6 +1310,22 @@ unsigned int IPXManagerClass::Response_Time(void)
} /* end of Response_Time */
+/// Returns the worst measured round trip among active private links.
+std::optional IPXManagerClass::Worst_Local_Round_Trip_MS(void) const
+{
+ NetTiming::Milliseconds worst = 0;
+ for (int i = 0; i < NumConnections; i++) {
+ std::optional const round_trip = Connection[i]->Smoothed_Round_Trip_MS();
+ if (!round_trip) {
+ return(std::nullopt);
+ }
+ worst = std::max(worst, *round_trip);
+ }
+
+ return(worst);
+}
+
+
///
/// Fetches the average response time of a single connection.
/// This routine is used by the network queue logic to pace itself against the slowest
@@ -1388,22 +1404,22 @@ void IPXManagerClass::Store_Stats(void)
/// column of round trip, resend and packet loss figures for every remote player in the
/// game. Use this routine when the multiplayer debug display has been switched on.
///
-void IPXManagerClass::Multiplayer_Debug_Print(void)
+void IPXManagerClass::Multiplayer_Debug_Print(int top)
{
char buffer[256];
sprintf(buffer, "Rtr delta : %d", 1000 * RetryDelta / TIMER_SECOND);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 450), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 50), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT));
sprintf(buffer, "Rtr timeout : %d", 1000 * Timeout / TIMER_SECOND);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 458), Fetch_Scheme_By_Name("Grey"), 0, TextPrintType(TPF_NOSHADOW|TPF_EFNT));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 58), Fetch_Scheme_By_Name("Grey"), 0, TextPrintType(TPF_NOSHADOW|TPF_EFNT));
sprintf(buffer, "Lat Fudge : %d", Session.LatencyFudge);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 466), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 66), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT));
if (SentFrameSyncTimer / TIMER_SECOND) {
sprintf(buffer, "FSPS : %d", SentFrameSyncCount / (SentFrameSyncTimer / TIMER_SECOND));
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 474), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 74), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT));
if ((Frame & 0x7F) == 0x7F) {
SentFrameSyncTimer = 0;
SentFrameSyncCount = 0;
@@ -1415,27 +1431,27 @@ void IPXManagerClass::Multiplayer_Debug_Print(void)
if (house != NULL && house != PlayerPtr) {
int scheme = house->Scheme;
- Fancy_Text_Print(Connection[i]->Name, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 402), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
+ Fancy_Text_Print(Connection[i]->Name, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 2), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
int avg = Connection[i]->Queue->Avg_Response_Time();
sprintf(buffer, "Average : %d", 1000 * avg / TIMER_SECOND);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 411), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 11), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
int max = Connection[i]->Queue->Max_Response_Time();
sprintf(buffer, "Max : %d", 1000 * max / TIMER_SECOND);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 418), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 18), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
int resends = Connection[i]->Num_Resends();
sprintf(buffer, "Resends : %d", resends);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 425), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 25), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
int numlost = std::max(0, Connection[i]->Num_Lost());
sprintf(buffer, "Num lost : %d", numlost);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 432), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 32), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
int pcnt_lost = Connection[i]->Percent_Lost();
sprintf(buffer, "Pcnt lost: %d", pcnt_lost);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 439), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 39), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
int process_time = 0;
for (int j = 0; j < Session.Players.Count(); ++j) {
@@ -1445,16 +1461,16 @@ void IPXManagerClass::Multiplayer_Debug_Print(void)
}
}
sprintf(buffer, "Process : %d", process_time);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 446), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 46), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
sprintf(buffer, "Frame : %d", -Session.PlayerLatency[i]);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 453), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 53), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
sprintf(buffer, "Queue s/r: %d/%d", Connection[i]->Queue->Num_Send(), Connection[i]->Queue->Num_Receive());
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 460), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 60), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
sprintf(buffer, "Missed o/m: %d/%d", Connection[i]->Missed_Overall(), Connection[i]->Missed_Magic());
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 467), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 67), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW));
}
}
}
diff --git a/code/ipxmgr.h b/code/ipxmgr.h
index f7b4e320..e500381a 100644
--- a/code/ipxmgr.h
+++ b/code/ipxmgr.h
@@ -230,6 +230,7 @@ class IPXManagerClass : public ConnManClass
reset the response time for all queues.
.....................................................................*/
virtual unsigned int Response_Time(void) override;
+ virtual std::optional Worst_Local_Round_Trip_MS(void) const override;
unsigned int Global_Response_Time(void);
virtual void Reset_Response_Time(bool zero) override;
@@ -249,7 +250,7 @@ class IPXManagerClass : public ConnManClass
virtual void Mono_Debug_Print(int index, int refresh = 0);
- void Multiplayer_Debug_Print(void);
+ void Multiplayer_Debug_Print(int top);
/*
--------------------------- Private Interface ----------------------------
diff --git a/code/language/language.h b/code/language/language.h
index 3418f8c3..7366a3c2 100644
--- a/code/language/language.h
+++ b/code/language/language.h
@@ -811,6 +811,8 @@
#define TXT_CHAT_TO_ALL_DESC 1053
#define TXT_CHAT_TO_ALLIES 1054
#define TXT_CHAT_TO_ALLIES_DESC 1055
+#define TXT_CONNECTION_QUALITY_STATUS 1056
+#define TXT_CONNECTION_QUALITY_RUNG 1057
#define IDC_LADDER_TYPE 1043
#define IDC_LADDER_LOCATION 1044
#define IDC_FINDGAME_LOCATION 1046
diff --git a/code/language/language.rc b/code/language/language.rc
index e54bc58b..bd3b7162 100644
--- a/code/language/language.rc
+++ b/code/language/language.rc
@@ -1443,7 +1443,7 @@ BEGIN
TBS_BOTH | TBS_NOTICKS,95,93,148,13
LTEXT "Connection",-1,39,93,58,13,SS_CENTERIMAGE | NOT
WS_GROUP
- RTEXT "Better",IDC_SCROLL_SPEED_LABEL,247,93,45,13,
+ RTEXT "Better",IDC_SCROLL_SPEED_LABEL,247,93,64,13,
SS_CENTERIMAGE | NOT WS_GROUP
GROUPBOX "Internet Game Controls",-1,28,75,283,68
END
@@ -2435,6 +2435,8 @@ BEGIN
TXT_CHAT_TO_ALL_DESC "Starts a message to every player."
TXT_CHAT_TO_ALLIES "Message to Team"
TXT_CHAT_TO_ALLIES_DESC "Starts a message to your allies, or to the other observers while you watch."
+ TXT_CONNECTION_QUALITY_STATUS "Connection quality target: %s."
+ TXT_CONNECTION_QUALITY_RUNG "%s (rung %u)"
END
#endif // English (U.S.) resources
diff --git a/code/mainloop.cpp b/code/mainloop.cpp
index 8a2ed93e..feefb3f0 100644
--- a/code/mainloop.cpp
+++ b/code/mainloop.cpp
@@ -74,7 +74,7 @@ int TeamNumber = 0; // which team was selected? (1-9)
void Message_Input(KeyNumType &input);
void Sync_Delay(void);
-void Multiplayer_Debug_Print(bool noframecheck);
+void Multiplayer_Debug_Print(void);
static void Do_Record_Playback(void);
@@ -268,28 +268,6 @@ bool Main_Loop(void)
FrameTimer = framedelay;
framedelay = 1000 / Session.DesiredFrameRate;
NetFrameTimer = framedelay;
-
- int maxahead = Session.MaxAhead;
- int worst_latency = 0;
- if (Session.Type == GAME_INTERNET) {
- for (int i = 0; i < Ipx.Num_Connections(); i++) {
- if (worst_latency <= Session.PlayerLatency[i]) {
- worst_latency = Session.PlayerLatency[i];
- }
- }
-
- if (worst_latency) {
- if (worst_latency >= maxahead / 4) {
- NetFrameTimer = NetFrameTimer + 10;
- }
- if (worst_latency >= maxahead / 2) {
- NetFrameTimer = NetFrameTimer + 10;
- }
- if (worst_latency >= (3 * maxahead) / 4) {
- NetFrameTimer = NetFrameTimer + 10;
- }
- }
- }
}
} else {
FrameTimer = Options.GameSpeed;
@@ -304,9 +282,6 @@ bool Main_Loop(void)
if (input) {
Keyboard_Process(input);
}
- if (Session.ShowInternetDebug) {
- Multiplayer_Debug_Print(false);
- }
if ((Frame & 7) == 7 && Session.Type == GAME_INTERNET) {
Ipx.Store_Stats();
}
@@ -743,45 +718,36 @@ void Message_Input(KeyNumType &input)
/// per-connection display. It is used while debugging a multiplayer game and does
/// nothing at all in a single player game.
///
-/// Should the display be drawn regardless of the frame
-/// counter?
-void Multiplayer_Debug_Print(bool noframecheck)
+void Multiplayer_Debug_Print(void)
{
- if (!noframecheck && (Frame & 7) != 7) {
- return;
- }
-
if (Session.Type == GAME_NORMAL) {
return;
}
- Hide_Mouse();
-
- VisibleSurface->Fill_Rect(Rect(0, 400, 639, 80), 0);
+ int const top = LogicalSurface->Get_Height() - 80;
+ LogicalSurface->Fill_Rect(Rect(0, top, LogicalSurface->Get_Width(), 80), 0);
char buffer[256];
sprintf(buffer, "Frame : %d", Frame);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 402), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 2), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
sprintf(buffer, "FPS : %d", LastFramesPerSecond);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 410), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 10), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
sprintf(buffer, "MaxAhead : %d", Session.MaxAhead);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 418), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 18), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
sprintf(buffer, "Resp Time : %d ms", (int)(Ipx.Response_Time() * 1000) / TIMER_SECOND);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 426), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 26), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
sprintf(buffer, "Req fps : %d", Session.DesiredFrameRate);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 434), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 34), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
sprintf(buffer, "Process : %d", Session.Players[0]->Player.ProcessTime);
- Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 442), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
-
- Ipx.Multiplayer_Debug_Print();
+ Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 42), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW));
- Show_Mouse();
+ Ipx.Multiplayer_Debug_Print(top);
}
diff --git a/code/netdlg2.cpp b/code/netdlg2.cpp
index 7e679fcb..2cb3da51 100644
--- a/code/netdlg2.cpp
+++ b/code/netdlg2.cpp
@@ -34,6 +34,7 @@
#include "msgbox.h"
#include "netdlg.h"
#include "netshare.h"
+#include "nettiming.h"
#include "newmenu.h"
#include "ownrdraw.h"
#include "rules.h"
@@ -919,18 +920,13 @@ bool Net2Remote_Connect(void)
PregameSetup();
- //.....................................................................
- // Compute frame delay value for packet transmissions:
- // - Divide global channel's response time by 8 (2 to convert to 1-way
- // value, 4 more to convert from ticks to frames)
- //.....................................................................
- Session.LatencyFudge = 0;
- Session.PrecalcMaxAhead = 0;
- Session.PrecalcDesiredFrameRate = 0;
- Session.FrameSendRate = 3;
+ // Compressed games bootstrap adaptively; legacy games retain measured timing.
if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) {
- Session.MaxAhead = std::max(((((Ipx.Global_Response_Time() / 8) + (Session.FrameSendRate - 1)) / Session.FrameSendRate) * Session.FrameSendRate), NETWORK_MIN_MAX_AHEAD * 3);
+ NetTiming::TimingSettings const initial = NetTiming::Settings_For_Rung(NetTiming::INITIAL_TIMING_RUNG);
+ Session.FrameSendRate = initial.FrameSendRate;
+ Session.MaxAhead = initial.MaxAhead;
} else {
+ Session.FrameSendRate = DEFAULT_FRAME_SEND_RATE;
Session.MaxAhead = std::max(((int)Ipx.Global_Response_Time() / 8), NETWORK_MIN_MAX_AHEAD);
}
@@ -963,18 +959,13 @@ bool Net2Remote_Connect(void)
PregameSetup();
- //.....................................................................
- // Compute frame delay value for packet transmissions:
- // - Divide global channel's response time by 8 (2 to convert to 1-way
- // value, 4 more to convert from ticks to frames)
- //.....................................................................
- Session.FrameSendRate = 3;
- Session.LatencyFudge = 0;
- Session.PrecalcMaxAhead = 0;
- Session.PrecalcDesiredFrameRate = 0;
+ // Compressed games bootstrap adaptively; legacy games retain measured timing.
if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) {
- Session.MaxAhead = std::max(((((Ipx.Global_Response_Time() / 8) + (Session.FrameSendRate - 1)) / Session.FrameSendRate) * Session.FrameSendRate), NETWORK_MIN_MAX_AHEAD * 3);
+ NetTiming::TimingSettings const initial = NetTiming::Settings_For_Rung(NetTiming::INITIAL_TIMING_RUNG);
+ Session.FrameSendRate = initial.FrameSendRate;
+ Session.MaxAhead = initial.MaxAhead;
} else {
+ Session.FrameSendRate = DEFAULT_FRAME_SEND_RATE;
Session.MaxAhead = std::max(((int)Ipx.Global_Response_Time() / 8), NETWORK_MIN_MAX_AHEAD);
}
@@ -2830,7 +2821,23 @@ static void Get_Join_Responses(void)
//------------------------------------------------------------------------
else if (Session.GPacket.Command==NET_GO || Session.GPacket.Command==NET_LOADGAME) {
if ( JoinState==JOIN_CONFIRMED) {
- Session.MaxAhead = Session.GPacket.ResponseTime.OneWay;
+ if (Session.GPacket.Command == NET_GO && Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) {
+ int const max_ahead = Session.GPacket.ResponseTime.OneWay;
+ if (max_ahead < 0) {
+ continue;
+ }
+
+ NetTiming::TimingSettings const initial = NetTiming::Settings_For_Rung(NetTiming::INITIAL_TIMING_RUNG);
+ NetTiming::TimingSettings const received{initial.FrameSendRate, static_cast(max_ahead)};
+ if (!NetTiming::Timing_Settings_Are_Valid(received) || received != initial) {
+ continue;
+ }
+
+ Session.FrameSendRate = received.FrameSendRate;
+ Session.MaxAhead = received.MaxAhead;
+ } else {
+ Session.MaxAhead = Session.GPacket.ResponseTime.OneWay;
+ }
Session.HostAddress = Session.GAddress;
Session.NumPlayers = Session.Players.Count();
_netresponse = IDOK;
diff --git a/code/nettiming.cpp b/code/nettiming.cpp
index 3dee007f..4af20990 100644
--- a/code/nettiming.cpp
+++ b/code/nettiming.cpp
@@ -11,17 +11,55 @@
#include "nettiming.h"
#include
+#include
+#include
namespace NetTiming
{
namespace
{
+ /// Divides positive integers without losing a remainder.
+ constexpr std::uint64_t Divide_Round_Up(std::uint64_t numerator, std::uint64_t denominator)
+ {
+ return((numerator + denominator - 1) / denominator);
+ }
+
+
/// Constrains a retransmission timeout to the supported range.
constexpr Milliseconds Clamp_Rto(std::uint64_t value)
{
return(static_cast(std::clamp(value, MINIMUM_RTO, MAXIMUM_RTO)));
}
+
+
+ /// Selects timing for the current report census.
+ TimingSettings Desired_Settings(TimingCensus const & census, unsigned int target_fps, bool require_headroom)
+ {
+ if (census.RequiresConservativeTiming) {
+ return(TimingSettings{MAXIMUM_TIMING_RUNG, MAXIMUM_MAX_AHEAD});
+ }
+ if (census.ActivePlayers == 0) {
+ return(Settings_For_Rung(INITIAL_TIMING_RUNG));
+ }
+ return(Select_Timing_Settings(census.WorstRoundTrip, target_fps, require_headroom));
+ }
+
+
+ /// Checks whether settings increase the scheduling horizon.
+ bool Timing_Is_Worse(TimingSettings candidate, TimingSettings current)
+ {
+ return(candidate.FrameSendRate > current.FrameSendRate
+ || (candidate.FrameSendRate == current.FrameSendRate && candidate.MaxAhead > current.MaxAhead));
+ }
+
+
+ /// Checks whether settings reduce the scheduling horizon.
+ bool Timing_Is_Better(TimingSettings candidate, TimingSettings current)
+ {
+ return(candidate.FrameSendRate < current.FrameSendRate
+ || (candidate.FrameSendRate == current.FrameSendRate && candidate.MaxAhead < current.MaxAhead));
+ }
}
@@ -148,4 +186,462 @@ namespace NetTiming
: Milliseconds_Have_Elapsed(state.LastSend, now, current_rto);
return(decision);
}
+
+
+ /// Maps a policy rung to its balanced timing settings.
+ TimingSettings Settings_For_Rung(unsigned int rung)
+ {
+ rung = std::clamp(rung, MINIMUM_TIMING_RUNG, MAXIMUM_TIMING_RUNG);
+ return(TimingSettings{rung, rung == 1 ? 4u : 3u * rung});
+ }
+
+
+ /// Maps balanced timing settings to player-facing connection quality.
+ ConnectionQuality Connection_Quality_For_Settings(TimingSettings settings)
+ {
+ if (!Timing_Settings_Are_Valid(settings) || settings.MaxAhead > Settings_For_Rung(settings.FrameSendRate).MaxAhead) {
+ return(ConnectionQuality::Bad);
+ }
+ if (settings.FrameSendRate <= 2) {
+ return(ConnectionQuality::Fast);
+ }
+ if (settings.FrameSendRate <= 5) {
+ return(ConnectionQuality::Normal);
+ }
+ if (settings.FrameSendRate <= 8) {
+ return(ConnectionQuality::Poor);
+ }
+ return(ConnectionQuality::Bad);
+ }
+
+
+ /// Checks timing bounds and send-period alignment.
+ bool Timing_Settings_Are_Valid(TimingSettings settings)
+ {
+ TimingSettings const minimum = Settings_For_Rung(settings.FrameSendRate);
+ return(settings.FrameSendRate >= MINIMUM_TIMING_RUNG && settings.FrameSendRate <= MAXIMUM_TIMING_RUNG
+ && settings.MaxAhead >= minimum.MaxAhead && settings.MaxAhead <= MAXIMUM_MAX_AHEAD && settings.MaxAhead % settings.FrameSendRate == 0);
+ }
+
+
+ /// Accepts a legacy aligned horizon as the source of a safe transition.
+ bool Timing_Transition_Source_Is_Valid(TimingSettings settings)
+ {
+ return(settings.FrameSendRate >= MINIMUM_TIMING_RUNG && settings.FrameSendRate <= MAXIMUM_TIMING_RUNG
+ && settings.MaxAhead >= 2 * settings.FrameSendRate && settings.MaxAhead <= MAXIMUM_MAX_AHEAD
+ && settings.MaxAhead % settings.FrameSendRate == 0);
+ }
+
+
+ /// Rounds a scheduling horizon up to a complete send period.
+ std::optional Align_Max_Ahead(unsigned int required, unsigned int frame_send_rate)
+ {
+ if (frame_send_rate == 0) {
+ return(std::nullopt);
+ }
+
+ std::uint64_t const aligned = Divide_Round_Up(required, frame_send_rate) * frame_send_rate;
+ if (aligned > MAXIMUM_MAX_AHEAD) {
+ return(std::nullopt);
+ }
+ return(static_cast(aligned));
+ }
+
+
+ /// Chooses the lowest rung that covers the adjusted RTT.
+ TimingSettings Select_Timing_Settings(Milliseconds worst_round_trip, unsigned int target_fps, bool require_headroom)
+ {
+ target_fps = std::clamp(target_fps, 1u, 60u);
+
+ std::uint64_t adjusted = worst_round_trip;
+ if (require_headroom) {
+ adjusted = Divide_Round_Up(adjusted * 5, 4);
+ }
+
+ std::uint64_t const one_way_frames = Divide_Round_Up(adjusted * target_fps, 2000);
+ // A rung must cover one-way flight time plus a complete send period.
+ for (unsigned int rung = MINIMUM_TIMING_RUNG; rung < MAXIMUM_TIMING_RUNG; rung++) {
+ TimingSettings const settings = Settings_For_Rung(rung);
+ std::uint64_t const floor = 3ull * settings.FrameSendRate;
+ std::uint64_t const needed = std::max(floor, one_way_frames + settings.FrameSendRate);
+ if (needed > std::numeric_limits::max()) {
+ continue;
+ }
+
+ std::optional const aligned = Align_Max_Ahead(static_cast(needed), settings.FrameSendRate);
+ if (aligned && *aligned <= settings.MaxAhead) {
+ return(settings);
+ }
+ }
+
+ TimingSettings settings = Settings_For_Rung(MAXIMUM_TIMING_RUNG);
+ std::uint64_t const needed = std::max(settings.MaxAhead, one_way_frames + settings.FrameSendRate);
+ if (needed >= MAXIMUM_MAX_AHEAD) {
+ settings.MaxAhead = MAXIMUM_MAX_AHEAD - (MAXIMUM_MAX_AHEAD % settings.FrameSendRate);
+ } else {
+ settings.MaxAhead = *Align_Max_Ahead(static_cast(needed), settings.FrameSendRate);
+ }
+ return(settings);
+ }
+
+
+ /// Uses two early reports before settling on the normal cadence.
+ bool Report_Is_Due(std::uint32_t elapsed_frames)
+ {
+ return(elapsed_frames > 0 && ((elapsed_frames <= BOOTSTRAP_FIRST_EVALUATION && elapsed_frames % BOOTSTRAP_REPORT_INTERVAL == 0)
+ || elapsed_frames % REPORT_INTERVAL == 0));
+ }
+
+
+ /// Schedules two bootstrap evaluations and the steady-state cadence.
+ bool Evaluation_Is_Due(std::uint32_t elapsed_frames)
+ {
+ return(elapsed_frames == BOOTSTRAP_FIRST_EVALUATION || elapsed_frames == BOOTSTRAP_FINAL_EVALUATION
+ || (elapsed_frames > 0 && elapsed_frames % EVALUATION_INTERVAL == 0));
+ }
+
+
+ /// Clears the active-player report census.
+ void TimingReportCensus::Reset(void)
+ {
+ Reports = {};
+ }
+
+
+ /// Adds or removes a player from the census.
+ bool TimingReportCensus::Set_Player_Active(unsigned int player, bool active, std::uint32_t frame)
+ {
+ if (player >= Reports.size()) {
+ return(false);
+ }
+
+ PlayerReport & report = Reports[player];
+ if (report.Active != active) {
+ report = {};
+ report.Active = active;
+ report.ActiveSinceFrame = frame;
+ }
+ return(true);
+ }
+
+
+ /// Checks whether a player belongs to the timing census.
+ bool TimingReportCensus::Is_Player_Active(unsigned int player) const
+ {
+ return(player < Reports.size() && Reports[player].Active);
+ }
+
+
+ /// Records process time, optional RTT, and longest wait as one report.
+ bool TimingReportCensus::Record_Report(unsigned int player, Milliseconds process_milliseconds, std::optional round_trip, std::uint32_t frame,
+ Milliseconds stall_milliseconds)
+ {
+ if (player >= Reports.size() || !Reports[player].Active || process_milliseconds > MAXIMUM_PROCESS_MILLISECONDS
+ || (round_trip && *round_trip > MAXIMUM_REPORTED_RTT)) {
+ return(false);
+ }
+
+ PlayerReport & report = Reports[player];
+ report.HasReport = true;
+ report.HasRoundTrip = round_trip.has_value();
+ report.EverHadRoundTrip |= round_trip.has_value();
+ report.ProcessMilliseconds = process_milliseconds;
+ report.RoundTrip = round_trip.value_or(0);
+ report.StallMilliseconds = stall_milliseconds;
+ report.ReportFrame = frame;
+ return(true);
+ }
+
+
+ /// Summarizes fresh reports for a simulation frame.
+ TimingCensus TimingReportCensus::Inspect(std::uint32_t frame) const
+ {
+ TimingCensus result;
+ for (PlayerReport const & report : Reports) {
+ if (!report.Active) {
+ continue;
+ }
+
+ result.ActivePlayers++;
+ bool const fresh = report.HasReport && frame - report.ReportFrame < REPORT_EXPIRY;
+ if (fresh) {
+ result.FreshProcessReports++;
+ result.WorstProcessMilliseconds = std::max(result.WorstProcessMilliseconds, report.ProcessMilliseconds);
+ result.WorstStallMilliseconds = std::max(result.WorstStallMilliseconds, report.StallMilliseconds);
+ } else {
+ result.ProcessComplete = false;
+ }
+
+ if (fresh && report.HasRoundTrip) {
+ result.FreshRoundTripReports++;
+ result.WorstRoundTrip = std::max(result.WorstRoundTrip, report.RoundTrip);
+ } else {
+ result.RoundTripComplete = false;
+ // Only a link that has never been measured forces conservative timing; a measured
+ // link holds the current timing until its next report.
+ if (!report.EverHadRoundTrip && frame - report.ActiveSinceFrame >= REPORT_EXPIRY) {
+ result.RequiresConservativeTiming = true;
+ }
+ }
+ }
+ return(result);
+ }
+
+
+ /// Uses fresh process reports without discarding the synchronized frame rate.
+ unsigned int Select_Desired_Frame_Rate(TimingCensus const & census, unsigned int synchronized_fps, unsigned int game_speed_fps)
+ {
+ synchronized_fps = std::clamp(synchronized_fps, 1u, 60u);
+ game_speed_fps = std::clamp(game_speed_fps, 1u, 60u);
+ if (!census.ProcessComplete) {
+ return(synchronized_fps);
+ }
+
+ unsigned int const process_fps = census.WorstProcessMilliseconds == 0 ? 60u
+ : static_cast(std::max(1, 1000 / census.WorstProcessMilliseconds));
+ return(std::min(process_fps, game_speed_fps));
+ }
+
+
+ /// Restores the balanced policy's initial state.
+ void BalancedTimingPolicy::Reset(std::uint32_t frame)
+ {
+ CurrentRung = INITIAL_TIMING_RUNG;
+ CurrentSettings = Settings_For_Rung(INITIAL_TIMING_RUNG);
+ GoodEvaluations = 0;
+ BootstrapStartFrame = frame;
+ LastEvaluationFrame = frame;
+ LastChangeFrame = 0;
+ HasEvaluated = false;
+ HasChanged = false;
+ Bootstrapping = true;
+ ImprovementStreak = false;
+ }
+
+
+ /// Restores synchronized policy state after a master handoff.
+ void BalancedTimingPolicy::Reset_From(TimingSettings settings, std::uint32_t frame)
+ {
+ CurrentRung = std::clamp(settings.FrameSendRate, MINIMUM_TIMING_RUNG, MAXIMUM_TIMING_RUNG);
+ CurrentSettings = settings;
+ GoodEvaluations = 0;
+ LastEvaluationFrame = frame;
+ LastChangeFrame = frame;
+ HasEvaluated = true;
+ HasChanged = true;
+ Bootstrapping = false;
+ ImprovementStreak = false;
+ }
+
+
+ /// Commits a policy change and resets hysteresis.
+ void BalancedTimingPolicy::Change_To(TimingSettings settings, std::uint32_t frame)
+ {
+ ImprovementStreak = Timing_Is_Better(settings, CurrentSettings);
+ CurrentRung = std::clamp(settings.FrameSendRate, MINIMUM_TIMING_RUNG, MAXIMUM_TIMING_RUNG);
+ CurrentSettings = settings;
+ GoodEvaluations = 0;
+ LastChangeFrame = frame;
+ HasChanged = true;
+ }
+
+
+ /// Anchors steady-state evaluations to 256 frames after reset.
+ void BalancedTimingPolicy::Finish_Bootstrap(void)
+ {
+ Bootstrapping = false;
+ GoodEvaluations = 0;
+ ImprovementStreak = false;
+ LastEvaluationFrame = BootstrapStartFrame;
+ HasEvaluated = true;
+ }
+
+
+ /// Applies cadence, hysteresis, and improvement headroom.
+ TimingEvaluation BalancedTimingPolicy::Evaluate(TimingCensus const & census, unsigned int target_fps, std::uint32_t frame)
+ {
+ TimingEvaluation result{Current_Settings(), CurrentRung, false, false};
+ if (Bootstrapping) {
+ std::uint32_t const elapsed_frames = frame - BootstrapStartFrame;
+ if (elapsed_frames < BOOTSTRAP_FIRST_EVALUATION || (HasEvaluated && elapsed_frames < BOOTSTRAP_FINAL_EVALUATION)) {
+ return(result);
+ }
+
+ HasEvaluated = true;
+ LastEvaluationFrame = frame;
+ result.Evaluated = true;
+ bool const complete = census.ProcessComplete && census.RoundTripComplete;
+ if (census.RequiresConservativeTiming || complete || elapsed_frames >= BOOTSTRAP_FINAL_EVALUATION) {
+ TimingSettings const selected = census.RequiresConservativeTiming ? Desired_Settings(census, target_fps, false)
+ : complete ? Desired_Settings(census, target_fps, true) : Settings_For_Rung(BOOTSTRAP_FALLBACK_RUNG);
+ if (selected != CurrentSettings) {
+ Change_To(selected, frame);
+ result.Changed = true;
+ }
+ Finish_Bootstrap();
+ result.Settings = Current_Settings();
+ result.Rung = CurrentRung;
+ }
+ return(result);
+ }
+
+ if (HasEvaluated && frame - LastEvaluationFrame < EVALUATION_INTERVAL) {
+ return(result);
+ }
+
+ HasEvaluated = true;
+ LastEvaluationFrame = frame;
+ result.Evaluated = true;
+ if (!census.RequiresConservativeTiming && census.ActivePlayers > 0 && !census.RoundTripComplete) {
+ // A lapsed report holds the current timing, but the reports that did arrive can still worsen it.
+ GoodEvaluations = 0;
+ ImprovementStreak = false;
+ if (census.FreshRoundTripReports > 0) {
+ TimingSettings const desired_settings = Desired_Settings(census, target_fps, false);
+ if (Timing_Is_Worse(desired_settings, CurrentSettings)) {
+ Change_To(desired_settings, frame);
+ result.Changed = true;
+ result.Settings = Current_Settings();
+ result.Rung = CurrentRung;
+ }
+ }
+ return(result);
+ }
+
+ // Worsening is immediate; the first improvement must clear the headroom, waiting, cadence, and
+ // cooldown gates, and a descent then continues one rung per evaluation while the headroom holds.
+ TimingSettings const desired_settings = Desired_Settings(census, target_fps, false);
+ if (Timing_Is_Worse(desired_settings, CurrentSettings)) {
+ Change_To(desired_settings, frame);
+ result.Changed = true;
+ } else if (Timing_Is_Better(desired_settings, CurrentSettings) && census.WorstStallMilliseconds < STALL_IMPROVE_MILLISECONDS
+ && (!HasChanged || ImprovementStreak || frame - LastChangeFrame >= CHANGE_COOLDOWN)) {
+ TimingSettings const headroom = Desired_Settings(census, target_fps, true);
+ if (Timing_Is_Better(headroom, CurrentSettings)) {
+ GoodEvaluations++;
+ if (GoodEvaluations >= (ImprovementStreak ? DESCENT_EVALUATIONS_REQUIRED : GOOD_EVALUATIONS_REQUIRED)) {
+ TimingSettings const next = desired_settings.FrameSendRate < CurrentRung
+ ? Settings_For_Rung(CurrentRung - 1) : desired_settings;
+ Change_To(next, frame);
+ result.Changed = true;
+ }
+ } else {
+ GoodEvaluations = 0;
+ ImprovementStreak = false;
+ }
+ } else {
+ GoodEvaluations = 0;
+ ImprovementStreak = false;
+ }
+
+ result.Settings = Current_Settings();
+ result.Rung = CurrentRung;
+ return(result);
+ }
+
+
+ /// Delays decreases until the old scheduling horizon drains.
+ std::optional Stage_Timing_Update(TimingSettings current, TimingSettings requested, std::uint32_t event_frame)
+ {
+ if (!Timing_Transition_Source_Is_Valid(current) || !Timing_Settings_Are_Valid(requested)) {
+ return(std::nullopt);
+ }
+
+ if (requested.FrameSendRate > current.FrameSendRate && requested.MaxAhead < current.MaxAhead) {
+ std::optional const immediate_horizon = Align_Max_Ahead(current.MaxAhead, requested.FrameSendRate);
+ if (immediate_horizon) {
+ return(StagedTimingUpdate{requested, *immediate_horizon, event_frame, true});
+ }
+ }
+
+ bool const decrease = requested.FrameSendRate < current.FrameSendRate || requested.MaxAhead < current.MaxAhead;
+ if (!decrease) {
+ return(StagedTimingUpdate{requested, requested.MaxAhead, event_frame, false});
+ }
+
+ std::uint64_t const period = std::lcm(current.FrameSendRate, requested.FrameSendRate);
+ std::uint64_t const old_horizon = static_cast(event_frame) + current.MaxAhead;
+ std::uint64_t const activation = Divide_Round_Up(old_horizon, period) * period;
+ if (activation > std::numeric_limits::max()) {
+ return(std::nullopt);
+ }
+
+ unsigned int const minimum_horizon = std::max(requested.MaxAhead, current.MaxAhead - current.FrameSendRate);
+ std::optional const initial_max_ahead = Align_Max_Ahead(minimum_horizon, requested.FrameSendRate);
+ if (!initial_max_ahead) {
+ return(std::nullopt);
+ }
+
+ return(StagedTimingUpdate{requested, *initial_max_ahead, static_cast(activation), true});
+ }
+
+
+ /// Returns the first send boundary strictly after an event frame.
+ std::optional Next_Send_Boundary(std::uint32_t frame, unsigned int frame_send_rate)
+ {
+ if (frame_send_rate == 0) {
+ return(std::nullopt);
+ }
+
+ std::uint64_t const boundary = (static_cast(frame) / frame_send_rate + 1) * frame_send_rate;
+ if (boundary > std::numeric_limits::max()) {
+ return(std::nullopt);
+ }
+ return(static_cast(boundary));
+ }
+
+
+ /// Advances one catch-up step without dropping below the target horizon.
+ std::optional Next_Transition_Max_Ahead(TimingSettings current, TimingSettings requested)
+ {
+ if (!Timing_Settings_Are_Valid(current) || !Timing_Settings_Are_Valid(requested) || current.FrameSendRate != requested.FrameSendRate) {
+ return(std::nullopt);
+ }
+
+ if (current.MaxAhead <= requested.MaxAhead) {
+ return(requested.MaxAhead);
+ }
+ return(std::max(requested.MaxAhead, current.MaxAhead - requested.FrameSendRate));
+ }
+
+
+ /// Advances one deterministic drain or catch-up boundary.
+ std::optional Advance_Timing_Transition(TimingTransitionState & transition, TimingSettings current, std::uint32_t frame)
+ {
+ bool const current_is_valid = transition.Activated ? Timing_Settings_Are_Valid(current) : Timing_Transition_Source_Is_Valid(current);
+ if (!transition.Plan.Deferred || !current_is_valid || !Timing_Settings_Are_Valid(transition.Plan.Settings)
+ || !Timing_Settings_Are_Valid({transition.Plan.Settings.FrameSendRate, transition.Plan.InitialMaxAhead})) {
+ return(std::nullopt);
+ }
+
+ TimingTransitionAdvance result{current};
+ if (!transition.Activated) {
+ if (!Timing_Update_Is_Due(frame, transition.Plan.ActivationFrame)) {
+ return(result);
+ }
+ result.Settings = {transition.Plan.Settings.FrameSendRate, transition.Plan.InitialMaxAhead};
+ result.Changed = result.Settings != current;
+ transition.LastStepFrame = frame;
+ transition.Activated = true;
+ } else if (current.MaxAhead > transition.Plan.Settings.MaxAhead && frame > transition.LastStepFrame
+ && frame % transition.Plan.Settings.FrameSendRate == 0) {
+ std::optional const next = Next_Transition_Max_Ahead(current, transition.Plan.Settings);
+ if (!next) {
+ return(std::nullopt);
+ }
+ result.Settings.MaxAhead = *next;
+ result.Changed = result.Settings != current;
+ transition.LastStepFrame = frame;
+ }
+
+ result.Complete = transition.Activated && result.Settings == transition.Plan.Settings;
+ return(result);
+ }
+
+
+ /// Checks a staged activation frame with wraparound semantics.
+ bool Timing_Update_Is_Due(std::uint32_t frame, std::uint32_t activation_frame)
+ {
+ return(static_cast(frame - activation_frame) >= 0);
+ }
}
diff --git a/code/nettiming.h b/code/nettiming.h
index dcc141c9..f580b0d4 100644
--- a/code/nettiming.h
+++ b/code/nettiming.h
@@ -12,6 +12,11 @@
#include "nettime.h"
+#include
+#include
+#include
+#include
+
namespace NetTiming
{
@@ -21,6 +26,27 @@ namespace NetTiming
constexpr Milliseconds MAXIMUM_RTO = 4000;
constexpr Milliseconds MINIMUM_CONNECTION_TIMEOUT = 2000;
constexpr Milliseconds MAXIMUM_CONNECTION_TIMEOUT = 30000;
+ constexpr Milliseconds MAXIMUM_PROCESS_MILLISECONDS = 1000;
+ constexpr Milliseconds MAXIMUM_REPORTED_RTT = UINT16_MAX - 1u;
+
+ constexpr unsigned int MAX_TIMING_PLAYERS = 8;
+ constexpr unsigned int MINIMUM_TIMING_RUNG = 1;
+ constexpr unsigned int MAXIMUM_TIMING_RUNG = 10;
+ constexpr unsigned int INITIAL_TIMING_RUNG = 2;
+ constexpr unsigned int BOOTSTRAP_FALLBACK_RUNG = 3;
+ constexpr unsigned int MAXIMUM_MAX_AHEAD = 250;
+
+ constexpr std::uint32_t BOOTSTRAP_REPORT_INTERVAL = 32;
+ constexpr std::uint32_t BOOTSTRAP_FIRST_EVALUATION = 64;
+ constexpr std::uint32_t BOOTSTRAP_FINAL_EVALUATION = 128;
+ constexpr std::uint32_t REPORT_INTERVAL = 128;
+ constexpr std::uint32_t EVALUATION_INTERVAL = 256;
+ constexpr std::uint32_t CHANGE_COOLDOWN = 256;
+ constexpr std::uint32_t REPORT_EXPIRY = 512;
+ constexpr unsigned int GOOD_EVALUATIONS_REQUIRED = 3;
+ constexpr unsigned int DESCENT_EVALUATIONS_REQUIRED = 1;
+ // Longest single wait that still allows a step down.
+ constexpr Milliseconds STALL_IMPROVE_MILLISECONDS = 100;
struct RetryDecision
{
@@ -66,4 +92,136 @@ namespace NetTiming
unsigned int prior_retransmissions, Milliseconds maximum_delay = MAXIMUM_RTO);
RetryDecision Evaluate_Retry(RetransmitState const & state, Milliseconds now, Milliseconds current_rto, Milliseconds connection_timeout,
bool timeout_enabled, bool adaptive);
+
+ struct TimingSettings {
+ unsigned int FrameSendRate = INITIAL_TIMING_RUNG;
+ unsigned int MaxAhead = 3 * INITIAL_TIMING_RUNG;
+
+ bool operator==(TimingSettings const &) const = default;
+ };
+
+ enum class ConnectionQuality : unsigned char {
+ Bad,
+ Poor,
+ Normal,
+ Fast,
+ };
+
+ TimingSettings Settings_For_Rung(unsigned int rung);
+ ConnectionQuality Connection_Quality_For_Settings(TimingSettings settings);
+ bool Timing_Settings_Are_Valid(TimingSettings settings);
+ bool Timing_Transition_Source_Is_Valid(TimingSettings settings);
+ std::optional Align_Max_Ahead(unsigned int required, unsigned int frame_send_rate);
+ TimingSettings Select_Timing_Settings(Milliseconds worst_round_trip, unsigned int target_fps, bool require_headroom = false);
+ bool Report_Is_Due(std::uint32_t elapsed_frames);
+ bool Evaluation_Is_Due(std::uint32_t elapsed_frames);
+
+ struct TimingCensus {
+ unsigned int ActivePlayers = 0;
+ unsigned int FreshProcessReports = 0;
+ unsigned int FreshRoundTripReports = 0;
+ Milliseconds WorstProcessMilliseconds = 0;
+ Milliseconds WorstRoundTrip = 0;
+ Milliseconds WorstStallMilliseconds = 0;
+ bool ProcessComplete = true;
+ bool RoundTripComplete = true;
+ bool RequiresConservativeTiming = false;
+ };
+
+ class TimingReportCensus
+ {
+ public:
+ void Reset(void);
+ bool Set_Player_Active(unsigned int player, bool active, std::uint32_t frame);
+ bool Is_Player_Active(unsigned int player) const;
+ bool Record_Report(unsigned int player, Milliseconds process_milliseconds, std::optional round_trip, std::uint32_t frame,
+ Milliseconds stall_milliseconds = 0);
+ TimingCensus Inspect(std::uint32_t frame) const;
+
+ private:
+ struct PlayerReport {
+ bool Active = false;
+ bool HasReport = false;
+ bool HasRoundTrip = false;
+ bool EverHadRoundTrip = false;
+ Milliseconds ProcessMilliseconds = 0;
+ Milliseconds RoundTrip = 0;
+ Milliseconds StallMilliseconds = 0;
+ std::uint32_t ActiveSinceFrame = 0;
+ std::uint32_t ReportFrame = 0;
+ };
+
+ std::array Reports = {};
+ };
+
+ unsigned int Select_Desired_Frame_Rate(TimingCensus const & census, unsigned int synchronized_fps, unsigned int game_speed_fps);
+
+ struct TimingEvaluation {
+ TimingSettings Settings;
+ unsigned int Rung = INITIAL_TIMING_RUNG;
+ bool Evaluated = false;
+ bool Changed = false;
+ };
+
+ class BalancedTimingPolicy
+ {
+ public:
+ void Reset(std::uint32_t frame = 0);
+ void Reset_From(TimingSettings settings, std::uint32_t frame);
+ TimingEvaluation Evaluate(TimingCensus const & census, unsigned int target_fps, std::uint32_t frame);
+
+ unsigned int Current_Rung(void) const {return(CurrentRung);}
+ TimingSettings Current_Settings(void) const {return(CurrentSettings);}
+ unsigned int Good_Evaluations(void) const {return(GoodEvaluations);}
+ bool Is_Bootstrapping(void) const {return(Bootstrapping);}
+ std::uint32_t Cadence_Origin(void) const {return(BootstrapStartFrame);}
+
+ private:
+ void Change_To(TimingSettings settings, std::uint32_t frame);
+ void Finish_Bootstrap(void);
+
+ unsigned int CurrentRung = INITIAL_TIMING_RUNG;
+ TimingSettings CurrentSettings = {INITIAL_TIMING_RUNG, 3 * INITIAL_TIMING_RUNG};
+ unsigned int GoodEvaluations = 0;
+ std::uint32_t BootstrapStartFrame = 0;
+ std::uint32_t LastEvaluationFrame = 0;
+ std::uint32_t LastChangeFrame = 0;
+ bool HasEvaluated = false;
+ bool HasChanged = false;
+ bool Bootstrapping = true;
+ // Set by an improvement; while it holds, each evaluation with headroom steps one more rung.
+ bool ImprovementStreak = false;
+ };
+
+ struct StagedTimingUpdate {
+ TimingSettings Settings;
+ unsigned int InitialMaxAhead = 0;
+ std::uint32_t ActivationFrame = 0;
+ bool Deferred = false;
+ };
+
+ struct TimingTransitionState {
+ StagedTimingUpdate Plan;
+ std::uint32_t LastStepFrame = 0;
+ bool Activated = false;
+ };
+
+ struct TimingTransitionAdvance {
+ TimingSettings Settings;
+ bool Changed = false;
+ bool Complete = false;
+ };
+
+ enum class ScheduleResult
+ {
+ Rejected,
+ Applied,
+ Staged,
+ };
+
+ std::optional Stage_Timing_Update(TimingSettings current, TimingSettings requested, std::uint32_t event_frame);
+ std::optional Next_Send_Boundary(std::uint32_t frame, unsigned int frame_send_rate);
+ std::optional Next_Transition_Max_Ahead(TimingSettings current, TimingSettings requested);
+ std::optional Advance_Timing_Transition(TimingTransitionState & transition, TimingSettings current, std::uint32_t frame);
+ bool Timing_Update_Is_Due(std::uint32_t frame, std::uint32_t activation_frame);
}
diff --git a/code/queue.cpp b/code/queue.cpp
index 831e1140..f7048f3a 100644
--- a/code/queue.cpp
+++ b/code/queue.cpp
@@ -128,6 +128,7 @@
#include "netglobal.h"
#include "netpacket.h"
#include "netshare.h"
+#include "nettiming.h"
#include "opents_build.h"
#include "overlay.h"
#include "overtype.h"
@@ -272,6 +273,12 @@ FrameSyncStruct SyncBarFrameSync[MAX_PLAYERS - 1];
BasicTimerClass SentFrameSyncTimer;
FrameSyncStruct TheirFrameSync[MAX_PLAYERS - 1];
unsigned short SentCommandCount; // # cmds I've sent out
+// Frame of the previous Execute_DoList call; a send-period decrease can skip an event's frame.
+static int LastExecutedFrame = -1;
+
+// A frame packet requests an acknowledgement at least this often while a link has no clean round-trip measurement.
+constexpr int ROUND_TRIP_PROBE_FRAMES = 32;
+static int LastRoundTripProbeFrame = -ROUND_TRIP_PROBE_FRAMES;
static std::array(NetPacket::DecodeError::COUNT)>
NetworkPacketDrops = {};
@@ -303,9 +310,8 @@ static void Queue_AI_Multiplayer(void);
static RetcodeType Wait_For_Players(int first_time, ConnManClass *net,
int resend_delta, int dialog_time, int timeout, char *multi_packet_buf,
int multi_packet_max, int my_sent, FrameSyncStruct *their);
-static void Generate_Timing_Event(ConnManClass *net, int my_sent);
-static void Generate_Real_Timing_Event(ConnManClass *net, int my_sent);
-static void Generate_Process_Time_Event(ConnManClass *net);
+static void Generate_Real_Timing_Event(void);
+static void Generate_Network_Report_Event(ConnManClass *net);
static int Process_Send_Period(ConnManClass *net); //, int init);
static int Send_Packets(ConnManClass *net, char *multi_packet_buf,
int multi_packet_max, int max_ahead, int my_sent);
@@ -322,7 +328,7 @@ BOOL CALLBACK Reconnect_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LP
static void Close_Reconnect_Dialog(void);
void Kick_Player_Now(ConnManClass *net, int kickee, FrameSyncStruct * their, bool error);
bool Cast_Kick_Vote(int kicker, int kickee);
-void Multiplayer_Debug_Print(bool noframecheck);
+void Multiplayer_Debug_Print(void);
//...........................................................................
// Packet compression/decompression:
@@ -487,6 +493,11 @@ bool Queue_Exit(void)
*=========================================================================*/
void Queue_AI(void)
{
+ if (Frame >= 0 && Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP
+ && (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET)) {
+ Session.Advance_Network_Timing(static_cast(Frame));
+ }
+
if (Session.Play) {
Queue_Playback();
}
@@ -728,6 +739,8 @@ static void Queue_AI_Multiplayer(void)
// If we've just started a game, or loaded a multiplayer game, we must
// wait for all other systems to signal ready.
//------------------------------------------------------------------------
+ std::uint32_t const network_timing_frame = Frame > 0
+ ? static_cast(Frame) - Session.NetworkTimingPolicy.Cadence_Origin() : 0;
if (Frame==0 || Session.LoadGame) {
//.....................................................................
// Initialize static locals
@@ -739,6 +752,8 @@ static void Queue_AI_Multiplayer(void)
}
skip_crc = Frame + ARRAY_SIZE(CRC);
SentCommandCount = 0;
+ LastExecutedFrame = Frame - 1;
+ LastRoundTripProbeFrame = Frame - ROUND_TRIP_PROBE_FRAMES;
for (i = 0; i < ARRAY_SIZE(CRC); i++)
CRC[i] = 0;
@@ -824,36 +839,16 @@ static void Queue_AI_Multiplayer(void)
} // end of Frame 0 wait
- //------------------------------------------------------------------------
- // Adjust connection timing parameters every 128 frames.
- //------------------------------------------------------------------------
-
- else if ( (Frame & 0x007f) == 0) {
- //
- // If we're using the new spiffy protocol, do proper timing handling.
- // If we're the net "master", compute our desired frame rate & new
- // 'MaxAhead' value.
- //
- //if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) {
-
- //
- // All systems will transmit their required process time.
- //
- Generate_Process_Time_Event(net);
-
- //} else {
- // //
- // // For the older protocols, do the old broken timing handling.
- // //
- // Generate_Timing_Event(net, SentCommandCount);
- // }
+ // Compressed games report sooner during bootstrap, then use the steady cadence.
+ else if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP && Frame > 0 && NetTiming::Report_Is_Due(network_timing_frame)) {
+ Generate_Network_Report_Event(net);
}
- //
- // The game "host" will transmit timing adjustment events.
- //
- if (Session.Am_I_Master() && (Session.PrecalcMaxAhead != 0 || Session.PrecalcDesiredFrameRate != 0 || !(char)Frame)) {
- Generate_Real_Timing_Event(net, SentCommandCount);
+ // The deterministic master evaluates bootstrap and steady-state reports.
+ int const timing_master = Session.Master_Player_ID();
+ if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP && PlayerPtr != NULL && PlayerPtr->HeapID == timing_master
+ && Frame > 0 && NetTiming::Evaluation_Is_Due(network_timing_frame)) {
+ Generate_Real_Timing_Event();
}
//------------------------------------------------------------------------
@@ -1411,7 +1406,7 @@ static RetcodeType Wait_For_Players(int first_time, ConnManClass *net,
*/
int show_stall = 1;
if (Session.ShowInternetDebug && loop_count > 0 && (!stall_drawn || frame_stall != -1 || count_stall != -1)) {
- Multiplayer_Debug_Print(true);
+ Multiplayer_Debug_Print();
} else if (stall_drawn) {
show_stall = 0;
}
@@ -1453,6 +1448,9 @@ static RetcodeType Wait_For_Players(int first_time, ConnManClass *net,
} /* end of while */
+ if (!first_time && (int)timer > Session.WorstStallTicks) {
+ Session.WorstStallTicks = (int)timer;
+ }
if (reconnect_dlg) {
Close_Reconnect_Dialog();
}
@@ -1465,322 +1463,89 @@ static RetcodeType Wait_For_Players(int first_time, ConnManClass *net,
} // end of Wait_For_Players
-/***************************************************************************
- * Generate_Timing_Event -- computes & queues a RESPONSE_TIME event *
- * *
- * This routine adjusts the connection timing on the local system; it also *
- * optionally generates a RESPONSE_TIME event, to tell all systems to *
- * dynamically adjust the current MaxAhead value. This allows both the *
- * MaxAhead & the connection retry logic to have dynamic timing, to adjust *
- * to varying line conditions. *
- * *
- * INPUT: *
- * net ptr to connection manager *
- * my_sent # commands I've sent out so far *
- * *
- * OUTPUT: *
- * none. *
- * *
- * WARNINGS: *
- * none. *
- * *
- * HISTORY: *
- * 11/21/1995 BRR : Created. *
- *=========================================================================*/
-static void Generate_Timing_Event(ConnManClass *net, int my_sent)
+/// Maps the validated game-speed setting to its historical frame-rate target.
+static int Game_Speed_Frame_Rate(void)
{
- unsigned int resp_time; // connection response time, in ticks
- EventClass ev;
-
- //------------------------------------------------------------------------
- // Measure the current connection response time. This time will be in
- // 60ths of a second, and represents full round-trip time of a packet.
- // To convert to one-way packet time, divide by 2; to convert to game
- // frames, divide again by 4, assuming a game rate of 15 fps.
- //------------------------------------------------------------------------
- resp_time = net->Response_Time();
-
- //------------------------------------------------------------------------
- // Adjust my connection retry timing; only do this if I've sent out more
- // than 5 commands, so I know I have a measure of the response time.
- //------------------------------------------------------------------------
- if (my_sent > 5) {
-
- net->Set_Timing (resp_time + TIMER_SECOND / 6, -1, (resp_time * 4) + TIMER_SECOND / 4);
-
- //.....................................................................
- // If I'm the network "master", I'm also responsible for updating the
- // MaxAhead value on all systems, so do that here too.
- //.....................................................................
- if (Session.Am_I_Master()) {
- ev.Type = EventClass::RESPONSE_TIME;
- //..................................................................
- // For multi-frame compressed events, the MaxAhead must be an even
- // multiple of the FrameSendRate.
- //..................................................................
- if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) {
- ev.Data.FrameInfo.Delay = std::max( ((((resp_time / 8) +
- (Session.FrameSendRate - 1)) / Session.FrameSendRate) *
- Session.FrameSendRate), (Session.FrameSendRate * 2) );
- }
- //..................................................................
- // For sending packets every frame, just use the 1-way connection
- // response time.
- //..................................................................
- else {
- if (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET) {
- ev.Data.FrameInfo.Delay = std::max( (resp_time / 8),
- NETWORK_MIN_MAX_AHEAD );
- }
- }
- OutList.push_back(ev);
- }
+ switch (Options.GameSpeed) {
+ case 0: return(60);
+ case 1: return(45);
+ case 2: return(30);
+ case 3: return(20);
+ case 4: return(15);
+ case 5: return(12);
+ case 6: return(10);
+ default: return(60);
}
-
-} // end of Generate_Timing_Event
+}
-/***************************************************************************
- * Generate_Real_Timing_Event -- Generates a TIMING event *
- * *
- * INPUT: *
- * net ptr to connection manager *
- * my_sent # commands I've sent out so far *
- * *
- * OUTPUT: *
- * none. *
- * *
- * WARNINGS: *
- * none. *
- * *
- * HISTORY: *
- * 07/02/1996 BRR : Created. *
- *=========================================================================*/
-static void Generate_Real_Timing_Event(ConnManClass *net, int my_sent)
+/// Queues timing selected from the synchronized report census.
+static void Generate_Real_Timing_Event(void)
{
- unsigned int resp_time; // connection response time, in ticks
- EventClass ev;
- int highest_ticks;
- int i;
- int specified_frame_rate;
- int maxahead;
- unsigned char frame_send_rate;
-
- if (Session.PrecalcMaxAhead != 0 || Session.PrecalcDesiredFrameRate != 0) {
- DebugString("Sending precalculated network timings on frame %d\n", Frame);
-
- ev.Type = EventClass::TIMING;
- ev.Data.Timing.DesiredFrameRate = Session.PrecalcDesiredFrameRate;
- ev.Data.Timing.MaxAhead = Session.PrecalcMaxAhead;
- ev.Data.Timing.FrameSendRate = Session.PrecalcDesiredFrameRate > 30u ? 10 : 5;
-
- OutList.push_back(ev);
-
- Session.PrecalcMaxAhead = 0;
- Session.PrecalcDesiredFrameRate = 0;
-
+ if (Frame < 0) {
return;
}
-
- //
- // If we haven't sent out at least 5 guaranteed-delivery packets, don't
- // bother trying to measure our connection response time; just return.
- //
- if (my_sent < 5) {
+ unsigned int const frame = static_cast(Frame);
+ int const master_id = Session.Master_Player_ID();
+ if (PlayerPtr == NULL || PlayerPtr->HeapID != master_id) {
return;
}
-
- //
- // Find the highest processing time we have stored
- //
- highest_ticks = 0;
- for (i = 0; i < Session.Players.Count(); i++) {
-
- //
- // If we haven't heard from all systems yet, bail out.
- //
- if (Session.Players[i]->Player.ProcessTime == -1) {
- return;
- }
- if (Session.Players[i]->Player.ProcessTime > highest_ticks) {
- highest_ticks = Session.Players[i]->Player.ProcessTime;
- }
- }
-
- //
- // Compute our "desired" frame rate as the lower of:
- // - What the user has dialed into the options screen
- // - What we're really able to run at
- //
- if (highest_ticks == 0) {
- Session.DesiredFrameRate = 60;
- } else {
- Session.DesiredFrameRate = std::max(1, 1000 / highest_ticks);
- }
-
- switch (Options.GameSpeed) {
- case 0:
- specified_frame_rate = 60;
- break;
- case 1:
- specified_frame_rate = 45;
- break;
- default:
- specified_frame_rate = 60 / Options.GameSpeed;
- break;
- }
-
- Session.DesiredFrameRate = std::min(Session.DesiredFrameRate, specified_frame_rate);
-
- //
- // Measure the current connection response time. This time will be in
- // 60ths of a second, and represents full round-trip time of a packet.
- // To convert to one-way packet time, divide by 2; to convert to game
- // frames, ....uh....
- //
- resp_time = net->Response_Time();
- frame_send_rate = Session.FrameSendRate;
- if (Session.Type == GAME_INTERNET) {
- frame_send_rate = Session.DesiredFrameRate > 30 ? 10 : 5;
- }
-
- int fudge = 0;
- if (resp_time != 0) {
- switch (Session.LatencyFudge) {
- case 0:
- DebugString("Response time = %d\n", resp_time);
- break;
- case 1:
- resp_time += resp_time >> 1;
- fudge = 10;
- DebugString("Response time = %d\n", resp_time);
- break;
- case 2:
- resp_time *= 2;
- fudge = 20;
- DebugString("Response time = %d\n", resp_time);
- break;
- case 3:
- resp_time *= 3;
- fudge = 30;
- DebugString("Response time = %d\n", resp_time);
- break;
- }
+ Session.Prepare_Network_Timing_Master(master_id, frame);
+
+ NetTiming::TimingCensus const census = Session.Network_Timing_Census(frame);
+ unsigned int const desired_frame_rate = NetTiming::Select_Desired_Frame_Rate(census,
+ static_cast(std::clamp(Session.DesiredFrameRate, 1, 60)), static_cast(Game_Speed_Frame_Rate()));
+ NetTiming::TimingEvaluation const evaluation = Session.Evaluate_Network_Timing(census, desired_frame_rate, frame);
+ if (evaluation.Evaluated) {
+ DebugString("Network timing evaluation at frame %u: %u of %u reports fresh, worst process %u ms, RTT %u ms%s, wait %u ms, %u fps -> %s %u/%u\n",
+ frame, census.FreshProcessReports, census.ActivePlayers, (unsigned int)census.WorstProcessMilliseconds, (unsigned int)census.WorstRoundTrip,
+ census.RequiresConservativeTiming ? " (never measured)" : census.RoundTripComplete ? "" : " (incomplete)",
+ (unsigned int)census.WorstStallMilliseconds, desired_frame_rate, evaluation.Changed ? "change to" : "keep",
+ evaluation.Settings.FrameSendRate, evaluation.Settings.MaxAhead);
+ }
+ // Comparing against applied state resends timing the session never adopted.
+ if (!evaluation.Evaluated || (evaluation.Settings == Session.Network_Timing_Target()
+ && desired_frame_rate == static_cast(Session.DesiredFrameRate))) {
+ return;
}
- //
- // Compute our new 'MaxAhead' value, based upon the response time of our
- // connection and our desired frame rate.
- // 'MaxAhead' in frames is:
- //
- // (resp_time / 2 ticks) * (1 sec/60 ticks) * (n Frames / sec)
- //
- // resp_time is divided by 2 because, as reported, it represents a round-
- // trip, and we only want to use a one-way trip.
- //
- maxahead = frame_send_rate + (resp_time * Session.DesiredFrameRate) / (2 * TIMER_SECOND);
-
- //
- // Now, we have to round 'maxahead' so it's an even multiple of our
- // send rate. It also must be at least thrice the FrameSendRate.
- // (Isn't "thrice" a cool word?)
- //
- maxahead = ((maxahead + fudge - 1) / frame_send_rate) * frame_send_rate;
- maxahead = std::max(maxahead, (int)frame_send_rate * 3);
- maxahead = std::min(maxahead, frame_send_rate * ((frame_send_rate + 249) / frame_send_rate));
-
- ev.Type = EventClass::TIMING;
- ev.Data.Timing.DesiredFrameRate = Session.DesiredFrameRate;
- ev.Data.Timing.MaxAhead = maxahead + (Scen->Special.IsFogOfWar ? 10 : 0);
- ev.Data.Timing.FrameSendRate = frame_send_rate;
-
- OutList.push_back(ev);
-
- //
- // Adjust my connection retry timing. These values set the retry timeout
- // to just over one round-trip time, the 'maxretries' to -1, and the
- // connection timeout to allow for about 4 retries.
- //
- if (Session.Players.Count() == 1 && resp_time == 0) {
- resp_time = TIMER_SECOND / 2;
- }
- net->Set_Timing (resp_time + TIMER_SECOND / 6, -1, std::max(2 * TIMER_SECOND, (resp_time*8) + TIMER_SECOND / 4), false);
+ EventClass event;
+ memset(&event, 0, sizeof(event));
+ event.Type = EventClass::TIMING;
+ event.Data.Timing.DesiredFrameRate = desired_frame_rate;
+ event.Data.Timing.MaxAhead = evaluation.Settings.MaxAhead;
+ event.Data.Timing.FrameSendRate = evaluation.Settings.FrameSendRate;
+ OutList.push_back(event);
}
-/***************************************************************************
- * Generate_Process_Time_Event -- Generates a PROCESS_TIME event *
- * *
- * INPUT: *
- * net ptr to connection manager *
- * *
- * OUTPUT: *
- * none. *
- * *
- * WARNINGS: *
- * none. *
- * *
- * HISTORY: *
- * 07/02/1996 BRR : Created. *
- *=========================================================================*/
-static void Generate_Process_Time_Event(ConnManClass *net)
+/// Queues the local process-time, waiting-time and worst-RTT report.
+static void Generate_Network_Report_Event(ConnManClass *net)
{
- EventClass ev;
- int avgticks;
- unsigned int resp_time; // connection response time, in ticks
-
- //
- // Measure the current connection response time. This time will be in
- // 60ths of a second, and represents full round-trip time of a packet.
- // To convert to one-way packet time, divide by 2; to convert to game
- // frames, ....uh....
- //
- resp_time = net->Response_Time();
-
- //
- // Adjust my connection retry timing. These values set the retry timeout
- // to just over one round-trip time, the 'maxretries' to -1, and the
- // connection timeout to allow for about 4 retries.
- //
- switch (Session.LatencyFudge) {
- case 0:
- DebugString("Response time = %d\n", resp_time);
- break;
- case 1:
- resp_time += resp_time >> 1;
- DebugString("Response time = %d\n", resp_time);
- break;
- case 2:
- resp_time *= 2;
- DebugString("Response time = %d\n", resp_time);
- break;
- case 3:
- resp_time *= 3;
- DebugString("Response time = %d\n", resp_time);
- break;
- }
- net->Set_Timing (resp_time + TIMER_SECOND / 6, -1, std::max(2 * TIMER_SECOND, (resp_time * 8) + TIMER_SECOND / 4), false);
-
- if (IsMono) {
- MonoClass::Enable();
- Mono_Set_Cursor(0,23);
- Mono_Printf("Processing Ticks:%03d Frames:%03d\n", Session.ProcessTicks,Session.ProcessFrames);
- MonoClass::Disable();
+ if (Session.ProcessFrames <= 0) {
+ return;
}
- avgticks = Session.ProcessTicks / Session.ProcessFrames;
+ int const average_process_milliseconds = std::clamp(Session.ProcessTicks / Session.ProcessFrames, 0,
+ static_cast(NetTiming::MAXIMUM_PROCESS_MILLISECONDS));
+ std::optional const worst_round_trip = net->Worst_Local_Round_Trip_MS();
- ev.Type = EventClass::PROCESS_TIME;
- ev.Data.ProcessTime.AverageTicks = avgticks;
- OutList.push_back(ev);
+ EventClass event;
+ memset(&event, 0, sizeof(event));
+ event.Type = EventClass::NETWORK_REPORT;
+ event.Data.NetworkReport.AverageProcessMilliseconds = static_cast(average_process_milliseconds);
+ event.Data.NetworkReport.WorstRoundTripMilliseconds = !worst_round_trip || *worst_round_trip >= EventClass::NETWORK_RTT_UNAVAILABLE
+ ? EventClass::NETWORK_RTT_UNAVAILABLE : static_cast(*worst_round_trip);
+ // Evaluations run every other report, so each report covers the last two intervals.
+ int const worst_stall_ticks = std::max(Session.WorstStallTicks, Session.PreviousWorstStallTicks);
+ event.Data.NetworkReport.StallMilliseconds = static_cast(std::clamp(worst_stall_ticks * 1000 / TIMER_SECOND, 0, 65535));
+ OutList.push_back(event);
Session.ProcessTicks = 0;
Session.ProcessFrames = 0;
-
- if (Session.Type == GAME_INTERNET && (Frame & 0x3FF) == 0) {
- net->Reset_Response_Time(false);
- }
+ Session.PreviousWorstStallTicks = Session.WorstStallTicks;
+ Session.WorstStallTicks = 0;
}
@@ -1884,6 +1649,10 @@ static int Send_Packets(ConnManClass *net, char *multi_packet_buf,
else {
ack_req = 1;
}
+ if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP && Session.NumPlayers > 1
+ && Frame - LastRoundTripProbeFrame >= ROUND_TRIP_PROBE_FRAMES && !net->Worst_Local_Round_Trip_MS()) {
+ ack_req = 1;
+ }
//.....................................................................
// Build & send out our message
@@ -1896,6 +1665,9 @@ static int Send_Packets(ConnManClass *net, char *multi_packet_buf,
if (processed) {
ack_req = 1;
}
+ if (ack_req) {
+ LastRoundTripProbeFrame = Frame;
+ }
net->Send_Private_Message (multi_packet_buf, packetlen, ack_req);
SentFrameSyncCount++;
@@ -3579,6 +3351,8 @@ static int Execute_DoList(int max_houses, HousesType base_house,
int i,j,k;
int index;
int check_crc;
+ int const previous_execution_frame = LastExecutedFrame;
+ LastExecutedFrame = Frame;
#if (TIMING_FIX)
//
@@ -3649,7 +3423,7 @@ static int Execute_DoList(int max_houses, HousesType base_house,
// Error if it's too late to execute this packet!
// (Hack: disable this check for solo or skirmish mode.)
//...............................................................
- if (Frame > DoList[j].Frame && DoList[j].Type !=
+ if (DoList[j].Frame <= previous_execution_frame && DoList[j].Type !=
EventClass::FRAMEINFO && Session.Type != GAME_NORMAL &&
Session.Type != GAME_SKIRMISH) {
Dump_Packet_Too_Late_Stuff(&DoList[j]);
@@ -3977,6 +3751,9 @@ static void Queue_Playback(void)
// routine didn't write anything the first time through); do this after the
// CRC is computed, since we'll still need a CRC for Frame 0.
//------------------------------------------------------------------------
+ if (Frame == 0) {
+ LastExecutedFrame = -1;
+ }
if (Frame==0 && Session.Type!=GAME_NORMAL) {
return;
}
diff --git a/code/session.cpp b/code/session.cpp
index 4782befb..69073b16 100644
--- a/code/session.cpp
+++ b/code/session.cpp
@@ -194,12 +194,13 @@ SessionClass::SessionClass(void)
MaxAhead = FrameSendRate * 3;
MaxMaxAhead = MaxAhead;
+ NetworkTimingReports.Reset();
+ NetworkTimingPolicy.Reset(0);
+ PendingNetworkTiming.reset();
+ NetworkTimingPolicyOwner = -1;
memset(ConnectionStats, 0, sizeof(ConnectionStats));
- PrecalcMaxAhead = 0;
- PrecalcDesiredFrameRate = 0;
-
ShowInternetDebug = false;
LoadGame = 0;
@@ -380,6 +381,8 @@ int SessionClass::Create_Connections(void)
}
}
+ Reset_Network_Timing(Frame >= 0 ? static_cast(Frame) : 0u);
+
DebugString("Leaving Create_Connections\n");
return(1);
@@ -440,13 +443,31 @@ bool SessionClass::Am_I_Master(void)
} // end of Am_I_Master
-/// Returns the first active network-human house in deterministic order.
+/// Returns the synchronized timing authority, selecting it initially and after accepted removal.
int SessionClass::Master_Player_ID(void) const
{
+ if (CommProtocol == COMM_PROTOCOL_MULTI_E_COMP && NetworkTimingPolicyOwner >= 0) {
+ for (int i = 0; i < Houses.Count(); i++) {
+ HouseClass const * house = Houses[i];
+ if (house != NULL && house->HeapID == NetworkTimingPolicyOwner && house->IsHuman
+ && Is_Network_Timing_Player_Active(house->HeapID)) {
+ return(house->HeapID);
+ }
+ }
+
+ for (int i = 0; i < Houses.Count(); i++) {
+ HouseClass const * house = Houses[i];
+ if (house != NULL && house->IsHuman && Is_Network_Timing_Player_Active(house->HeapID)) {
+ return(house->HeapID);
+ }
+ }
+ return(-1);
+ }
+
if (Type == GAME_INTERNET) {
for (int i = 0; i < Houses.Count(); i++) {
HouseClass const * house = Houses[i];
- if (house == NULL || !house->IsHuman) {
+ if (house == NULL || !house->IsHuman || !Is_Network_Timing_Player_Active(house->HeapID)) {
continue;
}
if ((MasterPlayerID >= 0 && house->HeapID == MasterPlayerID)
@@ -458,7 +479,7 @@ int SessionClass::Master_Player_ID(void) const
for (int i = 0; i < Houses.Count(); i++) {
HouseClass const * house = Houses[i];
- if (house != NULL && house->IsHuman) {
+ if (house != NULL && house->IsHuman && Is_Network_Timing_Player_Active(house->HeapID)) {
return(house->HeapID);
}
}
@@ -466,6 +487,198 @@ int SessionClass::Master_Player_ID(void) const
}
+/// Tests synchronized timing-roster membership.
+bool SessionClass::Is_Network_Timing_Player_Active(int id) const
+{
+ return(id >= 0 && id < static_cast(NetTiming::MAX_TIMING_PLAYERS) && NetworkTimingReports.Is_Player_Active(id));
+}
+
+
+/// Starts a fresh adaptive-timing census from the synchronized initial roster.
+void SessionClass::Reset_Network_Timing(unsigned int frame)
+{
+ if (CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) {
+ NetTiming::TimingSettings const initial = NetTiming::Settings_For_Rung(NetTiming::INITIAL_TIMING_RUNG);
+ FrameSendRate = initial.FrameSendRate;
+ MaxAhead = initial.MaxAhead;
+ MaxMaxAhead = MaxAhead;
+ }
+ NetworkTimingReports.Reset();
+ NetworkTimingPolicy.Reset(frame);
+ PendingNetworkTiming.reset();
+ NetworkTimingPolicyOwner = -1;
+
+ for (int i = 0; i < Players.Count(); i++) {
+ int const id = Players[i] != NULL ? Players[i]->Player.ID : -1;
+ if (id >= 0 && id < static_cast(NetTiming::MAX_TIMING_PLAYERS)) {
+ NetworkTimingReports.Set_Player_Active(id, true, frame);
+ }
+ }
+ Prepare_Network_Timing_Master(Master_Player_ID(), frame);
+}
+
+
+/// Validates and records a seated player's synchronized timing report.
+bool SessionClass::Record_Network_Report(int id, unsigned int process_milliseconds, unsigned int round_trip_milliseconds, unsigned int stall_milliseconds,
+ unsigned int frame)
+{
+ std::optional round_trip;
+ if (round_trip_milliseconds != EventClass::NETWORK_RTT_UNAVAILABLE) {
+ round_trip = round_trip_milliseconds;
+ }
+ if (!NetworkTimingReports.Record_Report(id, process_milliseconds, round_trip, frame, stall_milliseconds)) {
+ return(false);
+ }
+
+ for (int i = 0; i < Players.Count(); i++) {
+ if (Players[i] != NULL && Players[i]->Player.ID == id) {
+ Players[i]->Player.ProcessTime = process_milliseconds;
+ break;
+ }
+ }
+ return(true);
+}
+
+
+/// Removes a departed player from the timing census.
+void SessionClass::Remove_Network_Timing_Player(int id, unsigned int frame)
+{
+ if (Is_Network_Timing_Player_Active(id)) {
+ NetworkTimingReports.Set_Player_Active(id, false, frame);
+ Prepare_Network_Timing_Master(Master_Player_ID(), frame);
+ }
+}
+
+
+/// Returns a freshness-aware census of seated players.
+NetTiming::TimingCensus SessionClass::Network_Timing_Census(unsigned int frame)
+{
+ return(NetworkTimingReports.Inspect(frame));
+}
+
+
+/// Evaluates the adaptive-timing policy against the current census.
+NetTiming::TimingEvaluation SessionClass::Evaluate_Network_Timing(NetTiming::TimingCensus const & census, unsigned int target_fps, unsigned int frame)
+{
+ return(NetworkTimingPolicy.Evaluate(census, target_fps, frame));
+}
+
+
+/// Returns the synchronized target behind any active transition.
+NetTiming::TimingSettings SessionClass::Network_Timing_Target(void) const
+{
+ return(PendingNetworkTiming ? PendingNetworkTiming->Timing.Plan.Settings : NetTiming::TimingSettings{FrameSendRate, MaxAhead});
+}
+
+
+/// Rebases adaptive policy state when deterministic timing authority changes.
+void SessionClass::Prepare_Network_Timing_Master(int master_id, unsigned int frame)
+{
+ if (master_id == NetworkTimingPolicyOwner) {
+ return;
+ }
+ if (NetworkTimingPolicyOwner >= 0 && master_id >= 0) {
+ NetworkTimingPolicy.Reset_From(Network_Timing_Target(), frame);
+ }
+ NetworkTimingPolicyOwner = master_id;
+}
+
+
+/// Reconciles a legacy response-time update with adaptive state.
+void SessionClass::Apply_Network_Response_Time(unsigned int max_ahead, unsigned int event_frame)
+{
+ PendingNetworkTiming.reset();
+ MaxAhead = max_ahead;
+ MaxMaxAhead = std::max(MaxMaxAhead, static_cast(MaxAhead));
+ if (CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) {
+ NetworkTimingPolicy.Reset_From({FrameSendRate, MaxAhead}, event_frame);
+ }
+}
+
+
+/// Applies a timing increase or safely stages a decrease.
+NetTiming::ScheduleResult SessionClass::Schedule_Network_Timing(NetTiming::TimingSettings settings, unsigned int desired_frame_rate, unsigned int event_frame)
+{
+ if (desired_frame_rate == 0 || desired_frame_rate > 60 || !NetTiming::Timing_Settings_Are_Valid(settings)) {
+ return(NetTiming::ScheduleResult::Rejected);
+ }
+
+ NetTiming::TimingSettings const current{FrameSendRate, MaxAhead};
+ if (!NetTiming::Timing_Transition_Source_Is_Valid(current)) {
+ return(NetTiming::ScheduleResult::Rejected);
+ }
+
+ if (PendingNetworkTiming && settings == PendingNetworkTiming->Timing.Plan.Settings) {
+ PendingNetworkTiming->DesiredFrameRate = desired_frame_rate;
+ if (PendingNetworkTiming->Timing.Activated) {
+ DesiredFrameRate = desired_frame_rate;
+ }
+ return(NetTiming::ScheduleResult::Staged);
+ }
+
+ std::optional const staged = NetTiming::Stage_Timing_Update(current, settings, event_frame);
+ if (!staged) {
+ return(NetTiming::ScheduleResult::Rejected);
+ }
+ if (staged->Deferred) {
+ NetworkTimingTransition transition;
+ transition.Timing.Plan = *staged;
+ transition.DesiredFrameRate = desired_frame_rate;
+ if (staged->ActivationFrame == event_frame) {
+ std::optional const first_send_boundary = NetTiming::Next_Send_Boundary(event_frame, settings.FrameSendRate);
+ if (!first_send_boundary) {
+ return(NetTiming::ScheduleResult::Rejected);
+ }
+ transition.Timing.Activated = true;
+ transition.Timing.LastStepFrame = *first_send_boundary;
+ DesiredFrameRate = desired_frame_rate;
+ FrameSendRate = settings.FrameSendRate;
+ MaxAhead = staged->InitialMaxAhead;
+ MaxMaxAhead = std::max(MaxMaxAhead, static_cast(MaxAhead));
+ PendingNetworkTiming = transition;
+ return(NetTiming::ScheduleResult::Applied);
+ }
+ PendingNetworkTiming = transition;
+ return(NetTiming::ScheduleResult::Staged);
+ }
+
+ PendingNetworkTiming.reset();
+ DesiredFrameRate = desired_frame_rate;
+ FrameSendRate = settings.FrameSendRate;
+ MaxAhead = settings.MaxAhead;
+ MaxMaxAhead = std::max(MaxMaxAhead, static_cast(MaxAhead));
+ return(NetTiming::ScheduleResult::Applied);
+}
+
+
+/// Advances a deterministic drain/catch-up timing transition.
+bool SessionClass::Advance_Network_Timing(unsigned int frame)
+{
+ if (!PendingNetworkTiming) {
+ return(false);
+ }
+
+ NetworkTimingTransition & transition = *PendingNetworkTiming;
+ bool const was_activated = transition.Timing.Activated;
+ std::optional const advance = NetTiming::Advance_Timing_Transition(
+ transition.Timing, {FrameSendRate, MaxAhead}, frame);
+ if (!advance || !advance->Changed) {
+ return(false);
+ }
+
+ if (!was_activated && transition.Timing.Activated) {
+ DesiredFrameRate = transition.DesiredFrameRate;
+ }
+ FrameSendRate = advance->Settings.FrameSendRate;
+ MaxAhead = advance->Settings.MaxAhead;
+ MaxMaxAhead = std::max(MaxMaxAhead, static_cast(MaxAhead));
+ if (advance->Complete) {
+ PendingNetworkTiming.reset();
+ }
+ return(true);
+}
+
+
/***************************************************************************
* SessionClass::Read_MultiPlayer_Settings -- reads settings INI *
* *
diff --git a/code/session.h b/code/session.h
index 519b9f38..489671cc 100644
--- a/code/session.h
+++ b/code/session.h
@@ -38,6 +38,7 @@
#include "house.h" /// needed for HOUSE_NAME_MAX
#include "ipxaddr.h"
#include "msglist.h"
+#include "nettiming.h"
#include "special.h"
#include "sun.h" /// needed for MAX_PLAYERS
#include "typelist.h"
@@ -50,6 +51,8 @@
#include "dialog.hh"
#include "diff.hh"
+#include
+
//---------------------------------------------------------------------------
// Forward declarations
//---------------------------------------------------------------------------
@@ -455,6 +458,12 @@ class SessionClass
// Public interface
//------------------------------------------------------------------------
public:
+ struct NetworkTimingTransition
+ {
+ NetTiming::TimingTransitionState Timing;
+ unsigned int DesiredFrameRate = 30;
+ };
+
//.....................................................................
// Constructor/Destructor
//.....................................................................
@@ -481,6 +490,18 @@ class SessionClass
int Create_Connections(void);
bool Am_I_Master(void);
int Master_Player_ID(void) const;
+ bool Is_Network_Timing_Player_Active(int id) const;
+ void Reset_Network_Timing(unsigned int frame);
+ bool Record_Network_Report(int id, unsigned int process_milliseconds, unsigned int round_trip_milliseconds, unsigned int stall_milliseconds,
+ unsigned int frame);
+ void Remove_Network_Timing_Player(int id, unsigned int frame);
+ NetTiming::TimingCensus Network_Timing_Census(unsigned int frame);
+ NetTiming::TimingEvaluation Evaluate_Network_Timing(NetTiming::TimingCensus const & census, unsigned int target_fps, unsigned int frame);
+ NetTiming::TimingSettings Network_Timing_Target(void) const;
+ void Prepare_Network_Timing_Master(int master_id, unsigned int frame);
+ void Apply_Network_Response_Time(unsigned int max_ahead, unsigned int event_frame);
+ NetTiming::ScheduleResult Schedule_Network_Timing(NetTiming::TimingSettings settings, unsigned int desired_frame_rate, unsigned int event_frame);
+ bool Advance_Network_Timing(unsigned int frame);
unsigned int Compute_Unique_ID(void);
void Update_Progress(int percent);
void Init_Fixed_Alliances(void);
@@ -559,12 +580,19 @@ class SessionClass
//.....................................................................
unsigned int MaxAhead;
unsigned int FrameSendRate;
+ NetTiming::TimingReportCensus NetworkTimingReports;
+ NetTiming::BalancedTimingPolicy NetworkTimingPolicy;
+ std::optional PendingNetworkTiming;
+ int NetworkTimingPolicyOwner;
int DesiredFrameRate;
int ProcessTimer;
int ProcessTicks;
int ProcessFrames;
+ // Longest single wait for other players in ticks; reports cover two intervals.
+ int WorstStallTicks;
+ int PreviousWorstStallTicks;
/*
* This is the largest MaxAhead the game has run at, since the value only ever grows.
@@ -572,14 +600,6 @@ class SessionClass
*/
int MaxMaxAhead;
- /*
- * These are the frame timings Westwood Online worked out from the players' connection
- * speeds. While either is non-zero the host sends them out instead of measuring the
- * connections itself, and clears both once it has.
- */
- int PrecalcMaxAhead;
- int PrecalcDesiredFrameRate;
-
/*
* These are the network statistics gathered for each player over the course of the
* game. They feed the network diagnostics display and the sync bug report.
@@ -724,11 +744,7 @@ class SessionClass
*/
int PlayerLatency[MAX_PLAYERS];
- /*
- * This scales up the measured connection response time when the frame timing is
- * computed (0 - 3), buying tolerance of a laggy link at the cost of responsiveness.
- */
- int LatencyFudge;
+ int LatencyFudge; // Legacy synchronized option retained for event and replay compatibility.
//.....................................................................
// For finding Sync Bugs
diff --git a/code/startup.cpp b/code/startup.cpp
index 22199f90..64e939ae 100644
--- a/code/startup.cpp
+++ b/code/startup.cpp
@@ -603,11 +603,6 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho
}
}
- if (Session.ShowInternetDebug) {
- Options.ScreenWidth = 640;
- Options.ScreenHeight = 400;
- }
-
if (Options.ScreenWidth == -1 || Options.ScreenHeight == -1) {
Options.ScreenWidth = 640;
Options.ScreenHeight = 480;
diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md
new file mode 100644
index 00000000..68045226
--- /dev/null
+++ b/manual/changes/adaptive-network-timing.md
@@ -0,0 +1,35 @@
+---
+title: Adapt multiplayer timing to every connection
+category: performance
+release: 0.2.0
+targets:
+- type: system
+ id: network-synchronization
+ effect: added
+credit:
+- ZivDero
+---
+
+Compressed games start at a two-frame send period with six frames of look-ahead,
+then calibrate from every player's process time and worst local round trip.
+Early reports can select the measured target after 64 frames; incomplete
+calibration falls back to `3/9` after 128 frames.
+
+Worsening applies immediately. Recovery needs sustained headroom for its first
+step and then continues one rung per evaluation; timing decreases drain the old
+scheduling horizon before stepping down on aligned send boundaries. An event
+scheduled for a frame that a decrease skips executes on the next send frame,
+and a player whose measured RTT lapses holds the current timing instead of
+selecting `10/250`. Reports also carry each player's longest wait for the
+others, and improvement waits until nobody has waited 0.1 s or longer. The
+inherited per-frame slowdown for a lagging player is removed; at adaptive send
+periods it ran on every frame.
+
+The disabled WOL Connection slider shows the effective 1–10 rung and tier; the
+message list announces target-tier changes. Game speed remains separate.
+`LATENCYFUDGE` stays in the replay layout but is no longer emitted or used by
+the adaptive policy.
+
+`NETWORK_REPORT` extends network events and multiplayer recordings. Players and
+recordings therefore require the same OpenTS snapshot; existing event IDs are
+unchanged and no configuration migration is needed.
diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md
new file mode 100644
index 00000000..e121cb5c
--- /dev/null
+++ b/manual/content/systems/network-synchronization.md
@@ -0,0 +1,72 @@
+---
+title: Network synchronization
+summary: Adapts synchronized command delay to measured link and processing conditions.
+category: multiplayer-networking
+keys: []
+---
+
+Network games exchange commands tagged with the simulation frame on which every
+machine executes them. Look-ahead gives those commands time to arrive, while the
+send period controls how often compressed packets are emitted. Packet validation
+is covered by [Network packet validation](/systems/network-packet-validation/);
+per-link RTT and retry behavior belongs to
+[Network transport timing](/systems/network-transport-timing/).
+
+## Adaptive policy
+
+Compressed matches begin at `2/6`: a two-frame send period and six-frame
+look-ahead. Each player reports process time, its longest wait for other
+players, and optional worst-local RTT after 32 and 64 frames, then every
+128 frames. A player omits the RTT while any of its
+links has no [clean measurement](/systems/network-transport-timing/); a link
+keeps reporting its last measurement while it retransmits. The deterministic
+master evaluates at 64 and 128 frames, then every 256 frames.
+
+A report is one atomic process/RTT record and expires after 512 frames. A
+player whose RTT never appears within that time selects the conservative
+`10/250` target. An established player whose report expires or omits the RTT
+holds the current timing, though fresh reports from other players can still
+worsen it. Stale process data retains the last synchronized frame rate.
+Membership comes from the initial synchronized roster, and accepted removal
+clears that player's report.
+
+The first complete census may select its measured target with 20% headroom.
+Incomplete bootstrap falls back to `3/9` after 128 frames. Later worsening is
+immediate. The first improvement needs three evaluations with 20% headroom and
+no wait of 0.1 s or longer within any player's last two report intervals;
+while both hold, each following evaluation steps one more rung. A worsening or
+an evaluation without headroom or with such a wait restores the
+three-evaluation requirement.
+
+Timing decreases activate only after the old horizon drains on a frame aligned
+to both send periods. They switch rate with temporary look-ahead, then remove
+one new send period at each boundary. An event already scheduled for a frame
+that the new send period skips executes on the next send frame, identically on
+every machine. Replacement targets rebase this process; local connection
+teardown does not transfer authority. Accepted removal selects the first
+remaining human, which inherits the target and restarts the cooldown.
+
+Frame pacing follows the desired frame rate alone. The inherited slowdown that
+stretched every frame by up to 30 ms while a player's newest frame packet
+looked a quarter of the look-ahead old is gone: at the adaptive send periods
+that packet is always at least that old, so the slowdown ran on every frame
+and held the game well under its frame rate on an idle link.
+
+## Player feedback
+
+The disabled Connection slider shows the effective send-period rung, mirrored
+so that its right end is rung 1; the label beside it names the tier and the
+rung. Rungs 1–2 are Fast, 3–5 Normal, 6–8 Poor, and 9–10 Bad; extended
+look-ahead is also Bad.
+The message list announces target-tier changes, which may precede a safely
+staged improvement. The Speed slider continues to control game speed.
+
+Adaptive timing uses measured RTT directly. The legacy `LATENCYFUDGE` event and
+session field remain for replay compatibility, but the menu no longer emits it
+and the adaptive policy does not consume it.
+
+## Compatibility
+
+`NETWORK_REPORT` extends network events and multiplayer recordings. All players
+must use the same OpenTS snapshot, and recordings should be played by the
+snapshot that wrote them. Existing event IDs retain their values.
diff --git a/manual/content/systems/network-transport-timing.md b/manual/content/systems/network-transport-timing.md
index 4dbfc84a..a49160a8 100644
--- a/manual/content/systems/network-transport-timing.md
+++ b/manual/content/systems/network-transport-timing.md
@@ -9,7 +9,10 @@ Each private connection maintains smoothed round trip, variation, and a retry
timeout. Acknowledgements of first transmissions are the measurements. Until a
link has one, its first acknowledgement seeds a provisional estimate even after
a retry, so a link slower than the initial retry delay becomes measurable; the
-first clean acknowledgement replaces the seed.
+first clean acknowledgement replaces the seed. In a compressed game, a frame
+packet requests an acknowledgement at least every 32 frames while any link
+still lacks a clean measurement, so a quiet player's links are measured
+before the first timing evaluations.
The retry timeout is limited to 100–4000 ms. Repeated private transmissions
double their wait up to the connection timeout; that timeout follows measured
diff --git a/tests/netpacket/netcontract.cpp b/tests/netpacket/netcontract.cpp
index 7d13b4f6..08bafbcf 100644
--- a/tests/netpacket/netcontract.cpp
+++ b/tests/netpacket/netcontract.cpp
@@ -30,6 +30,7 @@ namespace {
using Bytes = std::vector;
using VariableDataType = decltype(std::declval().Data.Variable);
+using NetworkReportType = decltype(std::declval().Data.NetworkReport);
constexpr int Sender = 3;
constexpr int Frame = 120;
@@ -151,8 +152,11 @@ void Test_Reader(void)
void Test_Event_Contract(void)
{
Check(EventClass::LATENCYFUDGE == 35, "the last inherited event keeps numeric ID 35");
- Check(EventClass::LAST_EVENT == 36, "the decoder preserves the inherited event range");
- Check(sizeof(EventClass) == 46 && EnvelopeSize == 17, "full and envelope event layouts match the legacy wire");
+ Check(EventClass::NETWORK_REPORT == 36 && EventClass::LAST_EVENT == 37, "the timing report appends without renumbering inherited events");
+ Check(EventClass::EventLength[EventClass::NETWORK_REPORT] == sizeof(NetworkReportType) && sizeof(NetworkReportType) == 6, "NETWORK_REPORT uses its six-byte payload");
+ Check(std::strcmp(EventClass::EventNames[EventClass::NETWORK_REPORT], "NETWORK_REPORT") == 0, "NETWORK_REPORT has a diagnostic name");
+ Check(EventClass::NETWORK_RTT_UNAVAILABLE == UINT16_MAX, "the unavailable RTT sentinel is uint16 max");
+ Check(sizeof(EventClass) == 46 && EnvelopeSize == 17, "the report fits without changing full or envelope event layouts");
}
@@ -358,6 +362,22 @@ void Test_Full_Compressed_Table(void)
Check(decoded_response.Succeeded() && decoded_response.Events.size() == 2
&& decoded_response.Events[1].Event.Data.FrameInfo.Delay == 42,
"RESPONSE_TIME materializes its byte at FrameInfo.Delay");
+
+ Bytes report = Compressed_Packet();
+ std::uint16_t const average = 17;
+ std::uint16_t const worst = 240;
+ std::uint16_t const stalled = 350;
+ Bytes report_data;
+ Append_Value(report_data, average);
+ Append_Value(report_data, worst);
+ Append_Value(report_data, stalled);
+ Add_Compressed_Event(report, EventClass::NETWORK_REPORT, report_data);
+ NetPacket::DecodeResult decoded_report = NetPacket::Decode_Event_Packet(report, NetPacket::Encoding::COMPRESSED, Sender);
+ Check(decoded_report.Succeeded() && decoded_report.Events.size() == 2
+ && decoded_report.Events[1].Event.Data.NetworkReport.AverageProcessMilliseconds == average
+ && decoded_report.Events[1].Event.Data.NetworkReport.WorstRoundTripMilliseconds == worst
+ && decoded_report.Events[1].Event.Data.NetworkReport.StallMilliseconds == stalled,
+ "NETWORK_REPORT preserves all three millisecond fields");
}
diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp
index a791beb2..8b05e137 100644
--- a/tests/nettiming/nettiming.cpp
+++ b/tests/nettiming/nettiming.cpp
@@ -13,7 +13,11 @@
#include
#include
#include
+#include
+#include
#include
+#include
+#include
namespace
@@ -372,6 +376,651 @@ namespace
recovered.Note_Retransmit(1800);
Expect_Equal("stale capture doubles the RTO once", recovered.Retransmit_Timeout(), 1504u);
}
+
+
+ void Test_Census(void)
+ {
+ using namespace NetTiming;
+
+ TimingReportCensus census;
+ Expect("activate first peer", census.Set_Player_Active(1, true, 100));
+ Expect("activate second peer", census.Set_Player_Active(2, true, 100));
+ Expect("reject out of range peer", !census.Set_Player_Active(MAX_TIMING_PLAYERS, true, 100));
+ Expect("active membership is queryable", census.Is_Player_Active(1));
+ Expect("out of range membership is inactive", !census.Is_Player_Active(MAX_TIMING_PLAYERS));
+ Expect("record first peer", census.Record_Report(1, 12, 80, 100));
+ Expect("record second peer", census.Record_Report(2, 20, 180, 100));
+ Expect("accept RTT above retransmit clamp", census.Record_Report(2, 20, MAXIMUM_RTO + 1, 100));
+ Expect("reject process time beyond engine range", !census.Record_Report(2, MAXIMUM_PROCESS_MILLISECONDS + 1, 100, 150));
+ Expect("reject RTT beyond wire range", !census.Record_Report(2, 1, MAXIMUM_REPORTED_RTT + 1, 150));
+
+ TimingCensus result = census.Inspect(200);
+ Expect_Equal("active peer count", result.ActivePlayers, 2u);
+ Expect_Equal("fresh process report count", result.FreshProcessReports, 2u);
+ Expect_Equal("fresh RTT report count", result.FreshRoundTripReports, 2u);
+ Expect_Equal("worst process time", result.WorstProcessMilliseconds, 20u);
+ Expect_Equal("unequal links publish worst", result.WorstRoundTrip, MAXIMUM_RTO + 1);
+ Expect("fresh process census complete", result.ProcessComplete);
+ Expect("fresh RTT census complete", result.RoundTripComplete);
+ Expect("fresh census is not conservative", !result.RequiresConservativeTiming);
+ BalancedTimingPolicy aggregate;
+ TimingEvaluation const guest_degradation = aggregate.Evaluate(result, 60, 200);
+ Expect("a guest-to-guest slow path worsens the master policy", guest_degradation.Changed && guest_degradation.Rung == MAXIMUM_TIMING_RUNG);
+
+ result = census.Inspect(100 + REPORT_EXPIRY);
+ Expect("process reports expire on boundary", !result.ProcessComplete);
+ Expect("RTT reports expire on boundary", !result.RoundTripComplete);
+ Expect("expired established RTT holds instead of forcing conservative timing", !result.RequiresConservativeTiming);
+ Expect_Equal("expired process reports not fresh", result.FreshProcessReports, 0u);
+ Expect_Equal("expired RTT reports not fresh", result.FreshRoundTripReports, 0u);
+ Expect_Equal("expired process time excluded", result.WorstProcessMilliseconds, 0u);
+
+ Expect("departed peer removed", census.Set_Player_Active(2, false, 700));
+ Expect("remaining peer refreshed", census.Record_Report(1, 15, 90, 700));
+ result = census.Inspect(700);
+ Expect("departure restores complete process census", result.ProcessComplete);
+ Expect("departure restores complete RTT census", result.RoundTripComplete);
+ Expect_Equal("departed peer excluded", result.ActivePlayers, 1u);
+ Expect_Equal("remaining peer wins census", result.WorstRoundTrip, 90u);
+
+ Expect("established unavailable RTT report accepted", census.Record_Report(1, 16, std::nullopt, 701));
+ result = census.Inspect(701);
+ Expect("unavailable RTT retains fresh process time", result.ProcessComplete && result.FreshProcessReports == 1);
+ Expect("established unavailable RTT is incomplete", !result.RoundTripComplete);
+ Expect("established unavailable RTT is not conservative", !result.RequiresConservativeTiming);
+
+ TimingReportCensus grace;
+ Expect("activate grace peer", grace.Set_Player_Active(3, true, 1000));
+ Expect("process-only initial report is accepted", grace.Record_Report(3, 30, std::nullopt, 1000));
+ result = grace.Inspect(1000 + REPORT_EXPIRY - 1);
+ Expect("process-only report remains complete before expiry", result.ProcessComplete);
+ Expect("missing initial RTT is tolerated before expiry", !result.RequiresConservativeTiming);
+ result = grace.Inspect(1000 + REPORT_EXPIRY);
+ Expect("never-valid RTT becomes conservative at exact expiry", result.RequiresConservativeTiming);
+ Expect("never-valid RTT remains incomplete", !result.RoundTripComplete);
+ Expect("process data expires with its report", !result.ProcessComplete);
+ Expect_Equal("stale process data retains synchronized FPS", Select_Desired_Frame_Rate(result, 42, 60), 42u);
+ TimingCensus fresh_process;
+ fresh_process.WorstProcessMilliseconds = 50;
+ Expect_Equal("fresh process data respects game-speed FPS", Select_Desired_Frame_Rate(fresh_process, 42, 15), 15u);
+ fresh_process.WorstProcessMilliseconds = 0;
+ Expect_Equal("zero process time permits 60 FPS", Select_Desired_Frame_Rate(fresh_process, 42, 60), 60u);
+
+ TimingReportCensus atomic;
+ atomic.Set_Player_Active(4, true, 0);
+ Expect("atomic baseline report accepted", atomic.Record_Report(4, 25, 125, 10));
+ Expect("invalid process report rejected atomically", !atomic.Record_Report(4, MAXIMUM_PROCESS_MILLISECONDS + 1, 200, 20));
+ Expect("invalid RTT report rejected atomically", !atomic.Record_Report(4, 50, MAXIMUM_REPORTED_RTT + 1, 20));
+ result = atomic.Inspect(20);
+ Expect_Equal("invalid report preserves process time", result.WorstProcessMilliseconds, 25u);
+ Expect_Equal("invalid report preserves RTT", result.WorstRoundTrip, 125u);
+ Expect("removing a peer clears its complete report", atomic.Set_Player_Active(4, false, 30));
+ Expect_Equal("removed peer no longer contributes", atomic.Inspect(30).ActivePlayers, 0u);
+ Expect("reactivated peer starts with a clean report", atomic.Set_Player_Active(4, true, 40));
+ result = atomic.Inspect(40);
+ Expect("reactivated peer has no inherited process report", !result.ProcessComplete);
+ Expect("reactivated peer receives fresh RTT grace", !result.RequiresConservativeTiming);
+ }
+
+
+ void Test_Rungs(void)
+ {
+ using namespace NetTiming;
+
+ Expect_Equal("initial FSR", Settings_For_Rung(INITIAL_TIMING_RUNG).FrameSendRate, 2u);
+ Expect_Equal("initial MaxAhead", Settings_For_Rung(INITIAL_TIMING_RUNG).MaxAhead, 6u);
+ Expect("default settings match the bootstrap rung", TimingSettings{} == Settings_For_Rung(INITIAL_TIMING_RUNG));
+ Expect_Equal("best rung MaxAhead", Settings_For_Rung(1).MaxAhead, 4u);
+ Expect_Equal("worst rung MaxAhead", Settings_For_Rung(10).MaxAhead, 30u);
+ Expect("rung settings valid", Timing_Settings_Are_Valid(Settings_For_Rung(10)));
+ Expect("below-rung minimum invalid", !Timing_Settings_Are_Valid({3, 6}));
+ Expect("legacy two-period horizon can source a transition", Timing_Transition_Source_Is_Valid({3, 6}));
+ Expect("unaligned settings invalid", !Timing_Settings_Are_Valid({3, 10}));
+
+ Expect_Equal("zero RTT selects best rung", Select_Timing_Settings(0, 60).FrameSendRate, 1u);
+ Expect_Equal("100 ms fits best rung", Select_Timing_Settings(100, 60).FrameSendRate, 1u);
+ Expect_Equal("101 ms advances a rung", Select_Timing_Settings(101, 60).FrameSendRate, 2u);
+ Expect_Equal("300 ms selects balanced rung", Select_Timing_Settings(300, 60).FrameSendRate, 5u);
+ TimingSettings const high_rtt = Select_Timing_Settings(2000, 60);
+ Expect_Equal("two-second RTT selects highest FSR", high_rtt.FrameSendRate, 10u);
+ Expect_Equal("two-second RTT carries needed aligned MaxAhead", high_rtt.MaxAhead, 70u);
+ TimingSettings const capped = Select_Timing_Settings(MAXIMUM_REPORTED_RTT, 60);
+ Expect_Equal("wire-maximum RTT selects highest FSR", capped.FrameSendRate, 10u);
+ Expect_Equal("highest rung caps at largest aligned horizon", capped.MaxAhead, 250u);
+
+ Expect("alignment rejects zero period", !Align_Max_Ahead(10, 0));
+ Expect_Equal("alignment reaches cap", *Align_Max_Ahead(249, 10), 250u);
+ Expect("alignment rejects over cap", !Align_Max_Ahead(250, 9));
+ }
+
+
+ void Test_Connection_Quality(void)
+ {
+ using namespace NetTiming;
+
+ Expect("rung one reports fast", Connection_Quality_For_Settings(Settings_For_Rung(1)) == ConnectionQuality::Fast);
+ Expect("rung two reports fast", Connection_Quality_For_Settings(Settings_For_Rung(2)) == ConnectionQuality::Fast);
+ Expect("rung three reports normal", Connection_Quality_For_Settings(Settings_For_Rung(3)) == ConnectionQuality::Normal);
+ Expect("rung five reports normal", Connection_Quality_For_Settings(Settings_For_Rung(5)) == ConnectionQuality::Normal);
+ Expect("rung six reports poor", Connection_Quality_For_Settings(Settings_For_Rung(6)) == ConnectionQuality::Poor);
+ Expect("rung eight reports poor", Connection_Quality_For_Settings(Settings_For_Rung(8)) == ConnectionQuality::Poor);
+ Expect("rung nine reports bad", Connection_Quality_For_Settings(Settings_For_Rung(9)) == ConnectionQuality::Bad);
+ Expect("rung ten reports bad", Connection_Quality_For_Settings(Settings_For_Rung(10)) == ConnectionQuality::Bad);
+ Expect("bootstrap settings report fast", Connection_Quality_For_Settings({2, 6}) == ConnectionQuality::Fast);
+ Expect("fallback settings report normal", Connection_Quality_For_Settings({3, 9}) == ConnectionQuality::Normal);
+ Expect("extended conservative settings report bad", Connection_Quality_For_Settings({10, 250}) == ConnectionQuality::Bad);
+ Expect("invalid settings report bad", Connection_Quality_For_Settings({0, 0}) == ConnectionQuality::Bad);
+ Expect("extended fast-rung horizon reports bad", Connection_Quality_For_Settings({2, 8}) == ConnectionQuality::Bad);
+ }
+
+
+ void Record_One(NetTiming::TimingReportCensus & census, NetTiming::Milliseconds rtt, std::uint32_t frame)
+ {
+ census.Record_Report(1, 10, rtt, frame);
+ }
+
+
+ void Test_Bootstrap_Cadence(void)
+ {
+ using namespace NetTiming;
+
+ Expect("frame zero does not report", !Report_Is_Due(0));
+ Expect("bootstrap reports at frame 32", Report_Is_Due(32));
+ Expect("bootstrap reports at frame 64", Report_Is_Due(64));
+ Expect("bootstrap does not add a frame 96 report", !Report_Is_Due(96));
+ Expect("normal reports start at frame 128", Report_Is_Due(128));
+ Expect("normal reports continue at frame 256", Report_Is_Due(256));
+ Expect("off-cadence reports remain disabled", !Report_Is_Due(385));
+
+ Expect("frame zero does not evaluate", !Evaluation_Is_Due(0));
+ Expect("reports alone do not evaluate at frame 32", !Evaluation_Is_Due(32));
+ Expect("bootstrap evaluates at frame 64", Evaluation_Is_Due(64));
+ Expect("bootstrap evaluates again at frame 128", Evaluation_Is_Due(128));
+ Expect("normal evaluations start at frame 256", Evaluation_Is_Due(256));
+ Expect("frame 384 is not an evaluation", !Evaluation_Is_Due(384));
+ Expect("normal evaluations continue at frame 512", Evaluation_Is_Due(512));
+ }
+
+
+ void Test_Bootstrap_Policy(void)
+ {
+ using namespace NetTiming;
+
+ TimingReportCensus low_reports;
+ low_reports.Set_Player_Active(1, true, 0);
+ BalancedTimingPolicy low;
+ Expect("new policy starts in bootstrap", low.Is_Bootstrapping());
+ Expect("bootstrap starts at 2/6", low.Current_Settings() == TimingSettings{2, 6});
+ TimingEvaluation result = low.Evaluate(low_reports.Inspect(32), 60, 32);
+ Expect("bootstrap does not evaluate before frame 64", !result.Evaluated);
+ Record_One(low_reports, 0, 38);
+ result = low.Evaluate(low_reports.Inspect(64), 60, 64);
+ Expect("complete low-latency census finishes at frame 64", result.Evaluated && result.Changed && !low.Is_Bootstrapping());
+ Expect("low-latency bootstrap jumps directly to 1/4", low.Current_Settings() == TimingSettings{1, 4});
+ result = low.Evaluate(low_reports.Inspect(255), 60, 255);
+ Expect("steady evaluation remains anchored before frame 256", !result.Evaluated);
+ Record_One(low_reports, 0, 256);
+ result = low.Evaluate(low_reports.Inspect(256), 60, 256);
+ Expect("steady evaluation is anchored at frame 256", result.Evaluated && !result.Changed);
+
+ Expect("100 ms would select 1/4 without bootstrap headroom", Select_Timing_Settings(100, 60, false) == TimingSettings{1, 4});
+ Expect("100 ms retains 2/6 with bootstrap headroom", Select_Timing_Settings(100, 60, true) == TimingSettings{2, 6});
+ TimingReportCensus marginal_reports;
+ marginal_reports.Set_Player_Active(1, true, 0);
+ Record_One(marginal_reports, 100, 38);
+ BalancedTimingPolicy marginal;
+ result = marginal.Evaluate(marginal_reports.Inspect(64), 60, 64);
+ Expect("marginal bootstrap completes without changing 2/6", result.Evaluated && !result.Changed && !marginal.Is_Bootstrapping());
+
+ TimingReportCensus high_reports;
+ high_reports.Set_Player_Active(1, true, 0);
+ Record_One(high_reports, 2000, 38);
+ BalancedTimingPolicy high;
+ result = high.Evaluate(high_reports.Inspect(64), 60, 64);
+ Expect("high-latency bootstrap worsens directly", result.Changed && high.Current_Settings() == TimingSettings{10, 90});
+
+ TimingReportCensus delayed_reports;
+ delayed_reports.Set_Player_Active(1, true, 0);
+ delayed_reports.Record_Report(1, 10, std::nullopt, 38);
+ BalancedTimingPolicy delayed;
+ result = delayed.Evaluate(delayed_reports.Inspect(64), 60, 64);
+ Expect("incomplete frame 64 census keeps bootstrap open", result.Evaluated && !result.Changed && delayed.Is_Bootstrapping());
+ delayed_reports.Record_Report(1, 10, 0, 70);
+ result = delayed.Evaluate(delayed_reports.Inspect(100), 60, 100);
+ Expect("completed census waits for frame 128", !result.Evaluated && delayed.Is_Bootstrapping());
+ result = delayed.Evaluate(delayed_reports.Inspect(128), 60, 128);
+ Expect("second bootstrap evaluation accepts a complete census", result.Evaluated && result.Changed && !delayed.Is_Bootstrapping());
+ Expect("frame 128 completion selects the measured target", delayed.Current_Settings() == TimingSettings{1, 4});
+
+ TimingReportCensus incomplete_reports;
+ incomplete_reports.Set_Player_Active(1, true, 0);
+ incomplete_reports.Record_Report(1, 10, std::nullopt, 38);
+ BalancedTimingPolicy incomplete;
+ incomplete.Evaluate(incomplete_reports.Inspect(64), 60, 64);
+ incomplete_reports.Record_Report(1, 10, std::nullopt, 70);
+ result = incomplete.Evaluate(incomplete_reports.Inspect(128), 60, 128);
+ Expect("incomplete final census falls back immediately", result.Evaluated && result.Changed && !incomplete.Is_Bootstrapping());
+ Expect("incomplete bootstrap falls back to 3/9", incomplete.Current_Settings() == TimingSettings{3, 9});
+
+ TimingReportCensus lost_reports;
+ lost_reports.Set_Player_Active(1, true, 0);
+ lost_reports.Set_Player_Active(2, true, 0);
+ lost_reports.Record_Report(1, 10, 20, 38);
+ lost_reports.Record_Report(2, 10, std::nullopt, 38);
+ BalancedTimingPolicy lost;
+ result = lost.Evaluate(lost_reports.Inspect(64), 60, 64);
+ Expect("initial missing RTT keeps bootstrap open", result.Evaluated && !result.Changed && lost.Is_Bootstrapping());
+ lost_reports.Record_Report(1, 10, std::nullopt, 70);
+ result = lost.Evaluate(lost_reports.Inspect(128), 60, 128);
+ Expect("established RTT loss during bootstrap falls back to 3/9", result.Changed && lost.Current_Settings() == TimingSettings{3, 9});
+
+ for (std::uint32_t frame : {256u, 512u, 768u}) {
+ Record_One(high_reports, 0, frame);
+ result = high.Evaluate(high_reports.Inspect(frame), 60, frame);
+ }
+ Expect("bootstrap cooldown leaves only two good evaluations by frame 768", !result.Changed && high.Good_Evaluations() == 2);
+ Record_One(high_reports, 0, 1024);
+ result = high.Evaluate(high_reports.Inspect(1024), 60, 1024);
+ Expect("normal hysteresis resumes after bootstrap cooldown", result.Changed && high.Current_Settings() == TimingSettings{9, 27});
+
+ high.Reset();
+ Expect("reset starts a new bootstrap", high.Is_Bootstrapping());
+ Expect("reset restores 2/6", high.Current_Settings() == TimingSettings{2, 6});
+
+ BalancedTimingPolicy handoff;
+ handoff.Reset_From({10, 70}, 0);
+ Expect("handoff does not regain bootstrap", !handoff.Is_Bootstrapping());
+ Record_One(high_reports, 0, 64);
+ result = handoff.Evaluate(high_reports.Inspect(64), 60, 64);
+ Expect("handoff ignores bootstrap evaluation", !result.Evaluated && handoff.Current_Settings() == TimingSettings{10, 70});
+
+ TimingReportCensus resumed_reports;
+ resumed_reports.Set_Player_Active(1, true, 1024);
+ BalancedTimingPolicy resumed;
+ resumed.Reset(1024);
+ Expect_Equal("resumed bootstrap records its cadence origin", resumed.Cadence_Origin(), 1024u);
+ result = resumed.Evaluate(resumed_reports.Inspect(1056), 60, 1056);
+ Expect("resumed bootstrap does not evaluate after only 32 frames", !result.Evaluated);
+ Record_One(resumed_reports, 0, 1062);
+ result = resumed.Evaluate(resumed_reports.Inspect(1088), 60, 1088);
+ Expect("resumed bootstrap evaluates after 64 frames", result.Evaluated && result.Changed && !resumed.Is_Bootstrapping());
+ Expect("resumed bootstrap selects its measured target", resumed.Current_Settings() == TimingSettings{1, 4});
+ }
+
+
+ void Test_Hysteresis_And_Cooldown(void)
+ {
+ using namespace NetTiming;
+
+ TimingReportCensus reports;
+ reports.Set_Player_Active(1, true, 0);
+ BalancedTimingPolicy policy;
+ policy.Reset_From({3, 9}, 0);
+
+ Record_One(reports, 0, 256);
+ TimingEvaluation result = policy.Evaluate(reports.Inspect(256), 60, 256);
+ Expect("first good evaluation does not change", !result.Changed);
+ Record_One(reports, 0, 512);
+ result = policy.Evaluate(reports.Inspect(512), 60, 512);
+ Expect("second good evaluation does not change", !result.Changed);
+ Record_One(reports, 0, 768);
+ result = policy.Evaluate(reports.Inspect(768), 60, 768);
+ Expect("third good evaluation improves one rung", result.Changed);
+ Expect_Equal("one-rung improvement", policy.Current_Rung(), 2u);
+
+ Record_One(reports, 0, 800);
+ result = policy.Evaluate(reports.Inspect(800), 60, 800);
+ Expect("evaluation interval enforced", !result.Evaluated);
+ Expect_Equal("cooldown leaves rung", policy.Current_Rung(), 2u);
+
+ BalancedTimingPolicy headroom;
+ headroom.Reset_From({3, 9}, 0);
+ TimingReportCensus edge;
+ edge.Set_Player_Active(1, true, 0);
+ for (std::uint32_t frame : {256u, 512u, 768u}) {
+ Record_One(edge, 120, frame);
+ headroom.Evaluate(edge.Inspect(frame), 60, frame);
+ }
+ Expect_Equal("20 percent headroom blocks marginal improvement", headroom.Current_Rung(), 3u);
+
+ Record_One(reports, 2000, 1024);
+ result = policy.Evaluate(reports.Inspect(1024), 60, 1024);
+ Expect("worsening is immediate", result.Changed);
+ Expect_Equal("worsening reaches required rung", policy.Current_Rung(), 10u);
+ Expect_Equal("highest rung retains measured horizon", policy.Current_Settings().MaxAhead, 70u);
+
+ for (std::uint32_t frame : {1280u, 1536u, 1792u}) {
+ Record_One(reports, 1300, frame);
+ result = policy.Evaluate(reports.Inspect(frame), 60, frame);
+ }
+ Expect("same-rung horizon reduction uses hysteresis", result.Changed);
+ Expect_Equal("same-rung horizon retains aligned need", policy.Current_Settings().MaxAhead, 50u);
+
+ Record_One(reports, 0, 2048);
+ result = policy.Evaluate(reports.Inspect(2048), 60, 2048);
+ Expect("descent continues one rung per evaluation", result.Changed && policy.Current_Settings() == TimingSettings{9, 27});
+ Record_One(reports, 0, 2304);
+ result = policy.Evaluate(reports.Inspect(2304), 60, 2304);
+ Expect("descent keeps stepping while headroom holds", result.Changed && policy.Current_Settings() == TimingSettings{8, 24});
+ Record_One(reports, 2000, 2560);
+ result = policy.Evaluate(reports.Inspect(2560), 60, 2560);
+ Expect("worsening interrupts the descent", result.Changed && policy.Current_Rung() == 10u);
+ Record_One(reports, 0, 2816);
+ result = policy.Evaluate(reports.Inspect(2816), 60, 2816);
+ Expect("worsening restores the three-evaluation requirement", !result.Changed && policy.Good_Evaluations() == 1);
+
+ TimingReportCensus marginal_reports;
+ marginal_reports.Set_Player_Active(1, true, 0);
+ BalancedTimingPolicy marginal;
+ marginal.Reset_From({5, 15}, 0);
+ for (std::uint32_t frame : {256u, 512u, 768u}) {
+ Record_One(marginal_reports, 0, frame);
+ result = marginal.Evaluate(marginal_reports.Inspect(frame), 60, frame);
+ }
+ Expect("descent starts after three good evaluations", result.Changed && marginal.Current_Settings() == TimingSettings{4, 12});
+ Record_One(marginal_reports, 250, 1024);
+ result = marginal.Evaluate(marginal_reports.Inspect(1024), 60, 1024);
+ Expect("evaluation without headroom holds the rung", !result.Changed && marginal.Current_Settings() == TimingSettings{4, 12});
+ Record_One(marginal_reports, 0, 1280);
+ result = marginal.Evaluate(marginal_reports.Inspect(1280), 60, 1280);
+ Expect("a held evaluation ends the descent streak", !result.Changed && marginal.Good_Evaluations() == 1);
+ }
+
+
+ void Test_Stale_And_Long_Term_Recovery(void)
+ {
+ using namespace NetTiming;
+
+ TimingReportCensus stale;
+ stale.Set_Player_Active(1, true, 0);
+ BalancedTimingPolicy stale_policy;
+ stale_policy.Reset_From({3, 9}, 0);
+ TimingEvaluation result = stale_policy.Evaluate(stale.Inspect(0), 60, 0);
+ Expect("startup waits for a complete census", !result.Changed);
+ Expect_Equal("startup keeps initial rung", stale_policy.Current_Rung(), 3u);
+
+ stale.Record_Report(1, 10, 100, 256);
+ stale_policy.Evaluate(stale.Inspect(256), 60, 256);
+ result = stale_policy.Evaluate(stale.Inspect(256 + REPORT_EXPIRY), 60, 256 + REPORT_EXPIRY);
+ Expect("expired established report holds the current timing", result.Evaluated && !result.Changed);
+ Expect_Equal("expired established report keeps the rung", stale_policy.Current_Rung(), 3u);
+ Expect_Equal("expired established report discards improvement evidence", stale_policy.Good_Evaluations(), 0u);
+
+ stale.Set_Player_Active(1, false, 1024);
+ for (std::uint32_t frame : {1024u, 1280u, 1536u}) {
+ stale_policy.Evaluate(stale.Inspect(frame), 60, frame);
+ }
+ Expect_Equal("departed peer allows recovery", stale_policy.Current_Rung(), 2u);
+
+ TimingReportCensus partial;
+ partial.Set_Player_Active(1, true, 0);
+ partial.Set_Player_Active(2, true, 0);
+ BalancedTimingPolicy partial_policy;
+ partial_policy.Reset_From({3, 9}, 0);
+ partial.Record_Report(2, 10, 50, 0);
+ partial.Record_Report(1, 10, 2000, 256);
+ partial.Record_Report(2, 10, std::nullopt, 256);
+ result = partial_policy.Evaluate(partial.Inspect(256), 60, 256);
+ Expect("incomplete census still applies a worsening", result.Changed && partial_policy.Current_Settings() == TimingSettings{10, 70});
+ partial.Record_Report(1, 10, 0, 512);
+ partial.Record_Report(2, 10, std::nullopt, 512);
+ result = partial_policy.Evaluate(partial.Inspect(512), 60, 512);
+ Expect("incomplete census never improves", result.Evaluated && !result.Changed && partial_policy.Good_Evaluations() == 0);
+
+ TimingReportCensus reports;
+ reports.Set_Player_Active(1, true, 0);
+ BalancedTimingPolicy policy;
+ policy.Reset_From({3, 9}, 0);
+ std::uint32_t frame = EVALUATION_INTERVAL;
+ auto evaluate = [&](Milliseconds rtt) {
+ Record_One(reports, rtt, frame);
+ policy.Evaluate(reports.Inspect(frame), 60, frame);
+ frame += EVALUATION_INTERVAL;
+ };
+
+ for (int cycle = 0; cycle < 5; cycle++) {
+ evaluate(2000);
+ evaluate(0);
+ evaluate(0);
+ evaluate(0);
+ }
+ Expect_Equal("repeated degradation and recovery remains stable", policy.Current_Rung(), 9u);
+ evaluate(0);
+ evaluate(0);
+ evaluate(0);
+ Expect_Equal("descent continues after more than eight changes", policy.Current_Rung(), 6u);
+ }
+
+
+ void Test_Stall_Feedback(void)
+ {
+ using namespace NetTiming;
+
+ TimingReportCensus reports;
+ reports.Set_Player_Active(1, true, 0);
+ reports.Set_Player_Active(2, true, 0);
+ reports.Record_Report(1, 10, 0, 256, 50);
+ reports.Record_Report(2, 10, 0, 256, 400);
+ TimingCensus census = reports.Inspect(256);
+ Expect_Equal("census publishes the longest wait", census.WorstStallMilliseconds, 400u);
+
+ BalancedTimingPolicy policy;
+ policy.Reset_From({4, 12}, 0);
+ TimingEvaluation result = policy.Evaluate(census, 60, 256);
+ Expect("a long wait never steps the timing up", result.Evaluated && !result.Changed && policy.Current_Settings() == TimingSettings{4, 12});
+ Expect_Equal("a long wait resets the improvement count", policy.Good_Evaluations(), 0u);
+
+ reports.Record_Report(1, 10, 0, 512, 0);
+ reports.Record_Report(2, 10, 0, 512, 200);
+ result = policy.Evaluate(reports.Inspect(512), 60, 512);
+ Expect("waiting above the improvement limit holds the timing", result.Evaluated && !result.Changed && policy.Good_Evaluations() == 0);
+
+ for (std::uint32_t frame : {768u, 1024u, 1280u}) {
+ reports.Record_Report(1, 10, 0, frame, 0);
+ reports.Record_Report(2, 10, 0, frame, 20);
+ result = policy.Evaluate(reports.Inspect(frame), 60, frame);
+ }
+ Expect("quiet waiting allows the normal descent", result.Changed && policy.Current_Settings() == TimingSettings{3, 9});
+
+ reports.Record_Report(1, 10, 0, 1536, 0);
+ reports.Record_Report(2, 10, 0, 1536, 150);
+ result = policy.Evaluate(reports.Inspect(1536), 60, 1536);
+ Expect("a wait during the descent ends the streak", result.Evaluated && !result.Changed && policy.Good_Evaluations() == 0);
+ }
+
+
+ void Test_Master_Handoff_State(void)
+ {
+ using namespace NetTiming;
+
+ TimingReportCensus reports;
+ reports.Set_Player_Active(1, true, 1000);
+ BalancedTimingPolicy policy;
+ policy.Reset_From({10, 70}, 1000);
+ Expect("handoff restores authoritative settings", policy.Current_Settings() == TimingSettings{10, 70});
+ Expect_Equal("handoff discards improvement evidence", policy.Good_Evaluations(), 0u);
+
+ Record_One(reports, 0, 1000);
+ TimingEvaluation result = policy.Evaluate(reports.Inspect(1000), 60, 1000);
+ Expect("handoff starts an evaluation cooldown", !result.Evaluated);
+ Record_One(reports, 0, 1256);
+ result = policy.Evaluate(reports.Inspect(1256), 60, 1256);
+ Expect("one good evaluation preserves the handoff target", result.Evaluated && !result.Changed && policy.Current_Settings() == TimingSettings{10, 70});
+
+ TimingReportCensus recovery_reports;
+ recovery_reports.Set_Player_Active(1, true, 0);
+ BalancedTimingPolicy recover;
+ recover.Reset_From({10, 250}, 0);
+ for (std::uint32_t frame : {256u, 512u, 768u}) {
+ Record_One(recovery_reports, 0, frame);
+ result = recover.Evaluate(recovery_reports.Inspect(frame), 60, frame);
+ }
+ Expect("10/250 improves one rung after hysteresis", result.Changed && recover.Current_Settings() == TimingSettings{9, 27});
+ Record_One(recovery_reports, 0, 1024);
+ result = recover.Evaluate(recovery_reports.Inspect(1024), 60, 1024);
+ Expect("10/250 keeps descending one rung per evaluation", result.Changed && recover.Current_Settings() == TimingSettings{8, 24});
+
+ TimingReportCensus same_rung_reports;
+ same_rung_reports.Set_Player_Active(1, true, 0);
+ BalancedTimingPolicy same_rung;
+ same_rung.Reset_From({10, 70}, 0);
+ for (std::uint32_t frame : {256u, 512u, 768u}) {
+ Record_One(same_rung_reports, 1300, frame);
+ result = same_rung.Evaluate(same_rung_reports.Inspect(frame), 60, frame);
+ }
+ Expect("10/70 catches up toward 10/50 after hysteresis", result.Changed && same_rung.Current_Settings() == TimingSettings{10, 50});
+
+ TimingReportCensus legacy_reports;
+ legacy_reports.Set_Player_Active(1, true, 0);
+ legacy_reports.Record_Report(1, 10, 200, 256);
+ BalancedTimingPolicy legacy;
+ legacy.Reset_From({3, 6}, 0);
+ result = legacy.Evaluate(legacy_reports.Inspect(256), 60, 256);
+ Expect("adaptive policy recovers from a legacy two-period horizon", result.Changed && legacy.Current_Settings() == TimingSettings{3, 9});
+ }
+
+
+ void Test_Staged_Decrease(void)
+ {
+ using namespace NetTiming;
+
+ std::optional staged = Stage_Timing_Update({3, 9}, {1, 4}, 100);
+ Expect("decrease stages", staged && staged->Deferred);
+ Expect_Equal("old horizon and periods align", staged->ActivationFrame, 111u);
+ Expect_Equal("activation preserves most of the old horizon", staged->InitialMaxAhead, 6u);
+ Expect("staged update not early", !Timing_Update_Is_Due(110, staged->ActivationFrame));
+ Expect("staged update due", Timing_Update_Is_Due(111, staged->ActivationFrame));
+ Expect_Equal("first catch-up step removes one new period", *Next_Transition_Max_Ahead({1, 6}, {1, 4}), 5u);
+ Expect_Equal("second catch-up step reaches target", *Next_Transition_Max_Ahead({1, 5}, {1, 4}), 4u);
+ Expect_Equal("catch-up stays at target", *Next_Transition_Max_Ahead({1, 4}, {1, 4}), 4u);
+
+ staged = Stage_Timing_Update({3, 9}, {2, 6}, 100);
+ Expect_Equal("both periods use LCM", staged->ActivationFrame, 114u);
+ Expect_Equal("adjacent decrease activates at target horizon", staged->InitialMaxAhead, 6u);
+
+ staged = Stage_Timing_Update({10, 250}, {9, 27}, 100);
+ Expect_Equal("wide decrease aligns activation to both periods", staged->ActivationFrame, 360u);
+ Expect_Equal("wide decrease preserves a safe initial horizon", staged->InitialMaxAhead, 243u);
+ Expect_Equal("wide catch-up removes one new period", *Next_Transition_Max_Ahead({9, 243}, {9, 27}), 234u);
+
+ staged = Stage_Timing_Update({10, 70}, {10, 50}, 100);
+ Expect_Equal("same-rate decrease drains at old horizon", staged->ActivationFrame, 170u);
+ Expect_Equal("same-rate decrease keeps one intermediate period", staged->InitialMaxAhead, 60u);
+ Expect_Equal("same-rate catch-up reaches requested horizon", *Next_Transition_Max_Ahead({10, 60}, {10, 50}), 50u);
+
+ staged = Stage_Timing_Update({9, 234}, {8, 24}, 360);
+ Expect("replacement decrease restages from effective settings", staged && staged->Deferred);
+ Expect_Equal("replacement decrease safely rebases its horizon", staged->InitialMaxAhead, 232u);
+
+ staged = Stage_Timing_Update({9, 243}, {10, 40}, 369);
+ Expect("mixed worsening keeps an aligned catch-up", staged && staged->Deferred);
+ Expect_Equal("mixed worsening activates at its event frame", staged->ActivationFrame, 369u);
+ Expect_Equal("mixed worsening preserves the effective horizon", staged->InitialMaxAhead, 250u);
+ std::optional const first_boundary = Next_Send_Boundary(369, 10);
+ Expect("mixed worsening identifies its first new-rate send", first_boundary && *first_boundary == 370);
+ TimingTransitionState mixed{*staged, *first_boundary, true};
+ std::optional mixed_step = Advance_Timing_Transition(mixed, {10, 250}, 370);
+ Expect("first new-rate send keeps the temporary horizon", mixed_step && !mixed_step->Changed && mixed_step->Settings == TimingSettings{10, 250});
+ mixed_step = Advance_Timing_Transition(mixed, mixed_step->Settings, 380);
+ Expect("following boundary drains one new period", mixed_step && mixed_step->Changed && mixed_step->Settings == TimingSettings{10, 240});
+ Expect("mixed replacement never moves the command target backward", 369u + 243u <= 370u + 250u && 370u + 250u <= 380u + 240u);
+
+ std::optional immediate = Stage_Timing_Update({1, 4}, {5, 15}, 100);
+ Expect("worsening applies immediately", immediate && !immediate->Deferred);
+ Expect_Equal("immediate frame", immediate->ActivationFrame, 100u);
+ Expect_Equal("immediate update uses requested horizon", immediate->InitialMaxAhead, 15u);
+ staged = immediate;
+ Expect("an immediate worse update replaces a pending decrease", staged && !staged->Deferred && staged->Settings == TimingSettings{5, 15});
+
+ immediate = Stage_Timing_Update({9, 234}, {10, 250}, 360);
+ Expect("conservative update cancels catch-up immediately", immediate && !immediate->Deferred && immediate->InitialMaxAhead == 250);
+
+ Expect("zero-period staging rejected", !Stage_Timing_Update({0, 9}, {1, 4}, 100));
+ Expect("zero-period send boundary rejected", !Next_Send_Boundary(100, 0));
+ Expect("overflowing send boundary rejected", !Next_Send_Boundary((std::numeric_limits::max)(), 10));
+ Expect("unaligned staging rejected", !Stage_Timing_Update({3, 10}, {1, 4}, 100));
+ std::optional const legacy_recovery = Stage_Timing_Update({3, 6}, {3, 9}, 100);
+ Expect("legacy response horizon can recover immediately", legacy_recovery && !legacy_recovery->Deferred);
+ Expect("overflowing staging rejected", !Stage_Timing_Update({10, 30}, {9, 27}, (std::numeric_limits::max)() - 10));
+ Expect("catch-up rejects mismatched send periods", !Next_Transition_Max_Ahead({9, 243}, {8, 24}));
+ Expect("catch-up rejects invalid effective settings", !Next_Transition_Max_Ahead({9, 242}, {9, 27}));
+ }
+
+
+ struct TransitionTrace
+ {
+ std::vector> Changes;
+ std::vector CommandTargets;
+
+ bool operator==(TransitionTrace const &) const = default;
+ };
+
+
+ TransitionTrace Run_Transition(NetTiming::TimingSettings current, NetTiming::TimingSettings requested, std::uint32_t event_frame, std::uint32_t final_frame)
+ {
+ TransitionTrace trace;
+ std::optional const plan = NetTiming::Stage_Timing_Update(current, requested, event_frame);
+ if (!plan || !plan->Deferred) {
+ return(trace);
+ }
+
+ NetTiming::TimingTransitionState transition{*plan};
+ std::uint32_t const first_frame = event_frame - event_frame % current.FrameSendRate;
+ for (std::uint32_t frame = first_frame; frame <= final_frame; frame++) {
+ std::optional const advance = NetTiming::Advance_Timing_Transition(transition, current, frame);
+ if (!advance) {
+ trace.CommandTargets.clear();
+ return(trace);
+ }
+ if (advance->Changed) {
+ current = advance->Settings;
+ trace.Changes.emplace_back(frame, current);
+ }
+ if (frame % current.FrameSendRate == 0) {
+ trace.CommandTargets.push_back(static_cast(frame) + current.MaxAhead);
+ }
+ if (advance->Complete) {
+ break;
+ }
+ }
+ return(trace);
+ }
+
+
+ void Test_Transition_Sequences(void)
+ {
+ using namespace NetTiming;
+
+ for (std::pair const & transition : {
+ std::pair{TimingSettings{10, 250}, TimingSettings{9, 27}},
+ std::pair{TimingSettings{10, 70}, TimingSettings{10, 50}},
+ std::pair{TimingSettings{3, 9}, TimingSettings{2, 6}},
+ std::pair{TimingSettings{2, 6}, TimingSettings{1, 4}}}) {
+ TransitionTrace const first = Run_Transition(transition.first, transition.second, 100, 700);
+ TransitionTrace const repeat = Run_Transition(transition.first, transition.second, 100, 700);
+ Expect("repeated transition runs are deterministic", first == repeat);
+ Expect("a transition reaches its requested settings", !first.Changes.empty() && first.Changes.back().second == transition.second);
+ bool nondecreasing = !first.CommandTargets.empty();
+ for (std::size_t index = 1; index < first.CommandTargets.size(); index++) {
+ nondecreasing = nondecreasing && first.CommandTargets[index] >= first.CommandTargets[index - 1];
+ }
+ Expect("transition command targets never move backward", nondecreasing);
+ }
+
+ std::optional const plan = Stage_Timing_Update({10, 250}, {9, 27}, 100);
+ TimingTransitionState state{*plan};
+ TimingSettings current{10, 250};
+ for (std::uint32_t frame = 100; frame <= 369; frame++) {
+ std::optional const advance = Advance_Timing_Transition(state, current, frame);
+ if (advance && advance->Changed) {
+ current = advance->Settings;
+ }
+ }
+ std::optional const replacement = Stage_Timing_Update(current, {8, 24}, 369);
+ Expect("an active catch-up can be safely replaced", replacement && replacement->Deferred && replacement->InitialMaxAhead >= current.MaxAhead - current.FrameSendRate);
+ std::optional const conservative = Stage_Timing_Update(current, {10, 250}, 369);
+ Expect("a fully conservative replacement applies immediately", conservative && !conservative->Deferred);
+ }
}
@@ -385,6 +1034,17 @@ int main(void)
Test_Backoff_Persistence();
Test_Provisional_Seed();
Test_Note_Retransmit_Guards();
+ Test_Census();
+ Test_Rungs();
+ Test_Connection_Quality();
+ Test_Bootstrap_Cadence();
+ Test_Bootstrap_Policy();
+ Test_Hysteresis_And_Cooldown();
+ Test_Stale_And_Long_Term_Recovery();
+ Test_Stall_Feedback();
+ Test_Master_Handoff_State();
+ Test_Staged_Decrease();
+ Test_Transition_Sequences();
if (Failures != 0) {
std::cerr << Failures << " network timing checks failed\n";