diff --git a/Core/GameEngine/Source/Common/INI/INI.cpp b/Core/GameEngine/Source/Common/INI/INI.cpp index e0ac2e80cac..244f55bd8e1 100644 --- a/Core/GameEngine/Source/Common/INI/INI.cpp +++ b/Core/GameEngine/Source/Common/INI/INI.cpp @@ -60,15 +60,18 @@ #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 #endif #if USE_STD_FROM_CHARS_PARSING +#include #include +#include #include +#include #include #include #endif @@ -1675,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()) @@ -1682,6 +1686,27 @@ 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) + { + 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{}; @@ -1689,33 +1714,118 @@ Type scanType(std::string_view token) if (ec != std::errc{}) { + if (ec == std::errc::result_out_of_range) + { + 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) + { + 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); + 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; } + if (!std::isfinite(result)) + { + throw INI_INVALID_DATA; + } + 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; + } + + 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); + return static_cast(result); + } } #endif diff --git a/docs/WORKLOG/2026-09-DIARY.md b/docs/WORKLOG/2026-09-DIARY.md index 12d85a3f5e2..a4768e9dfd3 100644 --- a/docs/WORKLOG/2026-09-DIARY.md +++ b/docs/WORKLOG/2026-09-DIARY.md @@ -3,6 +3,26 @@ > [!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. + - 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 `, ``, 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`), 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. + ## 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.