From d6035863c7c59e8e6b05aced68505b812d5cfcbe Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 14:56:37 +0300 Subject: [PATCH 01/16] Add adaptive network timing policy --- code/_event.cpp | 2 + code/event.h | 9 + code/nettiming.cpp | 472 ++++++++++++++++++++++++++ code/nettiming.h | 150 +++++++++ tests/netpacket/netcontract.cpp | 21 +- tests/nettiming/nettiming.cpp | 575 ++++++++++++++++++++++++++++++++ 6 files changed, 1227 insertions(+), 2 deletions(-) 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/event.h b/code/event.h index 5a3aaf57..6436a0ec 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,11 @@ class EventClass unsigned short AverageTicks; } ProcessTime; + struct { + std::uint16_t AverageProcessMilliseconds; + std::uint16_t WorstRoundTripMilliseconds; + } NetworkReport; + } Data; //-------------- Constructors --------------------- diff --git a/code/nettiming.cpp b/code/nettiming.cpp index 2f62b5f5..6717b3a7 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,438 @@ 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 and optional RTT as one report. + bool TimingReportCensus::Record_Report(unsigned int player, Milliseconds process_milliseconds, std::optional round_trip, std::uint32_t frame) + { + 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.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); + } else { + result.ProcessComplete = false; + } + + if (fresh && report.HasRoundTrip) { + result.FreshRoundTripReports++; + result.WorstRoundTrip = std::max(result.WorstRoundTrip, report.RoundTrip); + } else { + result.RoundTripComplete = false; + 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; + } + + + /// 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; + } + + + /// Commits a policy change and resets hysteresis. + void BalancedTimingPolicy::Change_To(TimingSettings settings, std::uint32_t frame) + { + 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; + 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) { + GoodEvaluations = 0; + return(result); + } + + // Worsening is immediate; improvement must clear the headroom, cadence, and cooldown gates. + 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) && (!HasChanged || frame - LastChangeFrame >= CHANGE_COOLDOWN)) { + TimingSettings const headroom = Desired_Settings(census, target_fps, true); + if (Timing_Is_Better(headroom, CurrentSettings)) { + GoodEvaluations++; + if (GoodEvaluations >= 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; + } + } else { + GoodEvaluations = 0; + } + + 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 ec34f56b..6b83908c 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,24 @@ 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; struct RetryDecision { @@ -66,4 +89,131 @@ 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; + 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); + 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; + 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; + }; + + 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/tests/netpacket/netcontract.cpp b/tests/netpacket/netcontract.cpp index 7d13b4f6..d1692d8c 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), "NETWORK_REPORT uses its four-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,19 @@ 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; + Bytes report_data; + Append_Value(report_data, average); + Append_Value(report_data, worst); + 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, + "NETWORK_REPORT preserves both millisecond fields"); } diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp index 9e6c0cc6..81fb84a6 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -13,7 +13,11 @@ #include #include #include +#include +#include #include +#include +#include namespace @@ -429,6 +433,567 @@ 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("established RTT expiry is conservative", 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 immediately 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 remains immediately conservative", result.Changed && lost.Current_Settings() == TimingSettings{10, 250}); + + 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); + } + + + 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("established stale report worsens policy", result.Changed); + Expect_Equal("established stale report chooses worst rung", stale_policy.Current_Rung(), 10u); + Expect_Equal("established stale report chooses conservative horizon", stale_policy.Current_Settings().MaxAhead, MAXIMUM_MAX_AHEAD); + + 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(), 9u); + + 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("recovery remains possible after more than eight changes", policy.Current_Rung(), 8u); + } + + + 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}); + + 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); + } } @@ -444,6 +1009,16 @@ 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_Master_Handoff_State(); + Test_Staged_Decrease(); + Test_Transition_Sequences(); if (Failures != 0) { std::cerr << Failures << " network timing checks failed\n"; From 09262282f5bae8bdf018255d76e8a92983a06587 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 14:57:20 +0300 Subject: [PATCH 02/16] Adapt multiplayer frame timing --- code/connect.h | 9 ++ code/connmgr.h | 5 + code/event.cpp | 66 +++++--- code/init.cpp | 2 + code/ipxmgr.cpp | 16 ++ code/ipxmgr.h | 1 + code/netdlg2.cpp | 49 +++--- code/queue.cpp | 388 ++++++++--------------------------------------- code/session.cpp | 224 ++++++++++++++++++++++++++- code/session.h | 38 +++-- 10 files changed, 413 insertions(+), 385 deletions(-) diff --git a/code/connect.h b/code/connect.h index d7fd47c4..859d00b5 100644 --- a/code/connect.h +++ b/code/connect.h @@ -100,6 +100,8 @@ #include "netadmit.h" #include "nettiming.h" +#include + /* ********************************** Defines ********************************** */ @@ -186,6 +188,13 @@ 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 + { + if (!RoundTripEstimator.Has_Sample()) { + return(std::nullopt); + } + return(RoundTripEstimator.Smoothed_Rtt()); + } 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..e5acba5a 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,14 +1304,31 @@ 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}; + 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; } @@ -1314,26 +1340,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 +1367,14 @@ void EventClass::Execute(void) } break; + case NETWORK_REPORT: + if (Session.CommProtocol != COMM_PROTOCOL_MULTI_E_COMP || Frame < 0 + || !Session.Record_Network_Report(ID, Data.NetworkReport.AverageProcessMilliseconds, + Data.NetworkReport.WorstRoundTripMilliseconds, static_cast(Frame))) { + Log_Event_Rejection(EventRejectReason::InvalidNetworkReport, Type, ID, Data.NetworkReport.WorstRoundTripMilliseconds); + } + break; + /* ** Default: do nothing. */ diff --git a/code/init.cpp b/code/init.cpp index 7bc8abe2..92544e2a 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -1457,6 +1457,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..49d0d02f 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 diff --git a/code/ipxmgr.h b/code/ipxmgr.h index f7b4e320..aa1ccfaa 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; 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/queue.cpp b/code/queue.cpp index 831e1140..b362c684 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" @@ -303,9 +304,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); @@ -487,6 +487,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 +733,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 @@ -824,36 +831,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(); } //------------------------------------------------------------------------ @@ -1465,322 +1452,75 @@ 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; } + Session.Prepare_Network_Timing_Master(master_id, frame); - // - // 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; - } + 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.Changed && 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 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); + OutList.push_back(event); Session.ProcessTicks = 0; Session.ProcessFrames = 0; - - if (Session.Type == GAME_INTERNET && (Frame & 0x3FF) == 0) { - net->Reset_Response_Time(false); - } } diff --git a/code/session.cpp b/code/session.cpp index 4782befb..ea4f1544 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,197 @@ 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 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)) { + 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..03d78760 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,17 @@ 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 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,6 +579,10 @@ class SessionClass //..................................................................... unsigned int MaxAhead; unsigned int FrameSendRate; + NetTiming::TimingReportCensus NetworkTimingReports; + NetTiming::BalancedTimingPolicy NetworkTimingPolicy; + std::optional PendingNetworkTiming; + int NetworkTimingPolicyOwner; int DesiredFrameRate; @@ -572,14 +596,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 +740,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 From 83822c8e861396bba9a6396b12d8739742569785 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 14:58:36 +0300 Subject: [PATCH 03/16] Show adaptive connection quality --- code/event.cpp | 12 ++++++++ code/goptions.cpp | 64 ++++++++++++++++++++------------------- code/goptions.h | 2 ++ code/language/language.h | 2 ++ code/language/language.rc | 4 ++- 5 files changed, 52 insertions(+), 32 deletions(-) diff --git a/code/event.cpp b/code/event.cpp index e5acba5a..0c5c2acb 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -1314,6 +1314,7 @@ void EventClass::Execute(void) } 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; @@ -1332,6 +1333,17 @@ void EventClass::Execute(void) break; } + 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- 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/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 From 0f2172d244f1b000f681b4cd0aa7db307372dd70 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 14:59:29 +0300 Subject: [PATCH 04/16] Document adaptive network timing --- manual/changes/adaptive-network-timing.md | 29 ++++++++++ .../systems/network-synchronization.md | 54 +++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 manual/changes/adaptive-network-timing.md create mode 100644 manual/content/systems/network-synchronization.md diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md new file mode 100644 index 00000000..8b532bf3 --- /dev/null +++ b/manual/changes/adaptive-network-timing.md @@ -0,0 +1,29 @@ +--- +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 requires sustained headroom, and timing +decreases drain the old scheduling horizon before stepping down on aligned send +boundaries. + +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..7e7bbed3 --- /dev/null +++ b/manual/content/systems/network-synchronization.md @@ -0,0 +1,54 @@ +--- +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 and optional worst-local RTT after +32 and 64 frames, then every 128 frames. 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. Initial +missing RTT has that long to appear; missing or stale established RTT selects +the conservative `10/250` target immediately. 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. Improvement needs three evaluations with 20% headroom and a cooldown, +and moves one rung at a time. + +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. 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. + +## Player feedback + +The disabled Connection slider shows the effective send-period 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. From eedbc933db7e94739806b6bf99a03d3448b275e4 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 19:57:35 +0300 Subject: [PATCH 05/16] Harden adaptive timing against stale links and divergence --- code/connect.cpp | 11 +++++++++++ code/connect.h | 8 +------- code/event.cpp | 6 ++++-- code/queue.cpp | 4 +++- manual/content/systems/network-synchronization.md | 6 ++++-- 5 files changed, 23 insertions(+), 12 deletions(-) diff --git a/code/connect.cpp b/code/connect.cpp index 015ec5ab..d8c34ed6 100644 --- a/code/connect.cpp +++ b/code/connect.cpp @@ -999,6 +999,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 859d00b5..9b59d028 100644 --- a/code/connect.h +++ b/code/connect.h @@ -188,13 +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 - { - if (!RoundTripEstimator.Has_Sample()) { - return(std::nullopt); - } - return(RoundTripEstimator.Smoothed_Rtt()); - } + 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/event.cpp b/code/event.cpp index 0c5c2acb..84ea6aa3 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -1380,9 +1380,11 @@ void EventClass::Execute(void) break; case NETWORK_REPORT: - if (Session.CommProtocol != COMM_PROTOCOL_MULTI_E_COMP || Frame < 0 + // 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, static_cast(Frame))) { + Data.NetworkReport.WorstRoundTripMilliseconds, static_cast(Frame))) + && !Session.Play) { Log_Event_Rejection(EventRejectReason::InvalidNetworkReport, Type, ID, Data.NetworkReport.WorstRoundTripMilliseconds); } break; diff --git a/code/queue.cpp b/code/queue.cpp index b362c684..3ce4f965 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -1486,7 +1486,9 @@ static void Generate_Real_Timing_Event(void) 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.Changed && desired_frame_rate == static_cast(Session.DesiredFrameRate)) { + // 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; } diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index 7e7bbed3..bdc72326 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -16,8 +16,10 @@ per-link RTT and retry behavior belongs to Compressed matches begin at `2/6`: a two-frame send period and six-frame look-ahead. Each player reports process time and optional worst-local RTT after -32 and 64 frames, then every 128 frames. The deterministic master evaluates at -64 and 128 frames, then every 256 frames. +32 and 64 frames, then every 128 frames. A player omits the RTT while any of its +links has no measurement or a +[stale one](/systems/network-transport-timing/). 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. Initial missing RTT has that long to appear; missing or stale established RTT selects From d69cf63fadb31306182ddcc17fbe8fd254fdd282 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 2 Sep 2026 11:01:41 +0300 Subject: [PATCH 06/16] Execute events between send frames on the next send frame --- code/queue.cpp | 10 +++++++++- manual/content/systems/network-synchronization.md | 8 +++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/code/queue.cpp b/code/queue.cpp index 3ce4f965..a94e3d6d 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -273,6 +273,8 @@ 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; static std::array(NetPacket::DecodeError::COUNT)> NetworkPacketDrops = {}; @@ -746,6 +748,7 @@ static void Queue_AI_Multiplayer(void) } skip_crc = Frame + ARRAY_SIZE(CRC); SentCommandCount = 0; + LastExecutedFrame = Frame - 1; for (i = 0; i < ARRAY_SIZE(CRC); i++) CRC[i] = 0; @@ -3321,6 +3324,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) // @@ -3391,7 +3396,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]); @@ -3719,6 +3724,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/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index bdc72326..218340bd 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -34,9 +34,11 @@ and moves one rung at a time. 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. 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. +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. ## Player feedback From 637e9dca0144b559d6a95e59364f1da7ff7472dd Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 2 Sep 2026 11:04:14 +0300 Subject: [PATCH 07/16] Hold timing when a measured link's RTT report lapses --- code/nettiming.cpp | 14 ++++++++- .../systems/network-synchronization.md | 20 +++++++------ tests/nettiming/nettiming.cpp | 29 ++++++++++++++----- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/code/nettiming.cpp b/code/nettiming.cpp index 6717b3a7..240987a0 100644 --- a/code/nettiming.cpp +++ b/code/nettiming.cpp @@ -374,7 +374,9 @@ namespace NetTiming result.WorstRoundTrip = std::max(result.WorstRoundTrip, report.RoundTrip); } else { result.RoundTripComplete = false; - if (report.EverHadRoundTrip || frame - report.ActiveSinceFrame >= REPORT_EXPIRY) { + // 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; } } @@ -484,7 +486,17 @@ namespace NetTiming 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; + 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); } diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index 218340bd..6010a805 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -17,15 +17,17 @@ per-link RTT and retry behavior belongs to Compressed matches begin at `2/6`: a two-frame send period and six-frame look-ahead. Each player reports process time 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 measurement or a -[stale one](/systems/network-transport-timing/). 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. Initial -missing RTT has that long to appear; missing or stale established RTT selects -the conservative `10/250` target immediately. Stale process data retains the -last synchronized frame rate. Membership comes from the initial synchronized -roster, and accepted removal clears that player's report. +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 diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp index 81fb84a6..116650dd 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -467,7 +467,7 @@ namespace result = census.Inspect(100 + REPORT_EXPIRY); Expect("process reports expire on boundary", !result.ProcessComplete); Expect("RTT reports expire on boundary", !result.RoundTripComplete); - Expect("established RTT expiry is conservative", result.RequiresConservativeTiming); + 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); @@ -484,7 +484,7 @@ namespace 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 immediately conservative", result.RequiresConservativeTiming); + Expect("established unavailable RTT is not conservative", !result.RequiresConservativeTiming); TimingReportCensus grace; Expect("activate grace peer", grace.Set_Player_Active(3, true, 1000)); @@ -669,7 +669,7 @@ namespace 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 remains immediately conservative", result.Changed && lost.Current_Settings() == TimingSettings{10, 250}); + 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); @@ -770,15 +770,30 @@ namespace 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("established stale report worsens policy", result.Changed); - Expect_Equal("established stale report chooses worst rung", stale_policy.Current_Rung(), 10u); - Expect_Equal("established stale report chooses conservative horizon", stale_policy.Current_Settings().MaxAhead, MAXIMUM_MAX_AHEAD); + 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(), 9u); + 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); From efb90e0f29631be76bb152cf91f8927bc8080f11 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 2 Sep 2026 11:08:11 +0300 Subject: [PATCH 08/16] Continue timing descent one rung per evaluation --- code/nettiming.cpp | 15 ++++++-- code/nettiming.h | 3 ++ manual/changes/adaptive-network-timing.md | 9 +++-- .../systems/network-synchronization.md | 6 ++-- tests/nettiming/nettiming.cpp | 34 ++++++++++++++++++- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/code/nettiming.cpp b/code/nettiming.cpp index 240987a0..2c1f05de 100644 --- a/code/nettiming.cpp +++ b/code/nettiming.cpp @@ -412,6 +412,7 @@ namespace NetTiming HasEvaluated = false; HasChanged = false; Bootstrapping = true; + ImprovementStreak = false; } @@ -426,12 +427,14 @@ namespace NetTiming 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; @@ -445,6 +448,7 @@ namespace NetTiming { Bootstrapping = false; GoodEvaluations = 0; + ImprovementStreak = false; LastEvaluationFrame = BootstrapStartFrame; HasEvaluated = true; } @@ -488,6 +492,7 @@ namespace NetTiming 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)) { @@ -500,16 +505,18 @@ namespace NetTiming return(result); } - // Worsening is immediate; improvement must clear the headroom, cadence, and cooldown gates. + // Worsening is immediate; the first improvement must clear the headroom, 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) && (!HasChanged || frame - LastChangeFrame >= CHANGE_COOLDOWN)) { + } else if (Timing_Is_Better(desired_settings, CurrentSettings) + && (!HasChanged || ImprovementStreak || frame - LastChangeFrame >= CHANGE_COOLDOWN)) { TimingSettings const headroom = Desired_Settings(census, target_fps, true); if (Timing_Is_Better(headroom, CurrentSettings)) { GoodEvaluations++; - if (GoodEvaluations >= GOOD_EVALUATIONS_REQUIRED) { + 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); @@ -517,9 +524,11 @@ namespace NetTiming } } else { GoodEvaluations = 0; + ImprovementStreak = false; } } else { GoodEvaluations = 0; + ImprovementStreak = false; } result.Settings = Current_Settings(); diff --git a/code/nettiming.h b/code/nettiming.h index 6b83908c..a31f5c10 100644 --- a/code/nettiming.h +++ b/code/nettiming.h @@ -44,6 +44,7 @@ namespace NetTiming 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; struct RetryDecision { @@ -183,6 +184,8 @@ namespace NetTiming 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 { diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md index 8b532bf3..d474c4f2 100644 --- a/manual/changes/adaptive-network-timing.md +++ b/manual/changes/adaptive-network-timing.md @@ -15,9 +15,12 @@ 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 requires sustained headroom, and timing -decreases drain the old scheduling horizon before stepping down on aligned send -boundaries. +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`. The disabled WOL Connection slider shows the effective 1–10 rung and tier; the message list announces target-tier changes. Game speed remains separate. diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index 6010a805..0c9a91ff 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -31,8 +31,10 @@ 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. Improvement needs three evaluations with 20% headroom and a cooldown, -and moves one rung at a time. +immediate. The first improvement needs three evaluations with 20% headroom; +while the headroom persists, each following evaluation steps one more rung. A +worsening or an evaluation without headroom 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 diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp index 116650dd..bcd2d3aa 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -752,6 +752,35 @@ namespace } 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); } @@ -816,7 +845,7 @@ namespace evaluate(0); evaluate(0); evaluate(0); - Expect_Equal("recovery remains possible after more than eight changes", policy.Current_Rung(), 8u); + Expect_Equal("descent continues after more than eight changes", policy.Current_Rung(), 6u); } @@ -847,6 +876,9 @@ namespace 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); From 3d0cb7e27446dc94167709fc2d9ab04bd1197e3d Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 2 Sep 2026 16:04:16 +0300 Subject: [PATCH 09/16] Report each player's longest wait and hold improvement while it lasts --- code/event.cpp | 2 +- code/event.h | 1 + code/init.cpp | 2 + code/nettiming.cpp | 13 ++++--- code/nettiming.h | 7 +++- code/queue.cpp | 10 ++++- code/session.cpp | 5 ++- code/session.h | 6 ++- manual/changes/adaptive-network-timing.md | 3 +- .../systems/network-synchronization.md | 14 ++++--- tests/netpacket/netcontract.cpp | 9 +++-- tests/nettiming/nettiming.cpp | 38 +++++++++++++++++++ 12 files changed, 89 insertions(+), 21 deletions(-) diff --git a/code/event.cpp b/code/event.cpp index 84ea6aa3..c16f3b24 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -1383,7 +1383,7 @@ void EventClass::Execute(void) // 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, static_cast(Frame))) + Data.NetworkReport.WorstRoundTripMilliseconds, Data.NetworkReport.StallMilliseconds, static_cast(Frame))) && !Session.Play) { Log_Event_Rejection(EventRejectReason::InvalidNetworkReport, Type, ID, Data.NetworkReport.WorstRoundTripMilliseconds); } diff --git a/code/event.h b/code/event.h index 6436a0ec..ce45385f 100644 --- a/code/event.h +++ b/code/event.h @@ -243,6 +243,7 @@ class EventClass struct { std::uint16_t AverageProcessMilliseconds; std::uint16_t WorstRoundTripMilliseconds; + std::uint16_t StallMilliseconds; } NetworkReport; } Data; diff --git a/code/init.cpp b/code/init.cpp index 92544e2a..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; diff --git a/code/nettiming.cpp b/code/nettiming.cpp index 2c1f05de..ce8d2ba0 100644 --- a/code/nettiming.cpp +++ b/code/nettiming.cpp @@ -332,8 +332,9 @@ namespace NetTiming } - /// Records process time and optional RTT as one report. - bool TimingReportCensus::Record_Report(unsigned int player, Milliseconds process_milliseconds, std::optional round_trip, std::uint32_t frame) + /// 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)) { @@ -346,6 +347,7 @@ namespace NetTiming 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); } @@ -365,6 +367,7 @@ namespace NetTiming if (fresh) { result.FreshProcessReports++; result.WorstProcessMilliseconds = std::max(result.WorstProcessMilliseconds, report.ProcessMilliseconds); + result.WorstStallMilliseconds = std::max(result.WorstStallMilliseconds, report.StallMilliseconds); } else { result.ProcessComplete = false; } @@ -505,13 +508,13 @@ namespace NetTiming return(result); } - // Worsening is immediate; the first improvement must clear the headroom, cadence, and cooldown - // gates, and a descent then continues one rung per evaluation while the headroom holds. + // 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) + } 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)) { diff --git a/code/nettiming.h b/code/nettiming.h index a31f5c10..72efb2a6 100644 --- a/code/nettiming.h +++ b/code/nettiming.h @@ -45,6 +45,8 @@ namespace NetTiming 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 { @@ -120,6 +122,7 @@ namespace NetTiming unsigned int FreshRoundTripReports = 0; Milliseconds WorstProcessMilliseconds = 0; Milliseconds WorstRoundTrip = 0; + Milliseconds WorstStallMilliseconds = 0; bool ProcessComplete = true; bool RoundTripComplete = true; bool RequiresConservativeTiming = false; @@ -131,7 +134,8 @@ namespace NetTiming 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); + 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: @@ -142,6 +146,7 @@ namespace NetTiming bool EverHadRoundTrip = false; Milliseconds ProcessMilliseconds = 0; Milliseconds RoundTrip = 0; + Milliseconds StallMilliseconds = 0; std::uint32_t ActiveSinceFrame = 0; std::uint32_t ReportFrame = 0; }; diff --git a/code/queue.cpp b/code/queue.cpp index a94e3d6d..cbd4930b 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -1443,6 +1443,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(); } @@ -1505,7 +1508,7 @@ static void Generate_Real_Timing_Event(void) } -/// Queues the local process-time and worst-RTT report. +/// Queues the local process-time, waiting-time and worst-RTT report. static void Generate_Network_Report_Event(ConnManClass *net) { if (Session.ProcessFrames <= 0) { @@ -1522,10 +1525,15 @@ static void Generate_Network_Report_Event(ConnManClass *net) 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; + Session.PreviousWorstStallTicks = Session.WorstStallTicks; + Session.WorstStallTicks = 0; } diff --git a/code/session.cpp b/code/session.cpp index ea4f1544..69073b16 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -519,13 +519,14 @@ void SessionClass::Reset_Network_Timing(unsigned int 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 frame) +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)) { + if (!NetworkTimingReports.Record_Report(id, process_milliseconds, round_trip, frame, stall_milliseconds)) { return(false); } diff --git a/code/session.h b/code/session.h index 03d78760..489671cc 100644 --- a/code/session.h +++ b/code/session.h @@ -492,7 +492,8 @@ class SessionClass 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 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); @@ -589,6 +590,9 @@ class SessionClass 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. diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md index d474c4f2..a62319b3 100644 --- a/manual/changes/adaptive-network-timing.md +++ b/manual/changes/adaptive-network-timing.md @@ -20,7 +20,8 @@ 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`. +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 disabled WOL Connection slider shows the effective 1–10 rung and tier; the message list announces target-tier changes. Game speed remains separate. diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index 0c9a91ff..c7740834 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -15,8 +15,9 @@ per-link RTT and retry behavior belongs to ## Adaptive policy Compressed matches begin at `2/6`: a two-frame send period and six-frame -look-ahead. Each player reports process time and optional worst-local RTT after -32 and 64 frames, then every 128 frames. A player omits the RTT while any of its +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. @@ -31,10 +32,11 @@ 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; -while the headroom persists, each following evaluation steps one more rung. A -worsening or an evaluation without headroom restores the three-evaluation -requirement. +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 diff --git a/tests/netpacket/netcontract.cpp b/tests/netpacket/netcontract.cpp index d1692d8c..08bafbcf 100644 --- a/tests/netpacket/netcontract.cpp +++ b/tests/netpacket/netcontract.cpp @@ -153,7 +153,7 @@ void Test_Event_Contract(void) { Check(EventClass::LATENCYFUDGE == 35, "the last inherited event keeps numeric ID 35"); 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), "NETWORK_REPORT uses its four-byte payload"); + 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"); @@ -366,15 +366,18 @@ void Test_Full_Compressed_Table(void) 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, - "NETWORK_REPORT preserves both millisecond fields"); + && 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 bcd2d3aa..410eb507 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -849,6 +849,43 @@ namespace } + 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; @@ -1063,6 +1100,7 @@ int main(void) 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(); From adc002c75ef104f6e896f08f6aea44d7cf2a78a8 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 2 Sep 2026 16:04:16 +0300 Subject: [PATCH 10/16] Request acknowledgements until every link measures its round trip --- code/queue.cpp | 12 ++++++++++++ manual/content/systems/network-transport-timing.md | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/code/queue.cpp b/code/queue.cpp index cbd4930b..82c6f62e 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -276,6 +276,10 @@ 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 = {}; static constexpr std::uint32_t MAXIMUM_REPORTED_FRAME_LEAD = 250; @@ -749,6 +753,7 @@ 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; @@ -1637,6 +1642,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 @@ -1649,6 +1658,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++; diff --git a/manual/content/systems/network-transport-timing.md b/manual/content/systems/network-transport-timing.md index 94cf2fab..b9f4cd7d 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. For a measured link, the From c93feefd98da79a9e7dfd6f4c3a5e8a9711266c7 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 2 Sep 2026 19:06:40 +0300 Subject: [PATCH 11/16] Draw the -MPDEBUG overlay after the render at any resolution --- code/ipxmgr.cpp | 30 +++++++++++++++--------------- code/ipxmgr.h | 2 +- code/mainloop.cpp | 33 ++++++++++++++------------------- code/queue.cpp | 4 ++-- code/startup.cpp | 5 ----- 5 files changed, 32 insertions(+), 42 deletions(-) diff --git a/code/ipxmgr.cpp b/code/ipxmgr.cpp index 49d0d02f..088484e5 100644 --- a/code/ipxmgr.cpp +++ b/code/ipxmgr.cpp @@ -1404,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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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; @@ -1431,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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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) { @@ -1461,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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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 aa1ccfaa..e500381a 100644 --- a/code/ipxmgr.h +++ b/code/ipxmgr.h @@ -250,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/mainloop.cpp b/code/mainloop.cpp index 8a2ed93e..6cd60c64 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); @@ -304,14 +304,14 @@ 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(); } Update_Fogged_Objects(); Map.Render(); + if (Session.ShowInternetDebug) { + Multiplayer_Debug_Print(); + } } } @@ -743,43 +743,38 @@ 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 = VisibleSurface->Get_Height() - 80; + VisibleSurface->Fill_Rect(Rect(0, top, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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, *VisibleSurface, VisibleSurface->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)); + Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, top + 42), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); - Ipx.Multiplayer_Debug_Print(); + Ipx.Multiplayer_Debug_Print(top); Show_Mouse(); } diff --git a/code/queue.cpp b/code/queue.cpp index 82c6f62e..5aeef21e 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -328,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: @@ -1406,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; } 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; From 19a69af70bc82216a589fd96095fc2ffeb4b3fb9 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 2 Sep 2026 19:06:40 +0300 Subject: [PATCH 12/16] Log network timing reports, evaluations and events --- code/event.cpp | 7 +++++++ code/queue.cpp | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/code/event.cpp b/code/event.cpp index c16f3b24..027a3575 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -1333,6 +1333,8 @@ void EventClass::Execute(void) 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); @@ -1386,6 +1388,11 @@ void EventClass::Execute(void) 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; diff --git a/code/queue.cpp b/code/queue.cpp index 5aeef21e..f7048f3a 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -1497,6 +1497,13 @@ static void Generate_Real_Timing_Event(void) 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))) { From 70d49f547de4e599854b47ee381964c3aa53e64b Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 2 Sep 2026 19:19:22 +0300 Subject: [PATCH 13/16] Remove the legacy latency slowdown from the frame pacing --- code/mainloop.cpp | 22 ------------------- manual/changes/adaptive-network-timing.md | 4 +++- .../systems/network-synchronization.md | 6 +++++ 3 files changed, 9 insertions(+), 23 deletions(-) diff --git a/code/mainloop.cpp b/code/mainloop.cpp index 6cd60c64..db4c5adc 100644 --- a/code/mainloop.cpp +++ b/code/mainloop.cpp @@ -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; diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md index a62319b3..68045226 100644 --- a/manual/changes/adaptive-network-timing.md +++ b/manual/changes/adaptive-network-timing.md @@ -21,7 +21,9 @@ 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. +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. diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index c7740834..1748a67d 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -46,6 +46,12 @@ 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. Rungs 1–2 From cbae9235db636e23164661673efc531d0f30a7bc Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 2 Sep 2026 19:46:00 +0300 Subject: [PATCH 14/16] Draw the -MPDEBUG overlay in the screen render pass --- code/gscreen.cpp | 5 +++++ code/ipxmgr.cpp | 28 ++++++++++++++-------------- code/mainloop.cpp | 23 ++++++++--------------- 3 files changed, 27 insertions(+), 29 deletions(-) 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/ipxmgr.cpp b/code/ipxmgr.cpp index 088484e5..430f3b2f 100644 --- a/code/ipxmgr.cpp +++ b/code/ipxmgr.cpp @@ -1409,17 +1409,17 @@ 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, top + 50), 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, top + 58), 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, top + 66), 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, top + 74), 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; @@ -1431,27 +1431,27 @@ void IPXManagerClass::Multiplayer_Debug_Print(int top) if (house != NULL && house != PlayerPtr) { int scheme = house->Scheme; - Fancy_Text_Print(Connection[i]->Name, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, top + 2), 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, top + 11), 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, top + 18), 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, top + 25), 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, top + 32), 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, top + 39), 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) { @@ -1461,16 +1461,16 @@ void IPXManagerClass::Multiplayer_Debug_Print(int top) } } sprintf(buffer, "Process : %d", process_time); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, top + 46), 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, top + 53), 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, top + 60), 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, top + 67), 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/mainloop.cpp b/code/mainloop.cpp index db4c5adc..feefb3f0 100644 --- a/code/mainloop.cpp +++ b/code/mainloop.cpp @@ -287,9 +287,6 @@ bool Main_Loop(void) } Update_Fogged_Objects(); Map.Render(); - if (Session.ShowInternetDebug) { - Multiplayer_Debug_Print(); - } } } @@ -727,34 +724,30 @@ void Multiplayer_Debug_Print(void) return; } - Hide_Mouse(); - - int const top = VisibleSurface->Get_Height() - 80; - VisibleSurface->Fill_Rect(Rect(0, top, VisibleSurface->Get_Width(), 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, top + 2), 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, top + 10), 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, top + 18), 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, top + 26), 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, top + 34), 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, top + 42), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 42), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); Ipx.Multiplayer_Debug_Print(top); - - Show_Mouse(); } From b931124fb5518bd6850dedaebfa974a2463abdd5 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Wed, 2 Sep 2026 19:46:00 +0300 Subject: [PATCH 15/16] Document the mirrored Connection slider --- manual/content/systems/network-synchronization.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index 1748a67d..e121cb5c 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -54,8 +54,10 @@ 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. Rungs 1–2 -are Fast, 3–5 Normal, 6–8 Poor, and 9–10 Bad; extended look-ahead is also Bad. +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. From 4c450c26b6412b0bc2749d3ac8d40c312c748a1a Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sat, 5 Sep 2026 03:56:13 +0300 Subject: [PATCH 16/16] Preserve deferred multiplayer events in recordings --- code/nettiming.cpp | 7 + code/nettiming.h | 1 + code/queue.cpp | 25 +-- .../systems/network-synchronization.md | 5 +- tests/nettiming/nettiming.cpp | 166 ++++++++++++++++++ 5 files changed, 184 insertions(+), 20 deletions(-) diff --git a/code/nettiming.cpp b/code/nettiming.cpp index ce8d2ba0..db2e2135 100644 --- a/code/nettiming.cpp +++ b/code/nettiming.cpp @@ -644,4 +644,11 @@ namespace NetTiming { return(static_cast(frame - activation_frame) >= 0); } + + + /// Includes unexecuted events whose scheduled frame was skipped by a send-period change. + bool Event_Is_Due(int event_frame, bool is_executed, int frame) + { + return(!is_executed && event_frame <= frame); + } } diff --git a/code/nettiming.h b/code/nettiming.h index 72efb2a6..cc0f6055 100644 --- a/code/nettiming.h +++ b/code/nettiming.h @@ -224,4 +224,5 @@ namespace NetTiming 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); + bool Event_Is_Due(int event_frame, bool is_executed, int frame); } diff --git a/code/queue.cpp b/code/queue.cpp index f7048f3a..4f3734b8 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -554,13 +554,6 @@ static void Queue_AI_Normal(void) OutList.pop_front(); } - //------------------------------------------------------------------------ - // Save the DoList to disk, if we're in "Record" mode - //------------------------------------------------------------------------ - if (Session.Record) { - Queue_Record(); - } - //------------------------------------------------------------------------ // Execute the DoList; if an error occurs, bail out. //------------------------------------------------------------------------ @@ -909,13 +902,6 @@ static void Queue_AI_Multiplayer(void) return; } - //------------------------------------------------------------------------ - // Save the DoList to disk, if we're in "Record" mode - //------------------------------------------------------------------------ - if (Session.Record) { - Queue_Record(); - } - //------------------------------------------------------------------------ // Execute the DoList; if an error occurs, bail out. //------------------------------------------------------------------------ @@ -3376,6 +3362,10 @@ static int Execute_DoList(int max_houses, HousesType base_house, } #endif + if (Session.Record && !Session.Play) { + Queue_Record(); + } + //------------------------------------------------------------------------ // Execute the DoList. Events must be executed in the same order on all // systems; so, execute them in the order of the HouseClass array. This @@ -3416,8 +3406,7 @@ static int Execute_DoList(int max_houses, HousesType base_house, // If this event was from the currently-executing player ID, and it's // time to execute it, execute it. //.................................................................. - if (DoList[j].ID == hptr->HeapID && Frame >= DoList[j].Frame && - !DoList[j].IsExecuted) { + if (DoList[j].ID == hptr->HeapID && NetTiming::Event_Is_Due(DoList[j].Frame, DoList[j].IsExecuted, Frame)) { //............................................................... // Error if it's too late to execute this packet! @@ -3655,7 +3644,7 @@ static void Queue_Record(void) //------------------------------------------------------------------------ j = 0; for (i = 0; i < (int)DoList.size(); i++) { - if (Frame == DoList[i].Frame && !DoList[i].IsExecuted) { + if (NetTiming::Event_Is_Due(DoList[i].Frame, DoList[i].IsExecuted, Frame)) { j++; } } @@ -3665,7 +3654,7 @@ static void Queue_Record(void) //------------------------------------------------------------------------ Session.RecordFile.Write (&j,sizeof(j)); for (i = 0; i < (int)DoList.size(); i++) { - if (Frame == DoList[i].Frame && !DoList[i].IsExecuted) { + if (NetTiming::Event_Is_Due(DoList[i].Frame, DoList[i].IsExecuted, Frame)) { Session.RecordFile.Write (&DoList[i],sizeof (EventClass)); j--; } diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index e121cb5c..1dbcfeea 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -42,8 +42,9 @@ 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 +every machine. Recordings keep that event in its execution batch with its +scheduled frame unchanged. 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 diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp index 410eb507..43192412 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -1078,6 +1079,170 @@ namespace std::optional const conservative = Stage_Timing_Update(current, {10, 250}, 369); Expect("a fully conservative replacement applies immediately", conservative && !conservative->Deferred); } + + struct RecordingEvent + { + enum Kind : unsigned int {COMMAND, TIMING, FRAME_INFO}; + + int Frame; + Kind Type; + unsigned int Value; + NetTiming::TimingSettings Settings = {}; + bool IsExecuted = false; + + bool operator==(RecordingEvent const &) const = default; + }; + + + struct RecordedExecution + { + int Frame; + RecordingEvent Event; + + bool operator==(RecordedExecution const &) const = default; + }; + + + std::vector Run_Recorded_Transitions(std::stringstream & recording, bool playback, std::vector events) + { + using namespace NetTiming; + + TimingSettings current{3, 9}; + std::optional transition; + std::vector trace; + int reschedule_after = 0; + int reschedule_to = 0; + int previous_execution_frame = 93; + for (int frame = 96; frame <= 159; frame++) { + if (transition) { + std::optional const advance = Advance_Timing_Transition(*transition, current, frame); + Expect("recorded transition advances", advance.has_value()); + if (!advance) { + return(trace); + } + current = advance->Settings; + if (advance->Complete) { + transition.reset(); + } + } + if (frame % current.FrameSendRate != 0) { + continue; + } + + if (playback) { + int count = 0; + recording.read(reinterpret_cast(&count), sizeof(count)); + Expect("playback reads each execution batch", recording.good() && count >= 0 && count <= 10); + if (!recording.good() || count < 0 || count > 10) { + return(trace); + } + for (int index = 0; index < count; index++) { + RecordingEvent event{}; + recording.read(reinterpret_cast(&event), sizeof(event)); + Expect("playback reads a complete event", recording.good()); + event.IsExecuted = false; + events.push_back(event); + } + } + + for (RecordingEvent & event : events) { + if (event.Type != RecordingEvent::FRAME_INFO && event.Frame > reschedule_after && event.Frame < reschedule_to) { + event.Frame = reschedule_to; + } + } + + if (!playback) { + int count = 0; + for (RecordingEvent const & event : events) { + count += Event_Is_Due(event.Frame, event.IsExecuted, frame); + } + recording.write(reinterpret_cast(&count), sizeof(count)); + for (RecordingEvent const & event : events) { + if (Event_Is_Due(event.Frame, event.IsExecuted, frame)) { + recording.write(reinterpret_cast(&event), sizeof(event)); + } + } + } + + for (RecordingEvent & event : events) { + if (!Event_Is_Due(event.Frame, event.IsExecuted, frame)) { + continue; + } + Expect("recorded command remains eligible after the previous execution", event.Type == RecordingEvent::FRAME_INFO + || event.Frame > previous_execution_frame); + trace.push_back({frame, event}); + if (event.Type == RecordingEvent::TIMING) { + std::optional const plan = Stage_Timing_Update(current, event.Settings, event.Frame); + Expect("recorded timing event can be scheduled", plan.has_value()); + if (!plan) { + return(trace); + } + if (plan->Deferred) { + transition = TimingTransitionState{*plan}; + reschedule_after = 0; + reschedule_to = 0; + } else { + transition.reset(); + current = plan->Settings; + reschedule_after = event.Frame; + reschedule_to = ((event.Frame + current.MaxAhead + current.FrameSendRate - 1) + / current.FrameSendRate) * current.FrameSendRate; + } + } + event.IsExecuted = true; + } + previous_execution_frame = frame; + } + return(trace); + } + + + void Test_Recorded_Transitions(void) + { + std::stringstream recording(std::ios::in | std::ios::out | std::ios::binary); + std::vector events{ + {99, RecordingEvent::TIMING, 1, {2, 6}}, + {111, RecordingEvent::COMMAND, 2}, + {116, RecordingEvent::TIMING, 3, {5, 15}}, + {118, RecordingEvent::COMMAND, 4}, + {135, RecordingEvent::TIMING, 5, {3, 9}}, + {155, RecordingEvent::COMMAND, 6}, + {111, RecordingEvent::FRAME_INFO, 7}, + {124, RecordingEvent::FRAME_INFO, 8}, + {180, RecordingEvent::COMMAND, 9}, + {90, RecordingEvent::COMMAND, 10, {}, true}, + }; + std::vector const live = Run_Recorded_Transitions(recording, false, events); + recording.seekg(0); + int first_batch_count = -1; + recording.read(reinterpret_cast(&first_batch_count), sizeof(first_batch_count)); + Expect_Equal("recording keeps empty execution batches", first_batch_count, 0); + recording.seekg(0); + std::vector const replay = Run_Recorded_Transitions(recording, true, {}); + Expect("playback preserves transition and command execution", live == replay); + Expect("playback consumes exactly the recorded batches", recording.peek() == std::char_traits::eof()); + Expect_Equal("future and already executed events are excluded", live.size(), std::size_t{8}); + bool first_skipped_command = false; + bool second_skipped_command = false; + bool retagged_command = false; + bool unchanged_frame_info = false; + for (RecordedExecution const & execution : replay) { + if (execution.Event.Value == 2) { + first_skipped_command = execution.Frame == 112 && execution.Event.Frame == 111; + } else if (execution.Event.Value == 6) { + second_skipped_command = execution.Frame == 156 && execution.Event.Frame == 155; + } else if (execution.Event.Value == 4) { + retagged_command = execution.Frame == 135 && execution.Event.Frame == 135; + } else if (execution.Event.Value == 8) { + unchanged_frame_info = execution.Frame == 125 && execution.Event.Frame == 124; + } + } + Expect("3/9 to 2/6 recording preserves the skipped scheduled frame", first_skipped_command); + Expect("5/15 to 3/9 recording preserves the skipped scheduled frame", second_skipped_command); + Expect("worsening retags a command before recording its execution batch", retagged_command); + Expect("worsening does not retag frame information", unchanged_frame_info); + } + } @@ -1104,6 +1269,7 @@ int main(void) Test_Master_Handoff_State(); Test_Staged_Decrease(); Test_Transition_Sequences(); + Test_Recorded_Transitions(); if (Failures != 0) { std::cerr << Failures << " network timing checks failed\n";