Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 125 additions & 15 deletions Core/GameEngine/Source/Common/INI/INI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <cerrno>
#include <charconv>
#include <cmath>
#include <cstdlib>
#include <limits>
#include <string_view>
#include <type_traits>
#endif
Expand Down Expand Up @@ -1675,47 +1678,154 @@ 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())
{
throw INI_INVALID_DATA;
}

if (!std::isfinite(result) && errno != ERANGE)
{
throw INI_INVALID_DATA;
}

const double maxValue = static_cast<double>(std::numeric_limits<Type>::max());
if (result > maxValue)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
fprintf(stderr, "[INI] Numeric token '%.*s' out of range, saturating to limit\n",
static_cast<int>(token.size()), token.data());
fflush(stderr);
return std::numeric_limits<Type>::max();
}
if (result < -maxValue)
{
fprintf(stderr, "[INI] Numeric token '%.*s' out of range, saturating to limit\n",
static_cast<int>(token.size()), token.data());
fflush(stderr);
return -std::numeric_limits<Type>::max();
}

return static_cast<Type>(result);
#else
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)
{
const std::string tokenString(token);
char *end = nullptr;
errno = 0;
const double widened = std::strtod(tokenString.c_str(), &end);
const double maxValue =
static_cast<double>(std::numeric_limits<Type>::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<Type>(widened);
}

fprintf(stderr, "[INI] Numeric token '%.*s' out of range, saturating to limit\n",
static_cast<int>(token.size()), token.data());
fflush(stderr);
if (!token.empty() && token[0] == '-')
{
return -std::numeric_limits<Type>::max();
}
return std::numeric_limits<Type>::max();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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<int>(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<std::is_integral_v<Type>, 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<std::is_integral_v<Type>, 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<int>(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<int>(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<Type>::min();
}
return std::numeric_limits<Type>::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<int>(token.size()), token.data(),
"invalid number");
fflush(stderr);
throw INI_INVALID_DATA;
}

if constexpr (std::is_unsigned_v<Type>)
{
// 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<Int64>(std::numeric_limits<Type>::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<int>(token.size()), token.data());
fflush(stderr);
return std::numeric_limits<Type>::max();
}
}
else if constexpr (std::is_signed_v<Type>)
{
if (result > static_cast<Int64>(std::numeric_limits<Type>::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<int>(token.size()), token.data());
fflush(stderr);
return std::numeric_limits<Type>::max();
}
if (result < static_cast<Int64>(std::numeric_limits<Type>::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<int>(token.size()), token.data());
fflush(stderr);
return std::numeric_limits<Type>::min();
}
}

return static_cast<Type>(result);
return static_cast<Type>(result);
}
}

#endif
Expand Down
20 changes: 20 additions & 0 deletions docs/WORKLOG/2026-09-DIARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Type>()` (`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<Type>::min()` (for negative tokens) or `std::numeric_limits<Type>::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<Type>)` 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 <cerrno>`, `<cmath>`, and `<limits>`.
- **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.
Expand Down
Loading