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
1 change: 1 addition & 0 deletions Benchmarks/Google/Core/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
set(COREI_GOOGLEBENCHMARK_SRC
GoogleBenchmarkMain.cpp
GameEngine/Common/AsciiStringBenchmark.cpp
GameEngine/Common/INIBenchmark.cpp
Libraries/Compression/CompressionManagerBenchmark.cpp
)

Expand Down
99 changes: 99 additions & 0 deletions Benchmarks/Google/Core/GameEngine/Common/INIBenchmark.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
** Command & Conquer Generals Zero Hour(tm)
** Copyright 2026 TheSuperHackers
**
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation, either version 3 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

#include <benchmark/benchmark.h>
#include <cstdio>
#include <type_traits>

#include "Common/INI.h"

enum ScanParser
{
ScanParser_Current,
ScanParser_Legacy,
};

// Ordinary inputs use the fast path. Overflow and underflow (Real only) use the range fallback.
enum ScanInput
{
ScanInput_Ordinary,
ScanInput_Overflow,
ScanInput_Underflow,
};

// Keep the original conversion and failure check, with a call boundary like the production scanners.
// Out-of-range sscanf results characterize the local CRT, not portable C behavior.
template <typename Type>
NOINLINE static Type scanLegacy(const char* token)
{
Type value;
const char* format = std::is_same_v<Type, Real> ? "%f" : std::is_signed_v<Type> ? "%d" : "%u";
if (std::sscanf(token, format, &value) != 1)
throw INI_INVALID_DATA;
return value;
}

// Each iteration parses four fixed tokens. These are microbenchmarks, not a distribution of game INI inputs.
template <typename Type, ScanParser Parser, ScanInput Input>
static void BM_INIScan(benchmark::State &state)
{
const char* const integerTokens[][4] = {
{"0", "+42", "-1000", "2147483647"},
{"9999999999999999999", "+9999999999999999999", "-9999999999999999999", "18446744073709551616"}
};
const char* const realTokens[][4] = {
{"0.0", "+1.25", "-1000.5", "3.14159265"},
{"1e100", "+1e100", "-1e100", "3.5e38"},
{"1e-100", "+1e-100", "-1e-100", "1e-46"}
};
const auto& tokens = std::is_same_v<Type, Real> ? realTokens[Input] : integerTokens[Input];

for (auto _ : state)
{
for (const char* token : tokens)
{
benchmark::DoNotOptimize(token);
Type value;
if constexpr (Parser == ScanParser_Legacy)
value = scanLegacy<Type>(token);
else if constexpr (std::is_same_v<Type, Real>)
value = INI::scanReal(token);
else if constexpr (std::is_signed_v<Type>)
value = INI::scanInt(token);
else
value = INI::scanUnsignedInt(token);
benchmark::DoNotOptimize(value);
}
}
state.SetItemsProcessed(state.iterations() * 4);
}

BENCHMARK_TEMPLATE(BM_INIScan, Int, ScanParser_Current, ScanInput_Ordinary);
BENCHMARK_TEMPLATE(BM_INIScan, Int, ScanParser_Legacy, ScanInput_Ordinary);
BENCHMARK_TEMPLATE(BM_INIScan, Int, ScanParser_Current, ScanInput_Overflow);
BENCHMARK_TEMPLATE(BM_INIScan, Int, ScanParser_Legacy, ScanInput_Overflow);
BENCHMARK_TEMPLATE(BM_INIScan, UnsignedInt, ScanParser_Current, ScanInput_Ordinary);
BENCHMARK_TEMPLATE(BM_INIScan, UnsignedInt, ScanParser_Legacy, ScanInput_Ordinary);
BENCHMARK_TEMPLATE(BM_INIScan, UnsignedInt, ScanParser_Current, ScanInput_Overflow);
BENCHMARK_TEMPLATE(BM_INIScan, UnsignedInt, ScanParser_Legacy, ScanInput_Overflow);
BENCHMARK_TEMPLATE(BM_INIScan, Real, ScanParser_Current, ScanInput_Ordinary);
BENCHMARK_TEMPLATE(BM_INIScan, Real, ScanParser_Legacy, ScanInput_Ordinary);
BENCHMARK_TEMPLATE(BM_INIScan, Real, ScanParser_Current, ScanInput_Overflow);
BENCHMARK_TEMPLATE(BM_INIScan, Real, ScanParser_Legacy, ScanInput_Overflow);
BENCHMARK_TEMPLATE(BM_INIScan, Real, ScanParser_Current, ScanInput_Underflow);
BENCHMARK_TEMPLATE(BM_INIScan, Real, ScanParser_Legacy, ScanInput_Underflow);
33 changes: 26 additions & 7 deletions Core/GameEngine/Source/Common/INI/INI.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@

