diff --git a/.github/workflows/engine-build.yml b/.github/workflows/engine-build.yml index 78594db3c..b591e4e83 100644 --- a/.github/workflows/engine-build.yml +++ b/.github/workflows/engine-build.yml @@ -18,6 +18,11 @@ on: required: false default: '["Debug", "Release"]' type: string + platforms: + description: "JSON array of target platforms, named as the generator's -A argument." + required: false + default: '["Win32", "x64"]' + type: string official: description: "Configure an official build, which reports the version it declares without a commit." required: false @@ -29,7 +34,7 @@ permissions: jobs: build: - name: ${{ matrix.configuration }} + name: ${{ matrix.platform }} ${{ matrix.configuration }} # The runner image has to carry the Visual Studio 2022 toolchain that docs/BUILDING.md # names as the supported one. windows-2025 and windows-latest resolve to an image built # around Visual Studio 2026 and have no 2022 instance for the generator to find, so they @@ -39,6 +44,7 @@ jobs: strategy: fail-fast: false matrix: + platform: ${{ fromJSON(inputs.platforms) }} configuration: ${{ fromJSON(inputs.configurations) }} steps: - name: Check out OpenTS @@ -59,7 +65,7 @@ jobs: - name: Configure run: > - cmake -S . -B build -G "Visual Studio 17 2022" -A Win32 + cmake -S . -B build -G "Visual Studio 17 2022" -A ${{ matrix.platform }} -DOPENTS_OFFICIAL_BUILD=${{ inputs.official && 'ON' || 'OFF' }} - name: Build @@ -102,7 +108,7 @@ jobs: - name: Upload runtime files uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: ${{ inputs.artifact-prefix }}-${{ matrix.configuration }}-${{ steps.commit.outputs.short }} + name: ${{ inputs.artifact-prefix }}-${{ matrix.platform }}-${{ matrix.configuration }}-${{ steps.commit.outputs.short }} path: artifact if-no-files-found: error retention-days: ${{ inputs.artifact-retention-days }} diff --git a/.github/workflows/engine-release.yml b/.github/workflows/engine-release.yml index db7f5173c..901dec7a6 100644 --- a/.github/workflows/engine-release.yml +++ b/.github/workflows/engine-release.yml @@ -28,24 +28,29 @@ jobs: env: TAG: ${{ github.event.release.tag_name }} steps: - - name: Download the build artifact + - name: Download the build artifacts + # Each platform keeps its own directory here. Merging them would put two + # different Game.exe files in one place. uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - pattern: opents-release-Release-* - merge-multiple: true + pattern: opents-release-*-Release-* path: artifact - - name: Package the release zip - working-directory: artifact - run: > - zip -r "../OpenTS-${TAG}.zip" - Game.exe Game.pdb Language.dll - LICENSE.md THIRD_PARTY_NOTICES.md OpenTS_THIRD_PARTY_LICENSES + - name: Package a release zip for each platform + run: | + for dir in artifact/opents-release-*-Release-*; do + # Artifacts are named ---, and the + # prefix itself contains a hyphen, so count the fields from the end. + platform="$(basename "${dir}" | awk -F- '{print $(NF-2)}')" + ( cd "${dir}" && zip -r "${GITHUB_WORKSPACE}/OpenTS-${TAG}-${platform}.zip" \ + Game.exe Game.pdb Language.dll \ + LICENSE.md THIRD_PARTY_NOTICES.md OpenTS_THIRD_PARTY_LICENSES ) + done - - name: Attach the zip to the release + - name: Attach the zips to the release env: GH_TOKEN: ${{ github.token }} - run: gh release upload "$TAG" "OpenTS-${TAG}.zip" --clobber --repo "$GITHUB_REPOSITORY" + run: gh release upload "$TAG" OpenTS-"$TAG"-*.zip --clobber --repo "$GITHUB_REPOSITORY" notes: name: Append generated release notes diff --git a/AGENTS.md b/AGENTS.md index 2963565c6..03677c867 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,8 @@ The archived TibSun reconstruction and original executable are historical evidence. Binary matching is not the acceptance criterion for active OpenTS development. -Visual Studio 2022 Win32 Debug and Release are the supported build target. -A build result is not runtime evidence. +Visual Studio 2022 Win32 and x64, each in Debug and Release, are the +supported build targets. A build result is not runtime evidence. ## Writing prose diff --git a/CMakeLists.txt b/CMakeLists.txt index 51ebf94be..91814a0f9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,7 +12,6 @@ set(OPENTS_VERSION_PRERELEASE "") option(OPENTS_OFFICIAL_BUILD "Build as an official release of the declared version" OFF) option(OPENTS_EXPERIMENTAL_CLANG_CL "Build with clang-cl using the MSVC ABI" OFF) -option(OPENTS_EXPERIMENTAL_X64 "Configure an unsupported 64-bit Windows build" OFF) option(OPENTS_EXPERIMENTAL_NATIVE "Configure a native build for the host platform" OFF) # Lets a target's FOLDER place it in a Visual Studio solution folder. The bgfx submodule sets @@ -31,14 +30,9 @@ elseif(MSVC) if(MSVC_VERSION LESS 1930) message(FATAL_ERROR "OpenTS requires MSVC 19.30 or newer.") endif() - if(NOT CMAKE_SIZEOF_VOID_P EQUAL 4) - # A save records pointer identities at a fixed width, but the members and raw - # structures around them still travel at the build's own widths, and the packed - # version stamp that saves and network packets carry is the same either way. - message(WARNING - "OpenTS: this build has ${CMAKE_SIZEOF_VOID_P}-byte pointers. Saved games it " - "writes are not interchangeable with a supported 32-bit build's, and nothing in " - "the version stamp distinguishes them.") + if(NOT CMAKE_SIZEOF_VOID_P EQUAL 4 AND NOT CMAKE_SIZEOF_VOID_P EQUAL 8) + message(FATAL_ERROR + "OpenTS targets Win32 and x64. Reconfigure with -A Win32 or -A x64.") endif() elseif(OPENTS_EXPERIMENTAL_NATIVE) if(IOS) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84f188fcc..9659c2bd2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,9 @@ # Contributing to OpenTS OpenTS welcomes focused bug reports, proposals, documentation changes, and -pull requests. Visual Studio 2022 Win32 Debug and Release are the supported -development targets. A successful build is not runtime evidence. +pull requests. Visual Studio 2022 Win32 and x64, each in Debug and Release, +are the supported development targets. A successful build is not runtime +evidence. ## Before starting @@ -134,10 +135,10 @@ or behavior that optimization may affect. Existing MSVC warnings remain; identify new warnings instead of describing the build as warning-free. Behavior changes need focused, reproducible evidence. Automated tests must not -require proprietary game assets or original executables. CI builds Win32 Debug -and Release and runs CTest for ready engine pull requests; draft pull requests -do not run these checks until marked ready. This is build evidence and does not -replace any runtime testing the change needs. +require proprietary game assets or original executables. CI builds Debug and +Release on both platforms and runs CTest for ready engine pull requests; draft +pull requests do not run these checks until marked ready. This is build +evidence and does not replace any runtime testing the change needs. [Building OpenTS](docs/BUILDING.md#continuous-integration) documents the workflow. diff --git a/README.md b/README.md index 69af7d9b9..c3950c285 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,12 @@ endorsed by Electronic Arts. ## Downloads -- **Releases** are the recommended builds. Each zip on the - [releases page](https://github.com/OpenTS-Developers/OpenTS/releases) - contains `Game.exe`, `Language.dll`, and `Game.pdb`. +- **Releases** are the recommended builds. Every release on the + [releases page](https://github.com/OpenTS-Developers/OpenTS/releases) carries + a zip per platform, `OpenTS--Win32.zip` and + `OpenTS--x64.zip`, each containing `Game.exe`, `Language.dll`, and + `Game.pdb`. The 32-bit build runs on both 32-bit and 64-bit Windows and has + the longer runtime history; the 64-bit build runs on 64-bit Windows only. - **Nightly builds** are development snapshots from the [Engine nightly](https://github.com/OpenTS-Developers/OpenTS/actions/workflows/engine-nightly.yml) workflow. Download the latest one without a GitHub account through @@ -73,6 +76,12 @@ OpenTS supports Windows 10 version 1903 (build 18362) and newer. Earlier Windows versions are untested and unsupported. Wine may work, but there is no supported native Linux build. +Keep a saved game with the platform that wrote it, and play a network game with +peers on the same platform. The 32-bit and 64-bit builds write saves and +network packets at their own pointer widths, and neither checks which platform +produced what it is reading, so a mismatch surfaces as a failed load or a +desync. + OpenTS supplies the engine, not the game data: the installation above provides the original assets. There is no installer, and no extra runtime library or launch argument is required. @@ -223,7 +232,7 @@ reasoning. ## Building -OpenTS builds as a 32-bit Windows target with Visual Studio 2022 and CMake. +OpenTS builds for 32-bit and 64-bit Windows with Visual Studio 2022 and CMake. [Building OpenTS](docs/BUILDING.md) documents the exact requirements, commands, and outputs. diff --git a/code/CMakeLists.txt b/code/CMakeLists.txt index d1667b5ce..89861f725 100644 --- a/code/CMakeLists.txt +++ b/code/CMakeLists.txt @@ -1,14 +1,6 @@ # Limit the generated solution to supported configurations. set(CMAKE_CONFIGURATION_TYPES Debug Release CACHE STRING "" FORCE) -# OpenTS supports 32-bit (x86) builds. A 64-bit build is an unsupported experiment. -if(WIN32 AND NOT CMAKE_SIZEOF_VOID_P EQUAL 4 AND NOT OPENTS_EXPERIMENTAL_X64) - message(FATAL_ERROR - "OpenTS must be built as 32-bit x86. Reconfigure with -A Win32. " - "For the unsupported 64-bit experiment, configure with " - "-A x64 -DOPENTS_EXPERIMENTAL_X64=ON.") -endif() - # # --------------------------------------------------------- # Directories that build as their own project diff --git a/code/_event.cpp b/code/_event.cpp index 26403dd6f..66e95718c 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/abstype.cpp b/code/abstype.cpp index 4a8beb9e7..65299f007 100644 --- a/code/abstype.cpp +++ b/code/abstype.cpp @@ -21,6 +21,7 @@ #include "savestream.h" #include "vector.h" +#include #include @@ -47,8 +48,8 @@ AbstractTypeClass::AbstractTypeClass(char const * ininame) : GivenName() { if (ininame == NULL) { - char pstr[24]; - sprintf(pstr, "%p", (void *)this); + char pstr[2 * sizeof(void *) + 1]; + sprintf(pstr, "%0*" PRIXPTR, (int)(2 * sizeof(void *)), (uintptr_t)this); IniName = TStringID<24>(pstr); } else { IniName = TStringID<24>(ininame); diff --git a/code/combuf.h b/code/combuf.h index 03ae6d932..c5da4803c 100644 --- a/code/combuf.h +++ b/code/combuf.h @@ -59,6 +59,10 @@ struct SendQueueType { unsigned int IsUndeliverable : 1; /// 1 = gave up on it (retries or timeout) unsigned int FirstTime; // time this packet was first sent unsigned int LastTime; // time this packet was last sent + // The adaptive retry works in milliseconds; FirstTime and LastTime above stay in ticks. + unsigned int FirstTimeMilliseconds = 0; + unsigned int LastTimeMilliseconds = 0; + unsigned int RetransmitTimeoutMilliseconds = 0; // the RTO in force at the first transmission unsigned int SendCount; // # of times this packet has been sent int BufLen; // size of the packet stored in this entry char *Buffer; // the data packet diff --git a/code/connect.cpp b/code/connect.cpp index f074349f5..f8a3e35fc 100644 --- a/code/connect.cpp +++ b/code/connect.cpp @@ -47,8 +47,11 @@ #include "_timer.h" #include "dbgprint.h" +#include +#include #include #include +#include /* @@ -60,6 +63,25 @@ char const * ConnectionClass::Commands[PACKET_COUNT] = { "ACK" }; +namespace { + +NetTiming::Milliseconds Ticks_To_Milliseconds(unsigned int ticks) +{ + std::uint64_t const milliseconds = (static_cast(ticks) * 1000 + TIMER_SECOND - 1) / TIMER_SECOND; + if (milliseconds > std::numeric_limits::max()) { + return(std::numeric_limits::max()); + } + return(static_cast(milliseconds)); +} + + +NetTiming::Milliseconds Legacy_Connection_Timeout(unsigned int ticks) +{ + return(std::clamp(Ticks_To_Milliseconds(ticks), NetTiming::MINIMUM_CONNECTION_TIMEOUT, NetTiming::MAXIMUM_CONNECTION_TIMEOUT)); +} + +} + /*************************************************************************** * ConnectionClass::ConnectionClass -- class constructor * @@ -75,6 +97,7 @@ char const * ConnectionClass::Commands[PACKET_COUNT] = { * timeout the max amount of time before we give up on a packet* * (-1 means retry forever, based on this parameter) * * extralen max size of app-specific extra bytes (optional) * + * clock monotonic millisecond clock (default if NULL) * * * * OUTPUT: * * none. * @@ -87,7 +110,7 @@ char const * ConnectionClass::Commands[PACKET_COUNT] = { *=========================================================================*/ ConnectionClass::ConnectionClass (int numsend, int numreceive, int maxlen, unsigned short magicnum, unsigned int retry_delta, - unsigned int max_retries, unsigned int timeout, int extralen) + unsigned int max_retries, unsigned int timeout, int extralen, NetTiming::MillisecondClock const * clock) { /*------------------------------------------------------------------------ Compute our maximum packet length @@ -114,6 +137,7 @@ ConnectionClass::ConnectionClass (int numsend, int numreceive, Set the timeout for this connection. ------------------------------------------------------------------------*/ Timeout = timeout; + MillisecondTime = clock != nullptr ? clock : &NetTiming::Default_Clock(); /*------------------------------------------------------------------------ Allocate the packet staging buffer. This will be used to @@ -190,6 +214,8 @@ void ConnectionClass::Init (void) LastSeqID = 0xffffffff; LastReadID = 0xffffffff; + RoundTripEstimator.Reset(); + IsBad = false; Queue->Init(); @@ -718,11 +744,10 @@ int ConnectionClass::Service (void) been ACK'd yet. Entries that the app has read, and have been ACK'd, should be removed. ------------------------------------------------------------------------*/ - if ( Service_Send_Queue() && Service_Receive_Queue() ) { - return(1); - } else { - return(0); - } + int const send_status = Service_Send_Queue(); + int const receive_status = Service_Receive_Queue(); + IsBad = !(send_status && receive_status); + return(IsBad ? 0 : 1); } /* end of Service */ @@ -747,7 +772,7 @@ int ConnectionClass::Service_Send_Queue (void) int i; int num_entries; SendQueueType *send_entry; // ptr to send queue entry - CommHeaderType *packet_hdr; // packet header + CommHeaderType packet_header; // packet header unsigned int curtime; // current time int bad_conn = 0; @@ -768,9 +793,15 @@ int ConnectionClass::Service_Send_Queue (void) /*.................................................................. Update this queue's response time ..................................................................*/ - packet_hdr = (CommHeaderType *)send_entry->Buffer; - if (packet_hdr->Code == PACKET_DATA_ACK) { - Queue->Add_Delay(Time() - send_entry->FirstTime); + if (send_entry->BufLen >= (int)sizeof(CommHeaderType)) { + CommHeaderType header; + memcpy(&header, send_entry->Buffer, sizeof(header)); + if (header.Code == PACKET_DATA_ACK) { + Queue->Add_Delay(Time() - send_entry->FirstTime); + if (Adaptive_Timing_Enabled()) { + RoundTripEstimator.Acknowledge(send_entry->FirstTimeMilliseconds, send_entry->SendCount, *MillisecondTime); + } + } } /*.................................................................. @@ -786,6 +817,18 @@ int ConnectionClass::Service_Send_Queue (void) need it. ------------------------------------------------------------------------*/ num_entries = Queue->Num_Send(); + curtime = Time(); + NetTiming::Milliseconds const current_milliseconds = MillisecondTime->Now(); + bool const adaptive_channel = Adaptive_Timing_Enabled(); + bool const adaptive_timing = adaptive_channel && RoundTripEstimator.Has_Sample(); + bool const timeout_enabled = Timeout != (unsigned int)-1; + NetTiming::Milliseconds const connection_timeout = !timeout_enabled + ? NetTiming::MAXIMUM_CONNECTION_TIMEOUT + : (adaptive_timing ? NetTiming::Connection_Timeout(RoundTripEstimator.Smoothed_Rtt(), RoundTripEstimator.Retransmit_Timeout()) + : (adaptive_channel ? Legacy_Connection_Timeout(Timeout) : Ticks_To_Milliseconds(Timeout))); + NetTiming::Milliseconds const base_retry_timeout = adaptive_timing + ? NetTiming::Initial_Retry_Timeout(RoundTripEstimator.Retransmit_Timeout(), connection_timeout) + : Ticks_To_Milliseconds(RetryDelta); for (i = 0; i < num_entries; i++) { send_entry = Queue->Get_Send(i); @@ -794,13 +837,19 @@ int ConnectionClass::Service_Send_Queue (void) continue; } - /*..................................................................... - Only send the message if time has elapsed. (The message's Time - fields are init'd to 0 when a message is queue'd or unqueue'd, so the - first time through, the delta time will appear large.) - .....................................................................*/ - curtime = Time(); - if (curtime - send_entry->LastTime > RetryDelta) { + NetTiming::RetransmitState const retransmit_state{ + send_entry->FirstTimeMilliseconds, + send_entry->LastTimeMilliseconds, + send_entry->RetransmitTimeoutMilliseconds, + send_entry->SendCount + }; + NetTiming::RetryDecision const retry_decision = NetTiming::Evaluate_Retry( + retransmit_state, current_milliseconds, base_retry_timeout, connection_timeout, timeout_enabled, adaptive_channel); + if (retry_decision.TimedOut) { + bad_conn = 1; + send_entry->IsUndeliverable = true; + } + if (retry_decision.Send) { /*.................................................................. Send the message @@ -812,20 +861,26 @@ int ConnectionClass::Service_Send_Queue (void) Fill in Time fields ..................................................................*/ send_entry->LastTime = curtime; + send_entry->LastTimeMilliseconds = current_milliseconds; if (send_entry->SendCount==0) { send_entry->FirstTime = curtime; + send_entry->FirstTimeMilliseconds = current_milliseconds; + send_entry->RetransmitTimeoutMilliseconds = base_retry_timeout; /*............................................................... If this is the 1st time we're sending this packet, and it doesn't require an ACK, mark it as ACK'd; then, the next time through, it will just be removed from the queue. ...............................................................*/ - packet_hdr = (CommHeaderType *)send_entry->Buffer; - if (packet_hdr->Code == PACKET_DATA_NOACK) { + memcpy(&packet_header, send_entry->Buffer, sizeof(packet_header)); + if (packet_header.Code == PACKET_DATA_NOACK) { send_entry->IsACK = 1; } } else { NumResends++; + if (adaptive_channel) { + RoundTripEstimator.Note_Retransmit(send_entry->RetransmitTimeoutMilliseconds); + } } /*.................................................................. @@ -840,12 +895,6 @@ int ConnectionClass::Service_Send_Queue (void) bad_conn = 1; send_entry->IsUndeliverable = true; } - - if (Timeout != -1 && - (send_entry->LastTime - send_entry->FirstTime) > Timeout) { - bad_conn = 1; - send_entry->IsUndeliverable = true; - } } } @@ -947,6 +996,16 @@ unsigned int ConnectionClass::Time (void) } /* end of Time */ +/// Returns this link's smoothed round trip, or nothing until a clean acknowledgement measures it. +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 6f76829cb..9b7c9cf1e 100644 --- a/code/connect.h +++ b/code/connect.h @@ -98,6 +98,9 @@ */ #include "combuf.h" #include "netadmit.h" +#include "nettiming.h" + +#include #include #include @@ -148,9 +151,8 @@ class ConnectionClass /*..................................................................... Constructor/destructor. .....................................................................*/ - ConnectionClass (int numsend, int numrecieve, int maxlen, - unsigned short magicnum, unsigned int retry_delta, - unsigned int max_retries, unsigned int timeout, int extralen = 0); + ConnectionClass (int numsend, int numrecieve, int maxlen, unsigned short magicnum, unsigned int retry_delta, + unsigned int max_retries, unsigned int timeout, int extralen = 0, NetTiming::MillisecondClock const *clock = nullptr); virtual ~ConnectionClass (void); /*..................................................................... @@ -190,6 +192,8 @@ class ConnectionClass unsigned int Time_Out (void) { return(Timeout); } void Set_TimeOut (unsigned int t) { Timeout = t;} unsigned int Max_Packet_Len (void) { return(MaxPacketLen); } + void Reset_Round_Trip_Time(void) {RoundTripEstimator.Reset();} + std::optional Smoothed_Round_Trip_MS(void) const; static const char * Command_Name(int command); int Num_Resends(void) const { return(NumResends); } @@ -197,6 +201,7 @@ class ConnectionClass int Percent_Lost(void) const { return(PercentLost); } int Missed_Overall(void) const { return(MissedOverall); } int Missed_Magic(void) const { return(MissedMagic); } + bool Is_Bad(void) const { return(IsBad); } enum PacketDropReasonType { CONNECTION_DROP_SHORT_HEADER, @@ -232,8 +237,8 @@ class ConnectionClass is protected; it's only called by the ACK/Retry logic, not the application. .....................................................................*/ - virtual int Send(char *buf, int buflen, void *extrabuf, - int extralen) = 0; + virtual int Send(char *buf, int buflen, void *extrabuf, int extralen) = 0; + virtual bool Adaptive_Timing_Enabled(void) const {return(true);} void Record_Packet_Drop(PacketDropReasonType reason); void Record_Admission_Drop(NetAdmission::Error error, unsigned char code); @@ -298,6 +303,11 @@ class ConnectionClass .....................................................................*/ unsigned int Timeout; + // An injected clock must outlive the connection. + NetTiming::MillisecondClock const *MillisecondTime; + NetTiming::RttEstimator RoundTripEstimator; + bool IsBad = false; + /*..................................................................... Running totals of # of packets we send & receive which require an ACK, and those that don't. diff --git a/code/connmgr.h b/code/connmgr.h index 82ef04e28..fe8734b72 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/display.cpp b/code/display.cpp index b9775bef5..1ad38d1b2 100644 --- a/code/display.cpp +++ b/code/display.cpp @@ -3383,7 +3383,7 @@ void DisplayClass::Write_INI(CCINIClass & ini) /* ** Generate entry name. */ - wsprintf(entry, "%d", x + (y * 1000)); + snprintf(entry, sizeof(entry), "%d", x + (y * 1000)); /* ** Save entry. diff --git a/code/event.cpp b/code/event.cpp index 62a6d2164..e527fbd4d 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); @@ -708,7 +717,6 @@ void EventClass::Execute(void) // bool formation = false; int i; int index; - unsigned int ul; // RTTIType rt; //if (Debug_Print_Events) { @@ -1210,7 +1218,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; } @@ -1252,6 +1260,7 @@ void EventClass::Execute(void) DebugString("Executing REMOVEPLAYER event. Frame is %d\n", ::Frame); SaveManager.Disable_Multiplayer_Saving(); + Session.Remove_Network_Timing_Player(index, Frame >= 0 ? static_cast(Frame) : 0u); house = Houses[index]; if (house->IsObserver) { break; @@ -1297,17 +1306,48 @@ void EventClass::Execute(void) break; } - unsigned int const padding = Scen->Special.IsFogOfWar ? 10 : 0; - if (Data.Timing.MaxAhead < padding) { + if (Session.CommProtocol != COMM_PROTOCOL_MULTI_E_COMP || Frame < 0) { + Log_Event_Rejection(EventRejectReason::InvalidTimingArithmetic, Type, ID, Frame); + break; + } + if (!NetSemantic::Timing_Values_Are_Valid(Data.Timing.DesiredFrameRate, Data.Timing.MaxAhead, Data.Timing.FrameSendRate)) { Log_Event_Rejection(EventRejectReason::InvalidTimingValues, Type, ID, Data.Timing.MaxAhead); break; } - unsigned int const max_ahead = Data.Timing.MaxAhead - padding; - if (!NetSemantic::Timing_Values_Are_Valid(Data.Timing.DesiredFrameRate, max_ahead, Data.Timing.FrameSendRate)) { - Log_Event_Rejection(EventRejectReason::InvalidTimingValues, Type, ID, max_ahead); + + NetTiming::TimingSettings const settings{Data.Timing.FrameSendRate, Data.Timing.MaxAhead}; + NetTiming::ConnectionQuality const old_quality = NetTiming::Connection_Quality_For_Settings(Session.Network_Timing_Target()); + unsigned int const old_frame_send_rate = Session.FrameSendRate; + unsigned int const old_max_ahead = Session.MaxAhead; + + if (settings.MaxAhead > old_max_ahead || settings.FrameSendRate > old_frame_send_rate) { + std::uint64_t const boundary = settings.FrameSendRate * ((static_cast(Frame) + NetTiming::MAXIMUM_MAX_AHEAD + + settings.FrameSendRate - 1) / settings.FrameSendRate); + if (boundary > static_cast((std::numeric_limits::max)())) { + Log_Event_Rejection(EventRejectReason::InvalidTimingArithmetic, Type, ID, Frame); + break; + } + } + + NetTiming::ScheduleResult const result = Session.Schedule_Network_Timing(settings, Data.Timing.DesiredFrameRate, static_cast(Frame)); + if (result == NetTiming::ScheduleResult::Rejected) { + Log_Event_Rejection(EventRejectReason::UnschedulableTiming, Type, ID, static_cast(settings.MaxAhead)); break; } + DebugString("Network timing event at frame %d from player %d: %u/%u at %u fps %s\n", Frame, ID, settings.FrameSendRate, settings.MaxAhead, + Data.Timing.DesiredFrameRate, result == NetTiming::ScheduleResult::Applied ? "applied" : "staged"); + NetTiming::ConnectionQuality const quality = NetTiming::Connection_Quality_For_Settings(settings); + if (quality != old_quality) { + char const * format = Fetch_String(TXT_CONNECTION_QUALITY_STATUS); + char const * quality_name = Fetch_String(Network_Quality_Text_ID(quality)); + if (format != NULL && quality_name != NULL && format[0] != '\0' && quality_name[0] != '\0') { + snprintf(msg, sizeof(msg), format, quality_name); + Session.Messages.Add_Message(NULL, 0, msg, house->Scheme, + TextPrintType(TPF_6PT_GRAD|TPF_USE_GRAD_PAL|TPF_FULLSHADOW), Rule->MessageDelay * TICKS_PER_MINUTE); + } + } + #if (TIMING_FIX) // // If MaxAhead is about to increase, we're vulnerable to a Packet- @@ -1316,26 +1356,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; } @@ -1353,6 +1383,21 @@ void EventClass::Execute(void) } break; + case NETWORK_REPORT: + // A recording started without a roster has nobody to attribute reports to. + if ((Session.CommProtocol != COMM_PROTOCOL_MULTI_E_COMP || Frame < 0 + || !Session.Record_Network_Report(ID, Data.NetworkReport.AverageProcessMilliseconds, + Data.NetworkReport.WorstRoundTripMilliseconds, Data.NetworkReport.StallMilliseconds, static_cast(Frame))) + && !Session.Play) { + Log_Event_Rejection(EventRejectReason::InvalidNetworkReport, Type, ID, Data.NetworkReport.WorstRoundTripMilliseconds); + } else if (!Session.Play) { + DebugString("Network report at frame %d from player %d: process %u ms, RTT %d ms, longest wait %u ms\n", Frame, ID, + (unsigned int)Data.NetworkReport.AverageProcessMilliseconds, + Data.NetworkReport.WorstRoundTripMilliseconds == NETWORK_RTT_UNAVAILABLE ? -1 : (int)Data.NetworkReport.WorstRoundTripMilliseconds, + (unsigned int)Data.NetworkReport.StallMilliseconds); + } + break; + /* ** Default: do nothing. */ diff --git a/code/event.h b/code/event.h index ca3d4bb2e..bb654c743 100644 --- a/code/event.h +++ b/code/event.h @@ -109,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. /* @@ -238,6 +241,12 @@ class EventClass unsigned short AverageTicks; } ProcessTime; + struct { + std::uint16_t AverageProcessMilliseconds; + std::uint16_t WorstRoundTripMilliseconds; + std::uint16_t StallMilliseconds; + } NetworkReport; + } Data; //-------------- Constructors --------------------- diff --git a/code/goptions.cpp b/code/goptions.cpp index ceb3bba5c..4b0638f82 100644 --- a/code/goptions.cpp +++ b/code/goptions.cpp @@ -78,6 +78,18 @@ static void Game_Options_Finish(UIGameOptionsPresenterClass const & screen) } +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); +} + + /// /// Displays the in game options dialog. /// This routine is used by the special dialog handler when the player calls up the options diff --git a/code/goptions.h b/code/goptions.h index c090f7ad3..a2c69c11f 100644 --- a/code/goptions.h +++ b/code/goptions.h @@ -33,6 +33,7 @@ #pragma once #include "gadget.h" +#include "nettiming.h" #include "options.h" @@ -42,4 +43,5 @@ class GameOptionsClass : public OptionsClass { }; int Abort_Dialog(void); +int Network_Quality_Text_ID(NetTiming::ConnectionQuality quality); void Game_Options_Dialog(void); diff --git a/code/gscreen.cpp b/code/gscreen.cpp index b5e9fee1d..c705bf626 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/house.cpp b/code/house.cpp index a1bf5b687..7074f06e3 100644 --- a/code/house.cpp +++ b/code/house.cpp @@ -2188,7 +2188,7 @@ void HouseClass::Make_Ally(HouseClass * house) } if (Is_Human_Player() && Session.Type != GAME_NORMAL && !house->Class->IsMultiplayPassive) { - wsprintf(buffer, Fetch_String(TXT_HAS_ALLIED), (char const *)IniName, (char const *)house->IniName); + snprintf(buffer, sizeof(buffer), Fetch_String(TXT_HAS_ALLIED), (char const *)IniName, (char const *)house->IniName); Session.Messages.Add_Message(NULL, 0, buffer, Class->Scheme, TextPrintType(TPF_6PT_GRAD|TPF_USE_GRAD_PAL|TPF_FULLSHADOW), int(TICKS_PER_MINUTE * Rule->MessageDelay)); if (Is_Player_Control()) { @@ -2261,7 +2261,7 @@ void HouseClass::Make_Enemy(HouseClass * house) if (Session.Type != GAME_NORMAL && !ScenarioInit && IsHuman) { char buffer[80]; - wsprintf(buffer, Fetch_String(TXT_AT_WAR), (char const *)IniName, (char const *)house->IniName); + snprintf(buffer, sizeof(buffer), Fetch_String(TXT_AT_WAR), (char const *)IniName, (char const *)house->IniName); Session.Messages.Add_Message(NULL, 0, buffer, Class->Scheme, TextPrintType(TPF_6PT_GRAD|TPF_USE_GRAD_PAL|TPF_FULLSHADOW), int(TICKS_PER_MINUTE * Rule->MessageDelay)); Map.Flag_To_Redraw(); if (Is_Player_Control()) { @@ -3315,7 +3315,7 @@ void HouseClass::MPlayer_Defeated(void) /* ** Pop up a message showing that I was defeated */ - wsprintf(txt, Fetch_String(TXT_PLAYER_DEFEATED), (char const *)IniName); + snprintf(txt, sizeof(txt), Fetch_String(TXT_PLAYER_DEFEATED), (char const *)IniName); Session.Messages.Add_Message(NULL, 0, txt, Session.ColorIdx, TextPrintType(TPF_6PT_GRAD|TPF_USE_GRAD_PAL|TPF_FULLSHADOW), int(Rule->MessageDelay * TICKS_PER_MINUTE)); @@ -3329,7 +3329,7 @@ void HouseClass::MPlayer_Defeated(void) ** If it wasn't me, find out who was defeated */ if (!Class->IsMultiplayPassive) { - wsprintf(txt, Fetch_String(TXT_PLAYER_DEFEATED), (char const *)IniName); + snprintf(txt, sizeof(txt), Fetch_String(TXT_PLAYER_DEFEATED), (char const *)IniName); Session.Messages.Add_Message(NULL, 0, txt, Scheme, TextPrintType(TPF_6PT_GRAD | TPF_USE_GRAD_PAL | TPF_FULLSHADOW), int(Rule->MessageDelay * TICKS_PER_MINUTE)); diff --git a/code/init.cpp b/code/init.cpp index a085baf64..fb4142eac 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -942,6 +942,8 @@ bool Select_Game(bool ) Session.ProcessTicks = 0; Session.ProcessFrames = 0; + Session.WorstStallTicks = 0; + Session.PreviousWorstStallTicks = 0; Session.DesiredFrameRate = 30; NewMaxAheadFrame1 = 0; NewMaxAheadFrame2 = 0; @@ -1355,6 +1357,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); } /* @@ -5860,9 +5864,9 @@ void Init_Theater(TheaterType theater) /* ** Unload old mixfiles, and cache the new ones */ - wsprintf(fullname, "%s.MIX", data.Root.c_str()); - wsprintf(isofullname, "%s.MIX", data.IsoRoot.c_str()); - wsprintf(shortname, "%s.MIX", data.Suffix.c_str()); + snprintf(fullname, sizeof(fullname), "%s.MIX", data.Root.c_str()); + snprintf(isofullname, sizeof(isofullname), "%s.MIX", data.IsoRoot.c_str()); + snprintf(shortname, sizeof(shortname), "%s.MIX", data.Suffix.c_str()); DebugString("Init theater %s\n", data.Name()); @@ -5898,7 +5902,7 @@ void Init_Theater(TheaterType theater) ** Load the custom palette associated with this theater. ** The fading palettes will have to be generated as well. */ - wsprintf(fullname, "%s.PAL", data.Root.c_str()); + snprintf(fullname, sizeof(fullname), "%s.PAL", data.Root.c_str()); unsigned char * ptr = (unsigned char *)MFCD::Retrieve(fullname); @@ -5922,7 +5926,7 @@ void Init_Theater(TheaterType theater) if (!data.Suffix.empty()) { char palname[_MAX_PATH]; - wsprintf(palname, "UNIT%s.PAL", data.Suffix.c_str()); + snprintf(palname, sizeof(palname), "UNIT%s.PAL", data.Suffix.c_str()); unitpal = (PaletteClass *)MFCD::Retrieve(palname); } diff --git a/code/ipxgconn.h b/code/ipxgconn.h index eeb864c1b..a15d2d522 100644 --- a/code/ipxgconn.h +++ b/code/ipxgconn.h @@ -172,6 +172,8 @@ class IPXGlobalConnClass : public IPXConnClass // stored in the extra buffer within the Queue. //..................................................................... virtual int Send (char *buf, int buflen, void *extrabuf, int extralen) override; + virtual bool Adaptive_Timing_Enabled(void) const override {return(false);} + //..................................................................... // This routine is overloaded from SequencedConnClass, because the // Global Connection needs to ACK its packets differently from the diff --git a/code/ipxmgr.cpp b/code/ipxmgr.cpp index b816f9085..abde8838d 100644 --- a/code/ipxmgr.cpp +++ b/code/ipxmgr.cpp @@ -1068,10 +1068,13 @@ int IPXManagerClass::Service(void) } } for (i = 0; i < NumConnections; i++) { + bool const was_bad = Connection[i]->Is_Bad(); if (!Connection[i]->Service()) { rc = 0; BadConnection = Connection[i]->ID; - DebugString("Error - Connection %d has gone bad\n", BadConnection); + if (!was_bad) { + DebugString("Error - Connection %d has gone bad\n", BadConnection); + } } } @@ -1309,6 +1312,22 @@ unsigned int IPXManagerClass::Response_Time(void) } /* end of Response_Time */ +/// Returns the worst smoothed round trip among the private links, or nothing while any link is unmeasured. +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 @@ -1387,22 +1406,22 @@ void IPXManagerClass::Store_Stats(void) /// column of round trip, resend and packet loss figures for every remote player in the /// game. Use this routine when the multiplayer debug display has been switched on. /// -void IPXManagerClass::Multiplayer_Debug_Print(void) +void IPXManagerClass::Multiplayer_Debug_Print(int top) { char buffer[256]; sprintf(buffer, "Rtr delta : %d", 1000 * RetryDelta / TIMER_SECOND); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 450), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 50), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT)); sprintf(buffer, "Rtr timeout : %d", 1000 * Timeout / TIMER_SECOND); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 458), Fetch_Scheme_By_Name("Grey"), 0, TextPrintType(TPF_NOSHADOW|TPF_EFNT)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 58), Fetch_Scheme_By_Name("Grey"), 0, TextPrintType(TPF_NOSHADOW|TPF_EFNT)); sprintf(buffer, "Lat Fudge : %d", Session.LatencyFudge); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 466), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 66), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT)); if (SentFrameSyncTimer / TIMER_SECOND) { sprintf(buffer, "FSPS : %d", SentFrameSyncCount / (SentFrameSyncTimer / TIMER_SECOND)); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 474), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 74), Fetch_Scheme_By_Name("Grey"), TBLACK, TextPrintType(TPF_NOSHADOW|TPF_EFNT)); if ((Frame & 0x7F) == 0x7F) { SentFrameSyncTimer = 0; SentFrameSyncCount = 0; @@ -1414,27 +1433,27 @@ void IPXManagerClass::Multiplayer_Debug_Print(void) if (house != NULL && house != PlayerPtr) { int scheme = house->Scheme; - Fancy_Text_Print(Connection[i]->Name, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 402), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); + Fancy_Text_Print(Connection[i]->Name, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 2), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); int avg = Connection[i]->Queue->Avg_Response_Time(); sprintf(buffer, "Average : %d", 1000 * avg / TIMER_SECOND); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 411), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 11), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); int max = Connection[i]->Queue->Max_Response_Time(); sprintf(buffer, "Max : %d", 1000 * max / TIMER_SECOND); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 418), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 18), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); int resends = Connection[i]->Num_Resends(); sprintf(buffer, "Resends : %d", resends); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 425), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 25), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); int numlost = std::max(0, Connection[i]->Num_Lost()); sprintf(buffer, "Num lost : %d", numlost); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 432), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 32), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); int pcnt_lost = Connection[i]->Percent_Lost(); sprintf(buffer, "Pcnt lost: %d", pcnt_lost); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 439), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 39), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); int process_time = 0; for (int j = 0; j < Session.Players.Count(); ++j) { @@ -1444,16 +1463,16 @@ void IPXManagerClass::Multiplayer_Debug_Print(void) } } sprintf(buffer, "Process : %d", process_time); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 446), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 46), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); sprintf(buffer, "Frame : %d", -Session.PlayerLatency[i]); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 453), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 53), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); sprintf(buffer, "Queue s/r: %d/%d", Connection[i]->Queue->Num_Send(), Connection[i]->Queue->Num_Receive()); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 460), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 60), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); sprintf(buffer, "Missed o/m: %d/%d", Connection[i]->Missed_Overall(), Connection[i]->Missed_Magic()); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D((i + 1) * 100, 467), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D((i + 1) * 100, top + 67), ColorSchemes[scheme], TBLACK, TextPrintType(TPF_EFNT|TPF_NOSHADOW)); } } } @@ -1480,6 +1499,9 @@ void IPXManagerClass::Reset_Response_Time(bool zero) for (i = 0; i < NumConnections; i++) { Connection[i]->Queue->Reset_Response_Time(zero); + if (zero) { + Connection[i]->Reset_Round_Trip_Time(); + } } if (GlobalChannel) diff --git a/code/ipxmgr.h b/code/ipxmgr.h index f7b4e3202..e500381a4 100644 --- a/code/ipxmgr.h +++ b/code/ipxmgr.h @@ -230,6 +230,7 @@ class IPXManagerClass : public ConnManClass reset the response time for all queues. .....................................................................*/ virtual unsigned int Response_Time(void) override; + virtual std::optional Worst_Local_Round_Trip_MS(void) const override; unsigned int Global_Response_Time(void); virtual void Reset_Response_Time(bool zero) override; @@ -249,7 +250,7 @@ class IPXManagerClass : public ConnManClass virtual void Mono_Debug_Print(int index, int refresh = 0); - void Multiplayer_Debug_Print(void); + void Multiplayer_Debug_Print(int top); /* --------------------------- Private Interface ---------------------------- diff --git a/code/language/language.h b/code/language/language.h index 68e6b4d35..08846bf78 100644 --- a/code/language/language.h +++ b/code/language/language.h @@ -878,6 +878,8 @@ #define TXT_GAME_SAVED 1120 #define TXT_GAME_AUTO_SAVED 1121 #define TXT_SCENARIO_DATA_DAMAGED 1122 +#define TXT_CONNECTION_QUALITY_STATUS 1123 +#define TXT_CONNECTION_QUALITY_RUNG 1124 #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 7ee56f271..cb51ecb0a 100644 --- a/code/language/language.rc +++ b/code/language/language.rc @@ -1076,6 +1076,8 @@ BEGIN TXT_GAME_SAVED "Game saved." TXT_GAME_AUTO_SAVED "Game auto-saved." TXT_SCENARIO_DATA_DAMAGED "Unable to read scenario %s: its map data is damaged." + TXT_CONNECTION_QUALITY_STATUS "Connection quality target: %s." + TXT_CONNECTION_QUALITY_RUNG "%s (rung %u)" END #endif // English (U.S.) resources diff --git a/code/loaddlg.cpp b/code/loaddlg.cpp index c0272021b..1c23c4079 100644 --- a/code/loaddlg.cpp +++ b/code/loaddlg.cpp @@ -546,7 +546,7 @@ bool LoadOptionsClass::Read_File(FileEntryClass * fdata, WIN32_FIND_DATAA * ff) return(false); } - wsprintf(fdata->Descr, "%s", savever.Get_Scenario_Description()); + snprintf(fdata->Descr, sizeof(fdata->Descr), "%s", savever.Get_Scenario_Description()); fdata->Valid = ok; fdata->Scenario = savever.Get_Scenario_Number(); diff --git a/code/mainloop.cpp b/code/mainloop.cpp index 2880c7592..b3c4363d7 100644 --- a/code/mainloop.cpp +++ b/code/mainloop.cpp @@ -77,7 +77,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); @@ -271,28 +271,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 { /* @@ -313,9 +291,6 @@ bool Main_Loop(void) if (input) { Keyboard_Process(input); } - if (Session.ShowInternetDebug) { - Multiplayer_Debug_Print(false); - } if ((Frame & 7) == 7 && Session.Type == GAME_INTERNET) { Ipx.Store_Stats(); } @@ -759,45 +734,36 @@ void Message_Input(KeyNumType &input) /// per-connection display. It is used while debugging a multiplayer game and does /// nothing at all in a single player game. /// -/// Should the display be drawn regardless of the frame -/// counter? -void Multiplayer_Debug_Print(bool noframecheck) +void Multiplayer_Debug_Print(void) { - if (!noframecheck && (Frame & 7) != 7) { - return; - } - if (Session.Type == GAME_NORMAL) { return; } - Hide_Mouse(); - - VisibleSurface->Fill_Rect(Rect(0, 400, 639, 80), 0); + int const top = LogicalSurface->Get_Height() - 80; + LogicalSurface->Fill_Rect(Rect(0, top, LogicalSurface->Get_Width(), 80), 0); char buffer[256]; sprintf(buffer, "Frame : %d", Frame); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 402), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 2), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); sprintf(buffer, "FPS : %d", LastFramesPerSecond); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 410), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 10), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); sprintf(buffer, "MaxAhead : %d", Session.MaxAhead); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 418), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 18), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); sprintf(buffer, "Resp Time : %d ms", (int)(Ipx.Response_Time() * 1000) / TIMER_SECOND); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 426), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 26), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); sprintf(buffer, "Req fps : %d", Session.DesiredFrameRate); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 434), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 34), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); sprintf(buffer, "Process : %d", Session.Players[0]->Player.ProcessTime); - Fancy_Text_Print(buffer, *VisibleSurface, VisibleSurface->Get_Rect(), Point2D(0, 442), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); - - Ipx.Multiplayer_Debug_Print(); + Fancy_Text_Print(buffer, *LogicalSurface, LogicalSurface->Get_Rect(), Point2D(0, top + 42), Fetch_Scheme_By_Name("Grey"), 0, (TextPrintType)(TPF_EFNT | TPF_NOSHADOW)); - Show_Mouse(); + Ipx.Multiplayer_Debug_Print(top); } diff --git a/code/netdlg2.cpp b/code/netdlg2.cpp index dacc3accf..302e6f332 100644 --- a/code/netdlg2.cpp +++ b/code/netdlg2.cpp @@ -35,6 +35,7 @@ #include "netdlg.h" #include "netglobal.h" #include "netshare.h" +#include "nettiming.h" #include "newmenu.h" #include "rules.h" #include "scenario.h" @@ -899,18 +900,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; + // A compressed game starts at the fixed bootstrap rung and measures from there. 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); } @@ -949,18 +945,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; + // A compressed game starts at the fixed bootstrap rung and measures from there. 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); } @@ -2071,7 +2062,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(); Net2AnswerLobby(IDOK); diff --git a/code/nettime.cpp b/code/nettime.cpp new file mode 100644 index 000000000..3303da388 --- /dev/null +++ b/code/nettime.cpp @@ -0,0 +1,41 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "nettime.h" + +#include "hostclock.h" + + +namespace NetTiming +{ + namespace + { + class SystemMillisecondClock final : public MillisecondClock + { + public: + Milliseconds Now(void) const override; + }; + } + + + /// Reads the system's wrapping millisecond clock. + Milliseconds SystemMillisecondClock::Now(void) const + { + return(static_cast(Host_Milliseconds())); + } + + + /// Returns the process-wide network clock. + MillisecondClock const & Default_Clock(void) + { + static SystemMillisecondClock clock; + return(clock); + } +} diff --git a/code/nettime.h b/code/nettime.h new file mode 100644 index 000000000..d07200cd1 --- /dev/null +++ b/code/nettime.h @@ -0,0 +1,38 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#pragma once + +#include + + +namespace NetTiming +{ + using Milliseconds = std::uint32_t; + + class MillisecondClock + { + public: + virtual ~MillisecondClock() = default; + virtual Milliseconds Now(void) const = 0; + }; + + MillisecondClock const & Default_Clock(void); + + constexpr Milliseconds Elapsed_Milliseconds(Milliseconds start, Milliseconds finish) + { + return(finish - start); + } + + constexpr bool Milliseconds_Have_Elapsed(Milliseconds start, Milliseconds now, Milliseconds duration) + { + return(Elapsed_Milliseconds(start, now) >= duration); + } +} diff --git a/code/nettiming.cpp b/code/nettiming.cpp new file mode 100644 index 000000000..3103c499e --- /dev/null +++ b/code/nettiming.cpp @@ -0,0 +1,646 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "nettiming.h" + +#include +#include +#include + + +namespace NetTiming +{ + namespace + { + constexpr std::uint64_t Divide_Round_Up(std::uint64_t numerator, std::uint64_t denominator) + { + return((numerator + denominator - 1) / denominator); + } + + + constexpr Milliseconds Clamp_Rto(std::uint64_t value) + { + return(static_cast(std::clamp(value, MINIMUM_RTO, MAXIMUM_RTO))); + } + + + 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)); + } + } + + + void RttEstimator::Reset(void) + { + Initialized = false; + SmoothedRtt = 0; + RttVariation = 0; + RetransmitTimeout = MINIMUM_RTO; + Provisional = false; + } + + + /// Updates SRTT, RTTVAR, and RTO from an eligible sample. + bool RttEstimator::Add_Sample(Milliseconds round_trip, bool retransmitted) + { + // Karn's rule excludes ambiguous acknowledgements after retransmission. + if (retransmitted) { + return(false); + } + + if (!Initialized) { + Initialized = true; + SmoothedRtt = round_trip; + RttVariation = (round_trip + 1) / 2; + } else { + Milliseconds const error = SmoothedRtt > round_trip ? SmoothedRtt - round_trip : round_trip - SmoothedRtt; + RttVariation = static_cast((3ull * RttVariation + error + 2) / 4); + SmoothedRtt = static_cast((7ull * SmoothedRtt + round_trip + 4) / 8); + } + + std::uint64_t const variation = std::max(1, 4ull * RttVariation); + RetransmitTimeout = Clamp_Rto(static_cast(SmoothedRtt) + variation); + return(true); + } + + + /// Samples an acknowledgement; an unmeasured link takes an ambiguous one as a provisional upper bound. + bool RttEstimator::Acknowledge(Milliseconds sent_at, unsigned int transmission_count, MillisecondClock const & clock) + { + if (transmission_count == 0) { + return(false); + } + + Milliseconds const elapsed = Elapsed_Milliseconds(sent_at, clock.Now()); + if (transmission_count != 1) { + if (Initialized) { + return(false); + } + Provisional = Add_Sample(elapsed); + return(Provisional); + } + + // The first clean sample replaces a provisional seed instead of blending with it. + if (Provisional) { + Initialized = false; + Provisional = false; + } + return(Add_Sample(elapsed)); + } + + + /// Doubles the timeout once for each round of retransmissions, so a slower link stays measurable. + void RttEstimator::Note_Retransmit(Milliseconds captured_rto) + { + if (!Initialized) { + return; + } + + // Only a packet sent under the current timeout proves that timeout too short. + if (captured_rto >= RetransmitTimeout) { + RetransmitTimeout = Clamp_Rto(2ull * RetransmitTimeout); + } + } + + + /// Derives a timeout that covers measured latency and three transmissions at the current RTO. + Milliseconds Connection_Timeout(Milliseconds smoothed_rtt, Milliseconds retransmit_timeout) + { + std::uint64_t const timeout = std::max(8ull * smoothed_rtt + 250, 4ull * retransmit_timeout); + return(static_cast(std::clamp(timeout, MINIMUM_CONNECTION_TIMEOUT, MAXIMUM_CONNECTION_TIMEOUT))); + } + + + /// Bounds a packet's first retry so the connection timeout allows at least three transmissions. + Milliseconds Initial_Retry_Timeout(Milliseconds retransmit_timeout, Milliseconds connection_timeout) + { + return(std::max(MINIMUM_RTO, std::min(retransmit_timeout, connection_timeout / 4))); + } + + + /// Applies bounded exponential backoff to a packet's RTO. + Milliseconds Retransmit_Delay(Milliseconds base_rto, unsigned int prior_retransmissions, Milliseconds maximum_delay) + { + maximum_delay = std::max(maximum_delay, MINIMUM_RTO); + std::uint64_t delay = std::clamp(base_rto, MINIMUM_RTO, maximum_delay); + while (prior_retransmissions-- > 0 && delay < maximum_delay) { + delay = std::min(delay * 2, maximum_delay); + } + return(static_cast(delay)); + } + + + /// Checks whether a packet's current backoff interval has elapsed. + bool Retransmit_Is_Due(Milliseconds last_send, Milliseconds now, Milliseconds base_rto, unsigned int prior_retransmissions, Milliseconds maximum_delay) + { + return(Milliseconds_Have_Elapsed(last_send, now, Retransmit_Delay(base_rto, prior_retransmissions, maximum_delay))); + } + + + /// Chooses the next action for one queued packet; a timed-out packet still retries at its capped backoff. + RetryDecision Evaluate_Retry(RetransmitState const & state, Milliseconds now, Milliseconds current_rto, Milliseconds connection_timeout, + bool timeout_enabled, bool adaptive) + { + if (state.TransmissionCount == 0) { + return(RetryDecision{true, false}); + } + + RetryDecision decision; + decision.TimedOut = timeout_enabled && Milliseconds_Have_Elapsed(state.FirstSend, now, connection_timeout); + decision.Send = adaptive + ? Retransmit_Is_Due(state.LastSend, now, state.CapturedRto, state.TransmissionCount - 1, connection_timeout) + : Milliseconds_Have_Elapsed(state.LastSend, now, current_rto); + return(decision); + } + + + 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}); + } + + + 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)); + } + + + void TimingReportCensus::Reset(void) + { + Reports = {}; + } + + + /// Adds or removes a player, discarding any report the slot held. + 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); + } + + + bool TimingReportCensus::Is_Player_Active(unsigned int player) const + { + return(player < Reports.size() && Reports[player].Active); + } + + + /// Records process time, optional RTT, and longest wait as one report. + bool TimingReportCensus::Record_Report(unsigned int player, Milliseconds process_milliseconds, std::optional round_trip, std::uint32_t frame, + Milliseconds stall_milliseconds) + { + if (player >= Reports.size() || !Reports[player].Active || process_milliseconds > MAXIMUM_PROCESS_MILLISECONDS + || (round_trip && *round_trip > MAXIMUM_REPORTED_RTT)) { + return(false); + } + + PlayerReport & report = Reports[player]; + report.HasReport = true; + report.HasRoundTrip = round_trip.has_value(); + report.EverHadRoundTrip |= round_trip.has_value(); + report.ProcessMilliseconds = process_milliseconds; + report.RoundTrip = round_trip.value_or(0); + report.StallMilliseconds = stall_milliseconds; + report.ReportFrame = frame; + return(true); + } + + + /// Summarizes fresh reports for a simulation frame. + TimingCensus TimingReportCensus::Inspect(std::uint32_t frame) const + { + TimingCensus result; + for (PlayerReport const & report : Reports) { + if (!report.Active) { + continue; + } + + result.ActivePlayers++; + bool const fresh = report.HasReport && frame - report.ReportFrame < REPORT_EXPIRY; + if (fresh) { + result.FreshProcessReports++; + result.WorstProcessMilliseconds = std::max(result.WorstProcessMilliseconds, report.ProcessMilliseconds); + result.WorstStallMilliseconds = std::max(result.WorstStallMilliseconds, report.StallMilliseconds); + } else { + result.ProcessComplete = false; + } + + if (fresh && report.HasRoundTrip) { + result.FreshRoundTripReports++; + result.WorstRoundTrip = std::max(result.WorstRoundTrip, report.RoundTrip); + } else { + result.RoundTripComplete = false; + // Only a link that has never been measured forces conservative timing; a measured + // link holds the current timing until its next report. + if (!report.EverHadRoundTrip && frame - report.ActiveSinceFrame >= REPORT_EXPIRY) { + result.RequiresConservativeTiming = true; + } + } + } + return(result); + } + + + /// Uses fresh process reports without discarding the synchronized frame rate. + unsigned int Select_Desired_Frame_Rate(TimingCensus const & census, unsigned int synchronized_fps, unsigned int game_speed_fps) + { + synchronized_fps = std::clamp(synchronized_fps, 1u, 60u); + game_speed_fps = std::clamp(game_speed_fps, 1u, 60u); + if (!census.ProcessComplete) { + return(synchronized_fps); + } + + unsigned int const process_fps = census.WorstProcessMilliseconds == 0 ? 60u + : static_cast(std::max(1, 1000 / census.WorstProcessMilliseconds)); + return(std::min(process_fps, game_speed_fps)); + } + + + /// Restores the initial rung and anchors the evaluation cadence to a frame. + void BalancedTimingPolicy::Reset(std::uint32_t frame) + { + CurrentRung = INITIAL_TIMING_RUNG; + CurrentSettings = Settings_For_Rung(INITIAL_TIMING_RUNG); + GoodEvaluations = 0; + BootstrapStartFrame = frame; + LastEvaluationFrame = frame; + LastChangeFrame = 0; + HasEvaluated = false; + HasChanged = false; + Bootstrapping = true; + ImprovementStreak = false; + } + + + /// Restores synchronized policy state after a master handoff. + void BalancedTimingPolicy::Reset_From(TimingSettings settings, std::uint32_t frame) + { + CurrentRung = std::clamp(settings.FrameSendRate, MINIMUM_TIMING_RUNG, MAXIMUM_TIMING_RUNG); + CurrentSettings = settings; + GoodEvaluations = 0; + LastEvaluationFrame = frame; + LastChangeFrame = frame; + HasEvaluated = true; + HasChanged = true; + Bootstrapping = false; + ImprovementStreak = false; + } + + + /// Commits a policy change and resets hysteresis. + void BalancedTimingPolicy::Change_To(TimingSettings settings, std::uint32_t frame) + { + ImprovementStreak = Timing_Is_Better(settings, CurrentSettings); + CurrentRung = std::clamp(settings.FrameSendRate, MINIMUM_TIMING_RUNG, MAXIMUM_TIMING_RUNG); + CurrentSettings = settings; + GoodEvaluations = 0; + LastChangeFrame = frame; + HasChanged = true; + } + + + /// Anchors steady-state evaluations to 256 frames after reset. + void BalancedTimingPolicy::Finish_Bootstrap(void) + { + Bootstrapping = false; + GoodEvaluations = 0; + ImprovementStreak = false; + LastEvaluationFrame = BootstrapStartFrame; + HasEvaluated = true; + } + + + /// Applies cadence, hysteresis, and improvement headroom. + TimingEvaluation BalancedTimingPolicy::Evaluate(TimingCensus const & census, unsigned int target_fps, std::uint32_t frame) + { + TimingEvaluation result{Current_Settings(), CurrentRung, false, false}; + if (Bootstrapping) { + std::uint32_t const elapsed_frames = frame - BootstrapStartFrame; + if (elapsed_frames < BOOTSTRAP_FIRST_EVALUATION || (HasEvaluated && elapsed_frames < BOOTSTRAP_FINAL_EVALUATION)) { + return(result); + } + + HasEvaluated = true; + LastEvaluationFrame = frame; + result.Evaluated = true; + bool const complete = census.ProcessComplete && census.RoundTripComplete; + if (census.RequiresConservativeTiming || complete || elapsed_frames >= BOOTSTRAP_FINAL_EVALUATION) { + TimingSettings const selected = census.RequiresConservativeTiming ? Desired_Settings(census, target_fps, false) + : complete ? Desired_Settings(census, target_fps, true) : Settings_For_Rung(BOOTSTRAP_FALLBACK_RUNG); + if (selected != CurrentSettings) { + Change_To(selected, frame); + result.Changed = true; + } + Finish_Bootstrap(); + result.Settings = Current_Settings(); + result.Rung = CurrentRung; + } + return(result); + } + + if (HasEvaluated && frame - LastEvaluationFrame < EVALUATION_INTERVAL) { + return(result); + } + + HasEvaluated = true; + LastEvaluationFrame = frame; + result.Evaluated = true; + if (!census.RequiresConservativeTiming && census.ActivePlayers > 0 && !census.RoundTripComplete) { + // A lapsed report holds the current timing, but the reports that did arrive can still worsen it. + GoodEvaluations = 0; + ImprovementStreak = false; + if (census.FreshRoundTripReports > 0) { + TimingSettings const desired_settings = Desired_Settings(census, target_fps, false); + if (Timing_Is_Worse(desired_settings, CurrentSettings)) { + Change_To(desired_settings, frame); + result.Changed = true; + result.Settings = Current_Settings(); + result.Rung = CurrentRung; + } + } + return(result); + } + + // Worsening is immediate; the first improvement must clear the headroom, waiting, cadence, and + // cooldown gates, and a descent then continues one rung per evaluation while the headroom holds. + TimingSettings const desired_settings = Desired_Settings(census, target_fps, false); + if (Timing_Is_Worse(desired_settings, CurrentSettings)) { + Change_To(desired_settings, frame); + result.Changed = true; + } else if (Timing_Is_Better(desired_settings, CurrentSettings) && census.WorstStallMilliseconds < STALL_IMPROVE_MILLISECONDS + && (!HasChanged || ImprovementStreak || frame - LastChangeFrame >= CHANGE_COOLDOWN)) { + TimingSettings const headroom = Desired_Settings(census, target_fps, true); + if (Timing_Is_Better(headroom, CurrentSettings)) { + GoodEvaluations++; + if (GoodEvaluations >= (ImprovementStreak ? DESCENT_EVALUATIONS_REQUIRED : GOOD_EVALUATIONS_REQUIRED)) { + TimingSettings const next = desired_settings.FrameSendRate < CurrentRung + ? Settings_For_Rung(CurrentRung - 1) : desired_settings; + Change_To(next, frame); + result.Changed = true; + } + } else { + GoodEvaluations = 0; + ImprovementStreak = false; + } + } else { + GoodEvaluations = 0; + ImprovementStreak = false; + } + + result.Settings = Current_Settings(); + result.Rung = CurrentRung; + return(result); + } + + + /// Delays decreases until the old scheduling horizon drains. + std::optional Stage_Timing_Update(TimingSettings current, TimingSettings requested, std::uint32_t event_frame) + { + if (!Timing_Transition_Source_Is_Valid(current) || !Timing_Settings_Are_Valid(requested)) { + return(std::nullopt); + } + + if (requested.FrameSendRate > current.FrameSendRate && requested.MaxAhead < current.MaxAhead) { + std::optional const immediate_horizon = Align_Max_Ahead(current.MaxAhead, requested.FrameSendRate); + if (immediate_horizon) { + return(StagedTimingUpdate{requested, *immediate_horizon, event_frame, true}); + } + } + + bool const decrease = requested.FrameSendRate < current.FrameSendRate || requested.MaxAhead < current.MaxAhead; + if (!decrease) { + return(StagedTimingUpdate{requested, requested.MaxAhead, event_frame, false}); + } + + std::uint64_t const period = std::lcm(current.FrameSendRate, requested.FrameSendRate); + std::uint64_t const old_horizon = static_cast(event_frame) + current.MaxAhead; + std::uint64_t const activation = Divide_Round_Up(old_horizon, period) * period; + if (activation > std::numeric_limits::max()) { + return(std::nullopt); + } + + unsigned int const minimum_horizon = std::max(requested.MaxAhead, current.MaxAhead - current.FrameSendRate); + std::optional const initial_max_ahead = Align_Max_Ahead(minimum_horizon, requested.FrameSendRate); + if (!initial_max_ahead) { + return(std::nullopt); + } + + return(StagedTimingUpdate{requested, *initial_max_ahead, static_cast(activation), true}); + } + + + /// Returns the first send boundary strictly after an event frame. + std::optional Next_Send_Boundary(std::uint32_t frame, unsigned int frame_send_rate) + { + if (frame_send_rate == 0) { + return(std::nullopt); + } + + std::uint64_t const boundary = (static_cast(frame) / frame_send_rate + 1) * frame_send_rate; + if (boundary > std::numeric_limits::max()) { + return(std::nullopt); + } + return(static_cast(boundary)); + } + + + /// Advances one catch-up step without dropping below the target horizon. + std::optional Next_Transition_Max_Ahead(TimingSettings current, TimingSettings requested) + { + if (!Timing_Settings_Are_Valid(current) || !Timing_Settings_Are_Valid(requested) || current.FrameSendRate != requested.FrameSendRate) { + return(std::nullopt); + } + + if (current.MaxAhead <= requested.MaxAhead) { + return(requested.MaxAhead); + } + return(std::max(requested.MaxAhead, current.MaxAhead - requested.FrameSendRate)); + } + + + /// Advances one deterministic drain or catch-up boundary. + std::optional Advance_Timing_Transition(TimingTransitionState & transition, TimingSettings current, std::uint32_t frame) + { + bool const current_is_valid = transition.Activated ? Timing_Settings_Are_Valid(current) : Timing_Transition_Source_Is_Valid(current); + if (!transition.Plan.Deferred || !current_is_valid || !Timing_Settings_Are_Valid(transition.Plan.Settings) + || !Timing_Settings_Are_Valid({transition.Plan.Settings.FrameSendRate, transition.Plan.InitialMaxAhead})) { + return(std::nullopt); + } + + TimingTransitionAdvance result{current}; + if (!transition.Activated) { + if (!Timing_Update_Is_Due(frame, transition.Plan.ActivationFrame)) { + return(result); + } + result.Settings = {transition.Plan.Settings.FrameSendRate, transition.Plan.InitialMaxAhead}; + result.Changed = result.Settings != current; + transition.LastStepFrame = frame; + transition.Activated = true; + } else if (current.MaxAhead > transition.Plan.Settings.MaxAhead && frame > transition.LastStepFrame + && frame % transition.Plan.Settings.FrameSendRate == 0) { + std::optional const next = Next_Transition_Max_Ahead(current, transition.Plan.Settings); + if (!next) { + return(std::nullopt); + } + result.Settings.MaxAhead = *next; + result.Changed = result.Settings != current; + transition.LastStepFrame = frame; + } + + result.Complete = transition.Activated && result.Settings == transition.Plan.Settings; + return(result); + } + + + /// Checks a staged activation frame with wraparound semantics. + bool Timing_Update_Is_Due(std::uint32_t frame, std::uint32_t activation_frame) + { + return(static_cast(frame - activation_frame) >= 0); + } + + + /// 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 new file mode 100644 index 000000000..cc0f60559 --- /dev/null +++ b/code/nettiming.h @@ -0,0 +1,228 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#pragma once + +#include "nettime.h" + +#include +#include +#include +#include + + +namespace NetTiming +{ + constexpr Milliseconds MINIMUM_RTO = 100; + // Above any round trip a private link is expected to carry, so a slow link's first retry + // does not precede its acknowledgement. + constexpr Milliseconds MAXIMUM_RTO = 4000; + constexpr Milliseconds MINIMUM_CONNECTION_TIMEOUT = 2000; + constexpr Milliseconds MAXIMUM_CONNECTION_TIMEOUT = 30000; + constexpr Milliseconds MAXIMUM_PROCESS_MILLISECONDS = 1000; + constexpr Milliseconds MAXIMUM_REPORTED_RTT = UINT16_MAX - 1u; + + constexpr unsigned int MAX_TIMING_PLAYERS = 8; + constexpr unsigned int MINIMUM_TIMING_RUNG = 1; + constexpr unsigned int MAXIMUM_TIMING_RUNG = 10; + constexpr unsigned int INITIAL_TIMING_RUNG = 2; + constexpr unsigned int BOOTSTRAP_FALLBACK_RUNG = 3; + constexpr unsigned int MAXIMUM_MAX_AHEAD = 250; + + constexpr std::uint32_t BOOTSTRAP_REPORT_INTERVAL = 32; + constexpr std::uint32_t BOOTSTRAP_FIRST_EVALUATION = 64; + constexpr std::uint32_t BOOTSTRAP_FINAL_EVALUATION = 128; + constexpr std::uint32_t REPORT_INTERVAL = 128; + constexpr std::uint32_t EVALUATION_INTERVAL = 256; + constexpr std::uint32_t CHANGE_COOLDOWN = 256; + constexpr std::uint32_t REPORT_EXPIRY = 512; + constexpr unsigned int GOOD_EVALUATIONS_REQUIRED = 3; + constexpr unsigned int DESCENT_EVALUATIONS_REQUIRED = 1; + // Longest single wait that still allows a step down. + constexpr Milliseconds STALL_IMPROVE_MILLISECONDS = 100; + + struct RetryDecision + { + bool Send = false; + bool TimedOut = false; + }; + + struct RetransmitState + { + Milliseconds FirstSend = 0; + Milliseconds LastSend = 0; + Milliseconds CapturedRto = MINIMUM_RTO; + unsigned int TransmissionCount = 0; + }; + + class RttEstimator + { + public: + void Reset(void); + bool Add_Sample(Milliseconds round_trip, bool retransmitted = false); + bool Acknowledge(Milliseconds sent_at, unsigned int transmission_count, MillisecondClock const & clock = Default_Clock()); + void Note_Retransmit(Milliseconds captured_rto); + + bool Has_Sample(void) const {return(Initialized);} + bool Is_Provisional(void) const {return(Provisional);} + Milliseconds Smoothed_Rtt(void) const {return(SmoothedRtt);} + Milliseconds Rtt_Variation(void) const {return(RttVariation);} + Milliseconds Retransmit_Timeout(void) const {return(RetransmitTimeout);} + + private: + bool Initialized = false; + Milliseconds SmoothedRtt = 0; + Milliseconds RttVariation = 0; + Milliseconds RetransmitTimeout = MINIMUM_RTO; + // Set while the estimate comes from an ambiguous first acknowledgement. + bool Provisional = false; + }; + + Milliseconds Connection_Timeout(Milliseconds smoothed_rtt, Milliseconds retransmit_timeout); + Milliseconds Initial_Retry_Timeout(Milliseconds retransmit_timeout, Milliseconds connection_timeout); + Milliseconds Retransmit_Delay(Milliseconds base_rto, unsigned int prior_retransmissions, Milliseconds maximum_delay = MAXIMUM_RTO); + bool Retransmit_Is_Due(Milliseconds last_send, Milliseconds now, Milliseconds base_rto, + unsigned int prior_retransmissions, Milliseconds maximum_delay = MAXIMUM_RTO); + RetryDecision Evaluate_Retry(RetransmitState const & state, Milliseconds now, Milliseconds current_rto, Milliseconds connection_timeout, + bool timeout_enabled, bool adaptive); + + struct TimingSettings { + unsigned int FrameSendRate = INITIAL_TIMING_RUNG; + unsigned int MaxAhead = 3 * INITIAL_TIMING_RUNG; + + bool operator==(TimingSettings const &) const = default; + }; + + enum class ConnectionQuality : unsigned char { + Bad, + Poor, + Normal, + Fast, + }; + + TimingSettings Settings_For_Rung(unsigned int rung); + ConnectionQuality Connection_Quality_For_Settings(TimingSettings settings); + bool Timing_Settings_Are_Valid(TimingSettings settings); + bool Timing_Transition_Source_Is_Valid(TimingSettings settings); + std::optional Align_Max_Ahead(unsigned int required, unsigned int frame_send_rate); + TimingSettings Select_Timing_Settings(Milliseconds worst_round_trip, unsigned int target_fps, bool require_headroom = false); + bool Report_Is_Due(std::uint32_t elapsed_frames); + bool Evaluation_Is_Due(std::uint32_t elapsed_frames); + + struct TimingCensus { + unsigned int ActivePlayers = 0; + unsigned int FreshProcessReports = 0; + unsigned int FreshRoundTripReports = 0; + Milliseconds WorstProcessMilliseconds = 0; + Milliseconds WorstRoundTrip = 0; + Milliseconds WorstStallMilliseconds = 0; + bool ProcessComplete = true; + bool RoundTripComplete = true; + bool RequiresConservativeTiming = false; + }; + + class TimingReportCensus + { + public: + void Reset(void); + bool Set_Player_Active(unsigned int player, bool active, std::uint32_t frame); + bool Is_Player_Active(unsigned int player) const; + bool Record_Report(unsigned int player, Milliseconds process_milliseconds, std::optional round_trip, std::uint32_t frame, + Milliseconds stall_milliseconds = 0); + TimingCensus Inspect(std::uint32_t frame) const; + + private: + struct PlayerReport { + bool Active = false; + bool HasReport = false; + bool HasRoundTrip = false; + bool EverHadRoundTrip = false; + Milliseconds ProcessMilliseconds = 0; + Milliseconds RoundTrip = 0; + Milliseconds StallMilliseconds = 0; + std::uint32_t ActiveSinceFrame = 0; + std::uint32_t ReportFrame = 0; + }; + + std::array Reports = {}; + }; + + unsigned int Select_Desired_Frame_Rate(TimingCensus const & census, unsigned int synchronized_fps, unsigned int game_speed_fps); + + struct TimingEvaluation { + TimingSettings Settings; + unsigned int Rung = INITIAL_TIMING_RUNG; + bool Evaluated = false; + bool Changed = false; + }; + + class BalancedTimingPolicy + { + public: + void Reset(std::uint32_t frame = 0); + void Reset_From(TimingSettings settings, std::uint32_t frame); + TimingEvaluation Evaluate(TimingCensus const & census, unsigned int target_fps, std::uint32_t frame); + + unsigned int Current_Rung(void) const {return(CurrentRung);} + TimingSettings Current_Settings(void) const {return(CurrentSettings);} + unsigned int Good_Evaluations(void) const {return(GoodEvaluations);} + bool Is_Bootstrapping(void) const {return(Bootstrapping);} + std::uint32_t Cadence_Origin(void) const {return(BootstrapStartFrame);} + + private: + void Change_To(TimingSettings settings, std::uint32_t frame); + void Finish_Bootstrap(void); + + unsigned int CurrentRung = INITIAL_TIMING_RUNG; + TimingSettings CurrentSettings = {INITIAL_TIMING_RUNG, 3 * INITIAL_TIMING_RUNG}; + unsigned int GoodEvaluations = 0; + std::uint32_t BootstrapStartFrame = 0; + std::uint32_t LastEvaluationFrame = 0; + std::uint32_t LastChangeFrame = 0; + bool HasEvaluated = false; + bool HasChanged = false; + bool Bootstrapping = true; + // Set by an improvement; while it holds, each evaluation with headroom steps one more rung. + bool ImprovementStreak = false; + }; + + struct StagedTimingUpdate { + TimingSettings Settings; + unsigned int InitialMaxAhead = 0; + std::uint32_t ActivationFrame = 0; + bool Deferred = false; + }; + + struct TimingTransitionState { + StagedTimingUpdate Plan; + std::uint32_t LastStepFrame = 0; + bool Activated = false; + }; + + struct TimingTransitionAdvance { + TimingSettings Settings; + bool Changed = false; + bool Complete = false; + }; + + enum class ScheduleResult + { + Rejected, + Applied, + Staged, + }; + + std::optional Stage_Timing_Update(TimingSettings current, TimingSettings requested, std::uint32_t event_frame); + std::optional Next_Send_Boundary(std::uint32_t frame, unsigned int frame_send_rate); + std::optional Next_Transition_Max_Ahead(TimingSettings current, TimingSettings requested); + std::optional Advance_Timing_Transition(TimingTransitionState & transition, TimingSettings current, std::uint32_t frame); + bool Timing_Update_Is_Due(std::uint32_t frame, std::uint32_t activation_frame); + bool Event_Is_Due(int event_frame, bool is_executed, int frame); +} diff --git a/code/queue.cpp b/code/queue.cpp index 83fabec35..1dfcdafb5 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -130,6 +130,7 @@ #include "netpacket.h" #include "netsemantic.h" #include "netshare.h" +#include "nettiming.h" #include "opents_build.h" #include "overlay.h" #include "overtype.h" @@ -276,6 +277,12 @@ FrameSyncStruct SyncBarFrameSync[MAX_PLAYERS - 1]; BasicTimerClass SentFrameSyncTimer; FrameSyncStruct TheirFrameSync[MAX_PLAYERS - 1]; unsigned short SentCommandCount; // # cmds I've sent out +// Frame of the previous Execute_DoList call; a send-period decrease can skip an event's frame. +static int LastExecutedFrame = -1; + +// How often a frame packet asks for an acknowledgement while a link is still unmeasured. +constexpr int ROUND_TRIP_PROBE_FRAMES = 32; +static int LastRoundTripProbeFrame = -ROUND_TRIP_PROBE_FRAMES; static std::array(NetPacket::DecodeError::COUNT)> NetworkPacketDrops = {}; @@ -307,9 +314,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); @@ -325,7 +331,7 @@ static void Stop_Game(bool=false); 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: @@ -489,6 +495,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(); } @@ -546,13 +557,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. //------------------------------------------------------------------------ @@ -745,6 +749,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 @@ -756,6 +762,8 @@ static void Queue_AI_Multiplayer(void) } skip_crc = Frame + ARRAY_SIZE(CRC); SentCommandCount = 0; + LastExecutedFrame = Frame - 1; + LastRoundTripProbeFrame = Frame - ROUND_TRIP_PROBE_FRAMES; for (i = 0; i < ARRAY_SIZE(CRC); i++) CRC[i] = 0; @@ -845,36 +853,14 @@ 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); - // } + 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); + 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(); } //------------------------------------------------------------------------ @@ -939,13 +925,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. //------------------------------------------------------------------------ @@ -1442,7 +1421,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; } @@ -1484,6 +1463,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(); } @@ -1496,322 +1478,88 @@ 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) +static int Game_Speed_Frame_Rate(void) { - unsigned int resp_time; // connection response time, in ticks - EventClass ev; - - //------------------------------------------------------------------------ - // Measure the current connection response time. This time will be in - // 60ths of a second, and represents full round-trip time of a packet. - // To convert to one-way packet time, divide by 2; to convert to game - // frames, divide again by 4, assuming a game rate of 15 fps. - //------------------------------------------------------------------------ - resp_time = net->Response_Time(); - - //------------------------------------------------------------------------ - // Adjust my connection retry timing; only do this if I've sent out more - // than 5 commands, so I know I have a measure of the response time. - //------------------------------------------------------------------------ - if (my_sent > 5) { - - net->Set_Timing (resp_time + TIMER_SECOND / 6, -1, (resp_time * 4) + TIMER_SECOND / 4); - - //..................................................................... - // If I'm the network "master", I'm also responsible for updating the - // MaxAhead value on all systems, so do that here too. - //..................................................................... - if (Session.Am_I_Master()) { - ev.Type = EventClass::RESPONSE_TIME; - //.................................................................. - // For multi-frame compressed events, the MaxAhead must be an even - // multiple of the FrameSendRate. - //.................................................................. - if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { - ev.Data.FrameInfo.Delay = std::max( ((((resp_time / 8) + - (Session.FrameSendRate - 1)) / Session.FrameSendRate) * - Session.FrameSendRate), (Session.FrameSendRate * 2) ); - } - //.................................................................. - // For sending packets every frame, just use the 1-way connection - // response time. - //.................................................................. - else { - if (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET) { - ev.Data.FrameInfo.Delay = std::max( (resp_time / 8), - NETWORK_MIN_MAX_AHEAD ); - } - } - OutList.push_back(ev); - } + switch (Options.GameSpeed) { + case 0: return(60); + case 1: return(45); + case 2: return(30); + case 3: return(20); + case 4: return(15); + case 5: return(12); + case 6: return(10); + default: return(60); } - -} // end of Generate_Timing_Event +} -/*************************************************************************** - * Generate_Real_Timing_Event -- Generates a TIMING event * - * * - * INPUT: * - * net ptr to connection manager * - * my_sent # commands I've sent out so far * - * * - * OUTPUT: * - * none. * - * * - * WARNINGS: * - * none. * - * * - * HISTORY: * - * 07/02/1996 BRR : Created. * - *=========================================================================*/ -static void Generate_Real_Timing_Event(ConnManClass *net, int my_sent) +/// Queues timing selected from the synchronized report census. +static void Generate_Real_Timing_Event(void) { - unsigned int resp_time; // connection response time, in ticks - EventClass ev; - int highest_ticks; - int i; - int specified_frame_rate; - int maxahead; - unsigned char frame_send_rate; - - if (Session.PrecalcMaxAhead != 0 || Session.PrecalcDesiredFrameRate != 0) { - DebugString("Sending precalculated network timings on frame %d\n", Frame); - - ev.Type = EventClass::TIMING; - ev.Data.Timing.DesiredFrameRate = Session.PrecalcDesiredFrameRate; - ev.Data.Timing.MaxAhead = Session.PrecalcMaxAhead; - ev.Data.Timing.FrameSendRate = Session.PrecalcDesiredFrameRate > 30u ? 10 : 5; - - OutList.push_back(ev); - - Session.PrecalcMaxAhead = 0; - Session.PrecalcDesiredFrameRate = 0; - + if (Frame < 0) { return; } - - // - // If we haven't sent out at least 5 guaranteed-delivery packets, don't - // bother trying to measure our connection response time; just return. - // - if (my_sent < 5) { + unsigned int const frame = static_cast(Frame); + int const master_id = Session.Master_Player_ID(); + if (PlayerPtr == NULL || PlayerPtr->HeapID != master_id) { return; } - - // - // Find the highest processing time we have stored - // - highest_ticks = 0; - for (i = 0; i < Session.Players.Count(); i++) { - - // - // If we haven't heard from all systems yet, bail out. - // - if (Session.Players[i]->Player.ProcessTime == -1) { - return; - } - if (Session.Players[i]->Player.ProcessTime > highest_ticks) { - highest_ticks = Session.Players[i]->Player.ProcessTime; - } - } - - // - // Compute our "desired" frame rate as the lower of: - // - What the user has dialed into the options screen - // - What we're really able to run at - // - if (highest_ticks == 0) { - Session.DesiredFrameRate = 60; - } else { - Session.DesiredFrameRate = std::max(1, 1000 / highest_ticks); - } - - switch (Options.GameSpeed) { - case 0: - specified_frame_rate = 60; - break; - case 1: - specified_frame_rate = 45; - break; - default: - specified_frame_rate = 60 / Options.GameSpeed; - break; - } - - Session.DesiredFrameRate = std::min(Session.DesiredFrameRate, specified_frame_rate); - - // - // Measure the current connection response time. This time will be in - // 60ths of a second, and represents full round-trip time of a packet. - // To convert to one-way packet time, divide by 2; to convert to game - // frames, ....uh.... - // - resp_time = net->Response_Time(); - frame_send_rate = Session.FrameSendRate; - if (Session.Type == GAME_INTERNET) { - frame_send_rate = Session.DesiredFrameRate > 30 ? 10 : 5; - } - - int fudge = 0; - if (resp_time != 0) { - switch (Session.LatencyFudge) { - case 0: - DebugString("Response time = %d\n", resp_time); - break; - case 1: - resp_time += resp_time >> 1; - fudge = 10; - DebugString("Response time = %d\n", resp_time); - break; - case 2: - resp_time *= 2; - fudge = 20; - DebugString("Response time = %d\n", resp_time); - break; - case 3: - resp_time *= 3; - fudge = 30; - DebugString("Response time = %d\n", resp_time); - break; - } + Session.Prepare_Network_Timing_Master(master_id, frame); + + NetTiming::TimingCensus const census = Session.Network_Timing_Census(frame); + unsigned int const desired_frame_rate = NetTiming::Select_Desired_Frame_Rate(census, + static_cast(std::clamp(Session.DesiredFrameRate, 1, 60)), static_cast(Game_Speed_Frame_Rate())); + NetTiming::TimingEvaluation const evaluation = Session.Evaluate_Network_Timing(census, desired_frame_rate, frame); + if (evaluation.Evaluated) { + DebugString("Network timing evaluation at frame %u: %u of %u reports fresh, worst process %u ms, RTT %u ms%s, wait %u ms, %u fps -> %s %u/%u\n", + frame, census.FreshProcessReports, census.ActivePlayers, (unsigned int)census.WorstProcessMilliseconds, (unsigned int)census.WorstRoundTrip, + census.RequiresConservativeTiming ? " (never measured)" : census.RoundTripComplete ? "" : " (incomplete)", + (unsigned int)census.WorstStallMilliseconds, desired_frame_rate, evaluation.Changed ? "change to" : "keep", + evaluation.Settings.FrameSendRate, evaluation.Settings.MaxAhead); + } + // Comparing against the staged target avoids resending a change that has not activated yet. + if (!evaluation.Evaluated || (evaluation.Settings == Session.Network_Timing_Target() + && desired_frame_rate == static_cast(Session.DesiredFrameRate))) { + return; } - // - // Compute our new 'MaxAhead' value, based upon the response time of our - // connection and our desired frame rate. - // 'MaxAhead' in frames is: - // - // (resp_time / 2 ticks) * (1 sec/60 ticks) * (n Frames / sec) - // - // resp_time is divided by 2 because, as reported, it represents a round- - // trip, and we only want to use a one-way trip. - // - maxahead = frame_send_rate + (resp_time * Session.DesiredFrameRate) / (2 * TIMER_SECOND); - - // - // Now, we have to round 'maxahead' so it's an even multiple of our - // send rate. It also must be at least thrice the FrameSendRate. - // (Isn't "thrice" a cool word?) - // - maxahead = ((maxahead + fudge - 1) / frame_send_rate) * frame_send_rate; - maxahead = std::max(maxahead, (int)frame_send_rate * 3); - maxahead = std::min(maxahead, frame_send_rate * ((frame_send_rate + 249) / frame_send_rate)); - - ev.Type = EventClass::TIMING; - ev.Data.Timing.DesiredFrameRate = Session.DesiredFrameRate; - ev.Data.Timing.MaxAhead = maxahead + (Scen->Special.IsFogOfWar ? 10 : 0); - ev.Data.Timing.FrameSendRate = frame_send_rate; - - OutList.push_back(ev); - - // - // Adjust my connection retry timing. These values set the retry timeout - // to just over one round-trip time, the 'maxretries' to -1, and the - // connection timeout to allow for about 4 retries. - // - if (Session.Players.Count() == 1 && resp_time == 0) { - resp_time = TIMER_SECOND / 2; - } - net->Set_Timing (resp_time + TIMER_SECOND / 6, -1, std::max(2 * TIMER_SECOND, (resp_time*8) + TIMER_SECOND / 4), false); + EventClass event; + memset(&event, 0, sizeof(event)); + event.Type = EventClass::TIMING; + event.Data.Timing.DesiredFrameRate = desired_frame_rate; + event.Data.Timing.MaxAhead = evaluation.Settings.MaxAhead; + event.Data.Timing.FrameSendRate = evaluation.Settings.FrameSendRate; + OutList.push_back(event); } -/*************************************************************************** - * Generate_Process_Time_Event -- Generates a PROCESS_TIME event * - * * - * INPUT: * - * net ptr to connection manager * - * * - * OUTPUT: * - * none. * - * * - * WARNINGS: * - * none. * - * * - * HISTORY: * - * 07/02/1996 BRR : Created. * - *=========================================================================*/ -static void Generate_Process_Time_Event(ConnManClass *net) +/// Queues the local process-time, waiting-time and worst-RTT report. +static void Generate_Network_Report_Event(ConnManClass *net) { - EventClass ev; - int avgticks; - unsigned int resp_time; // connection response time, in ticks - - // - // Measure the current connection response time. This time will be in - // 60ths of a second, and represents full round-trip time of a packet. - // To convert to one-way packet time, divide by 2; to convert to game - // frames, ....uh.... - // - resp_time = net->Response_Time(); - - // - // Adjust my connection retry timing. These values set the retry timeout - // to just over one round-trip time, the 'maxretries' to -1, and the - // connection timeout to allow for about 4 retries. - // - switch (Session.LatencyFudge) { - case 0: - DebugString("Response time = %d\n", resp_time); - break; - case 1: - resp_time += resp_time >> 1; - DebugString("Response time = %d\n", resp_time); - break; - case 2: - resp_time *= 2; - DebugString("Response time = %d\n", resp_time); - break; - case 3: - resp_time *= 3; - DebugString("Response time = %d\n", resp_time); - break; - } - net->Set_Timing (resp_time + TIMER_SECOND / 6, -1, std::max(2 * TIMER_SECOND, (resp_time * 8) + TIMER_SECOND / 4), false); - - if (IsMono) { - MonoClass::Enable(); - Mono_Set_Cursor(0,23); - Mono_Printf("Processing Ticks:%03d Frames:%03d\n", Session.ProcessTicks,Session.ProcessFrames); - MonoClass::Disable(); + if (Session.ProcessFrames <= 0) { + return; } - avgticks = Session.ProcessTicks / Session.ProcessFrames; + int const average_process_milliseconds = std::clamp(Session.ProcessTicks / Session.ProcessFrames, 0, + static_cast(NetTiming::MAXIMUM_PROCESS_MILLISECONDS)); + std::optional const worst_round_trip = net->Worst_Local_Round_Trip_MS(); - ev.Type = EventClass::PROCESS_TIME; - ev.Data.ProcessTime.AverageTicks = avgticks; - OutList.push_back(ev); + EventClass event; + memset(&event, 0, sizeof(event)); + event.Type = EventClass::NETWORK_REPORT; + event.Data.NetworkReport.AverageProcessMilliseconds = static_cast(average_process_milliseconds); + event.Data.NetworkReport.WorstRoundTripMilliseconds = !worst_round_trip || *worst_round_trip >= EventClass::NETWORK_RTT_UNAVAILABLE + ? EventClass::NETWORK_RTT_UNAVAILABLE : static_cast(*worst_round_trip); + // Evaluations run every other report, so each report covers the last two intervals. + int const worst_stall_ticks = std::max(Session.WorstStallTicks, Session.PreviousWorstStallTicks); + event.Data.NetworkReport.StallMilliseconds = static_cast(std::clamp(worst_stall_ticks * 1000 / TIMER_SECOND, 0, 65535)); + OutList.push_back(event); Session.ProcessTicks = 0; Session.ProcessFrames = 0; - - if (Session.Type == GAME_INTERNET && (Frame & 0x3FF) == 0) { - net->Reset_Response_Time(false); - } + Session.PreviousWorstStallTicks = Session.WorstStallTicks; + Session.WorstStallTicks = 0; } @@ -1915,6 +1663,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 @@ -1927,6 +1679,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++; @@ -3318,6 +3073,8 @@ static int Execute_DoList(int max_houses, HousesType base_house, HouseClass *hptr; int i,j,k; int index; + int const previous_execution_frame = LastExecutedFrame; + LastExecutedFrame = Frame; #if (TIMING_FIX) // @@ -3341,6 +3098,10 @@ static int Execute_DoList(int max_houses, HousesType base_house, } #endif + if (Session.Record && !Session.Play) { + Queue_Record(); + } + //------------------------------------------------------------------------ // Compare the checksums the other systems reported before executing any of // this frame's events, so that a report describes the same frame boundary on @@ -3477,14 +3238,13 @@ 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! // (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]); @@ -3680,7 +3440,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++; } } @@ -3690,7 +3450,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--; } @@ -3776,6 +3536,9 @@ static void Queue_Playback(void) // routine didn't write anything the first time through); do this after the // CRC is computed, since we'll still need a CRC for Frame 0. //------------------------------------------------------------------------ + if (Frame == 0) { + LastExecutedFrame = -1; + } if (Frame==0 && Session.Type!=GAME_NORMAL) { return; } diff --git a/code/scenario.cpp b/code/scenario.cpp index 79a60bdc0..659d6d1e5 100644 --- a/code/scenario.cpp +++ b/code/scenario.cpp @@ -388,7 +388,7 @@ bool Start_Scenario(char const * name, bool briefing, CampaignType campaign) bool has_briefing_movie = Scen->BriefMovie != VQ_NONE; if (has_briefing_movie) { - wsprintf(buffer, "%s.VQA", Movies[Scen->BriefMovie]); + snprintf(buffer, sizeof(buffer), "%s.VQA", Movies[Scen->BriefMovie]); has_briefing_movie = CCFileClass(buffer).Is_Available(); } @@ -3729,8 +3729,8 @@ bool ScenarioClass::Write_Local_INI(CCINIClass & ini) const int length = ARRAY_SIZE(LocalFlags); for (int index = 0; index < length; index++) { if (LocalFlags[index].VariableName[0] != '\0') { - wsprintf(index_buffer, "%d", index); - wsprintf(buffer, "%s,%d", LocalFlags[index].VariableName, LocalFlags[index].Value ? 1 : 0); + snprintf(index_buffer, sizeof(index_buffer), "%d", index); + snprintf(buffer, sizeof(buffer), "%s,%d", LocalFlags[index].VariableName, LocalFlags[index].Value ? 1 : 0); ini.Put_String(SECTION, index_buffer, buffer); } } @@ -4178,7 +4178,7 @@ void ScenarioClass::Read_Waypoints(CCINIClass const & ini) char buf[20]; for (int i = 0; i < WAYPT_COUNT; i++) { - wsprintf(buf, "%d", i); + snprintf(buf, sizeof(buf), "%d", i); int val = ini.Get_Int("Waypoints", buf, 0); if (val == 0) { Waypoint[i] = CELL_NONE; @@ -4215,7 +4215,7 @@ void ScenarioClass::Write_Waypoints(CCINIClass & ini) const ini.Clear(WAYNAME); for (int i = 0; i < WAYPT_COUNT; i++) { if (Waypoint[i] != CELL_NONE) { - wsprintf(entry, "%d", i); + snprintf(entry, sizeof(entry), "%d", i); ini.Put_Int(WAYNAME, entry, Waypoint[i].Y * 1000 + Waypoint[i].X); } } diff --git a/code/session.cpp b/code/session.cpp index 23a979b3a..f537d2d8b 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -206,15 +206,16 @@ SessionClass::SessionClass(void) MaxAhead = FrameSendRate * 3; MaxMaxAhead = MaxAhead; + NetworkTimingReports.Reset(); + NetworkTimingPolicy.Reset(0); + PendingNetworkTiming.reset(); + NetworkTimingPolicyOwner = -1; ConnTimeout = 60 * TIMER_SECOND; ReconnectTimeout = 40 * TIMER_SECOND; memset(ConnectionStats, 0, sizeof(ConnectionStats)); - PrecalcMaxAhead = 0; - PrecalcDesiredFrameRate = 0; - ShowInternetDebug = false; LoadGame = 0; @@ -402,6 +403,8 @@ int SessionClass::Create_Connections(void) } } + Reset_Network_Timing(Frame >= 0 ? static_cast(Frame) : 0u); + DebugString("Leaving Create_Connections\n"); return(1); @@ -432,12 +435,31 @@ bool SessionClass::Am_I_Master(void) /// -/// Returns the house that decides for the match: the announced host while it still holds a -/// seat, else the lowest seated house. The timing events, the out-of-sync dialog and an -/// in-game load all answer to this one master, and every machine names the same one. +/// Returns the house that decides for the match: the timing authority while it still holds a +/// seat under the adaptive protocol, otherwise the announced host, otherwise the lowest +/// seated house. The timing events, the out-of-sync dialog and an in-game load all answer to +/// this one master, and every machine names the same one. /// int SessionClass::Master_Player_ID(void) const { + if (CommProtocol == COMM_PROTOCOL_MULTI_E_COMP && NetworkTimingPolicyOwner >= 0) { + for (int index = 0; index < Houses.Count(); index++) { + HouseClass const * house = Houses[index]; + if (house != NULL && house->HeapID == NetworkTimingPolicyOwner && house->IsHuman + && Is_Network_Timing_Player_Active(house->HeapID)) { + return(house->HeapID); + } + } + + for (int index = 0; index < Houses.Count(); index++) { + HouseClass const * house = Houses[index]; + if (house != NULL && house->IsHuman && Is_Network_Timing_Player_Active(house->HeapID)) { + return(house->HeapID); + } + } + return(-1); + } + int lowest = -1; for (int index = 0; index < Players.Count(); index++) { if (Players[index] == NULL) { @@ -485,6 +507,195 @@ void SessionClass::Adopt_Master(int house, char const * name) } +bool SessionClass::Is_Network_Timing_Player_Active(int id) const +{ + return(id >= 0 && id < static_cast(NetTiming::MAX_TIMING_PLAYERS) && NetworkTimingReports.Is_Player_Active(id)); +} + + +/// Starts a fresh adaptive-timing census from the synchronized initial roster. +void SessionClass::Reset_Network_Timing(unsigned int frame) +{ + if (CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { + NetTiming::TimingSettings const initial = NetTiming::Settings_For_Rung(NetTiming::INITIAL_TIMING_RUNG); + FrameSendRate = initial.FrameSendRate; + MaxAhead = initial.MaxAhead; + MaxMaxAhead = MaxAhead; + } + NetworkTimingReports.Reset(); + NetworkTimingPolicy.Reset(frame); + PendingNetworkTiming.reset(); + NetworkTimingPolicyOwner = -1; + + for (int i = 0; i < Players.Count(); i++) { + int const id = Players[i] != NULL ? Players[i]->Player.ID : -1; + if (id >= 0 && id < static_cast(NetTiming::MAX_TIMING_PLAYERS)) { + NetworkTimingReports.Set_Player_Active(id, true, frame); + } + } + Prepare_Network_Timing_Master(Master_Player_ID(), frame); +} + + +/// Validates and records a seated player's synchronized timing report. +bool SessionClass::Record_Network_Report(int id, unsigned int process_milliseconds, unsigned int round_trip_milliseconds, unsigned int stall_milliseconds, + unsigned int frame) +{ + std::optional round_trip; + if (round_trip_milliseconds != EventClass::NETWORK_RTT_UNAVAILABLE) { + round_trip = round_trip_milliseconds; + } + if (!NetworkTimingReports.Record_Report(id, process_milliseconds, round_trip, frame, stall_milliseconds)) { + return(false); + } + + for (int i = 0; i < Players.Count(); i++) { + if (Players[i] != NULL && Players[i]->Player.ID == id) { + Players[i]->Player.ProcessTime = process_milliseconds; + break; + } + } + return(true); +} + + +/// Removes a departed player and re-picks the timing authority. +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); + } +} + + +NetTiming::TimingCensus SessionClass::Network_Timing_Census(unsigned int frame) +{ + return(NetworkTimingReports.Inspect(frame)); +} + + +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 and stages a decrease until the old horizon drains. +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 86391286b..9ad4633be 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 //--------------------------------------------------------------------------- @@ -502,6 +505,12 @@ class SessionClass // Public interface //------------------------------------------------------------------------ public: + struct NetworkTimingTransition + { + NetTiming::TimingTransitionState Timing; + unsigned int DesiredFrameRate = 30; + }; + //..................................................................... // Constructor/Destructor //..................................................................... @@ -530,6 +539,18 @@ class SessionClass int Master_Player_ID(void) const; void Announce_Master(void); void Adopt_Master(int house, char const * name); + bool Is_Network_Timing_Player_Active(int id) const; + void Reset_Network_Timing(unsigned int frame); + bool Record_Network_Report(int id, unsigned int process_milliseconds, unsigned int round_trip_milliseconds, unsigned int stall_milliseconds, + unsigned int frame); + void Remove_Network_Timing_Player(int id, unsigned int frame); + NetTiming::TimingCensus Network_Timing_Census(unsigned int frame); + NetTiming::TimingEvaluation Evaluate_Network_Timing(NetTiming::TimingCensus const & census, unsigned int target_fps, unsigned int frame); + NetTiming::TimingSettings Network_Timing_Target(void) const; + void Prepare_Network_Timing_Master(int master_id, unsigned int frame); + void Apply_Network_Response_Time(unsigned int max_ahead, unsigned int event_frame); + NetTiming::ScheduleResult Schedule_Network_Timing(NetTiming::TimingSettings settings, unsigned int desired_frame_rate, unsigned int event_frame); + bool Advance_Network_Timing(unsigned int frame); unsigned int Compute_Unique_ID(void); void Update_Progress(int percent); void Init_Fixed_Alliances(void); @@ -608,6 +629,10 @@ class SessionClass //..................................................................... unsigned int MaxAhead; unsigned int FrameSendRate; + NetTiming::TimingReportCensus NetworkTimingReports; + NetTiming::BalancedTimingPolicy NetworkTimingPolicy; + std::optional PendingNetworkTiming; + int NetworkTimingPolicyOwner; // How long this machine waits on another, in game ticks. Each machine keeps its own: the // waits decide when this machine gives up, never what the match computes. @@ -619,6 +644,9 @@ class SessionClass int ProcessTimer; int ProcessTicks; int ProcessFrames; + // Longest single wait for other players, in ticks. A report covers this interval and the previous one. + int WorstStallTicks; + int PreviousWorstStallTicks; /* * This is the largest MaxAhead the game has run at, since the value only ever grows. @@ -626,14 +654,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. @@ -784,11 +804,7 @@ class SessionClass */ int PlayerLatency[MAX_PLAYERS]; - /* - * This scales up the measured connection response time when the frame timing is - * computed (0 - 3), buying tolerance of a laggy link at the cost of responsiveness. - */ - int LatencyFudge; + int LatencyFudge; // Legacy synchronized option retained for event and replay compatibility. //..................................................................... // For finding Sync Bugs diff --git a/code/startup.cpp b/code/startup.cpp index e7a3502f0..fa56981b9 100644 --- a/code/startup.cpp +++ b/code/startup.cpp @@ -537,18 +537,13 @@ int CALLBACK WinMain ( HINSTANCE instance , HINSTANCE , char * , int command_sho ** If there is not enough disk space free, don't allow the product to run. */ if (Disk_Space_Available() < INIT_FREE_DISK_SPACE) { - wsprintf (buffer, Fetch_String(TXT_CRITICALLY_LOW), (INIT_FREE_DISK_SPACE) / (1024 * 1024)); + snprintf(buffer, sizeof(buffer), Fetch_String(TXT_CRITICALLY_LOW), (INIT_FREE_DISK_SPACE) / (1024 * 1024)); int reply = MessageBox(NULL, buffer, Fetch_String(TXT_SHORT_TITLE), MB_ICONQUESTION|MB_YESNO); if (reply == IDNO) { return(EXIT_FAILURE); } } - if (Session.ShowInternetDebug) { - Options.ScreenWidth = 640; - Options.ScreenHeight = 400; - } - if (Options.ScreenWidth == -1 || Options.ScreenHeight == -1) { int framewidth = 0; int frameheight = 0; diff --git a/code/stats.cpp b/code/stats.cpp index 3f5604e2b..e62af6db2 100644 --- a/code/stats.cpp +++ b/code/stats.cpp @@ -478,7 +478,7 @@ void Send_Statistics_Packet(void) * Game version/build date */ char version[128]; - wsprintf (version, "V%s", VerNum.Version_Name() ); + snprintf(version, sizeof(version), "V%s", VerNum.Version_Name() ); stats.Add_Field (FIELD_GAME_VERSION, (char*)version); char path_to_exe[280]; diff --git a/code/taction.h b/code/taction.h index c87a3f454..f7e94bc9a 100644 --- a/code/taction.h +++ b/code/taction.h @@ -133,7 +133,7 @@ class TActionClass : public AbstractClass VoxelAnimType VAnim; CrateType Crate; bool Bool; // Boolean value. - long Value; + int Value; float Float; } Data; diff --git a/code/tagtype.cpp b/code/tagtype.cpp index 26bef7fbf..135dfb6a8 100644 --- a/code/tagtype.cpp +++ b/code/tagtype.cpp @@ -240,10 +240,10 @@ bool TagTypeClass::Write_INI(CCINIClass & ini) const char buffer[128]; if (FirstTrigger == NULL) { - wsprintf(buffer, "%s,", (char const *)GivenName); + snprintf(buffer, sizeof(buffer), "%s,", (char const *)GivenName); ini.Put_String(INI_NAME, IniName, buffer); } else { - wsprintf(buffer, "%d,%s,%s", Persistence, (char const *)GivenName, (char const *)FirstTrigger->IniName); + snprintf(buffer, sizeof(buffer), "%d,%s,%s", Persistence, (char const *)GivenName, (char const *)FirstTrigger->IniName); ini.Put_String(INI_NAME, IniName, buffer); } diff --git a/code/tevent.cpp b/code/tevent.cpp index 2b724d56a..a556428c5 100644 --- a/code/tevent.cpp +++ b/code/tevent.cpp @@ -490,18 +490,23 @@ bool TEventClass::operator () (TEventType event, HouseClass const * house, Objec * HISTORY: * * 11/28/1995 JLB : Created. * *=============================================================================================*/ -void TEventClass::Build_INI_Entry(char * ptr) const +void TEventClass::Build_INI_Entry(char * ptr, std::size_t size) const { int code = 0; int val = Data.Value; NeedType need = Event_Needs(Event); + + // The caller has already put the event count and a comma in the buffer, so this appends. + std::size_t const used = strlen(ptr); + if (used >= size) { + return; + } + if (Team != NULL) { code = 1; - ptr += strlen(ptr); - wsprintf(ptr, "%d,%d,%s", Event, code, (char const *)Team->IniName); + snprintf(ptr + used, size - used, "%d,%d,%s", Event, code, (char const *)Team->IniName); } else { - ptr += strlen(ptr); - wsprintf(ptr, "%d,%d,%d", Event, code, val); + snprintf(ptr + used, size - used, "%d,%d,%d", Event, code, val); } } diff --git a/code/tevent.h b/code/tevent.h index c7dc9e1bf..2bd48a350 100644 --- a/code/tevent.h +++ b/code/tevent.h @@ -46,6 +46,8 @@ #include "tevent.hh" #include "unit.hh" +#include + template class DynamicVectorClass; class TeamTypeClass; class TechnoClass; @@ -109,7 +111,7 @@ class TEventClass : public AbstractClass virtual void Serialize(SaveStreamClass & stream) override; void Read_INI(void); - void Build_INI_Entry(char * buffer) const; + void Build_INI_Entry(char * buffer, std::size_t size) const; virtual void Compute_CRC(CRCEngine & crc) const override; virtual void Detach(AbstractClass const * target, bool all=true) override; diff --git a/code/trigtype.cpp b/code/trigtype.cpp index 849f89755..081d45048 100644 --- a/code/trigtype.cpp +++ b/code/trigtype.cpp @@ -649,7 +649,7 @@ bool TriggerTypeClass::Write_INI(CCINIClass & ini) const tevent = FirstEvent; while (tevent != NULL) { strcat(buffer, ","); - tevent->Build_INI_Entry(buffer); + tevent->Build_INI_Entry(buffer, sizeof(buffer)); tevent = tevent->Next; } ini.Put_String(INI_EVENT_NAME, IniName, buffer); diff --git a/code/ui/uigameoptions.cpp b/code/ui/uigameoptions.cpp index e69acaa85..40f75e52c 100644 --- a/code/ui/uigameoptions.cpp +++ b/code/ui/uigameoptions.cpp @@ -33,7 +33,9 @@ #include "globals.h" #include "house.h" #include "language/language.h" +#include "goptions.h" #include "loaddlg.h" +#include "nettiming.h" #include "options.h" #include "savemgr.h" #include "scenario.h" @@ -49,15 +51,6 @@ #include -// The connection quality labels, best first, which is the order the slider counts in. -static int const _ConnectionNames[] = { - TXT_WORST_CONNECTION, - TXT_POOR_CONNECTION, - TXT_GOOD_CONNECTION, - TXT_BEST_CONNECTION -}; - -static int const CONNECTION_STEPS = 4; static bool Is_Solo_Session(void) @@ -90,17 +83,21 @@ void UIGameOptionsPresenterClass::Refresh(void) CanBrief = (Session.Type != GAME_SKIRMISH); SpeedStep = (OptionsClass::MAX_SPEED_SETTING - 1) - Options.GameSpeed; - ConnectionStep = (CONNECTION_STEPS - 1) - Session.LatencyFudge; + + NetTiming::TimingSettings const timing{Session.FrameSendRate, Session.MaxAhead}; + unsigned int const rung = (timing.FrameSendRate >= NetTiming::MINIMUM_TIMING_RUNG + && timing.FrameSendRate <= NetTiming::MAXIMUM_TIMING_RUNG) + ? timing.FrameSendRate : NetTiming::MAXIMUM_TIMING_RUNG; + + ConnectionRung = (int)rung; + ConnectionQualityTextID = Network_Quality_Text_ID(NetTiming::Connection_Quality_For_Settings(timing)); + // The template's slider runs worst to best from left to right, so rung 1 sits at its right end. + ConnectionStep = (int)(NetTiming::MINIMUM_TIMING_RUNG + NetTiming::MAXIMUM_TIMING_RUNG - rung); SpeedLabels.clear(); for (int step = 0; step < OptionsClass::MAX_SPEED_SETTING; step++) { SpeedLabels.push_back(Fetch_String(GameSpeedNames[step])); } - - ConnectionLabels.clear(); - for (int step = 0; step < CONNECTION_STEPS; step++) { - ConnectionLabels.push_back(Fetch_String(_ConnectionNames[step])); - } } @@ -123,11 +120,6 @@ void UIGameOptionsPresenterClass::Execute(UIIntent const & intent) return; } - if (intent.Action == UI_GAMEOPT_CONNECTION) { - ConnectionStep = intent.Value; - return; - } - // The two checks below are the dialog's own, made when the button was pressed rather // than when it was enabled, because a session can withdraw permission while the screen // is up. The view-model's CanSave and CanLoad say what to show, not what to allow. @@ -165,12 +157,6 @@ void UIGameOptionsPresenterClass::Execute(UIIntent const & intent) if (intent.Action == UI_GAMEOPT_RESUME) { if (Session.Type == GAME_INTERNET) { - int const fudge = (CONNECTION_STEPS - 1) - ConnectionStep; - if (fudge != Session.LatencyFudge) { - OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::LATENCYFUDGE, fudge)); - DebugString("LATENCYFUDGE event created - %d\n", fudge); - } - int const speed = (OptionsClass::MAX_SPEED_SETTING - 1) - SpeedStep; if (Options.GameSpeed != speed) { OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::GAMESPEED, speed)); @@ -279,10 +265,10 @@ void GameOptionsViewClass::Update_Labels(void) SpeedLabel = Screen.SpeedLabels[Screen.SpeedStep]; } - ConnectionLabel.clear(); - if (Screen.ConnectionStep >= 0 && Screen.ConnectionStep < (int)Screen.ConnectionLabels.size()) { - ConnectionLabel = Screen.ConnectionLabels[Screen.ConnectionStep]; - } + char connection[64]; + snprintf(connection, sizeof(connection), Fetch_String(TXT_CONNECTION_QUALITY_RUNG), + Fetch_String(Screen.ConnectionQualityTextID), (unsigned int)Screen.ConnectionRung); + ConnectionLabel = connection; } @@ -293,7 +279,6 @@ void GameOptionsViewClass::Move(char const * which, int step) // A position the screen already holds raises no intent, so setting a slider from the // model cannot look like a move the player did not make. if (which == UI_GAMEOPT_SPEED && step == Screen.SpeedStep) return; - if (which == UI_GAMEOPT_CONNECTION && step == Screen.ConnectionStep) return; Screen.Queue(UIIntent{which, "", step}); } @@ -326,7 +311,6 @@ void GameOptionsViewClass::Bind(Rml::DataModelConstructor & model) int const step = (int)(event.GetParameter("value", 0.0f) + 0.5f); if (which == UI_GAMEOPT_SPEED) Move(UI_GAMEOPT_SPEED, step); - else if (which == UI_GAMEOPT_CONNECTION) Move(UI_GAMEOPT_CONNECTION, step); }); // Escape resumes, which is the IDCANCEL the dialog answered with its resume arm. Enter diff --git a/code/ui/uigameoptions.h b/code/ui/uigameoptions.h index 65344d83a..cd9a35c2e 100644 --- a/code/ui/uigameoptions.h +++ b/code/ui/uigameoptions.h @@ -30,7 +30,6 @@ inline constexpr char const * UI_GAMEOPT_RESUME = "resume"; inline constexpr char const * UI_GAMEOPT_ABORT = "abort"; inline constexpr char const * UI_GAMEOPT_SETTINGS = "settings"; inline constexpr char const * UI_GAMEOPT_SPEED = "speed"; // Value: slider step -inline constexpr char const * UI_GAMEOPT_CONNECTION = "connection"; // Value: slider step class UIGameOptionsPresenterClass : public UIPresenterClass @@ -82,15 +81,23 @@ class UIGameOptionsPresenterClass : public UIPresenterClass // paths differ in what the buttons mean, not only in whether they are enabled. bool IsMultiplayer = false; - // Slider steps, counted the way the templates count them: the fastest game speed and - // the best connection sit at step zero, so a step is the setting counted backward. A - // view shows steps; only this class knows what they mean. + // Slider steps, counted the way the templates count them: the fastest game speed sits + // at step zero, so a step is the setting counted backward. A view shows steps; only + // this class knows what they mean. int SpeedStep = 0; + + // The connection slider reports the timing the session negotiated rather than taking + // a setting, so its step is the rung mirrored into the template's worst-to-best order + // and the player cannot move it. int ConnectionStep = 0; - // The label beside each slider, indexed by step. + // The rung itself and the name of the quality it falls in, which the caption states + // together. + int ConnectionRung = 0; + int ConnectionQualityTextID = 0; + + // The label beside the speed slider, indexed by step. std::vector SpeedLabels; - std::vector ConnectionLabels; ChoiceType Choice = CHOICE_NONE; SubScreenType Pending = SUB_NONE; diff --git a/code/waypoint.cpp b/code/waypoint.cpp index 59d2f4fb3..eac7f049e 100644 --- a/code/waypoint.cpp +++ b/code/waypoint.cpp @@ -44,11 +44,11 @@ const char *Waypoint_To_Name(WAYPOINT wp) if (wp < num_chars) { - wsprintf(_string, "%c", wp + 'A'); + snprintf(_string, sizeof(_string), "%c", wp + 'A'); return(_string); } - wsprintf(_string, "%c%c", (wp / num_chars) + ('A' - 1), (wp % num_chars) + 'A'); + snprintf(_string, sizeof(_string), "%c%c", (wp / num_chars) + ('A' - 1), (wp % num_chars) + 'A'); return(_string); } diff --git a/code/winfix.cpp b/code/winfix.cpp index 21edec860..ea8d6351c 100644 --- a/code/winfix.cpp +++ b/code/winfix.cpp @@ -441,7 +441,7 @@ BOOL CALLBACK read_view_from_ini(HWND window, INIClass const &ini) HTREEITEM item = TreeView_GetRoot(window); while (item != NULL) { i++; - wsprintf(buffer, "TV%d", i); + snprintf(buffer, sizeof(buffer), "TV%d", i); if (ini.Get_Bool(section, buffer, false)) { TreeView_Expand(window, item, TVE_EXPAND); @@ -461,7 +461,7 @@ BOOL CALLBACK read_view_from_ini(HWND window, INIClass const &ini) section = last_view_ini_section_name; if (window != NULL) { for (int i = 0; i < 10; i++) { - wsprintf(buffer, "LV%d", i); + snprintf(buffer, sizeof(buffer), "LV%d", i); unsigned int width = ListView_GetColumnWidth(window, i); width = ini.Get_Int(section, buffer, width); if (width < 1000) { @@ -544,7 +544,7 @@ BOOL CALLBACK write_view_to_ini(HWND window, INIClass &ini) while (item != NULL) { i++; - wsprintf(buf, "TV%d", i); + snprintf(buf, sizeof(buf), "TV%d", i); TVITEM *tmp = (TVITEM *)buffer; tmp->mask = TVIF_HANDLE|TVIF_STATE; @@ -571,7 +571,7 @@ BOOL CALLBACK write_view_to_ini(HWND window, INIClass &ini) section = last_view_ini_section_name; if (window != NULL) { for (int i = 0; i < 10; i++) { - wsprintf(buf, "LV%d", i); + snprintf(buf, sizeof(buf), "LV%d", i); unsigned int width = ListView_GetColumnWidth(window, i); if (width < 1000) { ini.Put_Int(section, buf, width); @@ -620,11 +620,11 @@ const char *Make_Identifier(char *str, int num) if ( str ) { - wsprintf(_buffer, "%s%d", str, num); + snprintf(_buffer, sizeof(_buffer), "%s%d", str, num); } else { - wsprintf(_buffer, "%d", num); + snprintf(_buffer, sizeof(_buffer), "%d", num); } return(_buffer); } diff --git a/docs/BUILDING.md b/docs/BUILDING.md index a4211599f..1815aebb6 100644 --- a/docs/BUILDING.md +++ b/docs/BUILDING.md @@ -1,21 +1,22 @@ # Building OpenTS > [!IMPORTANT] -> OpenTS supports Visual Studio 2022 Win32 Debug and Release builds. Both were -> verified from a fresh CMake configuration. A successful build does not -> verify runtime behavior. +> OpenTS supports Visual Studio 2022 `Win32` and `x64` builds, each in Debug +> and Release. All four were verified from a fresh CMake configuration. A +> successful build does not verify runtime behavior. ## Supported target | Component | Requirement | | --- | --- | -| Host and architecture | Windows, 32-bit (`Win32`) target | -| Processor | SSE2, so a Pentium 4 or Athlon 64 onward | +| Host | Windows | +| Target platforms | 32-bit (`Win32`) and 64-bit (`x64`) | +| Processor | SSE2, so a Pentium 4 or Athlon 64 onward; the `x64` build needs a 64-bit processor and Windows | | Generator and compiler | Visual Studio 2022 MSVC 19.30 or newer | | Windows SDK | A Visual Studio-installed Windows SDK | | CMake | 3.23 or newer | | C++ language level | C++20 | -| Configurations | Debug and Release | +| Configurations | Debug and Release, on both platforms | Other generators, compilers, architectures, and configurations are currently unsupported. @@ -25,6 +26,20 @@ a Windows SDK, and CMake 3.23 or newer. Git for Windows is needed to clone the repository and initialize its dependencies, but not to compile a complete source tree. +### Save and network compatibility between the platforms + +A save records pointer identities at a fixed width, but the members and raw +structures around them travel at the build's own widths, so a `Win32` build and +an `x64` build do not read each other's saves. Their network packets differ for +the same reason. + +Nothing detects this. The packed version stamp that saves and network packets +carry records the version, not the pointer width, so a build of either platform +accepts the other's save and admits it to a network game, and the result is a +failed load or a desync rather than a refusal. Until the stamp distinguishes +them, keep a saved game with the platform that wrote it, and play a network +game with peers running the same platform. + ## Dependencies The renderer uses [bgfx](https://github.com/bkaradzic/bgfx), vendored through @@ -77,14 +92,24 @@ cmake --build build --config Debug cmake --build build --config Release ``` +`-A` selects the platform, and a build directory holds one of them. Configure +`x64` beside the 32-bit build rather than over it: + +```powershell +cmake -S . -B build/x64 -G "Visual Studio 17 2022" -A x64 +cmake --build build/x64 --config Debug +cmake --build build/x64 --config Release +``` + CMake normally finds Visual Studio through the Visual Studio Installer. For an unregistered installation, set `CMAKE_GENERATOR_INSTANCE` to its directory and product version. The solution contains only Debug and Release. Each writes its runtime files to -`build/bin//` and copies nothing anywhere else. The test harnesses -build into `build/test-bin//`, so `bin/` holds only what the game -runs. Compiler and linker intermediates stay in the selected build directory. +`/bin//` and copies nothing anywhere else. The +test harnesses build into `/test-bin//`, so +`bin/` holds only what the game runs. Compiler and linker intermediates stay in +the selected build directory. | Configuration | Runtime files | | --- | --- | @@ -130,28 +155,6 @@ The toolchain requires `clang-cl`, `lld-link`, `llvm-lib`, `llvm-mt`, and `llvm-rc` on `PATH`. It exports `compile_commands.json`; one configuration in `.vscode/c_cpp_properties.clang.example.json` reads that file for IntelliSense. -## Experimental x64 build - -An unsupported 64-bit build is available for porting work. It does not expand the -supported build matrix or establish runtime behavior. - -The configuration has no continuous integration and no entry in the verification -boundary below, so treat a result from it as evidence about the port rather than -about the game. - -Configure it with the x64 platform and the opt-in: - -```powershell -cmake -S . -B build/x64 -G "Visual Studio 17 2022" -A x64 -DOPENTS_EXPERIMENTAL_X64=ON -cmake --build build/x64 --config Debug -``` - -A save records pointer identities at a fixed width, but the members and raw -structures around them still travel at the build's own widths, so a 64-bit -build's saves are not interchangeable with a supported build's. The packed -version stamp that saves and network packets carry is the same for both, so -nothing rejects a save or a peer on that basis. Configuring the build warns -about it. ## Experimental native build An unsupported native build for the host platform is available for portability @@ -311,7 +314,9 @@ state: The packed version stores the major, minor, and patch components in one byte each. Saves and network peers reject a different number. Builds within one release cycle, including prereleases, share it, but their saves, replays, and -network sessions may still be incompatible. +network sessions may still be incompatible. The stamp does not record the +target platform; see +[Save and network compatibility between the platforms](#save-and-network-compatibility-between-the-platforms). The version resources in `Game.exe` and `Language.dll`, the title screen, version dialog, crash report, and debug log banner all read these headers. A @@ -344,19 +349,21 @@ not build until marked ready; the workflow then builds their current commit. commit is at least 25 hours old; manually started runs always build. This keeps the latest successful scheduled run attached to downloadable artifacts. -Both use the reusable `Engine build` workflow. On a Windows runner with Visual -Studio 2022, it configures and builds Win32 Debug and Release with the commands -above, runs CTest, and uploads each configuration's executable, language -library, symbol file, and license notices. Artifact names contain the -configuration and short commit. Linker maps are omitted because the symbol -files are sufficient. +Both use the reusable `Engine build` workflow. It runs one job per platform and +configuration, four by default, each on its own Windows runner with Visual +Studio 2022. A job configures and builds its platform with the commands above, +runs CTest, and uploads the executable, language library, symbol file, and +license notices. Artifact names contain the platform, configuration, and short +commit, as in `opents-x64-Release-ab12cd3`. Linker maps are omitted because the +symbol files are sufficient. A failure on either platform fails the workflow. After a successful pull-request build, `Engine build comment` maintains one pull-request comment with direct nightly.link downloads. Publishing a GitHub release runs `Engine release`. It builds the release commit -with `-DOPENTS_OFFICIAL_BUILD=ON`, packages `Game.exe`, `Language.dll`, -`Game.pdb`, and the project and third-party license notices in a zip named -after the release tag, and attaches it to the release. It also appends notes +for both platforms with `-DOPENTS_OFFICIAL_BUILD=ON`, and packages each one's +`Game.exe`, `Language.dll`, `Game.pdb`, and the project and third-party license +notices in a zip named after the release tag and the platform, such as +`OpenTS-v0.2.0-x64.zip`. It attaches both to the release, and appends notes generated from the manual's change records by `python manual/tools/manage.py release-notes`. See [Maintaining](../manual/MAINTAINING.md) for the full release procedure. @@ -365,14 +372,17 @@ CI collects the uploaded artifacts from `build/bin//`. ## Verification boundary -The supported matrix was verified on August 16, 2026 with CMake 4.3.3, Visual -Studio 2022 Community 17.14.37328.6, MSVC 19.44.35228, and Windows SDK -10.0.26100. Fresh Win32 Debug and Release builds completed successfully. The -builds retain inherited MSVC warnings; warnings are not treated as errors, but +The supported matrix was verified on September 11, 2026 with CMake 4.3.3, +Visual Studio 2022 Community 17.14.37614.0, MSVC 19.44.35228, and Windows SDK +10.0.26100. Fresh Win32 and x64 builds completed successfully in both +configurations, and CTest passed all 40 tests in each of the four. The builds +retain inherited MSVC warnings; warnings are not treated as errors, but contributions should not add new warnings. -This verifies only that the supported toolchain compiles, links, and produces -the listed files. Runtime behavior requires separate play testing. +This verifies only that the supported toolchain compiles, links, passes the +tests, and produces the listed files. Runtime behavior requires separate play +testing, and the x64 build has none of that history: only the Win32 build has +been played. The repository contains no maps, movies, audio, or other original game assets. Keep legally obtained runtime data local and outside version control. The diff --git a/manual/MAINTAINING.md b/manual/MAINTAINING.md index d16a691ac..e525a17b9 100644 --- a/manual/MAINTAINING.md +++ b/manual/MAINTAINING.md @@ -116,8 +116,8 @@ To publish a release: 1. Confirm that the development entry names the release and that the commit to be tagged contains everything it ships. 2. Create and publish the GitHub release from a `v` tag on that - commit. The `Engine release` workflow builds the tag, attaches the packaged - zip, and appends + commit. The `Engine release` workflow builds the tag, attaches a packaged + zip per platform, and appends `python manual/tools/manage.py release-notes ` output to the release body. 3. Tag before opening the next development cycle. The tagged commit's CMake diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md new file mode 100644 index 000000000..5153f727c --- /dev/null +++ b/manual/changes/adaptive-network-timing.md @@ -0,0 +1,28 @@ +--- +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. A worsening takes effect at the evaluation that sees it. Recovery +needs sustained headroom and no player waiting 0.1 s or longer, and a decrease +drains the old scheduling horizon before it takes effect. The inherited +per-frame slowdown for a lagging player is gone; at adaptive send periods it +fired on every frame. + +The disabled WOL Connection slider shows the effective rung, 1 to 10, and its +tier, and the message list announces a change of target tier. The Speed slider +still sets game speed. `LATENCYFUDGE` keeps its place in the replay layout, but +nothing emits it and the timing policy does not read it. + +`NETWORK_REPORT` is a new network event and appears in multiplayer recordings, +so every player and every recording needs the same OpenTS snapshot. Existing +event IDs are unchanged, and no configuration migration is needed. diff --git a/manual/changes/network-transport-timing.md b/manual/changes/network-transport-timing.md new file mode 100644 index 000000000..5847aaf04 --- /dev/null +++ b/manual/changes/network-transport-timing.md @@ -0,0 +1,19 @@ +--- +title: Adapt private network retries +category: performance +release: 0.2.0 +targets: +- type: system + id: network-transport-timing + effect: added +credit: +- ZivDero +--- + +Each private connection estimates its own round trip and doubles the wait +between repeated transmissions. Its retry timeout doubles with them, so a link +whose latency climbs above that timeout can still be measured. A packet that +reaches the connection timeout keeps retrying while the receive queue keeps +freeing space, so a recovered link drains its backlog. Global lobby traffic +keeps its fixed retry cadence. Packet layouts, event IDs, and configuration are +unchanged. diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md new file mode 100644 index 000000000..bf6618a7f --- /dev/null +++ b/manual/content/systems/network-synchronization.md @@ -0,0 +1,78 @@ +--- +title: Network synchronization +summary: Adapts synchronized command delay to measured link and processing conditions. +category: multiplayer-networking +keys: [] +--- + +A network game tags every command with the simulation frame on which each +machine executes it. Look-ahead gives a command time to arrive, and the send +period sets how many frames go into one packet. +[Network packet validation](/systems/network-packet-validation/) owns what a +packet must satisfy; +[Network transport timing](/systems/network-transport-timing/) owns each link's +round trip and retries. + +## Adaptive policy + +A compressed game, which packs a run of frames into one packet, begins at +`2/6`: a two-frame send period and six frames of look-ahead. Each player +reports its process time and its longest wait for the other players after 32 +and 64 frames, then every 128 frames, and adds its worst local round trip once +it has one. A player leaves the round trip out while any of its links lacks a +[clean measurement](/systems/network-transport-timing/), and a link that is +retransmitting keeps reporting its last measurement. The master, the seat every +machine names the same way, evaluates at 64 and 128 frames, then every 256 +frames. + +A report carries process time and round trip together and expires after 512 +frames. A player whose round trip never arrives within that time forces the +widest target, `10/250`. If a player that has been measured lets its report +expire or leaves the round trip out, the timing holds, though fresh reports +from other players can still worsen it. While any process report is stale, the +frame rate keeps its last synchronized value. The census starts from the +initial synchronized roster, and an executed removal clears that player's +report. + +Headroom is the reported round trip raised by a quarter. The first complete +census selects its measured target with headroom, and a bootstrap still +incomplete at 128 frames falls back to `3/9`. A later worsening takes effect at +the evaluation that sees it. The first improvement needs three evaluations that +each have headroom and find no wait of 0.1 s or longer in any player's last two +report intervals. It also waits 256 frames after the last change, unless a +descent is already running. While headroom and the waiting limit both hold, +each following evaluation steps down one more rung. A worsening, or an +evaluation without headroom or with such a wait, restores the three-evaluation +requirement. + +A decrease activates only once the old horizon has drained, on a frame aligned +to both send periods. At that frame the send period changes, the look-ahead +takes a temporary value, and each following boundary removes one new send +period until it reaches the target. An event already scheduled for a frame that +the new send period skips executes on the next send frame, identically on every +machine. A recording keeps it in that batch with its scheduled frame unchanged. +A target chosen later stages again from the timing then in force. + +Losing a connection locally does not move the authority. The removal event +does, picking the first remaining human house, which inherits the target and +restarts the cooldown. + +Frame pacing follows the desired frame rate alone. + +## Player feedback + +The disabled Connection slider shows the send-period rung in force, mirrored so +that rung 1 sits at its right end, and the label beside it names the tier and +the rung. Rungs 1 and 2 are Fast, 3 to 5 Normal, 6 to 8 Poor, and 9 and 10 Bad; +a look-ahead longer than its rung allows also reads as Bad. The message list +announces a change of target tier, which can arrive before a staged decrease +takes effect. The Speed slider sets game speed. + +## Compatibility + +`NETWORK_REPORT` is a new network event and appears in multiplayer recordings, +so every player needs the same OpenTS snapshot and a recording should be played +by the snapshot that wrote it. Existing event IDs are unchanged. The policy +reads the measured round trip directly; `LATENCYFUDGE` keeps its event ID and +its session field for replay compatibility, but nothing emits it any more and +the policy does not read it. diff --git a/manual/content/systems/network-transport-timing.md b/manual/content/systems/network-transport-timing.md new file mode 100644 index 000000000..11fde3507 --- /dev/null +++ b/manual/content/systems/network-transport-timing.md @@ -0,0 +1,38 @@ +--- +title: Network transport timing +summary: Measures each private link and schedules retries without changing synchronized frame timing. +category: multiplayer-networking +keys: [] +--- + +Each private connection keeps a smoothed round trip, its variation, and a retry +timeout. Only the acknowledgement of a first transmission measures the link. +Until one arrives, the first acknowledgement after a retry seeds a provisional +estimate, so a link slower than the initial retry delay is still measurable; the +first clean acknowledgement replaces the seed. In a +[compressed game](/systems/network-synchronization/) a frame packet asks for 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 +evaluation. + +The retry timeout stays within 100 to 4000 ms, and each repeated private +transmission doubles its wait up to the connection timeout. For a measured link +the connection timeout is eight times the smoothed round trip plus 250 ms, or +four times the current retry timeout, whichever is larger, bounded to 2 to 30 +seconds. Four times the retry timeout leaves room for three transmissions before +a packet times out, even once that timeout has backed off. + +A packet older than the connection timeout marks the connection bad, and the +connection goes on retrying it until it is acknowledged. Receive-queue cleanup +runs during those retries, so a recovered link has room to drain its backlog. An +unmeasured link uses the bounded legacy timing, and global lobby traffic keeps +its fixed cadence. + +A retransmitting link also doubles its retry timeout, once for each round of +retransmissions: only a packet first sent under a timeout at least as long as +the current one proves that timeout too short. Without this, a link whose +latency has climbed above its timeout retransmits every packet before the +acknowledgement arrives, and every sample stays ambiguous. The next clean +acknowledgement recomputes the timeout from the measured latency; until then the +smoothed round trip keeps its last measured value while the doubled timeout +paces retries. diff --git a/manual/content/using/build-and-run.md b/manual/content/using/build-and-run.md index 6ff69e3c5..c1eed1c48 100644 --- a/manual/content/using/build-and-run.md +++ b/manual/content/using/build-and-run.md @@ -1,6 +1,6 @@ --- title: Build and run -summary: Builds the 32-bit Debug or Release executable and runs it against a directory of game data. +summary: Builds the Debug or Release executable for either platform and runs it against a directory of game data. category: getting-started source_files: - docs/BUILDING.md @@ -26,6 +26,8 @@ cmake --build build --config Debug The Debug build writes `GameD.exe`, its symbols, map file, and the matching `Language.dll` to `build/bin/Debug/`. Use `--config Release` to write `Game.exe` to `build/bin/Release/` instead. Nothing is copied out of the build directory, so the two configurations never overwrite each other. +`-A x64` builds the 64-bit executable. A build directory holds one platform, so give the 64-bit build its own, such as `-B build/x64`. A saved game belongs to the platform that wrote it, and a network game needs every player on the same platform. + Supply the required game data in `Run/`, then launch the built executable and name that data directory: ```powershell title="PowerShell" diff --git a/manual/content/using/developer-build-troubleshooting.md b/manual/content/using/developer-build-troubleshooting.md index a3f4f0f70..15cb56ceb 100644 --- a/manual/content/using/developer-build-troubleshooting.md +++ b/manual/content/using/developer-build-troubleshooting.md @@ -17,7 +17,7 @@ related: A message that `thirdparty/bgfx.cmake` is empty means the clone did not fetch the vendored renderer. Run `git submodule update --init --recursive` and configure again. -Use the Visual Studio 2022 generator and `-A Win32`. The build supports no other compilers, Visual Studio versions, or target architectures. +Use the Visual Studio 2022 generator with `-A Win32` or `-A x64`. The build supports no other compilers, Visual Studio versions, or target architectures. Configuring a platform over a build directory that already holds the other one fails; give each its own directory. For a Visual Studio installation that CMake cannot discover through the Visual Studio Installer, pass its installation path and product version as described in the repository's `docs/BUILDING.md`. diff --git a/manual/content/using/project-status.md b/manual/content/using/project-status.md index caecaed19..6e37f7210 100644 --- a/manual/content/using/project-status.md +++ b/manual/content/using/project-status.md @@ -31,8 +31,8 @@ original game assets; an existing Tiberian Sun installation provides them. ## Toolchain and targets - CMake with Visual Studio 2022 -- 32-bit Windows +- 32-bit and 64-bit Windows - C++20 - Debug and Release configurations -Both configurations compile with the documented toolchain. +Every platform and configuration compiles with the documented toolchain. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b2c0e9a8b..aa6eccd43 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -68,6 +68,7 @@ add_subdirectory(gamedirs) add_subdirectory(ini) add_subdirectory(logstress) add_subdirectory(netpacket) +add_subdirectory(nettiming) add_subdirectory(socketudp) add_subdirectory(sosparity) add_subdirectory(spawner) diff --git a/tests/netpacket/netcontract.cpp b/tests/netpacket/netcontract.cpp index ac3775c34..39a4e8cb2 100644 --- a/tests/netpacket/netcontract.cpp +++ b/tests/netpacket/netcontract.cpp @@ -31,6 +31,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; @@ -152,8 +153,11 @@ void Test_Reader(void) void Test_Event_Contract(void) { Check(EventClass::LATENCYFUDGE == 35, "the last inherited event keeps numeric ID 35"); - Check(EventClass::LAST_EVENT == 36, "the decoder preserves the inherited event range"); - Check(sizeof(EventClass) == 46 && EnvelopeSize == 17, "full and envelope event layouts match the legacy wire"); + Check(EventClass::NETWORK_REPORT == 36 && EventClass::LAST_EVENT == 37, "the timing report appends without renumbering inherited events"); + Check(EventClass::EventLength[EventClass::NETWORK_REPORT] == sizeof(NetworkReportType) && sizeof(NetworkReportType) == 6, "NETWORK_REPORT uses its six-byte payload"); + Check(std::strcmp(EventClass::EventNames[EventClass::NETWORK_REPORT], "NETWORK_REPORT") == 0, "NETWORK_REPORT has a diagnostic name"); + Check(EventClass::NETWORK_RTT_UNAVAILABLE == UINT16_MAX, "the unavailable RTT sentinel is uint16 max"); + Check(sizeof(EventClass) == 46 && EnvelopeSize == 17, "the report fits without changing full or envelope event layouts"); } @@ -359,6 +363,22 @@ void Test_Full_Compressed_Table(void) Check(decoded_response.Succeeded() && decoded_response.Events.size() == 2 && decoded_response.Events[1].Event.Data.FrameInfo.Delay == 42, "RESPONSE_TIME materializes its byte at FrameInfo.Delay"); + + Bytes report = Compressed_Packet(); + std::uint16_t const average = 17; + std::uint16_t const worst = 240; + std::uint16_t const stalled = 350; + Bytes report_data; + Append_Value(report_data, average); + Append_Value(report_data, worst); + Append_Value(report_data, stalled); + Add_Compressed_Event(report, EventClass::NETWORK_REPORT, report_data); + NetPacket::DecodeResult decoded_report = NetPacket::Decode_Event_Packet(report, NetPacket::Encoding::COMPRESSED, Sender); + Check(decoded_report.Succeeded() && decoded_report.Events.size() == 2 + && decoded_report.Events[1].Event.Data.NetworkReport.AverageProcessMilliseconds == average + && decoded_report.Events[1].Event.Data.NetworkReport.WorstRoundTripMilliseconds == worst + && decoded_report.Events[1].Event.Data.NetworkReport.StallMilliseconds == stalled, + "NETWORK_REPORT preserves all three millisecond fields"); } diff --git a/tests/nettiming/CMakeLists.txt b/tests/nettiming/CMakeLists.txt new file mode 100644 index 000000000..96454fd21 --- /dev/null +++ b/tests/nettiming/CMakeLists.txt @@ -0,0 +1,47 @@ +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + cmake_minimum_required(VERSION 3.23) + project(NetTimingContract LANGUAGES CXX) + enable_testing() + set(OPENTS_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../..") +else() + set(OPENTS_ROOT "${CMAKE_SOURCE_DIR}") +endif() + +# Compile the transport timing sources directly so this harness needs no game assets. +add_executable(NetTiming + "${CMAKE_CURRENT_SOURCE_DIR}/nettiming.cpp" + "${OPENTS_ROOT}/code/nettime.cpp" + "${OPENTS_ROOT}/code/nettiming.cpp" +) + +add_executable(NetConnection + "${CMAKE_CURRENT_SOURCE_DIR}/connectiontiming.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/connectionstubs.cpp" + "${OPENTS_ROOT}/code/combuf.cpp" + "${OPENTS_ROOT}/code/connect.cpp" + "${OPENTS_ROOT}/code/netadmit.cpp" + "${OPENTS_ROOT}/code/nettime.cpp" + "${OPENTS_ROOT}/code/nettiming.cpp" +) + +target_compile_definitions(NetConnection PRIVATE NOMINMAX) +set_source_files_properties("${OPENTS_ROOT}/code/combuf.cpp" PROPERTIES + COMPILE_OPTIONS "/source-charset:437;/execution-charset:437" +) + +foreach(target NetTiming NetConnection) + target_compile_features(${target} PRIVATE cxx_std_20) + target_include_directories(${target} PRIVATE "${OPENTS_ROOT}/code") + target_compile_definitions(${target} PRIVATE WIN32 _WINDOWS _MBCS) + target_compile_options(${target} PRIVATE + $<$:/MTd /EHsc /Zc:__cplusplus> + $<$:/MT /EHsc /Zc:__cplusplus> + ) + target_link_libraries(${target} PRIVATE kernel32 winmm) + set_target_properties(${target} PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + ) +endforeach() + +add_test(NAME nettiming COMMAND NetTiming) +add_test(NAME netconnection COMMAND NetConnection) diff --git a/tests/nettiming/connectionstubs.cpp b/tests/nettiming/connectionstubs.cpp new file mode 100644 index 000000000..5eba0f596 --- /dev/null +++ b/tests/nettiming/connectionstubs.cpp @@ -0,0 +1,41 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "_timer.h" +#include "mono.h" + + +// Connection timing uses its injected clock; the engine timer and debug display stay idle. +TTimerClass TickCount; +MonoClass Mono; + + +int SystemTimerClass::operator () (void) const {return(0);} + + +SystemTimerClass::operator int(void) const {return(0);} + + +void __cdecl DebugString(char const *, ...) {} + + +MonoClass::MonoClass(void) {} + + +MonoClass::~MonoClass(void) {} + + +void MonoClass::Clear(void) {} + + +void MonoClass::Set_Cursor(int, int) {} + + +void __cdecl MonoClass::Printf(char const *, ...) {} diff --git a/tests/nettiming/connectiontiming.cpp b/tests/nettiming/connectiontiming.cpp new file mode 100644 index 000000000..39618d47d --- /dev/null +++ b/tests/nettiming/connectiontiming.cpp @@ -0,0 +1,232 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "connect.h" + +#include +#include +#include +#include +#include + + +namespace +{ + class TestClock final : public NetTiming::MillisecondClock + { + public: + NetTiming::Milliseconds Now(void) const override {return(Current);} + NetTiming::Milliseconds Current = 0; + }; + + + class TestTransport + { + public: + explicit TestTransport(TestClock const & clock) : Clock(clock) {} + + void Send(ConnectionClass * destination, char const * buffer, int length) + { + if (Connected) { + Packets.push_back({destination, {buffer, buffer + length}, Clock.Now() + OneWayDelay}); + } + } + + void Deliver(void) + { + while (!Packets.empty() && Packets.front().Arrival <= Clock.Now()) { + Packet packet = std::move(Packets.front()); + Packets.pop_front(); + packet.Destination->Receive_Packet(packet.Bytes.data(), static_cast(packet.Bytes.size())); + } + } + + bool Connected = true; + NetTiming::Milliseconds OneWayDelay = 5; + + private: + struct Packet + { + ConnectionClass * Destination; + std::vector Bytes; + NetTiming::Milliseconds Arrival; + }; + + TestClock const & Clock; + std::deque Packets; + }; + + + class TestConnection final : public ConnectionClass + { + public: + TestConnection(TestClock const & clock, TestTransport & transport, int capacity) + : ConnectionClass(capacity, capacity, sizeof(int), 1234, 6, -1, 120, 0, &clock), Transport(transport) + { + Init(); + } + + void Read(void) + { + int payload; + int length; + while (Get_Packet(&payload, sizeof(payload), &length)) { + Received.push_back(payload); + } + } + + NetTiming::RttEstimator const & Rtt(void) const {return(RoundTripEstimator);} + + ConnectionClass * Remote = nullptr; + std::vector Received; + + private: + int Send(char * buffer, int length, void *, int) override + { + Transport.Send(Remote, buffer, length); + return(1); + } + + TestTransport & Transport; + }; + + + class ConnectionFixture + { + public: + explicit ConnectionFixture(int capacity) : Transport(Clock), First(Clock, Transport, capacity), Second(Clock, Transport, capacity) + { + First.Remote = &Second; + Second.Remote = &First; + } + + void Service(void) + { + Transport.Deliver(); + First.Service(); + Second.Service(); + First.Read(); + Second.Read(); + } + + void Advance_To(NetTiming::Milliseconds time) + { + while (Clock.Current < time) { + Clock.Current++; + Service(); + } + } + + TestClock Clock; + TestTransport Transport; + TestConnection First; + TestConnection Second; + }; + + + int Failures = 0; + + + void Expect(std::string const & name, bool condition) + { + if (!condition) { + std::cerr << name << " failed\n"; + Failures++; + } + } + + + void Establish_Rtt(ConnectionFixture & fixture) + { + int payload = 0; + Expect("first peer queues its initial packet", fixture.First.Send_Packet(&payload, sizeof(payload), true) != 0); + Expect("second peer queues its initial packet", fixture.Second.Send_Packet(&payload, sizeof(payload), true) != 0); + fixture.Service(); + fixture.Advance_To(10); + Expect("both connections measure the initial round trip", fixture.First.Rtt().Smoothed_Rtt() == 10 && fixture.Second.Rtt().Smoothed_Rtt() == 10); + } + + + void Test_Unsampled_Retry(void) + { + ConnectionFixture fixture(2); + fixture.Transport.Connected = false; + int payload = 0; + fixture.First.Send_Packet(&payload, sizeof(payload), true); + fixture.Service(); + Expect("initial send needs no elapsed clock time", fixture.First.Queue->Get_Send(0)->SendCount == 1); + fixture.Advance_To(99); + Expect("unsampled connection retains its legacy retry delay", fixture.First.Queue->Get_Send(0)->SendCount == 1); + fixture.Advance_To(100); + Expect("unsampled connection retries after six engine ticks", fixture.First.Queue->Get_Send(0)->SendCount == 2); + Expect("retry alone does not establish RTT", !fixture.First.Rtt().Has_Sample()); + } + + + void Test_Saturated_Outage_Recovery(int capacity) + { + ConnectionFixture fixture(capacity); + Establish_Rtt(fixture); + Expect("read packets retain the latest sequence entry", fixture.First.Queue->Num_Receive() == 1 && fixture.Second.Queue->Num_Receive() == 1); + fixture.Advance_To(100); + fixture.Transport.Connected = false; + for (int payload = 1; payload <= capacity; payload++) { + Expect("first peer fills its send queue", fixture.First.Send_Packet(&payload, sizeof(payload), true) != 0); + Expect("second peer fills its send queue", fixture.Second.Send_Packet(&payload, sizeof(payload), true) != 0); + } + fixture.Service(); + fixture.Advance_To(3000); + Expect("the outage times out both peers", fixture.First.Is_Bad() && fixture.Second.Is_Bad()); + fixture.Transport.Connected = true; + fixture.Advance_To(8000); + + std::vector expected; + for (int payload = 0; payload <= capacity; payload++) { + expected.push_back(payload); + } + Expect("first peer receives the complete ordered backlog", fixture.First.Received == expected); + Expect("second peer receives the complete ordered backlog", fixture.Second.Received == expected); + Expect("restored connectivity drains both send queues", fixture.First.Queue->Num_Send() == 0 && fixture.Second.Queue->Num_Send() == 0); + Expect("both connections recover from timeout", !fixture.First.Is_Bad() && !fixture.Second.Is_Bad()); + } + + + void Test_Latency_Increase(void) + { + ConnectionFixture fixture(2); + Establish_Rtt(fixture); + fixture.Transport.OneWayDelay = 300; + for (int payload = 1; payload <= 12; payload++) { + Expect("slower link accepts another packet", fixture.First.Send_Packet(&payload, sizeof(payload), true) != 0); + fixture.Service(); + fixture.Advance_To(fixture.Clock.Now() + 1000); + } + Expect("production retry capture lets the slower link become measurable", fixture.First.Rtt().Smoothed_Rtt() > 100); + Expect("slower link obtains an unambiguous estimate", fixture.First.Rtt().Has_Sample() && !fixture.First.Rtt().Is_Provisional()); + Expect("slower link delivers every packet", fixture.Second.Received.size() == 13); + Expect("slower link completes its acknowledgements", fixture.First.Queue->Num_Send() == 0); + } +} + + +int main(void) +{ + Test_Unsampled_Retry(); + Test_Saturated_Outage_Recovery(2); + Test_Saturated_Outage_Recovery(32); + Test_Latency_Increase(); + + if (Failures != 0) { + std::cerr << Failures << " connection timing checks failed\n"; + return(1); + } + std::cout << "All connection timing checks passed\n"; + return(0); +} diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp new file mode 100644 index 000000000..431924126 --- /dev/null +++ b/tests/nettiming/nettiming.cpp @@ -0,0 +1,1281 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "nettiming.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace +{ + class FakeClock final : public NetTiming::MillisecondClock + { + public: + NetTiming::Milliseconds Now(void) const override {return(Current);} + void Set(NetTiming::Milliseconds now) {Current = now;} + + private: + NetTiming::Milliseconds Current = 0; + }; + + + class FakeTransport + { + public: + void Send(NetTiming::Milliseconds now) + { + Clock.Set(now); + FirstSend = now; + LastSend = now; + TransmissionCount = 1; + BaseRto = NetTiming::Initial_Retry_Timeout(Estimator.Retransmit_Timeout(), Timeout()); + } + + bool Retry(NetTiming::Milliseconds now) + { + Clock.Set(now); + NetTiming::RetransmitState const state{FirstSend, LastSend, BaseRto, TransmissionCount}; + if (!NetTiming::Evaluate_Retry(state, now, BaseRto, Timeout(), true, true).Send) { + return(false); + } + LastSend = now; + TransmissionCount++; + Estimator.Note_Retransmit(BaseRto); + return(true); + } + + bool Acknowledge(NetTiming::Milliseconds now) + { + Clock.Set(now); + return(Estimator.Acknowledge(FirstSend, TransmissionCount, Clock)); + } + + NetTiming::RttEstimator const & Rtt(void) const {return(Estimator);} + NetTiming::Milliseconds Base_Rto(void) const {return(BaseRto);} + NetTiming::Milliseconds Timeout(void) const + { + return(NetTiming::Connection_Timeout(Estimator.Smoothed_Rtt(), Estimator.Retransmit_Timeout())); + } + + private: + FakeClock Clock; + NetTiming::RttEstimator Estimator; + NetTiming::Milliseconds FirstSend = 0; + NetTiming::Milliseconds LastSend = 0; + NetTiming::Milliseconds BaseRto = NetTiming::MINIMUM_RTO; + unsigned int TransmissionCount = 0; + }; + + + int Failures = 0; + + + template + void Expect_Equal(std::string const & name, Actual const & actual, Expected const & expected) + { + if (actual == expected) { + return; + } + + std::cerr << name << ": expected " << expected << ", got " << actual << '\n'; + Failures++; + } + + + void Expect(std::string const & name, bool condition) + { + if (!condition) { + std::cerr << name << " failed\n"; + Failures++; + } + } + + + void Test_Rtt_Estimator(void) + { + using namespace NetTiming; + + RttEstimator estimator; + Expect("estimator starts empty", !estimator.Has_Sample()); + Expect("first sample accepted", estimator.Add_Sample(100)); + Expect_Equal("first smoothed RTT", estimator.Smoothed_Rtt(), 100u); + Expect_Equal("first variation", estimator.Rtt_Variation(), 50u); + Expect_Equal("first RTO", estimator.Retransmit_Timeout(), 300u); + + Expect("second sample accepted", estimator.Add_Sample(140)); + Expect_Equal("alpha one eighth", estimator.Smoothed_Rtt(), 105u); + Expect_Equal("beta one quarter", estimator.Rtt_Variation(), 48u); + Expect_Equal("updated RTO", estimator.Retransmit_Timeout(), 297u); + + Expect("retransmitted sample rejected", !estimator.Add_Sample(900, true)); + Expect_Equal("Karn keeps smoothed RTT", estimator.Smoothed_Rtt(), 105u); + Expect_Equal("Karn keeps RTO", estimator.Retransmit_Timeout(), 297u); + + RttEstimator minimum; + minimum.Add_Sample(0); + Expect_Equal("minimum RTO clamp", minimum.Retransmit_Timeout(), MINIMUM_RTO); + + RttEstimator maximum; + maximum.Add_Sample(2000); + Expect_Equal("maximum RTO clamp", maximum.Retransmit_Timeout(), MAXIMUM_RTO); + + RttEstimator fast_link; + RttEstimator slow_link; + fast_link.Add_Sample(50); + slow_link.Add_Sample(300); + Expect("unequal links keep independent RTOs", fast_link.Retransmit_Timeout() < slow_link.Retransmit_Timeout()); + + estimator.Reset(); + Expect("reset clears estimator", !estimator.Has_Sample()); + Expect_Equal("reset restores RTO", estimator.Retransmit_Timeout(), MINIMUM_RTO); + } + + + void Test_Clock_And_Wrap(void) + { + using namespace NetTiming; + + FakeClock clock; + clock.Set(0x00000020u); + RttEstimator estimator; + Expect("wrap sample accepted", estimator.Acknowledge(0xfffffff0u, 1, clock)); + Expect_Equal("wrap elapsed", estimator.Smoothed_Rtt(), 48u); + Expect("retransmitted acknowledgement ignored", !estimator.Acknowledge(0, 2, clock)); + + Expect("wrapped retry due", Retransmit_Is_Due(0xfffffff0u, 0x00000054u, 100, 0)); + Expect("wrapped retry not early", !Retransmit_Is_Due(0xfffffff0u, 0x00000040u, 100, 0)); + } + + + void Test_Retransmit_Backoff(void) + { + using namespace NetTiming; + + Expect_Equal("base retry", Retransmit_Delay(100, 0), 100u); + Expect_Equal("first backoff", Retransmit_Delay(100, 1), 200u); + Expect_Equal("second backoff", Retransmit_Delay(100, 2), 400u); + Expect_Equal("third backoff", Retransmit_Delay(100, 3), 800u); + Expect_Equal("fourth backoff", Retransmit_Delay(100, 4), 1600u); + Expect_Equal("backoff saturation", Retransmit_Delay(100, 20), MAXIMUM_RTO); + Expect_Equal("base clamp", Retransmit_Delay(1, 0), MINIMUM_RTO); + Expect_Equal("connection timeout minimum", Connection_Timeout(0, MINIMUM_RTO), 2000u); + Expect_Equal("connection timeout follows RTT", Connection_Timeout(500, MINIMUM_RTO), 4250u); + Expect_Equal("connection timeout ceiling", Connection_Timeout(10000, MAXIMUM_RTO), 30000u); + Expect_Equal("backoff reaches connection timeout", Retransmit_Delay(500, 8, 4250), 4250u); + + Expect_Equal("first retry keeps a small RTO", Initial_Retry_Timeout(300, 2000), 300u); + Expect_Equal("first retry is bounded by a quarter of the timeout", Initial_Retry_Timeout(1600, 2000), 500u); + Expect_Equal("first retry bound keeps the floor", Initial_Retry_Timeout(1600, 300), MINIMUM_RTO); + Expect_Equal("slow link keeps its RTO under a long timeout", Initial_Retry_Timeout(2055, Connection_Timeout(2015, 2055)), 2055u); + Expect_Equal("bounded first retry allows three sends before the timeout", Retransmit_Delay(500, 0) + Retransmit_Delay(500, 1), 1500u); + } + + + void Test_Backoff_Timeout(void) + { + using namespace NetTiming; + + Expect_Equal("backed off RTO extends the timeout", Connection_Timeout(10, 800), 3200u); + Expect_Equal("maximum RTO fits below the timeout ceiling", Connection_Timeout(10, MAXIMUM_RTO), 16000u); + Expect_Equal("large RTO calculation keeps the hard ceiling", Connection_Timeout(10, 0xffffffffu), MAXIMUM_CONNECTION_TIMEOUT); + + for (Milliseconds rto : {100u, 500u, 800u, 1600u, MAXIMUM_RTO}) { + Milliseconds const timeout = Connection_Timeout(10, rto); + Milliseconds const initial_retry = Initial_Retry_Timeout(rto, timeout); + Expect_Equal("packet captures the full backed off RTO", initial_retry, rto); + + RetransmitState state{1000, 1000, initial_retry, 1}; + RetryDecision decision = Evaluate_Retry(state, 1000 + rto, rto, timeout, true, true); + Expect("second transmission precedes the extended timeout", decision.Send && !decision.TimedOut); + state.LastSend += rto; + state.TransmissionCount++; + decision = Evaluate_Retry(state, 1000 + 3 * rto, rto, timeout, true, true); + Expect("third transmission precedes the extended timeout", decision.Send && !decision.TimedOut); + Expect("extended timeout still expires", Evaluate_Retry(state, 1000 + timeout, rto, timeout, true, true).TimedOut); + } + } + + + void Test_Latency_Increase(void) + { + using namespace NetTiming; + + for (Milliseconds round_trip : {600u, 1500u}) { + FakeTransport transport; + transport.Send(0); + Expect("latency increase starts from a measured fast link", transport.Acknowledge(10)); + + bool measured = false; + for (unsigned int packet = 0; packet < 10 && !measured; packet++) { + Milliseconds const sent_at = 1000 + packet * (round_trip + 1000); + transport.Send(sent_at); + for (Milliseconds elapsed = 1; elapsed < round_trip; elapsed++) { + transport.Retry(sent_at + elapsed); + } + measured = transport.Acknowledge(sent_at + round_trip); + } + + Expect("slower link eventually produces a clean sample", measured); + Expect("clean sample updates the stale fast-link RTT", transport.Rtt().Smoothed_Rtt() > 10); + Expect("clean sample arrives before the captured retry", transport.Base_Rto() > round_trip); + Expect("recovery keeps the connection timeout bounded", transport.Timeout() <= MAXIMUM_CONNECTION_TIMEOUT); + } + } + + + void Test_Retry_Decisions(void) + { + using namespace NetTiming; + + RetransmitState state; + RetryDecision decision = Evaluate_Retry(state, 1000, 800, 2000, true, true); + Expect("new packet sends immediately", decision.Send && !decision.TimedOut); + + state = {1000, 1000, 100, 1}; + Expect("adaptive packet keeps captured RTO", !Evaluate_Retry(state, 1099, 800, 2000, true, true).Send); + Expect("adaptive packet sends at captured RTO", Evaluate_Retry(state, 1100, 800, 2000, true, true).Send); + + state = {1000, 1100, 100, 2}; + Expect("adaptive retry waits through backoff", !Evaluate_Retry(state, 1299, 800, 2000, true, true).Send); + Expect("adaptive retry sends after backoff", Evaluate_Retry(state, 1300, 800, 2000, true, true).Send); + + state = {1000, 1000, 100, 4}; + Expect("fixed channel uses current retry delay", !Evaluate_Retry(state, 1399, 400, 2000, true, false).Send); + decision = Evaluate_Retry(state, 1400, 400, 2000, true, false); + Expect("fixed channel does not back off", decision.Send && !decision.TimedOut); + + state = {1000, 1900, 100, 1}; + decision = Evaluate_Retry(state, 3000, 100, 2000, true, true); + Expect("connection timeout flags the link", decision.TimedOut); + Expect("timed-out packet still retries when due", decision.Send); + decision = Evaluate_Retry(state, 3000, 100, 2000, false, true); + Expect("disabled connection timeout still retries", !decision.TimedOut && decision.Send); + + state = {1000, 2950, 100, 1}; + decision = Evaluate_Retry(state, 3000, 100, 2000, true, true); + Expect("timed-out packet waits for its backoff", decision.TimedOut && !decision.Send); + + state = {1000, 5000, 100, 6}; + Expect("timed-out packet waits for the connection timeout cap", !Evaluate_Retry(state, 6999, 100, 2000, true, true).Send); + Expect("timed-out packet retries at the connection timeout cap", Evaluate_Retry(state, 7000, 100, 2000, true, true).Send); + + state = {0xffffff00u, 0xfffffff0u, 100, 1}; + Expect("retry decision handles clock wrap", Evaluate_Retry(state, 0x00000054u, 800, 2000, true, true).Send); + } + + + void Test_Loss_Jitter_And_Reordering(void) + { + using namespace NetTiming; + + FakeClock clock; + RttEstimator reordered; + clock.Set(1200); + Expect("newer packet ACK samples first", reordered.Acknowledge(1100, 1, clock)); + clock.Set(1300); + Expect("older packet ACK can sample after reordering", reordered.Acknowledge(1000, 1, clock)); + Expect_Equal("reordered samples keep alpha filter", reordered.Smoothed_Rtt(), 125u); + Expect_Equal("reordered samples keep beta filter", reordered.Rtt_Variation(), 88u); + + clock.Set(2000); + Expect("duplicate ambiguous ACK is excluded by Karn", !reordered.Acknowledge(1500, 2, clock)); + Expect_Equal("ambiguous ACK leaves SRTT unchanged", reordered.Smoothed_Rtt(), 125u); + + RttEstimator jitter; + for (Milliseconds sample : {20u, 400u, 35u, 350u, 40u}) { + jitter.Add_Sample(sample); + } + Expect("jitter raises variation", jitter.Rtt_Variation() > 0); + Expect("jittered RTO remains bounded", jitter.Retransmit_Timeout() >= MINIMUM_RTO && jitter.Retransmit_Timeout() <= MAXIMUM_RTO); + + Expect("loss does not retransmit before the base RTO", !Retransmit_Is_Due(1000, 1099, 100, 0, 2000)); + Expect("first loss retransmits at the base RTO", Retransmit_Is_Due(1000, 1100, 100, 0, 2000)); + Expect("second loss waits for exponential backoff", !Retransmit_Is_Due(1100, 1299, 100, 1, 2000)); + Expect("second loss retransmits at doubled RTO", Retransmit_Is_Due(1100, 1300, 100, 1, 2000)); + + FakeTransport clean_transport; + clean_transport.Send(1000); + Expect("fake transport accepts a clean ACK sample", clean_transport.Acknowledge(1080)); + Expect_Equal("fake transport publishes clean RTT", clean_transport.Rtt().Smoothed_Rtt(), 80u); + + FakeTransport lossy_transport; + lossy_transport.Send(1000); + Expect("fake transport retries a lost packet", lossy_transport.Retry(1100)); + Expect("ambiguous first ACK seeds a provisional sample", lossy_transport.Acknowledge(1180)); + Expect("provisional seed is the elapsed upper bound", lossy_transport.Rtt().Smoothed_Rtt() == 180u && lossy_transport.Rtt().Is_Provisional()); + lossy_transport.Send(2000); + Expect_Equal("provisional seed paces the next packet", lossy_transport.Base_Rto(), 540u); + Expect("clean sample replaces the provisional seed", lossy_transport.Acknowledge(2080)); + Expect_Equal("replaced smoothed RTT", lossy_transport.Rtt().Smoothed_Rtt(), 80u); + Expect_Equal("replaced variation", lossy_transport.Rtt().Rtt_Variation(), 40u); + Expect_Equal("replaced RTO", lossy_transport.Rtt().Retransmit_Timeout(), 240u); + Expect("replaced estimate is measured", !lossy_transport.Rtt().Is_Provisional()); + } + + + void Test_Backoff_Persistence(void) + { + using namespace NetTiming; + + FakeTransport transport; + transport.Send(0); + Expect("fast link samples cleanly", transport.Acknowledge(10)); + Expect_Equal("fast link floors the RTO", transport.Rtt().Retransmit_Timeout(), MINIMUM_RTO); + + // The link now takes 500 ms, so every packet is retransmitted before its ACK arrives. + transport.Send(1000); + Expect("first era retransmits at the floor RTO", transport.Retry(1100)); + Expect_Equal("first era doubles the RTO", transport.Rtt().Retransmit_Timeout(), 200u); + Expect("same era retransmits again", transport.Retry(1300)); + Expect_Equal("same era does not double twice", transport.Rtt().Retransmit_Timeout(), 200u); + Expect("ambiguous ACK is excluded", !transport.Acknowledge(1500)); + + transport.Send(2000); + Expect_Equal("new packet captures the backed off RTO", transport.Base_Rto(), 200u); + Expect("second era retransmits", transport.Retry(2200)); + Expect_Equal("second era doubles the RTO", transport.Rtt().Retransmit_Timeout(), 400u); + + transport.Send(3000); + Expect_Equal("third packet captures the backed off RTO", transport.Base_Rto(), 400u); + Expect("third era retransmits", transport.Retry(3400)); + Expect_Equal("third era doubles the RTO", transport.Rtt().Retransmit_Timeout(), 800u); + + // The RTO now exceeds the real round trip, so a first transmission is acknowledged. + transport.Send(4000); + Expect("backed off RTO lets a clean sample through", transport.Acknowledge(4500)); + Expect_Equal("recovered smoothed RTT", transport.Rtt().Smoothed_Rtt(), 71u); + Expect_Equal("recovered variation", transport.Rtt().Rtt_Variation(), 126u); + Expect_Equal("recovered RTO covers the slower link", transport.Rtt().Retransmit_Timeout(), 575u); + Expect("recovered estimate is measured", transport.Rtt().Has_Sample() && !transport.Rtt().Is_Provisional()); + } + + + void Test_Provisional_Seed(void) + { + using namespace NetTiming; + + FakeClock clock; + RttEstimator estimator; + Expect("unsent packet never samples", !estimator.Acknowledge(0, 0, clock)); + Expect("unsent acknowledgement leaves the estimator empty", !estimator.Has_Sample()); + + clock.Set(2000); + Expect("two-second link seeds through a retransmitted ACK", estimator.Acknowledge(0, 2, clock)); + Expect("seed is provisional", estimator.Has_Sample() && estimator.Is_Provisional()); + Expect_Equal("seed smoothed RTT", estimator.Smoothed_Rtt(), 2000u); + Expect_Equal("seed RTO reaches the ceiling", estimator.Retransmit_Timeout(), MAXIMUM_RTO); + + clock.Set(4500); + Expect("second ambiguous ACK does not move a provisional seed", !estimator.Acknowledge(1000, 3, clock)); + Expect_Equal("seed unchanged by a second ambiguous ACK", estimator.Smoothed_Rtt(), 2000u); + + clock.Set(6900); + Expect("clean sample replaces the seed", estimator.Acknowledge(5000, 1, clock)); + Expect_Equal("clean sample replaces rather than blends", estimator.Smoothed_Rtt(), 1900u); + Expect("replaced seed is measured", !estimator.Is_Provisional()); + + clock.Set(9000); + Expect("Karn applies once the estimate is measured", !estimator.Acknowledge(7000, 2, clock)); + + RttEstimator backed_off; + clock.Set(300); + backed_off.Acknowledge(0, 2, clock); + Expect_Equal("provisional RTO", backed_off.Retransmit_Timeout(), 900u); + backed_off.Note_Retransmit(900); + Expect_Equal("provisional estimate backs off like a measured one", backed_off.Retransmit_Timeout(), 1800u); + + backed_off.Reset(); + Expect("reset clears the provisional flag", !backed_off.Is_Provisional() && !backed_off.Has_Sample()); + } + + + void Test_Note_Retransmit_Guards(void) + { + using namespace NetTiming; + + RttEstimator unsampled; + unsampled.Note_Retransmit(MINIMUM_RTO); + Expect("retransmission does not invent a sample", !unsampled.Has_Sample()); + Expect_Equal("unsampled RTO is unchanged", unsampled.Retransmit_Timeout(), MINIMUM_RTO); + + RttEstimator ceiling; + ceiling.Add_Sample(300); + Expect_Equal("sampled RTO", ceiling.Retransmit_Timeout(), 900u); + ceiling.Note_Retransmit(900); + Expect_Equal("backoff doubles below the ceiling", ceiling.Retransmit_Timeout(), 1800u); + ceiling.Note_Retransmit(1800); + Expect_Equal("backoff doubles again below the ceiling", ceiling.Retransmit_Timeout(), 3600u); + ceiling.Note_Retransmit(3600); + Expect_Equal("backoff clamps at the ceiling", ceiling.Retransmit_Timeout(), MAXIMUM_RTO); + ceiling.Note_Retransmit(MAXIMUM_RTO); + Expect_Equal("backoff stays at the ceiling", ceiling.Retransmit_Timeout(), MAXIMUM_RTO); + + // A packet captured during backoff can double a freshly lowered RTO once. + RttEstimator recovered; + recovered.Add_Sample(300); + recovered.Note_Retransmit(900); + recovered.Add_Sample(300); + Expect_Equal("clean sample lowers the RTO", recovered.Retransmit_Timeout(), 752u); + recovered.Note_Retransmit(1800); + Expect_Equal("stale capture doubles the RTO once", recovered.Retransmit_Timeout(), 1504u); + } + + + void Test_Census(void) + { + using namespace NetTiming; + + TimingReportCensus census; + Expect("activate first peer", census.Set_Player_Active(1, true, 100)); + Expect("activate second peer", census.Set_Player_Active(2, true, 100)); + Expect("reject out of range peer", !census.Set_Player_Active(MAX_TIMING_PLAYERS, true, 100)); + Expect("active membership is queryable", census.Is_Player_Active(1)); + Expect("out of range membership is inactive", !census.Is_Player_Active(MAX_TIMING_PLAYERS)); + Expect("record first peer", census.Record_Report(1, 12, 80, 100)); + Expect("record second peer", census.Record_Report(2, 20, 180, 100)); + Expect("accept RTT above retransmit clamp", census.Record_Report(2, 20, MAXIMUM_RTO + 1, 100)); + Expect("reject process time beyond engine range", !census.Record_Report(2, MAXIMUM_PROCESS_MILLISECONDS + 1, 100, 150)); + Expect("reject RTT beyond wire range", !census.Record_Report(2, 1, MAXIMUM_REPORTED_RTT + 1, 150)); + + TimingCensus result = census.Inspect(200); + Expect_Equal("active peer count", result.ActivePlayers, 2u); + Expect_Equal("fresh process report count", result.FreshProcessReports, 2u); + Expect_Equal("fresh RTT report count", result.FreshRoundTripReports, 2u); + Expect_Equal("worst process time", result.WorstProcessMilliseconds, 20u); + Expect_Equal("unequal links publish worst", result.WorstRoundTrip, MAXIMUM_RTO + 1); + Expect("fresh process census complete", result.ProcessComplete); + Expect("fresh RTT census complete", result.RoundTripComplete); + Expect("fresh census is not conservative", !result.RequiresConservativeTiming); + BalancedTimingPolicy aggregate; + TimingEvaluation const guest_degradation = aggregate.Evaluate(result, 60, 200); + Expect("a guest-to-guest slow path worsens the master policy", guest_degradation.Changed && guest_degradation.Rung == MAXIMUM_TIMING_RUNG); + + result = census.Inspect(100 + REPORT_EXPIRY); + Expect("process reports expire on boundary", !result.ProcessComplete); + Expect("RTT reports expire on boundary", !result.RoundTripComplete); + Expect("expired established RTT holds instead of forcing conservative timing", !result.RequiresConservativeTiming); + Expect_Equal("expired process reports not fresh", result.FreshProcessReports, 0u); + Expect_Equal("expired RTT reports not fresh", result.FreshRoundTripReports, 0u); + Expect_Equal("expired process time excluded", result.WorstProcessMilliseconds, 0u); + + Expect("departed peer removed", census.Set_Player_Active(2, false, 700)); + Expect("remaining peer refreshed", census.Record_Report(1, 15, 90, 700)); + result = census.Inspect(700); + Expect("departure restores complete process census", result.ProcessComplete); + Expect("departure restores complete RTT census", result.RoundTripComplete); + Expect_Equal("departed peer excluded", result.ActivePlayers, 1u); + Expect_Equal("remaining peer wins census", result.WorstRoundTrip, 90u); + + Expect("established unavailable RTT report accepted", census.Record_Report(1, 16, std::nullopt, 701)); + result = census.Inspect(701); + Expect("unavailable RTT retains fresh process time", result.ProcessComplete && result.FreshProcessReports == 1); + Expect("established unavailable RTT is incomplete", !result.RoundTripComplete); + Expect("established unavailable RTT is not conservative", !result.RequiresConservativeTiming); + + TimingReportCensus grace; + Expect("activate grace peer", grace.Set_Player_Active(3, true, 1000)); + Expect("process-only initial report is accepted", grace.Record_Report(3, 30, std::nullopt, 1000)); + result = grace.Inspect(1000 + REPORT_EXPIRY - 1); + Expect("process-only report remains complete before expiry", result.ProcessComplete); + Expect("missing initial RTT is tolerated before expiry", !result.RequiresConservativeTiming); + result = grace.Inspect(1000 + REPORT_EXPIRY); + Expect("never-valid RTT becomes conservative at exact expiry", result.RequiresConservativeTiming); + Expect("never-valid RTT remains incomplete", !result.RoundTripComplete); + Expect("process data expires with its report", !result.ProcessComplete); + Expect_Equal("stale process data retains synchronized FPS", Select_Desired_Frame_Rate(result, 42, 60), 42u); + TimingCensus fresh_process; + fresh_process.WorstProcessMilliseconds = 50; + Expect_Equal("fresh process data respects game-speed FPS", Select_Desired_Frame_Rate(fresh_process, 42, 15), 15u); + fresh_process.WorstProcessMilliseconds = 0; + Expect_Equal("zero process time permits 60 FPS", Select_Desired_Frame_Rate(fresh_process, 42, 60), 60u); + + TimingReportCensus atomic; + atomic.Set_Player_Active(4, true, 0); + Expect("atomic baseline report accepted", atomic.Record_Report(4, 25, 125, 10)); + Expect("invalid process report rejected atomically", !atomic.Record_Report(4, MAXIMUM_PROCESS_MILLISECONDS + 1, 200, 20)); + Expect("invalid RTT report rejected atomically", !atomic.Record_Report(4, 50, MAXIMUM_REPORTED_RTT + 1, 20)); + result = atomic.Inspect(20); + Expect_Equal("invalid report preserves process time", result.WorstProcessMilliseconds, 25u); + Expect_Equal("invalid report preserves RTT", result.WorstRoundTrip, 125u); + Expect("removing a peer clears its complete report", atomic.Set_Player_Active(4, false, 30)); + Expect_Equal("removed peer no longer contributes", atomic.Inspect(30).ActivePlayers, 0u); + Expect("reactivated peer starts with a clean report", atomic.Set_Player_Active(4, true, 40)); + result = atomic.Inspect(40); + Expect("reactivated peer has no inherited process report", !result.ProcessComplete); + Expect("reactivated peer receives fresh RTT grace", !result.RequiresConservativeTiming); + } + + + void Test_Rungs(void) + { + using namespace NetTiming; + + Expect_Equal("initial FSR", Settings_For_Rung(INITIAL_TIMING_RUNG).FrameSendRate, 2u); + Expect_Equal("initial MaxAhead", Settings_For_Rung(INITIAL_TIMING_RUNG).MaxAhead, 6u); + Expect("default settings match the bootstrap rung", TimingSettings{} == Settings_For_Rung(INITIAL_TIMING_RUNG)); + Expect_Equal("best rung MaxAhead", Settings_For_Rung(1).MaxAhead, 4u); + Expect_Equal("worst rung MaxAhead", Settings_For_Rung(10).MaxAhead, 30u); + Expect("rung settings valid", Timing_Settings_Are_Valid(Settings_For_Rung(10))); + Expect("below-rung minimum invalid", !Timing_Settings_Are_Valid({3, 6})); + Expect("legacy two-period horizon can source a transition", Timing_Transition_Source_Is_Valid({3, 6})); + Expect("unaligned settings invalid", !Timing_Settings_Are_Valid({3, 10})); + + Expect_Equal("zero RTT selects best rung", Select_Timing_Settings(0, 60).FrameSendRate, 1u); + Expect_Equal("100 ms fits best rung", Select_Timing_Settings(100, 60).FrameSendRate, 1u); + Expect_Equal("101 ms advances a rung", Select_Timing_Settings(101, 60).FrameSendRate, 2u); + Expect_Equal("300 ms selects balanced rung", Select_Timing_Settings(300, 60).FrameSendRate, 5u); + TimingSettings const high_rtt = Select_Timing_Settings(2000, 60); + Expect_Equal("two-second RTT selects highest FSR", high_rtt.FrameSendRate, 10u); + Expect_Equal("two-second RTT carries needed aligned MaxAhead", high_rtt.MaxAhead, 70u); + TimingSettings const capped = Select_Timing_Settings(MAXIMUM_REPORTED_RTT, 60); + Expect_Equal("wire-maximum RTT selects highest FSR", capped.FrameSendRate, 10u); + Expect_Equal("highest rung caps at largest aligned horizon", capped.MaxAhead, 250u); + + Expect("alignment rejects zero period", !Align_Max_Ahead(10, 0)); + Expect_Equal("alignment reaches cap", *Align_Max_Ahead(249, 10), 250u); + Expect("alignment rejects over cap", !Align_Max_Ahead(250, 9)); + } + + + void Test_Connection_Quality(void) + { + using namespace NetTiming; + + Expect("rung one reports fast", Connection_Quality_For_Settings(Settings_For_Rung(1)) == ConnectionQuality::Fast); + Expect("rung two reports fast", Connection_Quality_For_Settings(Settings_For_Rung(2)) == ConnectionQuality::Fast); + Expect("rung three reports normal", Connection_Quality_For_Settings(Settings_For_Rung(3)) == ConnectionQuality::Normal); + Expect("rung five reports normal", Connection_Quality_For_Settings(Settings_For_Rung(5)) == ConnectionQuality::Normal); + Expect("rung six reports poor", Connection_Quality_For_Settings(Settings_For_Rung(6)) == ConnectionQuality::Poor); + Expect("rung eight reports poor", Connection_Quality_For_Settings(Settings_For_Rung(8)) == ConnectionQuality::Poor); + Expect("rung nine reports bad", Connection_Quality_For_Settings(Settings_For_Rung(9)) == ConnectionQuality::Bad); + Expect("rung ten reports bad", Connection_Quality_For_Settings(Settings_For_Rung(10)) == ConnectionQuality::Bad); + Expect("bootstrap settings report fast", Connection_Quality_For_Settings({2, 6}) == ConnectionQuality::Fast); + Expect("fallback settings report normal", Connection_Quality_For_Settings({3, 9}) == ConnectionQuality::Normal); + Expect("extended conservative settings report bad", Connection_Quality_For_Settings({10, 250}) == ConnectionQuality::Bad); + Expect("invalid settings report bad", Connection_Quality_For_Settings({0, 0}) == ConnectionQuality::Bad); + Expect("extended fast-rung horizon reports bad", Connection_Quality_For_Settings({2, 8}) == ConnectionQuality::Bad); + } + + + void Record_One(NetTiming::TimingReportCensus & census, NetTiming::Milliseconds rtt, std::uint32_t frame) + { + census.Record_Report(1, 10, rtt, frame); + } + + + void Test_Bootstrap_Cadence(void) + { + using namespace NetTiming; + + Expect("frame zero does not report", !Report_Is_Due(0)); + Expect("bootstrap reports at frame 32", Report_Is_Due(32)); + Expect("bootstrap reports at frame 64", Report_Is_Due(64)); + Expect("bootstrap does not add a frame 96 report", !Report_Is_Due(96)); + Expect("normal reports start at frame 128", Report_Is_Due(128)); + Expect("normal reports continue at frame 256", Report_Is_Due(256)); + Expect("off-cadence reports remain disabled", !Report_Is_Due(385)); + + Expect("frame zero does not evaluate", !Evaluation_Is_Due(0)); + Expect("reports alone do not evaluate at frame 32", !Evaluation_Is_Due(32)); + Expect("bootstrap evaluates at frame 64", Evaluation_Is_Due(64)); + Expect("bootstrap evaluates again at frame 128", Evaluation_Is_Due(128)); + Expect("normal evaluations start at frame 256", Evaluation_Is_Due(256)); + Expect("frame 384 is not an evaluation", !Evaluation_Is_Due(384)); + Expect("normal evaluations continue at frame 512", Evaluation_Is_Due(512)); + } + + + void Test_Bootstrap_Policy(void) + { + using namespace NetTiming; + + TimingReportCensus low_reports; + low_reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy low; + Expect("new policy starts in bootstrap", low.Is_Bootstrapping()); + Expect("bootstrap starts at 2/6", low.Current_Settings() == TimingSettings{2, 6}); + TimingEvaluation result = low.Evaluate(low_reports.Inspect(32), 60, 32); + Expect("bootstrap does not evaluate before frame 64", !result.Evaluated); + Record_One(low_reports, 0, 38); + result = low.Evaluate(low_reports.Inspect(64), 60, 64); + Expect("complete low-latency census finishes at frame 64", result.Evaluated && result.Changed && !low.Is_Bootstrapping()); + Expect("low-latency bootstrap jumps directly to 1/4", low.Current_Settings() == TimingSettings{1, 4}); + result = low.Evaluate(low_reports.Inspect(255), 60, 255); + Expect("steady evaluation remains anchored before frame 256", !result.Evaluated); + Record_One(low_reports, 0, 256); + result = low.Evaluate(low_reports.Inspect(256), 60, 256); + Expect("steady evaluation is anchored at frame 256", result.Evaluated && !result.Changed); + + Expect("100 ms would select 1/4 without bootstrap headroom", Select_Timing_Settings(100, 60, false) == TimingSettings{1, 4}); + Expect("100 ms retains 2/6 with bootstrap headroom", Select_Timing_Settings(100, 60, true) == TimingSettings{2, 6}); + TimingReportCensus marginal_reports; + marginal_reports.Set_Player_Active(1, true, 0); + Record_One(marginal_reports, 100, 38); + BalancedTimingPolicy marginal; + result = marginal.Evaluate(marginal_reports.Inspect(64), 60, 64); + Expect("marginal bootstrap completes without changing 2/6", result.Evaluated && !result.Changed && !marginal.Is_Bootstrapping()); + + TimingReportCensus high_reports; + high_reports.Set_Player_Active(1, true, 0); + Record_One(high_reports, 2000, 38); + BalancedTimingPolicy high; + result = high.Evaluate(high_reports.Inspect(64), 60, 64); + Expect("high-latency bootstrap worsens directly", result.Changed && high.Current_Settings() == TimingSettings{10, 90}); + + TimingReportCensus delayed_reports; + delayed_reports.Set_Player_Active(1, true, 0); + delayed_reports.Record_Report(1, 10, std::nullopt, 38); + BalancedTimingPolicy delayed; + result = delayed.Evaluate(delayed_reports.Inspect(64), 60, 64); + Expect("incomplete frame 64 census keeps bootstrap open", result.Evaluated && !result.Changed && delayed.Is_Bootstrapping()); + delayed_reports.Record_Report(1, 10, 0, 70); + result = delayed.Evaluate(delayed_reports.Inspect(100), 60, 100); + Expect("completed census waits for frame 128", !result.Evaluated && delayed.Is_Bootstrapping()); + result = delayed.Evaluate(delayed_reports.Inspect(128), 60, 128); + Expect("second bootstrap evaluation accepts a complete census", result.Evaluated && result.Changed && !delayed.Is_Bootstrapping()); + Expect("frame 128 completion selects the measured target", delayed.Current_Settings() == TimingSettings{1, 4}); + + TimingReportCensus incomplete_reports; + incomplete_reports.Set_Player_Active(1, true, 0); + incomplete_reports.Record_Report(1, 10, std::nullopt, 38); + BalancedTimingPolicy incomplete; + incomplete.Evaluate(incomplete_reports.Inspect(64), 60, 64); + incomplete_reports.Record_Report(1, 10, std::nullopt, 70); + result = incomplete.Evaluate(incomplete_reports.Inspect(128), 60, 128); + Expect("incomplete final census falls back immediately", result.Evaluated && result.Changed && !incomplete.Is_Bootstrapping()); + Expect("incomplete bootstrap falls back to 3/9", incomplete.Current_Settings() == TimingSettings{3, 9}); + + TimingReportCensus lost_reports; + lost_reports.Set_Player_Active(1, true, 0); + lost_reports.Set_Player_Active(2, true, 0); + lost_reports.Record_Report(1, 10, 20, 38); + lost_reports.Record_Report(2, 10, std::nullopt, 38); + BalancedTimingPolicy lost; + result = lost.Evaluate(lost_reports.Inspect(64), 60, 64); + Expect("initial missing RTT keeps bootstrap open", result.Evaluated && !result.Changed && lost.Is_Bootstrapping()); + lost_reports.Record_Report(1, 10, std::nullopt, 70); + result = lost.Evaluate(lost_reports.Inspect(128), 60, 128); + Expect("established RTT loss during bootstrap falls back to 3/9", result.Changed && lost.Current_Settings() == TimingSettings{3, 9}); + + for (std::uint32_t frame : {256u, 512u, 768u}) { + Record_One(high_reports, 0, frame); + result = high.Evaluate(high_reports.Inspect(frame), 60, frame); + } + Expect("bootstrap cooldown leaves only two good evaluations by frame 768", !result.Changed && high.Good_Evaluations() == 2); + Record_One(high_reports, 0, 1024); + result = high.Evaluate(high_reports.Inspect(1024), 60, 1024); + Expect("normal hysteresis resumes after bootstrap cooldown", result.Changed && high.Current_Settings() == TimingSettings{9, 27}); + + high.Reset(); + Expect("reset starts a new bootstrap", high.Is_Bootstrapping()); + Expect("reset restores 2/6", high.Current_Settings() == TimingSettings{2, 6}); + + BalancedTimingPolicy handoff; + handoff.Reset_From({10, 70}, 0); + Expect("handoff does not regain bootstrap", !handoff.Is_Bootstrapping()); + Record_One(high_reports, 0, 64); + result = handoff.Evaluate(high_reports.Inspect(64), 60, 64); + Expect("handoff ignores bootstrap evaluation", !result.Evaluated && handoff.Current_Settings() == TimingSettings{10, 70}); + + TimingReportCensus resumed_reports; + resumed_reports.Set_Player_Active(1, true, 1024); + BalancedTimingPolicy resumed; + resumed.Reset(1024); + Expect_Equal("resumed bootstrap records its cadence origin", resumed.Cadence_Origin(), 1024u); + result = resumed.Evaluate(resumed_reports.Inspect(1056), 60, 1056); + Expect("resumed bootstrap does not evaluate after only 32 frames", !result.Evaluated); + Record_One(resumed_reports, 0, 1062); + result = resumed.Evaluate(resumed_reports.Inspect(1088), 60, 1088); + Expect("resumed bootstrap evaluates after 64 frames", result.Evaluated && result.Changed && !resumed.Is_Bootstrapping()); + Expect("resumed bootstrap selects its measured target", resumed.Current_Settings() == TimingSettings{1, 4}); + } + + + void Test_Hysteresis_And_Cooldown(void) + { + using namespace NetTiming; + + TimingReportCensus reports; + reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy policy; + policy.Reset_From({3, 9}, 0); + + Record_One(reports, 0, 256); + TimingEvaluation result = policy.Evaluate(reports.Inspect(256), 60, 256); + Expect("first good evaluation does not change", !result.Changed); + Record_One(reports, 0, 512); + result = policy.Evaluate(reports.Inspect(512), 60, 512); + Expect("second good evaluation does not change", !result.Changed); + Record_One(reports, 0, 768); + result = policy.Evaluate(reports.Inspect(768), 60, 768); + Expect("third good evaluation improves one rung", result.Changed); + Expect_Equal("one-rung improvement", policy.Current_Rung(), 2u); + + Record_One(reports, 0, 800); + result = policy.Evaluate(reports.Inspect(800), 60, 800); + Expect("evaluation interval enforced", !result.Evaluated); + Expect_Equal("cooldown leaves rung", policy.Current_Rung(), 2u); + + BalancedTimingPolicy headroom; + headroom.Reset_From({3, 9}, 0); + TimingReportCensus edge; + edge.Set_Player_Active(1, true, 0); + for (std::uint32_t frame : {256u, 512u, 768u}) { + Record_One(edge, 120, frame); + headroom.Evaluate(edge.Inspect(frame), 60, frame); + } + Expect_Equal("20 percent headroom blocks marginal improvement", headroom.Current_Rung(), 3u); + + Record_One(reports, 2000, 1024); + result = policy.Evaluate(reports.Inspect(1024), 60, 1024); + Expect("worsening is immediate", result.Changed); + Expect_Equal("worsening reaches required rung", policy.Current_Rung(), 10u); + Expect_Equal("highest rung retains measured horizon", policy.Current_Settings().MaxAhead, 70u); + + for (std::uint32_t frame : {1280u, 1536u, 1792u}) { + Record_One(reports, 1300, frame); + result = policy.Evaluate(reports.Inspect(frame), 60, frame); + } + Expect("same-rung horizon reduction uses hysteresis", result.Changed); + Expect_Equal("same-rung horizon retains aligned need", policy.Current_Settings().MaxAhead, 50u); + + Record_One(reports, 0, 2048); + result = policy.Evaluate(reports.Inspect(2048), 60, 2048); + Expect("descent continues one rung per evaluation", result.Changed && policy.Current_Settings() == TimingSettings{9, 27}); + Record_One(reports, 0, 2304); + result = policy.Evaluate(reports.Inspect(2304), 60, 2304); + Expect("descent keeps stepping while headroom holds", result.Changed && policy.Current_Settings() == TimingSettings{8, 24}); + Record_One(reports, 2000, 2560); + result = policy.Evaluate(reports.Inspect(2560), 60, 2560); + Expect("worsening interrupts the descent", result.Changed && policy.Current_Rung() == 10u); + Record_One(reports, 0, 2816); + result = policy.Evaluate(reports.Inspect(2816), 60, 2816); + Expect("worsening restores the three-evaluation requirement", !result.Changed && policy.Good_Evaluations() == 1); + + TimingReportCensus marginal_reports; + marginal_reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy marginal; + marginal.Reset_From({5, 15}, 0); + for (std::uint32_t frame : {256u, 512u, 768u}) { + Record_One(marginal_reports, 0, frame); + result = marginal.Evaluate(marginal_reports.Inspect(frame), 60, frame); + } + Expect("descent starts after three good evaluations", result.Changed && marginal.Current_Settings() == TimingSettings{4, 12}); + Record_One(marginal_reports, 250, 1024); + result = marginal.Evaluate(marginal_reports.Inspect(1024), 60, 1024); + Expect("evaluation without headroom holds the rung", !result.Changed && marginal.Current_Settings() == TimingSettings{4, 12}); + Record_One(marginal_reports, 0, 1280); + result = marginal.Evaluate(marginal_reports.Inspect(1280), 60, 1280); + Expect("a held evaluation ends the descent streak", !result.Changed && marginal.Good_Evaluations() == 1); + } + + + void Test_Stale_And_Long_Term_Recovery(void) + { + using namespace NetTiming; + + TimingReportCensus stale; + stale.Set_Player_Active(1, true, 0); + BalancedTimingPolicy stale_policy; + stale_policy.Reset_From({3, 9}, 0); + TimingEvaluation result = stale_policy.Evaluate(stale.Inspect(0), 60, 0); + Expect("startup waits for a complete census", !result.Changed); + Expect_Equal("startup keeps initial rung", stale_policy.Current_Rung(), 3u); + + stale.Record_Report(1, 10, 100, 256); + stale_policy.Evaluate(stale.Inspect(256), 60, 256); + result = stale_policy.Evaluate(stale.Inspect(256 + REPORT_EXPIRY), 60, 256 + REPORT_EXPIRY); + Expect("expired established report holds the current timing", result.Evaluated && !result.Changed); + Expect_Equal("expired established report keeps the rung", stale_policy.Current_Rung(), 3u); + Expect_Equal("expired established report discards improvement evidence", stale_policy.Good_Evaluations(), 0u); + + stale.Set_Player_Active(1, false, 1024); + for (std::uint32_t frame : {1024u, 1280u, 1536u}) { + stale_policy.Evaluate(stale.Inspect(frame), 60, frame); + } + Expect_Equal("departed peer allows recovery", stale_policy.Current_Rung(), 2u); + + TimingReportCensus partial; + partial.Set_Player_Active(1, true, 0); + partial.Set_Player_Active(2, true, 0); + BalancedTimingPolicy partial_policy; + partial_policy.Reset_From({3, 9}, 0); + partial.Record_Report(2, 10, 50, 0); + partial.Record_Report(1, 10, 2000, 256); + partial.Record_Report(2, 10, std::nullopt, 256); + result = partial_policy.Evaluate(partial.Inspect(256), 60, 256); + Expect("incomplete census still applies a worsening", result.Changed && partial_policy.Current_Settings() == TimingSettings{10, 70}); + partial.Record_Report(1, 10, 0, 512); + partial.Record_Report(2, 10, std::nullopt, 512); + result = partial_policy.Evaluate(partial.Inspect(512), 60, 512); + Expect("incomplete census never improves", result.Evaluated && !result.Changed && partial_policy.Good_Evaluations() == 0); + + TimingReportCensus reports; + reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy policy; + policy.Reset_From({3, 9}, 0); + std::uint32_t frame = EVALUATION_INTERVAL; + auto evaluate = [&](Milliseconds rtt) { + Record_One(reports, rtt, frame); + policy.Evaluate(reports.Inspect(frame), 60, frame); + frame += EVALUATION_INTERVAL; + }; + + for (int cycle = 0; cycle < 5; cycle++) { + evaluate(2000); + evaluate(0); + evaluate(0); + evaluate(0); + } + Expect_Equal("repeated degradation and recovery remains stable", policy.Current_Rung(), 9u); + evaluate(0); + evaluate(0); + evaluate(0); + Expect_Equal("descent continues after more than eight changes", policy.Current_Rung(), 6u); + } + + + void Test_Stall_Feedback(void) + { + using namespace NetTiming; + + TimingReportCensus reports; + reports.Set_Player_Active(1, true, 0); + reports.Set_Player_Active(2, true, 0); + reports.Record_Report(1, 10, 0, 256, 50); + reports.Record_Report(2, 10, 0, 256, 400); + TimingCensus census = reports.Inspect(256); + Expect_Equal("census publishes the longest wait", census.WorstStallMilliseconds, 400u); + + BalancedTimingPolicy policy; + policy.Reset_From({4, 12}, 0); + TimingEvaluation result = policy.Evaluate(census, 60, 256); + Expect("a long wait never steps the timing up", result.Evaluated && !result.Changed && policy.Current_Settings() == TimingSettings{4, 12}); + Expect_Equal("a long wait resets the improvement count", policy.Good_Evaluations(), 0u); + + reports.Record_Report(1, 10, 0, 512, 0); + reports.Record_Report(2, 10, 0, 512, 200); + result = policy.Evaluate(reports.Inspect(512), 60, 512); + Expect("waiting above the improvement limit holds the timing", result.Evaluated && !result.Changed && policy.Good_Evaluations() == 0); + + for (std::uint32_t frame : {768u, 1024u, 1280u}) { + reports.Record_Report(1, 10, 0, frame, 0); + reports.Record_Report(2, 10, 0, frame, 20); + result = policy.Evaluate(reports.Inspect(frame), 60, frame); + } + Expect("quiet waiting allows the normal descent", result.Changed && policy.Current_Settings() == TimingSettings{3, 9}); + + reports.Record_Report(1, 10, 0, 1536, 0); + reports.Record_Report(2, 10, 0, 1536, 150); + result = policy.Evaluate(reports.Inspect(1536), 60, 1536); + Expect("a wait during the descent ends the streak", result.Evaluated && !result.Changed && policy.Good_Evaluations() == 0); + } + + + void Test_Master_Handoff_State(void) + { + using namespace NetTiming; + + TimingReportCensus reports; + reports.Set_Player_Active(1, true, 1000); + BalancedTimingPolicy policy; + policy.Reset_From({10, 70}, 1000); + Expect("handoff restores authoritative settings", policy.Current_Settings() == TimingSettings{10, 70}); + Expect_Equal("handoff discards improvement evidence", policy.Good_Evaluations(), 0u); + + Record_One(reports, 0, 1000); + TimingEvaluation result = policy.Evaluate(reports.Inspect(1000), 60, 1000); + Expect("handoff starts an evaluation cooldown", !result.Evaluated); + Record_One(reports, 0, 1256); + result = policy.Evaluate(reports.Inspect(1256), 60, 1256); + Expect("one good evaluation preserves the handoff target", result.Evaluated && !result.Changed && policy.Current_Settings() == TimingSettings{10, 70}); + + TimingReportCensus recovery_reports; + recovery_reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy recover; + recover.Reset_From({10, 250}, 0); + for (std::uint32_t frame : {256u, 512u, 768u}) { + Record_One(recovery_reports, 0, frame); + result = recover.Evaluate(recovery_reports.Inspect(frame), 60, frame); + } + Expect("10/250 improves one rung after hysteresis", result.Changed && recover.Current_Settings() == TimingSettings{9, 27}); + Record_One(recovery_reports, 0, 1024); + result = recover.Evaluate(recovery_reports.Inspect(1024), 60, 1024); + Expect("10/250 keeps descending one rung per evaluation", result.Changed && recover.Current_Settings() == TimingSettings{8, 24}); + + TimingReportCensus same_rung_reports; + same_rung_reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy same_rung; + same_rung.Reset_From({10, 70}, 0); + for (std::uint32_t frame : {256u, 512u, 768u}) { + Record_One(same_rung_reports, 1300, frame); + result = same_rung.Evaluate(same_rung_reports.Inspect(frame), 60, frame); + } + Expect("10/70 catches up toward 10/50 after hysteresis", result.Changed && same_rung.Current_Settings() == TimingSettings{10, 50}); + + TimingReportCensus legacy_reports; + legacy_reports.Set_Player_Active(1, true, 0); + legacy_reports.Record_Report(1, 10, 200, 256); + BalancedTimingPolicy legacy; + legacy.Reset_From({3, 6}, 0); + result = legacy.Evaluate(legacy_reports.Inspect(256), 60, 256); + Expect("adaptive policy recovers from a legacy two-period horizon", result.Changed && legacy.Current_Settings() == TimingSettings{3, 9}); + } + + + void Test_Staged_Decrease(void) + { + using namespace NetTiming; + + std::optional staged = Stage_Timing_Update({3, 9}, {1, 4}, 100); + Expect("decrease stages", staged && staged->Deferred); + Expect_Equal("old horizon and periods align", staged->ActivationFrame, 111u); + Expect_Equal("activation preserves most of the old horizon", staged->InitialMaxAhead, 6u); + Expect("staged update not early", !Timing_Update_Is_Due(110, staged->ActivationFrame)); + Expect("staged update due", Timing_Update_Is_Due(111, staged->ActivationFrame)); + Expect_Equal("first catch-up step removes one new period", *Next_Transition_Max_Ahead({1, 6}, {1, 4}), 5u); + Expect_Equal("second catch-up step reaches target", *Next_Transition_Max_Ahead({1, 5}, {1, 4}), 4u); + Expect_Equal("catch-up stays at target", *Next_Transition_Max_Ahead({1, 4}, {1, 4}), 4u); + + staged = Stage_Timing_Update({3, 9}, {2, 6}, 100); + Expect_Equal("both periods use LCM", staged->ActivationFrame, 114u); + Expect_Equal("adjacent decrease activates at target horizon", staged->InitialMaxAhead, 6u); + + staged = Stage_Timing_Update({10, 250}, {9, 27}, 100); + Expect_Equal("wide decrease aligns activation to both periods", staged->ActivationFrame, 360u); + Expect_Equal("wide decrease preserves a safe initial horizon", staged->InitialMaxAhead, 243u); + Expect_Equal("wide catch-up removes one new period", *Next_Transition_Max_Ahead({9, 243}, {9, 27}), 234u); + + staged = Stage_Timing_Update({10, 70}, {10, 50}, 100); + Expect_Equal("same-rate decrease drains at old horizon", staged->ActivationFrame, 170u); + Expect_Equal("same-rate decrease keeps one intermediate period", staged->InitialMaxAhead, 60u); + Expect_Equal("same-rate catch-up reaches requested horizon", *Next_Transition_Max_Ahead({10, 60}, {10, 50}), 50u); + + staged = Stage_Timing_Update({9, 234}, {8, 24}, 360); + Expect("replacement decrease restages from effective settings", staged && staged->Deferred); + Expect_Equal("replacement decrease safely rebases its horizon", staged->InitialMaxAhead, 232u); + + staged = Stage_Timing_Update({9, 243}, {10, 40}, 369); + Expect("mixed worsening keeps an aligned catch-up", staged && staged->Deferred); + Expect_Equal("mixed worsening activates at its event frame", staged->ActivationFrame, 369u); + Expect_Equal("mixed worsening preserves the effective horizon", staged->InitialMaxAhead, 250u); + std::optional const first_boundary = Next_Send_Boundary(369, 10); + Expect("mixed worsening identifies its first new-rate send", first_boundary && *first_boundary == 370); + TimingTransitionState mixed{*staged, *first_boundary, true}; + std::optional mixed_step = Advance_Timing_Transition(mixed, {10, 250}, 370); + Expect("first new-rate send keeps the temporary horizon", mixed_step && !mixed_step->Changed && mixed_step->Settings == TimingSettings{10, 250}); + mixed_step = Advance_Timing_Transition(mixed, mixed_step->Settings, 380); + Expect("following boundary drains one new period", mixed_step && mixed_step->Changed && mixed_step->Settings == TimingSettings{10, 240}); + Expect("mixed replacement never moves the command target backward", 369u + 243u <= 370u + 250u && 370u + 250u <= 380u + 240u); + + std::optional immediate = Stage_Timing_Update({1, 4}, {5, 15}, 100); + Expect("worsening applies immediately", immediate && !immediate->Deferred); + Expect_Equal("immediate frame", immediate->ActivationFrame, 100u); + Expect_Equal("immediate update uses requested horizon", immediate->InitialMaxAhead, 15u); + staged = immediate; + Expect("an immediate worse update replaces a pending decrease", staged && !staged->Deferred && staged->Settings == TimingSettings{5, 15}); + + immediate = Stage_Timing_Update({9, 234}, {10, 250}, 360); + Expect("conservative update cancels catch-up immediately", immediate && !immediate->Deferred && immediate->InitialMaxAhead == 250); + + Expect("zero-period staging rejected", !Stage_Timing_Update({0, 9}, {1, 4}, 100)); + Expect("zero-period send boundary rejected", !Next_Send_Boundary(100, 0)); + Expect("overflowing send boundary rejected", !Next_Send_Boundary((std::numeric_limits::max)(), 10)); + Expect("unaligned staging rejected", !Stage_Timing_Update({3, 10}, {1, 4}, 100)); + std::optional const legacy_recovery = Stage_Timing_Update({3, 6}, {3, 9}, 100); + Expect("legacy response horizon can recover immediately", legacy_recovery && !legacy_recovery->Deferred); + Expect("overflowing staging rejected", !Stage_Timing_Update({10, 30}, {9, 27}, (std::numeric_limits::max)() - 10)); + Expect("catch-up rejects mismatched send periods", !Next_Transition_Max_Ahead({9, 243}, {8, 24})); + Expect("catch-up rejects invalid effective settings", !Next_Transition_Max_Ahead({9, 242}, {9, 27})); + } + + + struct TransitionTrace + { + std::vector> Changes; + std::vector CommandTargets; + + bool operator==(TransitionTrace const &) const = default; + }; + + + TransitionTrace Run_Transition(NetTiming::TimingSettings current, NetTiming::TimingSettings requested, std::uint32_t event_frame, std::uint32_t final_frame) + { + TransitionTrace trace; + std::optional const plan = NetTiming::Stage_Timing_Update(current, requested, event_frame); + if (!plan || !plan->Deferred) { + return(trace); + } + + NetTiming::TimingTransitionState transition{*plan}; + std::uint32_t const first_frame = event_frame - event_frame % current.FrameSendRate; + for (std::uint32_t frame = first_frame; frame <= final_frame; frame++) { + std::optional const advance = NetTiming::Advance_Timing_Transition(transition, current, frame); + if (!advance) { + trace.CommandTargets.clear(); + return(trace); + } + if (advance->Changed) { + current = advance->Settings; + trace.Changes.emplace_back(frame, current); + } + if (frame % current.FrameSendRate == 0) { + trace.CommandTargets.push_back(static_cast(frame) + current.MaxAhead); + } + if (advance->Complete) { + break; + } + } + return(trace); + } + + + void Test_Transition_Sequences(void) + { + using namespace NetTiming; + + for (std::pair const & transition : { + std::pair{TimingSettings{10, 250}, TimingSettings{9, 27}}, + std::pair{TimingSettings{10, 70}, TimingSettings{10, 50}}, + std::pair{TimingSettings{3, 9}, TimingSettings{2, 6}}, + std::pair{TimingSettings{2, 6}, TimingSettings{1, 4}}}) { + TransitionTrace const first = Run_Transition(transition.first, transition.second, 100, 700); + TransitionTrace const repeat = Run_Transition(transition.first, transition.second, 100, 700); + Expect("repeated transition runs are deterministic", first == repeat); + Expect("a transition reaches its requested settings", !first.Changes.empty() && first.Changes.back().second == transition.second); + bool nondecreasing = !first.CommandTargets.empty(); + for (std::size_t index = 1; index < first.CommandTargets.size(); index++) { + nondecreasing = nondecreasing && first.CommandTargets[index] >= first.CommandTargets[index - 1]; + } + Expect("transition command targets never move backward", nondecreasing); + } + + std::optional const plan = Stage_Timing_Update({10, 250}, {9, 27}, 100); + TimingTransitionState state{*plan}; + TimingSettings current{10, 250}; + for (std::uint32_t frame = 100; frame <= 369; frame++) { + std::optional const advance = Advance_Timing_Transition(state, current, frame); + if (advance && advance->Changed) { + current = advance->Settings; + } + } + std::optional const replacement = Stage_Timing_Update(current, {8, 24}, 369); + Expect("an active catch-up can be safely replaced", replacement && replacement->Deferred && replacement->InitialMaxAhead >= current.MaxAhead - current.FrameSendRate); + std::optional const conservative = Stage_Timing_Update(current, {10, 250}, 369); + Expect("a fully conservative replacement applies immediately", conservative && !conservative->Deferred); + } + + 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); + } + +} + + +int main(void) +{ + Test_Rtt_Estimator(); + Test_Clock_And_Wrap(); + Test_Retransmit_Backoff(); + Test_Backoff_Timeout(); + Test_Latency_Increase(); + Test_Retry_Decisions(); + Test_Loss_Jitter_And_Reordering(); + Test_Backoff_Persistence(); + Test_Provisional_Seed(); + Test_Note_Retransmit_Guards(); + Test_Census(); + Test_Rungs(); + Test_Connection_Quality(); + Test_Bootstrap_Cadence(); + Test_Bootstrap_Policy(); + Test_Hysteresis_And_Cooldown(); + Test_Stale_And_Long_Term_Recovery(); + Test_Stall_Feedback(); + Test_Master_Handoff_State(); + Test_Staged_Decrease(); + Test_Transition_Sequences(); + Test_Recorded_Transitions(); + + if (Failures != 0) { + std::cerr << Failures << " network timing checks failed\n"; + return(1); + } + + std::cout << "All network timing checks passed\n"; + return(0); +} diff --git a/ui/gameoptionswol.rcss b/ui/gameoptionswol.rcss index ac8d69b06..4bf39b637 100644 --- a/ui/gameoptionswol.rcss +++ b/ui/gameoptionswol.rcss @@ -91,3 +91,10 @@ font-family: dlgsys; font-size: 18dp; } + + +/* The connection slider reports the timing the session negotiated; it takes no setting. */ +#connection +{ + pointer-events: none; +} diff --git a/ui/gameoptionswol.rml b/ui/gameoptionswol.rml index 914c1dd38..6075c5c61 100644 --- a/ui/gameoptionswol.rml +++ b/ui/gameoptionswol.rml @@ -15,7 +15,7 @@
Internet Game Controls
Connection
- +
{{ connectionlabel }}
Game Speed