diff --git a/ParadoxSaveParser.Benchmarks/ExtractionBenchmarks.cs b/ParadoxSaveParser.Benchmarks/ExtractionBenchmarks.cs
index ef7c2a5..b175b84 100644
--- a/ParadoxSaveParser.Benchmarks/ExtractionBenchmarks.cs
+++ b/ParadoxSaveParser.Benchmarks/ExtractionBenchmarks.cs
@@ -20,13 +20,10 @@ public class ExtractionBenchmarks
private byte[] _json = null!;
private ISearchExpression _query = null!;
- private Regex _flatInterpreted = null!;
- private Regex _flatCompiled = null!;
- private Regex _flatSourceGen = null!;
+ private Regex _technologyBlock = null!;
private Regex _pathAwareCompiled = null!;
private Regex _pathAwareNonBacktracking = null!;
private Regex _countriesBlock = null!;
- private PcreRegex _pcreFlat = null!;
private PcreRegex _pcrePathAware = null!;
[GlobalSetup]
@@ -37,13 +34,10 @@ public class ExtractionBenchmarks
_json = File.ReadAllBytes(BenchData.JsonPath);
_query = SearchExpressionCompiler.Compile(BenchData.PdxQuery);
- _flatInterpreted = new Regex(Extractors.FlatPattern, RegexOptions.None);
- _flatCompiled = new Regex(Extractors.FlatPattern, RegexOptions.Compiled);
- _flatSourceGen = Extractors.FlatSourceGen();
+ _technologyBlock = new Regex(Extractors.TechnologyBlockPattern, RegexOptions.Compiled);
_pathAwareCompiled = new Regex(Extractors.PathAwarePattern, RegexOptions.Compiled);
_pathAwareNonBacktracking = new Regex(Extractors.PathAwarePattern, RegexOptions.NonBacktracking);
_countriesBlock = new Regex(Extractors.CountriesBlockPattern, RegexOptions.Compiled);
- _pcreFlat = new PcreRegex(Extractors.FlatPattern, PcreOptions.Compiled);
_pcrePathAware = new PcreRegex(Extractors.PathAwarePattern, PcreOptions.Compiled);
}
@@ -53,26 +47,14 @@ public class ExtractionBenchmarks
[Benchmark(Description = "Full parse, then select")]
public long FullParse() => Extractors.FullParseThenSelect(_pdx);
- [Benchmark(Description = ".NET Regex flat, interpreted")]
- public long RegexFlatInterpreted() => Extractors.RegexScan(_flatInterpreted, _pdxText);
-
- [Benchmark(Description = ".NET Regex flat, compiled")]
- public long RegexFlatCompiled() => Extractors.RegexScan(_flatCompiled, _pdxText);
-
- [Benchmark(Description = ".NET Regex flat, source generated")]
- public long RegexFlatSourceGen() => Extractors.RegexScan(_flatSourceGen, _pdxText);
-
[Benchmark(Description = ".NET Regex path aware, compiled")]
public long RegexPathAwareCompiled() => Extractors.RegexScan(_pathAwareCompiled, _pdxText);
[Benchmark(Description = ".NET Regex path aware, NonBacktracking")]
public long RegexPathAwareNonBacktracking() => Extractors.RegexScan(_pathAwareNonBacktracking, _pdxText);
- [Benchmark(Description = ".NET Regex balanced block + flat")]
- public long RegexBalancedTwoStage() => Extractors.RegexTwoStage(_countriesBlock, _flatCompiled, _pdxText);
-
- [Benchmark(Description = "PCRE.NET flat, JIT compiled")]
- public long PcreFlat() => Extractors.PcreScan(_pcreFlat, _pdxText);
+ [Benchmark(Description = ".NET Regex balanced block + inner scan")]
+ public long RegexBalancedTwoStage() => Extractors.RegexTwoStage(_countriesBlock, _technologyBlock, _pdxText);
[Benchmark(Description = "PCRE.NET path aware, JIT compiled")]
public long PcrePathAware() => Extractors.PcreScan(_pcrePathAware, _pdxText);
diff --git a/ParadoxSaveParser.Benchmarks/Extractors.cs b/ParadoxSaveParser.Benchmarks/Extractors.cs
index e180733..25ee59d 100644
--- a/ParadoxSaveParser.Benchmarks/Extractors.cs
+++ b/ParadoxSaveParser.Benchmarks/Extractors.cs
@@ -65,10 +65,11 @@ public static partial class Extractors
// ---------------------------------------------------------------- .NET Regex
///
- /// Flat scan: matches technology blocks anywhere in the file.
- /// Fastest regex option, but it does not verify that the match really sits under countries.
+ /// Matches a single technology block. On its own it would match anywhere in the file,
+ /// so it is only used as the second stage of , inside the
+ /// already extracted countries block.
///
- public const string FlatPattern =
+ public const string TechnologyBlockPattern =
@"\n\t\ttechnology=\{\n\t\t\tadm_tech=(\d+)\n\t\t\tdip_tech=(\d+)\n\t\t\tmil_tech=(\d+)\n\t\t\}";
///
@@ -109,9 +110,6 @@ public static partial class Extractors
return RegexScan(innerRegex, block.Value);
}
- [GeneratedRegex(FlatPattern)]
- public static partial Regex FlatSourceGen();
-
[GeneratedRegex(PathAwarePattern)]
public static partial Regex PathAwareSourceGen();
diff --git a/ParadoxSaveParser.Benchmarks/ParadoxSaveParser.Benchmarks.csproj b/ParadoxSaveParser.Benchmarks/ParadoxSaveParser.Benchmarks.csproj
index 3edacc6..8f6fc8f 100644
--- a/ParadoxSaveParser.Benchmarks/ParadoxSaveParser.Benchmarks.csproj
+++ b/ParadoxSaveParser.Benchmarks/ParadoxSaveParser.Benchmarks.csproj
@@ -10,7 +10,7 @@
-
+
diff --git a/ParadoxSaveParser.Benchmarks/Program.cs b/ParadoxSaveParser.Benchmarks/Program.cs
index f152946..3f7f681 100644
--- a/ParadoxSaveParser.Benchmarks/Program.cs
+++ b/ParadoxSaveParser.Benchmarks/Program.cs
@@ -43,16 +43,13 @@ public static class Program
Time("SearchExpression", () => Extractors.SearchExpression(pdx, query));
Time("FullParse", () => Extractors.FullParseThenSelect(pdx));
- Time("Regex flat compiled",
- () => Extractors.RegexScan(new Regex(Extractors.FlatPattern, RegexOptions.Compiled), text));
Time("Regex path aware compiled",
() => Extractors.RegexScan(new Regex(Extractors.PathAwarePattern, RegexOptions.Compiled), text));
Time("Regex path aware nonbacktracking",
() => Extractors.RegexScan(new Regex(Extractors.PathAwarePattern, RegexOptions.NonBacktracking), text));
Time("Regex balanced two stage",
() => Extractors.RegexTwoStage(new Regex(Extractors.CountriesBlockPattern, RegexOptions.Compiled),
- new Regex(Extractors.FlatPattern, RegexOptions.Compiled), text));
- Time("PCRE flat", () => Extractors.PcreScan(new PcreRegex(Extractors.FlatPattern, PcreOptions.Compiled), text));
+ new Regex(Extractors.TechnologyBlockPattern, RegexOptions.Compiled), text));
Time("PCRE path aware",
() => Extractors.PcreScan(new PcreRegex(Extractors.PathAwarePattern, PcreOptions.Compiled), text));
Time("Utf8JsonReader", () => Extractors.Utf8JsonReaderScan(json));
diff --git a/ParadoxSaveParser.Benchmarks/README.md b/ParadoxSaveParser.Benchmarks/README.md
index d110d74..5ddffef 100644
--- a/ParadoxSaveParser.Benchmarks/README.md
+++ b/ParadoxSaveParser.Benchmarks/README.md
@@ -9,7 +9,7 @@ regex engines and `jq` on one realistic task:
|---|---|
| this repo | `countries.*.technology` |
| jq | `.countries \| map_values(.technology)` |
-| .NET `Regex` / PCRE.NET | see `Extractors.FlatPattern` / `Extractors.PathAwarePattern` |
+| .NET `Regex` / PCRE.NET | see `Extractors.PathAwarePattern` / `Extractors.CountriesBlockPattern` |
| `Utf8JsonReader` / `JsonDocument` | hand written navigation |
## Corpus
@@ -48,64 +48,70 @@ shows immediately when an engine finds something different from the others.
## Results
-AMD Ryzen 7 5700X, 16 logical cores, Windows 10 21H2, .NET 10.0.12, jq 1.8.2,
-109 MB save / 92 MB JSON twin, BenchmarkDotNet `RunStrategy.Monitoring`, 5 iterations.
+AMD Ryzen 7 5700X, 8 physical cores (16 logical), Windows 10 21H2, .NET 10.0.12, jq 1.8.2,
+109 MB save / 92 MB JSON twin, BenchmarkDotNet `RunStrategy.Monitoring`,
+3 warmups and 10 iterations for the in-process engines, 5 for jq.
| engine | mean | vs this project | allocated | correct? |
|---|---:|---:|---:|---|
-| .NET Regex flat, source generated | 14.8 ms | 0.02x | 1.2 MB | path-blind |
-| .NET Regex flat, compiled | 17.0 ms | 0.03x | 1.2 MB | path-blind |
-| .NET Regex flat, interpreted | 19.3 ms | 0.03x | 1.2 MB | path-blind |
-| PCRE.NET flat, JIT compiled | 97.4 ms | 0.15x | 0.9 MB | path-blind |
-| .NET Regex balanced block + flat | 112.3 ms | 0.17x | 224 MB | yes |
-| .NET Regex path aware, compiled | 116.2 ms | 0.18x | 1.4 MB | 1869/1870 |
-| PCRE.NET path aware, JIT compiled | 122.9 ms | 0.19x | 0.9 MB | 1869/1870 |
-| Utf8JsonReader over JSON twin | 155.7 ms | 0.24x | 0 B | yes |
-| JsonDocument over JSON twin | 321.4 ms | 0.50x | 0 B (native) | yes |
-| .NET Regex path aware, NonBacktracking | 444.5 ms | 0.69x | 32 MB | 1869/1870 |
-| **SearchExpression (this project)** | **645.1 ms** | **1.00x** | **1.6 MB** | yes |
-| Full parse, then select | 1831 ms | 2.84x | 1153 MB | yes |
-| jq `.countries \| map_values(.technology)` | 4340 ms | 6.73x | n/a | yes |
-| jq `empty` (parse the file, emit nothing) | 4227 ms | 6.55x | n/a | n/a |
-| jq `--stream` | 17490 ms | 27.1x | n/a | n/a |
-| jq process startup only | 4.2 ms | 0.01x | n/a | n/a |
+| **SearchExpression (this project)** | **49.6 ms** | **1.00x** | **1.46 MB** | yes |
+| .NET Regex balanced block + inner scan | 114.0 ms | 2.30x | 213 MB | yes |
+| PCRE.NET path aware, JIT compiled | 123.4 ms | 2.49x | 0.90 MB | 1869/1870 |
+| .NET Regex path aware, compiled | 135.1 ms | 2.72x | 1.34 MB | 1869/1870 |
+| Utf8JsonReader over JSON twin | 155.5 ms | 3.13x | 16 KB | yes |
+| JsonDocument over JSON twin | 323.2 ms | 6.51x | 72 B (native) | yes |
+| .NET Regex path aware, NonBacktracking | 440.5 ms | 8.87x | 30.6 MB | 1869/1870 |
+| Full parse, then select | 1204 ms | 24.3x | 962 MB | yes |
+| jq `empty` (parse the file, emit nothing) | 4188 ms | 84.4x | n/a | n/a |
+| jq `.countries \| map_values(.technology)` | 4339 ms | 87.4x | n/a | yes |
+| jq `--stream` | 17497 ms | 352x | n/a | n/a |
+| jq process startup only | 4.1 ms | 0.08x | n/a | n/a |
+
+Reading the save from disk instead of a preloaded `byte[]` costs the parser ~9 ms more:
+58.4 ms via `FileStream` (`ParserInputBenchmarks`).
### Reading the table
-* **jq is ~6.7x slower than this parser** and 97% of that time is JSON parsing, not the
- query: `jq empty` on the same file costs 4227 ms of the 4340 ms. Process startup is
- negligible (4 ms). jq's `--stream` mode, often recommended for large inputs, is 4x
- *slower* still. jq also needs the data converted to JSON first, which this parser has to
- do anyway — so end to end jq is strictly more expensive here.
-* **The regexes are 5-40x faster, but they are not doing the same job.** A regex never
- parses the structure; it scans bytes for a literal and validates a short window around it.
- `Regex` with a literal prefix (`technology={`) is vectorized, so 109 MB is scanned at
- several GB/s. That speed is real and the cost is real too: nothing verifies that the hit
- is under `countries`, at the right depth, or belongs to the tag matched before it.
-* **Path-aware regexes lose both the speed and the correctness.** Forcing the tag into the
- pattern costs 7x (17 ms -> 116 ms) and still returns 1869 of 1870 countries: the tag class
- `[A-Z0-9]{3}` silently drops EU4's `---` pseudo-country. Pairing survives here only by
- luck of the file layout — all 933 tech-less country blocks happen to be grouped at the end
- of the save, so the lazy gap never runs across one. A save that interleaves them would
- make the regex report a technology block under the wrong country tag with no error.
-* **`RegexOptions.NonBacktracking`** guarantees linear time but is 26x slower than the
- compiled backtracking engine on this pattern and allocates 32 MB.
-* **PCRE.NET** (the most used non-BCL regex engine in .NET, ~460k downloads) is 6x slower
- than `System.Text.RegularExpressions` on the flat pattern, because .NET's vectorized
- literal prefix search beats PCRE2's JIT here. On the path-aware pattern the two are equal.
- There is no reason to leave the BCL engine for this workload.
-* **The query is what makes this parser fast, not the parsing.** Same parser, same file:
- 645 ms with `countries.*.technology`, 1831 ms and 1.1 GB allocated without a query. The
- search expression prunes ~65% of the work and 99.9% of the allocations.
-* **Against a JSON reader on equivalent data**, the parser is 4x slower than `Utf8JsonReader`
- and 2x slower than `JsonDocument`. Those numbers exclude the pdx -> JSON conversion
- (~4.7 s), so they are a ceiling for the format, not a usable alternative.
+* **This parser is the fastest engine measured here.** It is 2.3x faster than the only
+ regex approach that enforces the path, 3.1x faster than a hand written `Utf8JsonReader`
+ over the equivalent JSON, 6.5x faster than `JsonDocument`, and 87x faster than jq — while
+ allocating 1.46 MB for a 109 MB input.
+* **jq is ~87x slower and 97% of that is JSON parsing, not the query**: `jq empty` on the
+ same file costs 4188 ms of the 4339 ms. Process startup is negligible (4 ms). jq's
+ `--stream` mode, often recommended for large inputs, is 4x *slower* still. jq also cannot
+ read the Paradox format, so it first needs the save converted to JSON — a conversion this
+ parser has to perform anyway.
+* **Only one regex approach here is actually correct**, and it is the expensive one: cut the
+ `countries={...}` block out with a balancing-group pattern (a .NET-only feature; PCRE would
+ need recursion), then scan inside it. It allocates 213 MB, because stage one materialises
+ the whole block as a string.
+* **The cheaper path-aware pattern pairs a country tag with the next technology block over a
+ lazy gap, and silently gets it wrong**: 1869 of 1870 countries, because the tag class
+ `[A-Z0-9]{3}` drops EU4's `---` pseudo-country. Even that much only holds by luck of the
+ file layout — all 933 tech-less country blocks happen to be grouped at the end of the save,
+ so the lazy gap never runs across one. A save that interleaved them would report a
+ technology block under the wrong country tag, with no error.
+* **`RegexOptions.NonBacktracking`** guarantees linear time but is 3.3x slower than the
+ compiled backtracking engine on this pattern and allocates 31 MB.
+* **PCRE.NET** (the most used non-BCL regex engine in .NET, ~460k downloads) is within ~10%
+ of `System.Text.RegularExpressions` on the path-aware pattern (123 ms vs 135 ms). Nothing
+ here justifies leaving the BCL engine.
+* **The query is what makes this parser fast.** Same parser, same file: 50 ms with
+ `countries.*.technology`, 1204 ms and 962 MB allocated without a query — 24x and 660x.
-### Where this parser's time goes
+### How the parser got here
-645 ms for 109 MB is ~170 MB/s, or ~13 cycles per input byte. The lexer reads the save one
-byte at a time through `Stream.ReadByte()` (`SaveParserEU4.LexTextSave`), which is a virtual
-call plus bounds check per byte, and appends char by char into a `StringBuilder`. Reading
-into a `byte[]` buffer and scanning it with `ReadOnlySpan.IndexOfAny` would be the
-first thing to try — the regex numbers above show what the same hardware does when it scans
-a span instead of a stream.
+Three measurements on the same benchmark (`ParserInputBenchmarks`, reading a `FileStream`):
+
+| lexer | mean | allocated |
+|---|---:|---:|
+| `Stream.ReadByte()` per byte | 834 ms | 1.55 MB |
+| copy the file into a `MemoryStream` first, still `ReadByte()` | 738 ms | 257 MB |
+| 64 KB buffer + `Tokenizer` with byte-level block skipping | 58 ms | 1.46 MB |
+
+The first jump came from removing a virtual call per byte. The large one came from making
+the query prune *work* rather than just data: `Tokenizer.SkipBlock` throws away a rejected
+`{...}` block by scanning raw bytes for brace depth with `SearchValues.IndexOfAny`,
+so blocks the search expression rejects are never turned into tokens or strings at all.
+Retained tokens are spans into the read buffer, decoded only when their value is kept, and
+numbers are parsed straight from UTF8.
diff --git a/ParadoxSaveParser.Lib.Tests/SearchExpressionTests.cs b/ParadoxSaveParser.Lib.Tests/SearchExpressionTests.cs
index a97c6ca..10ce9b5 100644
--- a/ParadoxSaveParser.Lib.Tests/SearchExpressionTests.cs
+++ b/ParadoxSaveParser.Lib.Tests/SearchExpressionTests.cs
@@ -1,4 +1,6 @@
+using System.Collections.Generic;
using System.IO;
+using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -41,12 +43,64 @@ public class SearchExpressionTests
[TestCase("a.(b.e|f)", "a={ b={ e=2 } f=3 }")]
public void TestSearchOnSmallData(string input, string expectedOutput)
{
- using var saveStream = new MemoryStream(_smallSaveData, false);
- var se = SearchExpressionCompiler.Compile(input);
- var parser = new SaveParserEU4(saveStream, se);
+ Assert.That(Search(_smallSaveData, input), Is.EqualTo(expectedOutput));
+ }
+
+ ///
+ /// The same queries, but with a read buffer so small that tokens, quoted strings and
+ /// skipped blocks are split across refills.
+ ///
+ [TestCase("a", "a={ b={ c=0 d=1 e=2 } f=3 }")]
+ [TestCase("a.b", "a={ b={ c=0 d=1 e=2 } }")]
+ [TestCase("a.[1]", "a={ f=3 }")]
+ [TestCase("a.(b.e|f)", "a={ b={ e=2 } f=3 }")]
+ public void TestSearchAcrossBufferRefills(string input, string expectedOutput)
+ {
+ foreach (int bufferSize in new[] { 16, 17, 23, 64 })
+ Assert.That(Search(_smallSaveData, input, bufferSize), Is.EqualTo(expectedOutput),
+ $"buffer size {bufferSize}");
+ }
+
+ [Test]
+ public void BracesInsideQuotedStringAreText()
+ {
+ byte[] data = "EU4txt a={ name=\"x{y}z\" b=1 }".ToBytes();
+ using var saveStream = new MemoryStream(data, false);
+ var parser = new SaveParserEU4(saveStream, SearchExpressionCompiler.Compile("a"));
+ var a = (Dictionary)parser.Parse()["a"];
+ Assert.Multiple(() =>
+ {
+ Assert.That(a["name"], Is.EqualTo("x{y}z"));
+ Assert.That(a["b"], Is.EqualTo(1L));
+ });
+ }
+
+ [Test]
+ public void SkippedBlockIgnoresBracesInsideQuotedStrings()
+ {
+ byte[] data = "EU4txt a={ s=\"{{{\" } b=2".ToBytes();
+ using var saveStream = new MemoryStream(data, false);
+ var parser = new SaveParserEU4(saveStream, SearchExpressionCompiler.Compile("b"));
+ Assert.That(parser.Parse()["b"], Is.EqualTo(2L));
+ }
+
+ [Test]
+ public void EncodingIsConfigurable()
+ {
+ byte[] data = Encoding.UTF8.GetBytes("EU4txt a={ name=\"Ä\" }");
+ using var saveStream = new MemoryStream(data, false);
+ var parser = new SaveParserEU4(saveStream, SearchExpressionCompiler.Compile("a"), Encoding.UTF8);
+ var a = (Dictionary)parser.Parse()["a"];
+ Assert.That(a["name"], Is.EqualTo("Ä"));
+ }
+
+ private static string Search(byte[] saveData, string query, int bufferSize = 64 * 1024)
+ {
+ using var saveStream = new MemoryStream(saveData, false);
+ var se = SearchExpressionCompiler.Compile(query);
+ var parser = new SaveParserEU4(saveStream, se, bufferSize: bufferSize);
var rootNode = parser.Parse();
string json = JsonSerializer.Serialize(rootNode, _smallSaveSerializerOptions);
- string pdx = JsonToPdx(json);
- Assert.That(pdx, Is.EqualTo(expectedOutput));
+ return JsonToPdx(json);
}
}
\ No newline at end of file
diff --git a/ParadoxSaveParser.Lib/BufferedEnumerator.cs b/ParadoxSaveParser.Lib/BufferedEnumerator.cs
deleted file mode 100644
index fdabe3c..0000000
--- a/ParadoxSaveParser.Lib/BufferedEnumerator.cs
+++ /dev/null
@@ -1,121 +0,0 @@
-using System.Collections;
-
-namespace ParadoxSaveParser.Lib;
-
-///
-/// Enumerator wrapper that stores N/2 items before and N/2-1 after Current item.
-///
-///
-/// IEnumerator<int> Enumerator()
-/// {
-/// for(int i = 0; i < 6; i++)
-/// yield return i;
-/// }
-///
-/// var en = Enumerator();
-/// var bufen = new BufferedEnumerator<int>(en, 5);
-///
-/// while(bufen.MoveNext())
-/// {
-/// var cur = bufen.Current;
-/// for (var prev = cur.List?.First; prev != cur; prev = prev?.Next)
-/// Console.Write($"{prev?.Value} ");
-///
-/// Console.Write($"| {cur.Value} |");
-///
-/// for (var next = cur.Next; next is not null; next = next.Next)
-/// Console.Write($" {next.Value}");
-/// Console.WriteLine();
-/// }
-///
-/// Output:
-///
-/// | 0 | 1 2 3 4
-/// 0 | 1 | 2 3 4
-/// 0 1 | 2 | 3 4
-/// 1 2 | 3 | 4 5
-/// 2 3 | 4 | 5
-/// 3 4 | 5 |
-///
-public class BufferedEnumerator : IEnumerator.Node>
-{
- public class Node
- {
-#nullable disable
- public Node Previous;
- public Node Next;
- public T Value;
-#nullable enable
- }
-
- private readonly IEnumerator _enumerator;
- private readonly Node[] _ringBuffer;
- private Node? _currentNode;
- private int _currentBufferIndex = -1;
- private int _lastValueIndex = -1;
-
- public BufferedEnumerator(IEnumerator enumerator, int bufferSize)
- {
- _enumerator = enumerator;
- _ringBuffer = new Node[bufferSize];
- }
-
- private void InitBuffer()
- {
- _ringBuffer[0] = new Node
- {
- Value = default!
- };
- for (int i = 1; i < _ringBuffer.Length; i++)
- {
- _ringBuffer[i] = new Node
- {
- Previous = _ringBuffer[i - 1],
- Value = default!,
- };
- _ringBuffer[i - 1].Next = _ringBuffer[i];
- }
- _ringBuffer[^1].Next = _ringBuffer[0];
- _ringBuffer[0].Previous = _ringBuffer[^1];
- }
-
- public bool MoveNext()
- {
- if (_currentBufferIndex == -1)
- {
- InitBuffer();
-
- int beforeMidpoint = _ringBuffer.Length / 2 - 1;
- for (int i = 0; i <= beforeMidpoint && _enumerator.MoveNext(); i++)
- {
- _ringBuffer[i].Value = _enumerator.Current;
- }
- }
-
- _currentBufferIndex = (_currentBufferIndex + 1) % _ringBuffer.Length;
- if (_enumerator.MoveNext())
- {
- int midpoint = (_currentBufferIndex + _ringBuffer.Length / 2) % _ringBuffer.Length;
- _ringBuffer[midpoint].Value = _enumerator.Current;
- _lastValueIndex = midpoint;
- }
- if(_currentBufferIndex == (_lastValueIndex + 1) % _ringBuffer.Length)
- return false;
-
- _currentNode = _ringBuffer[_currentBufferIndex];
- return true;
- }
-
- public void Reset()
- {
- throw new NotImplementedException();
- }
-
- public Node Current => _currentNode!;
-
- object IEnumerator.Current => Current;
-
- public void Dispose()
- {
- }
-}
\ No newline at end of file
diff --git a/ParadoxSaveParser.Lib/ParadoxSaveParser.Lib.csproj b/ParadoxSaveParser.Lib/ParadoxSaveParser.Lib.csproj
index 62f6358..261b6f5 100644
--- a/ParadoxSaveParser.Lib/ParadoxSaveParser.Lib.csproj
+++ b/ParadoxSaveParser.Lib/ParadoxSaveParser.Lib.csproj
@@ -6,8 +6,8 @@
disabletrue
-
+
-
+
diff --git a/ParadoxSaveParser.Lib/SaveParserEU4.cs b/ParadoxSaveParser.Lib/SaveParserEU4.cs
index 2c1f431..f2dd1f0 100644
--- a/ParadoxSaveParser.Lib/SaveParserEU4.cs
+++ b/ParadoxSaveParser.Lib/SaveParserEU4.cs
@@ -1,430 +1,261 @@
-global using System;
-global using System.Collections.Generic;
-global using System.IO;
-global using System.Text;
-using Microsoft.Extensions.ObjectPool;
-
-namespace ParadoxSaveParser.Lib;
-
-///
-/// Sequential parser that doesn't cache anything.
-///
-public class SaveParserEU4
-{
- protected readonly Stream _saveFile;
- private readonly BufferedEnumerator _tokens;
- private readonly ObjectPool _stringBuilderPool;
- private ISearchExpression? _searchExprCurrent;
-
- ///
- /// Uncompressed stream of gamestate file which can be extracted from save archive
- ///
- ///
- /// Parsing whole save takes 10 seconds on mid pc and takes 1GB of RAM,
- /// so you should specify what exactly you want to get from save file
- ///
- public SaveParserEU4(Stream savefile, ISearchExpression? query)
- {
- _saveFile = savefile;
- _searchExprCurrent = query;
- const int tokenBufSize = 5;
- _tokens = new BufferedEnumerator(LexTextSave(), tokenBufSize);
- _stringBuilderPool = new DefaultObjectPool(
- new StringBuilderPooledObjectPolicy
- {
- InitialCapacity = tokenBufSize * 13,
- MaximumRetainedCapacity = tokenBufSize * 13,
- });
- }
-
- protected IEnumerator LexTextSave()
- {
- string expectedHeader = "EU4txt";
- byte[] headBytes = new byte[expectedHeader.Length];
- _saveFile.ReadExactly(headBytes);
- string headStr = Encoding.UTF8.GetString(headBytes);
- if (headStr != expectedHeader)
- throw new Exception($"Invalid gamestate header. Expected '{expectedHeader}', got '{headStr}'.");
-
- StringBuilder strb = _stringBuilderPool.Get();
- int line = 2;
- int column = 0;
- bool isQuoteOpen = false;
- bool isStrInQuotes = false;
- Token strToken = new()
- {
- type = TokenType.Invalid,
- column = -1,
- line = -1,
- value = null,
- };
-
- bool TryCompleteStringToken()
- {
- if (isQuoteOpen)
- return false;
-
- // strings in quotes may be empty
- if (!isStrInQuotes && (strb.Length <= 0 || strb[0] == '#'))
- return false;
-
- strToken = new Token
- {
- type = TokenType.StringOrNumber,
- column = (short)(column - strb.Length),
- line = line,
- value = strb,
- };
- strb = _stringBuilderPool.Get();
- isStrInQuotes = false;
- return true;
- }
-
- // Reading the save one byte at a time through Stream.ReadByte() costs a virtual call
- // per byte, which dominated the parsing time. Bytes are pulled into this buffer
- // instead, so the stream is touched once per 64 KB and the inner loop reads an array.
- byte[] buffer = new byte[64 * 1024];
- int bufferLength;
- while ((bufferLength = _saveFile.Read(buffer, 0, buffer.Length)) > 0)
- {
- for (int i = 0; i < bufferLength; i++)
- {
- int c = buffer[i];
- column++;
- switch (c)
- {
- case '\"':
- isQuoteOpen = !isQuoteOpen;
- isStrInQuotes = true;
- break;
- case ' ':
- case '\t':
- case '\r':
- if (TryCompleteStringToken())
- yield return strToken;
- break;
- case '\n':
- if (TryCompleteStringToken())
- yield return strToken;
- line++;
- column = 0;
- break;
- case '=':
- if (TryCompleteStringToken())
- yield return strToken;
- yield return new Token
- {
- type = TokenType.Equals,
- line = line, column = (short)column
- };
- break;
- case '{':
- if (TryCompleteStringToken())
- yield return strToken;
- yield return new Token
- {
- type = TokenType.BracketOpen,
- line = line, column = (short)column
- };
- break;
- case '}':
- if (TryCompleteStringToken())
- yield return strToken;
- yield return new Token
- {
- type = TokenType.BracketClose,
- line = line, column = (short)column
- };
- break;
- default:
- // Skip control characters, which are invisible and causing frontend bugs.
- // I dont know why there are so many of them in strings.
- if (c >= 0x20)
- strb.Append((char)c);
- break;
- }
- }
- }
-
- // end of file: the last token may still be unterminated
- if (TryCompleteStringToken())
- yield return strToken;
- _stringBuilderPool.Return(strb);
- }
-
-
- // doesn't move next
- private object? ParseValue()
- {
- var tok = _tokens.Current.Value;
- switch (tok.type)
- {
- case TokenType.StringOrNumber:
- try
- {
- // string values can be empty
- if (tok.value!.Length == 0)
- return string.Empty;
- if (tok.value.Equals("yes"))
- return true;
- if (tok.value.Equals("no"))
- return false;
-
- string tokStr = tok.value.ToString();
- if (tokStr[0] != '-' && !char.IsDigit(tokStr[0]))
- return tokStr;
- if (tokStr.Contains('.') && double.TryParse(tokStr, out double d))
- return d;
- if (long.TryParse(tokStr, out long l))
- return l;
- return tokStr;
- }
- finally
- {
- _stringBuilderPool.Return(tok.value!);
- }
- case TokenType.BracketOpen:
- object obj = ParseListOrDict();
- return obj;
- case TokenType.BracketClose:
- return null;
- default:
- throw new UnexpectedTokenException(tok);
- }
- }
-
-
- // skips next value
- /// true if skipped value, false if current token is closing bracket
- private bool SkipValue()
- {
- var tok = _tokens.Current.Value;
- switch (tok.type)
- {
- case TokenType.BracketOpen:
- SkipObject();
- return true;
- case TokenType.StringOrNumber:
- _stringBuilderPool.Return(tok.value!);
- return true;
- case TokenType.BracketClose:
- return false;
- default:
- throw new UnexpectedTokenException(tok);
- }
- }
-
- // skips all tokens inside curly braces block
- private void SkipObject(int bracketBalance = 1)
- {
- while (bracketBalance != 0 && _tokens.MoveNext())
- {
- var tok = _tokens.Current.Value;
- if (tok.type == TokenType.BracketOpen)
- bracketBalance++;
- else if (tok.type == TokenType.BracketClose)
- bracketBalance--;
- else if (tok.type == TokenType.StringOrNumber)
- {
- _stringBuilderPool.Return(tok.value!);
- }
- }
- }
-
- private static bool IsEmptyCollection(object value)
- => value is Dictionary { Count: 0 } or List