#if USE_STD_FROM_CHARS_PARSING
#include <charconv>
#include <cstdlib>
#include <string_view>
#include <type_traits>
#endif
Expand Down Expand Up @@ -1611,20 +1612,38 @@ void INI::initFromINIMulti( void *what, const MultiIniFieldParse& parseTableList
#if USE_STD_FROM_CHARS_PARSING

template <typename Type>
Type scanType(std::string_view token)
Type scanType(const char* tokenString)
{
std::string_view token(tokenString);
DEBUG_ASSERTCRASH(!token.empty(), ("token is not expected to be empty"));

// Unlike sscanf, std::from_chars cannot parse "+".
// Consume the plus symbol to accommodate custom ini files that have numbers prefixed with a plus.
if (token[0] == '+')
{
token.remove_prefix(1);
}
// Skip it for from_chars while preserving the original token for the fallback.
const size_t tokenOffset = (token[0] == '+');
if (tokenOffset && token.size() > 1 && token[1] == '-')
Comment thread
CryoTheRenegade marked this conversation as resolved.
throw INI_INVALID_DATA;

// Unlike sscanf, std::from_chars cannot parse "-" as unsigned integer.
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);
const auto [ptr, ec] = std::from_chars(token.data() + tokenOffset, token.data() + token.size(), result);

// Preserve legacy range handling with defined strto* conversions.
if (ec == std::errc::result_out_of_range)
{
char* end;
Type value;
if constexpr (std::is_floating_point_v<Type>)
value = std::strtof(tokenString, &end);
else if constexpr (std::is_signed_v<Type>)
value = static_cast<Type>(std::strtoll(tokenString, &end, 10));
else
value = static_cast<Type>(std::strtoull(tokenString, &end, 10));

if (end == tokenString)
throw INI_INVALID_DATA;

return value;
}

if (ec != std::errc{})
{
Expand Down
10 changes: 10 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,13 @@ The test executables use the regular user data folder of the game, so a `RELEASE
Benchmarks use [Google Benchmark](https://github.com/TheSuperHackers/google-benchmark). Enable them with `RTS_BUILD_OPTION_BENCHMARKS`. They are organized like the unit tests under Benchmarks/Google, with the executables g_googlebenchmark and z_googlebenchmark. CI builds them, but does not run them.

Run benchmarks from a Release build. Select benchmarks with `--benchmark_filter=<regex>`, and use `--benchmark_out=<file>.json` to compare the results before and after a change.

The `BM_INIScan` benchmarks compare the production numeric scanners with the original `sscanf` conversion and failure check. Ordinary inputs exercise `from_chars`; overflow and underflow inputs exercise the range fallback. Each iteration parses four fixed tokens, so divide the reported CPU time by four for ns/token. `items_per_second` already counts individual tokens. Range-error `sscanf` results characterize the local CRT and are not portable C behavior. These measurements do not represent game startup time or the frequency of range errors in game data.

For example, from an x86 developer shell:
```
cmake --preset win32 -DRTS_BUILD_OPTION_BENCHMARKS=ON
cmake --build --preset win32 --target z_googlebenchmark g_googlebenchmark
build/win32/Benchmarks/Google/Release/z_googlebenchmark.exe --benchmark_filter=^BM_INIScan --benchmark_min_time=0.2s --benchmark_repetitions=5 --benchmark_enable_random_interleaving=true --benchmark_out=ini-benchmark.json --benchmark_out_format=json
```
Use the median CPU time for each case when comparing repeated runs. The same filter also works with `g_googlebenchmark.exe`. Relative output paths are resolved from the executable's directory; use an absolute `--benchmark_out` path to save elsewhere.
1 change: 1 addition & 0 deletions Tests/Google/Core/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
set(COREI_GOOGLETEST_SRC
GoogleTestMain.cpp
GameEngine/Common/AsciiStringTest.cpp
GameEngine/Common/INITest.cpp
Libraries/Compression/CompressionManagerTest.cpp
)

Expand Down
122 changes: 122 additions & 0 deletions Tests/Google/Core/GameEngine/Common/INITest.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
** Command & Conquer Generals Zero Hour(tm)
** Copyright 2026 TheSuperHackers
**
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation, either version 3 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

#include <cmath>
#include <cstdio>
#include <limits>
#include <gtest/gtest.h>

#include "Common/INI.h"

TEST(INI, OversizedIntegersMatchLegacyParser)
Comment thread
CryoTheRenegade marked this conversation as resolved.
{
const struct {
const char* token;
Int signedExpected;
UnsignedInt unsignedExpected;
} cases[] = {
{"9999999999999999999", -1, 2313682943u},
{"+9999999999999999999", -1, 2313682943u},
{"-9999999999999999999", 0, 1981284353u},
{"9999999999999999999suffix", -1, 2313682943u},
{"9223372036854775807", -1, 4294967295u},
{"-9223372036854775808", 0, 0u},
{"9223372036854775808", -1, 0u},
{"-9223372036854775809", 0, 4294967295u},
{"18446744073709551616", -1, 4294967295u},
{"-18446744073709551616", 0, 4294967295u},
{"99999999999999999999999999999999999999999999999999", -1, 4294967295u}
};
// Windows legacy parsing saturates at 64 bits, then keeps the low 32 bits.
for (const auto& test : cases)
{
SCOPED_TRACE(test.token);
EXPECT_EQ(INI::scanInt(test.token), test.signedExpected);
EXPECT_EQ(INI::scanUnsignedInt(test.token), test.unsignedExpected);
#ifdef _WIN32
// Characterize the Windows CRT; out-of-range scanf conversions are not portable.
Int legacySigned;
UnsignedInt legacyUnsigned;
ASSERT_EQ(std::sscanf(test.token, "%d", &legacySigned), 1);
ASSERT_EQ(std::sscanf(test.token, "%u", &legacyUnsigned), 1);
EXPECT_EQ(legacySigned, test.signedExpected);
EXPECT_EQ(legacyUnsigned, test.unsignedExpected);
#endif
}
}

TEST(INI, InvalidIntegersAreRejected)
{
for (const char* token : {"abc", "+", "-", "++1", "+-99999999", "+-9999999999999999999"})
{
SCOPED_TRACE(token);
EXPECT_THROW(INI::scanInt(token), decltype(INI_INVALID_DATA));
EXPECT_THROW(INI::scanUnsignedInt(token), decltype(INI_INVALID_DATA));
Int legacySigned;
UnsignedInt legacyUnsigned;
EXPECT_NE(std::sscanf(token, "%d", &legacySigned), 1);
EXPECT_NE(std::sscanf(token, "%u", &legacyUnsigned), 1);
}
}

TEST(INI, RealRangeErrorsMatchLegacyParser)
{
const Real infinity = std::numeric_limits<Real>::infinity();
const Real subnormal = std::numeric_limits<Real>::denorm_min();
const struct {
const char* token;
Real expected;
} cases[] = {
{"1e100", infinity},
{"+1e100", infinity},
{"-1e100", -infinity},
{"3.5e38", infinity},
{"1e100suffix", infinity},
{"1e-100", 0.0f},
{"+1e-100", 0.0f},
{"-1e-100", -0.0f},
{"1e-45", subnormal},
{"-1e-45", -subnormal},
{"+1.25", 1.25f}
};
for (const auto& test : cases)
{
SCOPED_TRACE(test.token);
const Real actual = INI::scanReal(test.token);
EXPECT_EQ(actual, test.expected);
EXPECT_EQ(std::signbit(actual), std::signbit(test.expected));
#ifdef _WIN32
// As above, scanf is a Windows compatibility check, not a portable overflow oracle.
Real legacy;
ASSERT_EQ(std::sscanf(test.token, "%f", &legacy), 1);
EXPECT_EQ(legacy, test.expected);
EXPECT_EQ(std::signbit(legacy), std::signbit(test.expected));
#endif
}
}

TEST(INI, InvalidRealsAreRejected)
{
for (const char* token : {"abc", "+", "-", "++1.25", "+-1.25", "+-1e100", "+-1e-100"})
Comment thread
CryoTheRenegade marked this conversation as resolved.
{
SCOPED_TRACE(token);
EXPECT_THROW(INI::scanReal(token), decltype(INI_INVALID_DATA));
Real legacy;
EXPECT_NE(std::sscanf(token, "%f", &legacy), 1);
}
}
Loading