From 65949a517947e91cd43e4de9ebd6fd1a87805cbf Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Fri, 25 Sep 2026 12:23:34 -0300 Subject: [PATCH 1/3] fix(ini): saturate out-of-range integers to match retail mod behavior In retail Zero Hour (MSVC 6), sscanf on integer overflow saturated to LONG_MAX / ULONG_MAX and succeeded without error. Mods like Shockwave 1.201 rely on this by setting huge delays (e.g. 9999999999999999999) to represent infinite duration. With std::from_chars, values exceeding 64-bit integers returned result_out_of_range and threw INI_INVALID_DATA, crashing mod loading. Saturate out-of-range integer tokens to field limits (min/max) with a warning logged to stderr, while preserving the -1 sentinel for unsigned fields. Also enclose integral parsing in an else branch to prevent instantiating std::from_chars for float on macOS, unifying parser usage across platforms. Fixes #297 --- Core/GameEngine/Source/Common/INI/INI.cpp | 92 +++++++++++++++++++---- docs/WORKLOG/2026-09-DIARY.md | 18 +++++ 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/Core/GameEngine/Source/Common/INI/INI.cpp b/Core/GameEngine/Source/Common/INI/INI.cpp index e0ac2e80cac..c4c5d06f8f8 100644 --- a/Core/GameEngine/Source/Common/INI/INI.cpp +++ b/Core/GameEngine/Source/Common/INI/INI.cpp @@ -60,7 +60,7 @@ #include "GameLogic/ScriptEngine.h" #include "GameLogic/Weapon.h" -#if __cplusplus >= 201611L && !defined(__APPLE__) +#if __cplusplus >= 201611L #define USE_STD_FROM_CHARS_PARSING 1 #else #define USE_STD_FROM_CHARS_PARSING 0 @@ -69,6 +69,7 @@ #if USE_STD_FROM_CHARS_PARSING #include #include +#include #include #include #endif @@ -1689,10 +1690,22 @@ Type scanType(std::string_view token) if (ec != std::errc{}) { + if (ec == std::errc::result_out_of_range) + { + fprintf(stderr, "[INI] Numeric token '%.*s' out of range, saturating to limit\n", + static_cast(token.size()), token.data()); + fflush(stderr); + if (!token.empty() && token[0] == '-') + { + return -std::numeric_limits::max(); + } + return std::numeric_limits::max(); + } + // GeneralsX @bugfix Copilot 20/09/2026 Keep numeric conversion failures visible in release builds. fprintf(stderr, "[INI] Cannot parse numeric token '%.*s': %s\n", static_cast(token.size()), token.data(), - ec == std::errc::result_out_of_range ? "out of range" : "invalid number"); + "invalid number"); fflush(stderr); throw INI_INVALID_DATA; } @@ -1700,22 +1713,71 @@ Type scanType(std::string_view token) return result; #endif } + else + { + // TheSuperHackers @info std::from_chars cannot parse "-1" as uint32 so the result needs to be int64 for integers. + std::conditional_t, Int64, Type> result{}; + const auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), result); - // TheSuperHackers @info std::from_chars cannot parse "-1" as uint32 so the result needs to be int64 for integers. - std::conditional_t, Int64, Type> result{}; - const auto [ptr, ec] = std::from_chars(token.data(), token.data() + token.size(), result); + if (ec != std::errc{}) + { + if (ec == std::errc::result_out_of_range) + { + // GeneralsX @bugfix fbraz 25/09/2026 Saturate overflowing integers to field limits to match retail behavior for mods (#297). + fprintf(stderr, "[INI] Numeric token '%.*s' out of range, saturating to limit\n", + static_cast(token.size()), token.data()); + fflush(stderr); - if (ec != std::errc{}) - { - // GeneralsX @bugfix Copilot 20/09/2026 Identify overflowing mod values without changing their interpretation. - fprintf(stderr, "[INI] Cannot parse numeric token '%.*s': %s\n", - static_cast(token.size()), token.data(), - ec == std::errc::result_out_of_range ? "out of range" : "invalid number"); - fflush(stderr); - throw INI_INVALID_DATA; - } + if (!token.empty() && token[0] == '-') + { + return std::numeric_limits::min(); + } + return std::numeric_limits::max(); + } + + // GeneralsX @bugfix Copilot 20/09/2026 Keep numeric conversion failures visible in release builds. + fprintf(stderr, "[INI] Cannot parse numeric token '%.*s': %s\n", + static_cast(token.size()), token.data(), + "invalid number"); + fflush(stderr); + throw INI_INVALID_DATA; + } - return static_cast(result); + if constexpr (std::is_unsigned_v) + { + // For unsigned integers, negative values like -1 are sentinels (~0U) and should wrap via static_cast. + // Positive values exceeding Type's range saturate to max. + if (result > static_cast(std::numeric_limits::max())) + { + // GeneralsX @bugfix fbraz 25/09/2026 Saturate overflowing integers to field limits to match retail behavior for mods (#297). + fprintf(stderr, "[INI] Numeric token '%.*s' exceeds max value, saturating to limit\n", + static_cast(token.size()), token.data()); + fflush(stderr); + return std::numeric_limits::max(); + } + } + else if constexpr (std::is_signed_v) + { + if (result > static_cast(std::numeric_limits::max())) + { + // GeneralsX @bugfix fbraz 25/09/2026 Saturate overflowing integers to field limits to match retail behavior for mods (#297). + fprintf(stderr, "[INI] Numeric token '%.*s' exceeds max value, saturating to limit\n", + static_cast(token.size()), token.data()); + fflush(stderr); + return std::numeric_limits::max(); + } + if (result < static_cast(std::numeric_limits::min())) + { + // GeneralsX @bugfix fbraz 25/09/2026 Saturate overflowing integers to field limits to match retail behavior for mods (#297). + fprintf(stderr, "[INI] Numeric token '%.*s' exceeds min value, saturating to limit\n", + static_cast(token.size()), token.data()); + fflush(stderr); + return std::numeric_limits::min(); + } + } + + return static_cast(result); + } } #endif diff --git a/docs/WORKLOG/2026-09-DIARY.md b/docs/WORKLOG/2026-09-DIARY.md index 12d85a3f5e2..76d74b1d218 100644 --- a/docs/WORKLOG/2026-09-DIARY.md +++ b/docs/WORKLOG/2026-09-DIARY.md @@ -3,6 +3,24 @@ > [!NOTE] > **AI-Generated Content Disclosure**: This worklog is automatically generated and maintained by AI coding agents to document daily progress, debugging sessions, and technical decisions. +## 25/09/2026 +### Saturate Out-of-Range INI Integers to Match Retail Mod Compatibility (#297) +- **Context**: In issue #297, Shockwave mod 1.201 failed to load with `[INI] Cannot parse numeric token '9999999999999999999': out of range` on `SpawnReplaceDelay` in `MinigunnerSquad.ini` and `RecenterTime` in `HellStorm.ini`. +- **Root Cause**: + - In retail Zero Hour (32-bit Windows MSVC 6), INI numeric parsing used `sscanf("%d")` and `sscanf("%u")`. On integer overflow, CRT `strtol`/`strtoul` saturated values to `LONG_MAX` / `ULONG_MAX` and `sscanf` returned 1 without error. Mod authors relied on this retail behavior by specifying astronomically large numbers (e.g. `9999999999999999999 ; 5 Years`) to represent effectively infinite delays. + - The modernization to `std::from_chars` introduced in upstream PR #2532 threw `INI_INVALID_DATA` on `std::errc::result_out_of_range`, aborting file loading and causing crashes on mods with oversized numbers. +- **Changes**: + - In `scanType()` (`Core/GameEngine/Source/Common/INI/INI.cpp`): + - When `std::from_chars` returns `std::errc::result_out_of_range`, log a warning to stderr and saturate to `std::numeric_limits::min()` (for negative tokens) or `std::numeric_limits::max()` (for positive tokens) instead of throwing `INI_INVALID_DATA`. + - For values fitting in `Int64` but exceeding `Type` range (e.g. values between 2^32 and 2^64), saturate to `Type` min/max limits rather than wrapping, while preserving the `-1` sentinel for unsigned fields (`0xFFFFFFFF`). + - Enclosed integral parsing in the `else` branch of `if constexpr (std::is_floating_point_v)` to prevent template instantiation of `std::from_chars` for `float` on macOS. + - Unified `USE_STD_FROM_CHARS_PARSING` across macOS and Linux for C++17 builds. + - Added `#include `. +- **Validation**: + - Validated standalone unit test covering `9999999999999999999`, `99999999999999999999999999999999`, `-9999999999999999999`, `5000000000`, `-5000000000`, `+500`, `-1` unsigned sentinel, standard numbers, and invalid strings. + - Built `GeneralsXZH` and `GeneralsX` locally via CMake preset `macos-vulkan` with 0 errors. + - Verified `git diff --check` passes cleanly. + ## 22/09/2026 ### Fix Replay Frame 0 Desync and Playback Completion Infinite Loop (#315, #325) - **Context**: Investigated replay desync on custom scripted maps reported in #315 (`AOD Snipe Fest Final`). Initial fix deferred replay command playback via `!isInReplayGame()` check, which broke CI deterministic replay testing by inducing an infinite simulation loop (`FAIL (exit 124)`) on macOS, Linux, and Windows runners. From 35c45b03a6fd874d8b7c0396c54c4d93d055be77 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Fri, 25 Sep 2026 13:30:17 -0300 Subject: [PATCH 2/3] fix(ini): distinguish floating-point underflow from overflow during range recovery --- Core/GameEngine/Source/Common/INI/INI.cpp | 28 +++++++++++++++++++++++ docs/WORKLOG/2026-09-DIARY.md | 3 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Core/GameEngine/Source/Common/INI/INI.cpp b/Core/GameEngine/Source/Common/INI/INI.cpp index c4c5d06f8f8..46942e8af80 100644 --- a/Core/GameEngine/Source/Common/INI/INI.cpp +++ b/Core/GameEngine/Source/Common/INI/INI.cpp @@ -1683,6 +1683,22 @@ Type scanType(std::string_view token) throw INI_INVALID_DATA; } + const double maxValue = static_cast(std::numeric_limits::max()); + if (result > maxValue) + { + fprintf(stderr, "[INI] Numeric token '%.*s' out of range, saturating to limit\n", + static_cast(token.size()), token.data()); + fflush(stderr); + return std::numeric_limits::max(); + } + if (result < -maxValue) + { + fprintf(stderr, "[INI] Numeric token '%.*s' out of range, saturating to limit\n", + static_cast(token.size()), token.data()); + fflush(stderr); + return -std::numeric_limits::max(); + } + return static_cast(result); #else Type result{}; @@ -1692,6 +1708,18 @@ Type scanType(std::string_view token) { if (ec == std::errc::result_out_of_range) { + const std::string tokenString(token); + char *end = nullptr; + const double widened = std::strtod(tokenString.c_str(), &end); + const double maxValue = + static_cast(std::numeric_limits::max()); + + if (end != tokenString.c_str() && + widened >= -maxValue && widened <= maxValue) + { + return static_cast(widened); + } + fprintf(stderr, "[INI] Numeric token '%.*s' out of range, saturating to limit\n", static_cast(token.size()), token.data()); fflush(stderr); diff --git a/docs/WORKLOG/2026-09-DIARY.md b/docs/WORKLOG/2026-09-DIARY.md index 76d74b1d218..739adf5a17f 100644 --- a/docs/WORKLOG/2026-09-DIARY.md +++ b/docs/WORKLOG/2026-09-DIARY.md @@ -14,10 +14,11 @@ - When `std::from_chars` returns `std::errc::result_out_of_range`, log a warning to stderr and saturate to `std::numeric_limits::min()` (for negative tokens) or `std::numeric_limits::max()` (for positive tokens) instead of throwing `INI_INVALID_DATA`. - For values fitting in `Int64` but exceeding `Type` range (e.g. values between 2^32 and 2^64), saturate to `Type` min/max limits rather than wrapping, while preserving the `-1` sentinel for unsigned fields (`0xFFFFFFFF`). - Enclosed integral parsing in the `else` branch of `if constexpr (std::is_floating_point_v)` to prevent template instantiation of `std::from_chars` for `float` on macOS. + - Handled floating-point range errors by re-parsing with `strtod` to distinguish underflow (returned as widened value) from true overflow (saturated to float limits), ensuring cross-platform parity on macOS and Linux. - Unified `USE_STD_FROM_CHARS_PARSING` across macOS and Linux for C++17 builds. - Added `#include `. - **Validation**: - - Validated standalone unit test covering `9999999999999999999`, `99999999999999999999999999999999`, `-9999999999999999999`, `5000000000`, `-5000000000`, `+500`, `-1` unsigned sentinel, standard numbers, and invalid strings. + - Validated standalone unit test covering `9999999999999999999`, `99999999999999999999999999999999`, `-9999999999999999999`, `5000000000`, `-5000000000`, `+500`, `-1` unsigned sentinel, standard numbers, float underflow (`1e-50`), float overflow (`1e50`), and invalid strings. - Built `GeneralsXZH` and `GeneralsX` locally via CMake preset `macos-vulkan` with 0 errors. - Verified `git diff --check` passes cleanly. From 919a788c391e82d2338006936051374f747e9f29 Mon Sep 17 00:00:00 2001 From: Felipe Keller Braz Date: Fri, 25 Sep 2026 14:08:21 -0300 Subject: [PATCH 3/3] fix(ini): reject literal non-finite floating-point tokens --- Core/GameEngine/Source/Common/INI/INI.cpp | 20 ++++++++++++++++++++ docs/WORKLOG/2026-09-DIARY.md | 5 +++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/Core/GameEngine/Source/Common/INI/INI.cpp b/Core/GameEngine/Source/Common/INI/INI.cpp index 46942e8af80..244f55bd8e1 100644 --- a/Core/GameEngine/Source/Common/INI/INI.cpp +++ b/Core/GameEngine/Source/Common/INI/INI.cpp @@ -67,7 +67,9 @@ #endif #if USE_STD_FROM_CHARS_PARSING +#include #include +#include #include #include #include @@ -1676,6 +1678,7 @@ Type scanType(std::string_view token) #if defined(__APPLE__) const std::string tokenString(token); char *end = nullptr; + errno = 0; const double result = std::strtod(tokenString.c_str(), &end); if (end == tokenString.c_str()) @@ -1683,6 +1686,11 @@ Type scanType(std::string_view token) throw INI_INVALID_DATA; } + if (!std::isfinite(result) && errno != ERANGE) + { + throw INI_INVALID_DATA; + } + const double maxValue = static_cast(std::numeric_limits::max()); if (result > maxValue) { @@ -1710,10 +1718,17 @@ Type scanType(std::string_view token) { const std::string tokenString(token); char *end = nullptr; + errno = 0; const double widened = std::strtod(tokenString.c_str(), &end); const double maxValue = static_cast(std::numeric_limits::max()); + if (end != tokenString.c_str() && + !std::isfinite(widened) && errno != ERANGE) + { + throw INI_INVALID_DATA; + } + if (end != tokenString.c_str() && widened >= -maxValue && widened <= maxValue) { @@ -1738,6 +1753,11 @@ Type scanType(std::string_view token) throw INI_INVALID_DATA; } + if (!std::isfinite(result)) + { + throw INI_INVALID_DATA; + } + return result; #endif } diff --git a/docs/WORKLOG/2026-09-DIARY.md b/docs/WORKLOG/2026-09-DIARY.md index 739adf5a17f..a4768e9dfd3 100644 --- a/docs/WORKLOG/2026-09-DIARY.md +++ b/docs/WORKLOG/2026-09-DIARY.md @@ -15,10 +15,11 @@ - For values fitting in `Int64` but exceeding `Type` range (e.g. values between 2^32 and 2^64), saturate to `Type` min/max limits rather than wrapping, while preserving the `-1` sentinel for unsigned fields (`0xFFFFFFFF`). - Enclosed integral parsing in the `else` branch of `if constexpr (std::is_floating_point_v)` to prevent template instantiation of `std::from_chars` for `float` on macOS. - Handled floating-point range errors by re-parsing with `strtod` to distinguish underflow (returned as widened value) from true overflow (saturated to float limits), ensuring cross-platform parity on macOS and Linux. + - Rejected literal non-finite tokens (`NaN`/`Inf`) in floating-point parsing unless accompanied by numeric `ERANGE` overflow, and verified finite return from `std::from_chars`. - Unified `USE_STD_FROM_CHARS_PARSING` across macOS and Linux for C++17 builds. - - Added `#include `. + - Added `#include `, ``, and ``. - **Validation**: - - Validated standalone unit test covering `9999999999999999999`, `99999999999999999999999999999999`, `-9999999999999999999`, `5000000000`, `-5000000000`, `+500`, `-1` unsigned sentinel, standard numbers, float underflow (`1e-50`), float overflow (`1e50`), and invalid strings. + - Validated standalone unit test covering `9999999999999999999`, `99999999999999999999999999999999`, `-9999999999999999999`, `5000000000`, `-5000000000`, `+500`, `-1` unsigned sentinel, standard numbers, float underflow (`1e-50`), float overflow (`1e50`), literal non-finite values (`nan`, `inf`, `-infinity`), and invalid strings. - Built `GeneralsXZH` and `GeneralsX` locally via CMake preset `macos-vulkan` with 0 errors. - Verified `git diff --check` passes cleanly.