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";