From 79668971c46575d935f217b18793797ed11b6a8f Mon Sep 17 00:00:00 2001 From: Casper Van Gheluwe Date: Wed, 17 Dec 2025 07:54:41 +0100 Subject: [PATCH 1/9] fix: increase INI_MAX_LINE limit for DBPFKitLib so that longer lines can be read successfully --- CMakeLists.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index e340d67..709528e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -82,6 +82,9 @@ target_link_libraries(DBPFKitTests PRIVATE libsquish::Squish Catch2::Catch2WithMain ) +target_compile_definitions( + DBPFKitLib PUBLIC INI_MAX_LINE=1000 +) # Enable testing enable_testing() From 2f9d00d0923d2e61c4fd13878fabb9f62c7c678e Mon Sep 17 00:00:00 2001 From: Casper Van Gheluwe Date: Wed, 17 Dec 2025 07:54:57 +0100 Subject: [PATCH 2/9] fix: add comment prefix and correct query ID key naming in RUL0.h --- src/RUL0.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/RUL0.h b/src/RUL0.h index b7dd8e8..4e5aa57 100644 --- a/src/RUL0.h +++ b/src/RUL0.h @@ -11,6 +11,7 @@ namespace RUL0 { constexpr auto kListDelimiter = ','; + constexpr auto kCommentPrefix = ';'; constexpr auto kOrderingSection = "Ordering"; constexpr auto kRotationRingKey = "RotationRing"; @@ -24,8 +25,8 @@ namespace RUL0 { constexpr auto kCheckTypeKey = "CheckType"; constexpr auto kAutoTileBaseKey = "AutoTileBase"; constexpr auto kAutoPathBaseKey = "AutoPathBase"; - constexpr auto kPlaceQueryIdKey = "PlaceQueryId"; - constexpr auto kConvertQueryIdKey = "ConvertQueryId"; + constexpr auto kPlaceQueryIdKey = "PlaceQueryID"; + constexpr auto kConvertQueryIdKey = "ConvertQueryID"; constexpr auto kCostsKey = "Costs"; constexpr auto kAutoPlaceKey = "AutoPlace"; constexpr auto kOneWayDirKey = "OneWayDir"; From 4141e90829e48ce5ccba0afdb1f57b0b5c8f6ffa Mon Sep 17 00:00:00 2001 From: Casper Van Gheluwe Date: Wed, 17 Dec 2025 07:55:40 +0100 Subject: [PATCH 3/9] fix: replace sscanf with safer parsing functions in RUL0.cpp, and add case insensitive string comparison of the section and key names --- src/RUL0.cpp | 184 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 138 insertions(+), 46 deletions(-) diff --git a/src/RUL0.cpp b/src/RUL0.cpp index 377153f..6443140 100644 --- a/src/RUL0.cpp +++ b/src/RUL0.cpp @@ -1,7 +1,8 @@ #include "RUL0.h" +#include +#include #include -#include #include #include #include @@ -9,6 +10,84 @@ #include "ParseTypes.h" #include "ini.h" +namespace { + bool EqualsIgnoreCase(const std::string_view a, const std::string_view b) { + if (a.size() != b.size()) { + return false; + } + for (size_t i = 0; i < a.size(); ++i) { + const auto ca = static_cast(a[i]); + const auto cb = static_cast(b[i]); + if (std::tolower(ca) != std::tolower(cb)) { + return false; + } + } + return true; + } + + bool StartsWithIgnoreCase(const std::string_view text, const std::string_view prefix) { + if (prefix.size() > text.size()) { + return false; + } + for (size_t i = 0; i < prefix.size(); ++i) { + const auto ct = static_cast(text[i]); + const auto cp = static_cast(prefix[i]); + if (std::tolower(ct) != std::tolower(cp)) { + return false; + } + } + return true; + } + + std::string_view Trim(std::string_view s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) s.remove_prefix(1); + while (!s.empty() && std::isspace(static_cast(s.back()))) s.remove_suffix(1); + return s; + } + + template + bool ParseIntAuto(const std::string_view s, T& out) { + auto base = 10; + if (s.size() >= 2 && s[0] == '0') { + if (s[1] == 'x' || s[1] == 'X') { + base = 16; + } + else { + base = 8; + } + } + auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out, base); + return ec == std::errc() && ptr == s.data() + s.size(); + } + + template + bool ParseInt(std::string_view s, T& out) { + auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out); + return ec == std::errc() && ptr == s.data() + s.size(); + } + + bool ParseFloat(std::string_view s, float& out) { + auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out, std::chars_format::general); + return ec == std::errc() && ptr == s.data() + s.size(); + } + + bool ParseHex(std::string_view s, uint32_t& out) { + if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) { + s = s.substr(2); + } + auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out, 16); + return ec == std::errc() && ptr == s.data() + s.size(); + } + + template + bool ParseIntPair(std::string_view s, A& a, B& b) { + const auto comma = s.find(RUL0::kListDelimiter); + if (comma == std::string_view::npos) return false; + return ParseInt(Trim(s.substr(0, comma)), a) && + ParseInt(Trim(s.substr(comma + 1)), b); + } +} + namespace RUL0 { // Convert PuzzlePiece to human-readable string representation std::string PuzzlePiece::ToString() const { @@ -153,10 +232,29 @@ namespace RUL0 { int rotation, flip; // The game reads these as %i (which can also be octal or hexadecimal format), so we do too uint32_t instanceId; std::string name; - const auto res = sscanf(value.data(), "%f, %f, %i, %i, 0x%x", &x, &y, &rotation, &flip, &instanceId); - if (res != 5) { + + std::string_view parts[5]; + size_t count = 0; + size_t start = 0; + size_t semi = value.find(kCommentPrefix); + if (semi != std::string_view::npos) { + value = value.substr(0, semi); + } + + while (start < value.size() && count < 5) { + size_t comma = value.find(kListDelimiter, start); + if (comma == std::string_view::npos) comma = value.size(); + parts[count++] = Trim(value.substr(start, comma - start)); + start = comma + 1; + } + if (count != 5) return false; + + if (!ParseFloat(parts[0], x) || !ParseFloat(parts[1], y) || + !ParseIntAuto(parts[2], rotation) || !ParseIntAuto(parts[3], flip) || + !ParseHex(parts[4], instanceId)) { return false; } + previewEffect.initialized = true; previewEffect.x = x; previewEffect.y = y; @@ -228,7 +326,7 @@ namespace RUL0 { if (expectChar(',')) { auto mask = nextToken(); - nc.hexMask = std::stoul(std::string(mask.substr(0,std::min(mask.length(), 10zu))), nullptr, 16); + nc.hexMask = std::stoul(std::string(mask.substr(0, std::min(mask.length(), 10zu))), nullptr, 16); } ct.networks.push_back(nc); @@ -245,13 +343,13 @@ namespace RUL0 { const auto valStr = std::string_view(value); // We are either in the Ordering section or in a sectionless preamble - if (secStr == kOrderingSection || secStr.empty()) { - if (keyStr == kRotationRingKey) { + if (EqualsIgnoreCase(secStr, kOrderingSection) || secStr.empty()) { + if (EqualsIgnoreCase(keyStr, kRotationRingKey)) { // Start a new ordering when we discovered a new RotationRing entry data->orderings.emplace_back(); data->orderings.back().rotationRing = ParseIdList(valStr); } - else if (keyStr == kAddTypesKey) { + else if (EqualsIgnoreCase(keyStr, kAddTypesKey)) { if (data->orderings.empty()) { // Malformed RUL0: AddTypes before RotationRing return 0; @@ -267,7 +365,7 @@ namespace RUL0 { } // We have found a HighwayIntersectionInfo section - if (secStr.starts_with(kIntersectionInfoPrefix)) { + if (StartsWithIgnoreCase(secStr, kIntersectionInfoPrefix)) { const uint32_t id = ParsePieceId(secStr); // We are starting a new puzzle piece @@ -279,32 +377,31 @@ namespace RUL0 { auto* piece = data->currentPiece; - if (keyStr == kPieceKey) { + if (EqualsIgnoreCase(keyStr, kPieceKey)) { ParsePieceValue(valStr, piece->effect); } - else if (keyStr == kPreviewEffectKey) { + else if (EqualsIgnoreCase(keyStr, kPreviewEffectKey)) { piece->effect.name = std::string(valStr); } - else if (keyStr == kCellLayoutKey) { - piece->cellLayout.push_back(std::string(valStr)); + else if (EqualsIgnoreCase(keyStr, kCellLayoutKey)) { + piece->cellLayout.emplace_back(valStr); } - else if (keyStr == kCheckTypeKey) { + else if (EqualsIgnoreCase(keyStr, kCheckTypeKey)) { piece->checkTypes.push_back(ParseCheckType(valStr)); } - else if (keyStr == kConsLayoutKey) { - piece->consLayout.push_back(std::string(valStr)); + else if (EqualsIgnoreCase(keyStr, kConsLayoutKey)) { + piece->consLayout.emplace_back(valStr); } - else if (keyStr == kAutoPathBaseKey) { + else if (EqualsIgnoreCase(keyStr, kAutoPathBaseKey)) { piece->autoPathBase = std::strtoul(value, nullptr, 16); } - else if (keyStr == kAutoTileBaseKey) { + else if (EqualsIgnoreCase(keyStr, kAutoTileBaseKey)) { piece->autoTileBase = std::strtoul(value, nullptr, 16); } - else if (keyStr == kReplacementIntersectionKey) { + else if (StartsWithIgnoreCase(keyStr, kReplacementIntersectionKey)) { int replRotation; uint32_t replFlip; - auto const ret = sscanf(value, "%d, %d", &replRotation, &replFlip); - if (ret != 2) { + if (!ParseIntPair(value, replRotation, replFlip)) { // Invalid ReplacementIntersection format return 0; } @@ -318,44 +415,38 @@ namespace RUL0 { replFlip }; } - else if (keyStr == kPlaceQueryIdKey) { + else if (EqualsIgnoreCase(keyStr, kPlaceQueryIdKey)) { piece->placeQueryId = std::strtoul(value, nullptr, 16); } - else if (keyStr == kCostsKey) { - if (valStr.size() > 0) { + else if (EqualsIgnoreCase(keyStr, kCostsKey)) { + if (!valStr.empty()) { piece->costs = std::stoi(value); } else { piece->costs = 0; } } - else if (keyStr == kConvertQueryIdKey) { + else if (EqualsIgnoreCase(keyStr, kConvertQueryIdKey)) { piece->convertQueryId = std::strtoul(value, nullptr, 16); } - else if (keyStr == kAutoPlaceKey) { + else if (EqualsIgnoreCase(keyStr, kAutoPlaceKey)) { piece->autoPlace = (std::stoi(value) != 0); } - else if (keyStr == kHandleOffsetKey) { - const auto ret = sscanf(value, - "%d, %d", - &piece->handleOffset.deltaStraight, - &piece->handleOffset.deltaSide - ); - if (ret == 2) { + else if (EqualsIgnoreCase(keyStr, kHandleOffsetKey)) { + if (ParseIntPair(value, + piece->handleOffset.deltaStraight, + piece->handleOffset.deltaSide)) { piece->stepOffsets.initialized = true; } } - else if (keyStr == kStepOffsetsKey) { - const auto ret = sscanf(value, - "%d, %d", - &piece->stepOffsets.dragStartThreshold, - &piece->stepOffsets.dragCompletionOffset - ); - if (ret == 2) { + else if (EqualsIgnoreCase(keyStr, kStepOffsetsKey)) { + if (ParseIntPair(value, + piece->stepOffsets.dragStartThreshold, + piece->stepOffsets.dragCompletionOffset)) { piece->stepOffsets.initialized = true; } } - else if (keyStr == kOneWayDirKey) { + else if (EqualsIgnoreCase(keyStr, kOneWayDirKey)) { const auto val = std::stoi(value); if (val < +OneWayDir::WEST || val > +OneWayDir::SOUTH_WEST) { // Invalid OneWayDir value @@ -363,11 +454,11 @@ namespace RUL0 { } piece->oneWayDir = static_cast(val); } - else if (keyStr == kCopyFromKey) { + else if (EqualsIgnoreCase(keyStr, kCopyFromKey)) { piece->copyFrom = std::strtoul(value, nullptr, 16); // TODO: Actually do something with this! } - else if (keyStr == kRotateKey) { + else if (EqualsIgnoreCase(keyStr, kRotateKey)) { const auto val = std::stoi(value); if (val < +Rotation::ROT_0 || val > +Rotation::ROT_270) { // Invalid rotation value @@ -375,12 +466,14 @@ namespace RUL0 { } piece->rotate = static_cast(val); } - else if (keyStr == kTransposeKey) { + else if (EqualsIgnoreCase(keyStr, kTransposeKey)) { piece->transpose = (std::stoi(value) != 0); } - else if (keyStr == kTranslateKey) { + else if (EqualsIgnoreCase(keyStr, kTranslateKey)) { // This key is not documented, but present in SC4 game decompilation, so included. - sscanf(value, "%d, %d", &piece->translate.x, &piece->translate.z); + if (ParseIntPair(value, piece->translate.x, piece->translate.z)) { + piece->translate.initialized = true; + } } else { // Malformed RUL0: Unknown key in HighwayIntersectionInfo section @@ -718,5 +811,4 @@ namespace RUL0 { BuildNavigationIndices(data); return data; } - } From a351dde59c4bfee8e2081a3e6384c0934bd0b022 Mon Sep 17 00:00:00 2001 From: Casper Van Gheluwe Date: Wed, 17 Dec 2025 08:10:54 +0100 Subject: [PATCH 4/9] fix: improve integer parsing in RUL0.cpp by handling base conversion more safely --- src/RUL0.cpp | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/RUL0.cpp b/src/RUL0.cpp index 6443140..2912a69 100644 --- a/src/RUL0.cpp +++ b/src/RUL0.cpp @@ -47,17 +47,32 @@ namespace { template bool ParseIntAuto(const std::string_view s, T& out) { + std::string_view to_parse = s; auto base = 10; if (s.size() >= 2 && s[0] == '0') { if (s[1] == 'x' || s[1] == 'X') { base = 16; + to_parse = s.substr(2); } else { - base = 8; + auto all_octal_digits = true; + for (std::size_t i = 1; i < s.size(); ++i) { + const auto ch = static_cast(s[i]); + if (!std::isdigit(ch)) { + break; + } + if (ch >= '8') { + all_octal_digits = false; + break; + } + } + if (all_octal_digits) { + base = 8; + } } } - auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out, base); - return ec == std::errc() && ptr == s.data() + s.size(); + auto [ptr, ec] = std::from_chars(to_parse.data(), to_parse.data() + to_parse.size(), out, base); + return ec == std::errc() && ptr == to_parse.data() + to_parse.size(); } template @@ -436,7 +451,7 @@ namespace RUL0 { if (ParseIntPair(value, piece->handleOffset.deltaStraight, piece->handleOffset.deltaSide)) { - piece->stepOffsets.initialized = true; + piece->handleOffset.initialized = true; } } else if (EqualsIgnoreCase(keyStr, kStepOffsetsKey)) { From 79e2d71278aed2afb2b40256480682cd5fd882b1 Mon Sep 17 00:00:00 2001 From: Casper Van Gheluwe Date: Wed, 17 Dec 2025 08:22:21 +0100 Subject: [PATCH 5/9] refactor: put parsing helpers in separate namespace --- src/RUL0.cpp | 92 +++++++++++++++++----------------------------------- src/RUL0.h | 50 ++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 63 deletions(-) diff --git a/src/RUL0.cpp b/src/RUL0.cpp index 2912a69..5150e65 100644 --- a/src/RUL0.cpp +++ b/src/RUL0.cpp @@ -10,7 +10,32 @@ #include "ParseTypes.h" #include "ini.h" -namespace { +namespace RUL0::ParseHelpers { + std::string_view Trim(std::string_view s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) { + s.remove_prefix(1); + } + while (!s.empty() && std::isspace(static_cast(s.back()))) { + s.remove_suffix(1); + } + return s; + } + + bool ParseFloat(std::string_view s, float& out) { + s = Trim(s); + const auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out, std::chars_format::general); + return ec == std::errc() && ptr == s.data() + s.size(); + } + + bool ParseHex(std::string_view s, uint32_t& out) { + s = Trim(s); + if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) { + s = s.substr(2); + } + const auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out, 16); + return ec == std::errc() && ptr == s.data() + s.size(); + } + bool EqualsIgnoreCase(const std::string_view a, const std::string_view b) { if (a.size() != b.size()) { return false; @@ -38,69 +63,10 @@ namespace { } return true; } +} - std::string_view Trim(std::string_view s) { - while (!s.empty() && std::isspace(static_cast(s.front()))) s.remove_prefix(1); - while (!s.empty() && std::isspace(static_cast(s.back()))) s.remove_suffix(1); - return s; - } - - template - bool ParseIntAuto(const std::string_view s, T& out) { - std::string_view to_parse = s; - auto base = 10; - if (s.size() >= 2 && s[0] == '0') { - if (s[1] == 'x' || s[1] == 'X') { - base = 16; - to_parse = s.substr(2); - } - else { - auto all_octal_digits = true; - for (std::size_t i = 1; i < s.size(); ++i) { - const auto ch = static_cast(s[i]); - if (!std::isdigit(ch)) { - break; - } - if (ch >= '8') { - all_octal_digits = false; - break; - } - } - if (all_octal_digits) { - base = 8; - } - } - } - auto [ptr, ec] = std::from_chars(to_parse.data(), to_parse.data() + to_parse.size(), out, base); - return ec == std::errc() && ptr == to_parse.data() + to_parse.size(); - } - - template - bool ParseInt(std::string_view s, T& out) { - auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out); - return ec == std::errc() && ptr == s.data() + s.size(); - } - - bool ParseFloat(std::string_view s, float& out) { - auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out, std::chars_format::general); - return ec == std::errc() && ptr == s.data() + s.size(); - } - - bool ParseHex(std::string_view s, uint32_t& out) { - if (s.size() >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X')) { - s = s.substr(2); - } - auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out, 16); - return ec == std::errc() && ptr == s.data() + s.size(); - } - - template - bool ParseIntPair(std::string_view s, A& a, B& b) { - const auto comma = s.find(RUL0::kListDelimiter); - if (comma == std::string_view::npos) return false; - return ParseInt(Trim(s.substr(0, comma)), a) && - ParseInt(Trim(s.substr(comma + 1)), b); - } +namespace { + using namespace RUL0::ParseHelpers; } namespace RUL0 { diff --git a/src/RUL0.h b/src/RUL0.h index 4e5aa57..e3e0413 100644 --- a/src/RUL0.h +++ b/src/RUL0.h @@ -1,8 +1,11 @@ #pragma once #include +#include +#include #include #include #include +#include #include #include @@ -38,6 +41,53 @@ namespace RUL0 { constexpr auto kTranslateKey = "Translate"; constexpr auto kReplacementIntersectionKey = "ReplacementIntersection"; + namespace ParseHelpers { + std::string_view Trim(std::string_view s); + + template + bool ParseInt(std::string_view s, T& out) { + s = Trim(s); + const auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), out); + return ec == std::errc() && ptr == s.data() + s.size(); + } + + template + bool ParseIntAuto(std::string_view s, T& out) { + s = Trim(s); + std::string_view toParse = s; + int base = 10; + if (s.size() >= 2 && s[0] == '0') { + if (s[1] == 'x' || s[1] == 'X') { + base = 16; + toParse = s.substr(2); + } + else { + // Match %i semantics: leading zero => octal, even if later digits are invalid + base = 8; + } + } + const auto [ptr, ec] = std::from_chars(toParse.data(), toParse.data() + toParse.size(), out, base); + return ec == std::errc() && ptr == toParse.data() + toParse.size(); + } + + bool ParseFloat(std::string_view s, float& out); + + bool ParseHex(std::string_view s, uint32_t& out); + + template + bool ParseIntPair(std::string_view s, A& a, B& b) { + const auto comma = s.find(kListDelimiter); + if (comma == std::string_view::npos) { + return false; + } + return ParseInt(s.substr(0, comma), a) && + ParseInt(s.substr(comma + 1), b); + } + + bool EqualsIgnoreCase(std::string_view a, std::string_view b); + bool StartsWithIgnoreCase(std::string_view text, std::string_view prefix); + } + // From @Pixelchemist on StackOverflow: https://stackoverflow.com/a/42198760 template constexpr auto operator+(T e) noexcept From 6a14a5ca3286629dc8e0558549905e0242260ebf Mon Sep 17 00:00:00 2001 From: Casper Van Gheluwe Date: Wed, 17 Dec 2025 08:22:30 +0100 Subject: [PATCH 6/9] test: add unit tests for parsing helpers --- CMakeLists.txt | 1 + tests/parse_helpers_tests.cpp | 98 +++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 tests/parse_helpers_tests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 709528e..592f9dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,6 +76,7 @@ endif() # Test executable add_executable(DBPFKitTests tests/tests.cpp + tests/parse_helpers_tests.cpp ) target_link_libraries(DBPFKitTests PRIVATE DBPFKitLib diff --git a/tests/parse_helpers_tests.cpp b/tests/parse_helpers_tests.cpp new file mode 100644 index 0000000..871b45d --- /dev/null +++ b/tests/parse_helpers_tests.cpp @@ -0,0 +1,98 @@ +#include +#include + +#include "RUL0.h" + +using namespace RUL0::ParseHelpers; + +TEST_CASE("Trim removes leading and trailing whitespace") +{ + CHECK(Trim(" abc ") == "abc"); + CHECK(Trim("\txyz\t") == "xyz"); + CHECK(Trim("no-space") == "no-space"); + CHECK(Trim(" ").empty()); +} + +TEST_CASE("ParseInt parses signed integers") +{ + int value = 0; + CHECK(ParseInt("42", value)); + CHECK(value == 42); + + CHECK(ParseInt("-7", value)); + CHECK(value == -7); + + CHECK_FALSE(ParseInt("12a", value)); +} + +TEST_CASE("ParseIntAuto handles decimal, octal, and hex") +{ + auto value = 0; + CHECK(ParseIntAuto("10", value)); + CHECK(value == 10); + + CHECK(ParseIntAuto("012", value)); // octal -> 10 decimal + CHECK(value == 10); + + CHECK(ParseIntAuto("0007", value)); // octal -> 7 decimal + CHECK(value == 7); + + CHECK(ParseIntAuto("0x1A", value)); + CHECK(value == 26); + + CHECK(ParseIntAuto(" 0Xf ", value)); + CHECK(value == 15); + + CHECK_FALSE(ParseIntAuto("0x", value)); + CHECK_FALSE(ParseIntAuto("089", value)); // invalid octal digits + CHECK_FALSE(ParseIntAuto("09", value)); // invalid octal digits +} + +TEST_CASE("ParseFloat parses floating point numbers") +{ + auto value = 0.0f; + CHECK(ParseFloat("3.14", value)); + CHECK(value == Catch::Approx(3.14f)); + + CHECK(ParseFloat(" -2.5 ", value)); + CHECK(value == Catch::Approx(-2.5f)); + + CHECK_FALSE(ParseFloat("nan-ish", value)); +} + +TEST_CASE("ParseHex accepts optional 0x prefix") +{ + uint32_t value = 0; + CHECK(ParseHex("1a", value)); + CHECK(value == 0x1a); + + CHECK(ParseHex("0xFF", value)); + CHECK(value == 0xFF); + + CHECK_FALSE(ParseHex("0x", value)); + CHECK_FALSE(ParseHex("G1", value)); +} + +TEST_CASE("ParseIntPair parses comma-separated integer pairs") +{ + int a = 0; + int b = 0; + CHECK(ParseIntPair("1,2", a, b)); + CHECK(a == 1); + CHECK(b == 2); + + CHECK(ParseIntPair(" -3 , 4", a, b)); + CHECK(a == -3); + CHECK(b == 4); + + CHECK_FALSE(ParseIntPair("1;", a, b)); +} + +TEST_CASE("Case-insensitive helpers") +{ + CHECK(EqualsIgnoreCase("Piece", "piece")); + CHECK_FALSE(EqualsIgnoreCase("Piece", "pieces")); + + CHECK(StartsWithIgnoreCase("ReplacementIntersection", "replacement")); + CHECK_FALSE(StartsWithIgnoreCase("Ordering", "Orderx")); +} From 2c9c00dccf40aeadc35fe1fa0fc80501ab50851e Mon Sep 17 00:00:00 2001 From: Casper Van Gheluwe Date: Wed, 17 Dec 2025 08:25:04 +0100 Subject: [PATCH 7/9] fix: simplify control character check for LText --- src/LTextReader.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/LTextReader.cpp b/src/LTextReader.cpp index 54309ff..d1275a1 100644 --- a/src/LTextReader.cpp +++ b/src/LTextReader.cpp @@ -123,10 +123,9 @@ namespace LText { const size_t payloadBytes = buffer.size() - 4; const size_t expectedBytes = static_cast(charCount) * 2; - const bool hasControl = control == kControlChar; const bool lengthMatches = payloadBytes == expectedBytes && (payloadBytes % 2 == 0); - if (!hasControl || !lengthMatches) { + if (!lengthMatches) { auto fallback = ParseFallback(buffer); if (fallback.has_value()) { return fallback; From 45b3f5586e7138a588a0fa4ec71c84757602cfc7 Mon Sep 17 00:00:00 2001 From: Casper Van Gheluwe Date: Wed, 17 Dec 2025 08:39:51 +0100 Subject: [PATCH 8/9] ci: add configuration for build and test workflow --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2090aee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies + run: sudo apt-get update && sudo apt-get install -y ninja-build + + - name: Configure + run: cmake -S . -B build -G Ninja -DCMAKE_CXX_STANDARD=23 + + - name: Build + run: cmake --build build + + - name: Test + run: ctest --test-dir build --output-on-failure From 549d9e89f3a2989468b1029754d80e96276c2208 Mon Sep 17 00:00:00 2001 From: Casper Van Gheluwe Date: Wed, 17 Dec 2025 08:47:25 +0100 Subject: [PATCH 9/9] ci: update CI configuration to use gcc-14 and g++-14 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2090aee..5ecaa49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,10 +17,10 @@ jobs: submodules: recursive - name: Install dependencies - run: sudo apt-get update && sudo apt-get install -y ninja-build + run: sudo apt-get update && sudo apt-get install -y ninja-build g++-14 gcc-14 - name: Configure - run: cmake -S . -B build -G Ninja -DCMAKE_CXX_STANDARD=23 + run: cmake -S . -B build -G Ninja -DCMAKE_CXX_STANDARD=23 -DCMAKE_C_COMPILER=gcc-14 -DCMAKE_CXX_COMPILER=g++-14 - name: Build run: cmake --build build