Skip to content
Merged
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 1 addition & 2 deletions src/LTextReader.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,9 @@ namespace LText {

const size_t payloadBytes = buffer.size() - 4;
const size_t expectedBytes = static_cast<size_t>(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;
Expand Down
167 changes: 120 additions & 47 deletions src/RUL0.cpp
Original file line number Diff line number Diff line change
@@ -1,14 +1,74 @@
#include "RUL0.h"

#include <cctype>
#include <charconv>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <format>
#include <ranges>

#include "ParseTypes.h"
#include "ini.h"

namespace RUL0::ParseHelpers {
std::string_view Trim(std::string_view s) {
while (!s.empty() && std::isspace(static_cast<unsigned char>(s.front()))) {
s.remove_prefix(1);
}
while (!s.empty() && std::isspace(static_cast<unsigned char>(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<unsigned char>(a[i]);
const auto cb = static_cast<unsigned char>(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<unsigned char>(text[i]);
const auto cp = static_cast<unsigned char>(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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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;
}
Expand All @@ -318,69 +396,65 @@ 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
return 0;
}
piece->oneWayDir = static_cast<OneWayDir>(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
return 0;
}
piece->rotate = static_cast<Rotation>(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
Expand Down Expand Up @@ -718,5 +792,4 @@ namespace RUL0 {
BuildNavigationIndices(data);
return data;
}

}
Loading