diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5ecaa49 --- /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 g++-14 gcc-14 + + - name: Configure + 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 + + - name: Test + run: ctest --test-dir build --output-on-failure diff --git a/CMakeLists.txt b/CMakeLists.txt index e340d67..592f9dc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -76,12 +76,16 @@ endif() # Test executable add_executable(DBPFKitTests tests/tests.cpp + tests/parse_helpers_tests.cpp ) target_link_libraries(DBPFKitTests PRIVATE DBPFKitLib libsquish::Squish Catch2::Catch2WithMain ) +target_compile_definitions( + DBPFKitLib PUBLIC INI_MAX_LINE=1000 +) # Enable testing enable_testing() 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; diff --git a/src/RUL0.cpp b/src/RUL0.cpp index 377153f..5150e65 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,65 @@ #include "ParseTypes.h" #include "ini.h" +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; + } + 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; + } +} + +namespace { + using namespace RUL0::ParseHelpers; +} + namespace RUL0 { // Convert PuzzlePiece to human-readable string representation std::string PuzzlePiece::ToString() const { @@ -153,10 +213,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 +307,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 +324,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 +346,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 +358,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 +396,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) { - piece->stepOffsets.initialized = true; + else if (EqualsIgnoreCase(keyStr, kHandleOffsetKey)) { + if (ParseIntPair(value, + piece->handleOffset.deltaStraight, + piece->handleOffset.deltaSide)) { + piece->handleOffset.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 +435,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 +447,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 +792,4 @@ namespace RUL0 { BuildNavigationIndices(data); return data; } - } diff --git a/src/RUL0.h b/src/RUL0.h index b7dd8e8..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 @@ -11,6 +14,7 @@ namespace RUL0 { constexpr auto kListDelimiter = ','; + constexpr auto kCommentPrefix = ';'; constexpr auto kOrderingSection = "Ordering"; constexpr auto kRotationRingKey = "RotationRing"; @@ -24,8 +28,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"; @@ -37,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 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")); +}