diff --git a/ChessRealms.Engine.slnx b/ChessRealms.Engine.slnx
index 551ca3c..4f18fa5 100644
--- a/ChessRealms.Engine.slnx
+++ b/ChessRealms.Engine.slnx
@@ -5,6 +5,7 @@
+
diff --git a/README.md b/README.md
index 58ea648..fca26ad 100644
--- a/README.md
+++ b/README.md
@@ -3,12 +3,13 @@
ChessRealms.Engine is a .NET 10 library for standard chess positions and legal
play. It uses bitboards and magic attack tables for move generation. The public
`ChessGame` API supports FEN import/export, legal moves, promotion, history and
-undo, canonical SAN formatting and parsing, JSON game records, and standard
+undo, canonical SAN formatting and parsing, mainline PGN import/export,
+JSON game records, and standard
checkmate, stalemate and supported draw rules. The repository also includes
tests, a console game, a perft runner,
benchmarks and a magic-number search tool.
-It does not provide an AI/search engine, UCI, PGN, Chess960 or tournament
+It does not provide an AI/search engine, UCI, annotated PGN, Chess960 or tournament
services such as clocks and draw agreements. Dead-position recognition is
deliberately incomplete; see the
[game API guide](https://github.com/ChessRealms/Engine/blob/main/docs/game-rules-api.md)
@@ -31,8 +32,8 @@ The `Deep` test category contains slower perft cases and is opt-in. See the
for contributor checks.
See the
-[performance measurements](https://github.com/ChessRealms/Engine/blob/main/docs/performance.md)
-for game API allocation benchmarks, reproduction commands and ownership guarantees.
+[benchmark guide](https://github.com/ChessRealms/Engine/blob/main/docs/performance.md)
+for core, game API, SAN and PGN timing and allocation measurements.
## Public API
@@ -73,6 +74,21 @@ game.MakeSanMove("d5"); // Resolve and play text directly.
See the [SAN guide](https://github.com/ChessRealms/Engine/blob/main/docs/san.md)
for canonical spelling, state guarantees, historical moves and performance costs.
+Exchange a single game's tags and mainline in PGN:
+
+```csharp
+PgnGame document = PgnGame.FromGame(game, result: PgnResult.WhiteWin);
+string pgn = document.ToPgn();
+PgnGame imported = PgnGame.Parse(pgn);
+ChessGame replay = imported.ToGame();
+```
+
+`PgnGame` is an immutable snapshot. Its declared result can represent resignation
+or a draw agreement without changing the engine's board outcome. Import accepts
+canonical SAN and rejects comments, annotations and variations with a structured
+diagnostic. See the [PGN guide](https://github.com/ChessRealms/Engine/blob/main/docs/pgn.md)
+for result rules, custom starting positions, diagnostics and costs.
+
Save and restore the current move line, including a successful draw claim:
```csharp
diff --git a/docs/game-rules-api.md b/docs/game-rules-api.md
index a1953b1..3aa0c53 100644
--- a/docs/game-rules-api.md
+++ b/docs/game-rules-api.md
@@ -55,6 +55,19 @@ search through the current position's legal moves. All SAN operations reject
finished games. The [SAN guide](san.md) describes exact notation rules, failure
behavior, examples and performance tradeoffs.
+## Portable Game Notation
+
+`PgnGame.FromGame(game, tags, result)` snapshots the current line as an immutable
+PGN model. `ToPgn()` and `WriteTo(TextWriter)` export it; `Parse` and `TryParse`
+import one game's tags, initial FEN, canonical SAN mainline and declared result.
+`ToGame()` replays an independent game with history and undo.
+
+The declared `PgnResult` is separate from `ChessGame.Outcome`: a decisive result
+can describe resignation while the position still has legal moves. PGN does not
+retain a draw claim's reason or unplayed intended move. Use `GameRecord` when
+those details must survive restoration. The [PGN guide](pgn.md) covers validation,
+structured errors and unsupported comments, NAGs and variations.
+
## Game record export and restoration
`ExportRecord()` creates an independent, immutable `GameRecord` containing the
@@ -206,5 +219,5 @@ fortresses or blocked pawn structures. Lack of a forced mate alone does not
end a game.
The library supports standard board play, not resignation, draw agreement,
-clocks, tournament claim procedures, PGN, Chess960, AI/search, UCI,
+clocks, tournament claim procedures, annotated PGN, Chess960, AI/search, UCI,
networking or a graphical interface.
diff --git a/docs/performance.md b/docs/performance.md
index 59c63dd..bd4747f 100644
--- a/docs/performance.md
+++ b/docs/performance.md
@@ -1,76 +1,55 @@
-# Measuring game API allocations
+# Running performance benchmarks
-`GameApiBenchmarks` measures public operations with an empty game and a game
-after 20 plies. Setup, notation parsing and input JSON preparation happen
-outside the measured operations. `MakeAndUndoMove` plays one legal move and
-undoes it on the same game; warmup has already grown the internal collections.
-Restoration and deserialization create a new game each time, including all
-validation and replay. Serialization measures an already exported record.
-
-Run from the repository root:
+The benchmark project measures execution time and managed allocations with
+BenchmarkDotNet. Run from the repository root after restoring dependencies:
```sh
-dotnet build src/ChessRealms.Engine.Benchmark -c Release
-dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --filter '*GameApiBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 5 --iterationTime 100 --artifacts artifacts/benchmarks/game-api --exporters JSON
+dotnet build src/ChessRealms.Engine.Benchmark -c Release --no-restore
+dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --filter '*CoreBenchmarks*' '*GameApiBenchmarks*' '*SanBenchmarks*' '*PgnBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 8 --iterationTime 150 --artifacts artifacts/benchmarks/local --exporters JSON
```
-The benchmark project also supports the usual BenchmarkDotNet arguments. For a
-longer run with isolated benchmark processes, omit `--inProcess` and the short
-iteration settings. Running without arguments preserves the existing perft
-benchmark. Benchmark artifacts are ignored by Git.
+Use a single filter to focus on one suite:
+
+| Suite | Coverage |
+| --- | --- |
+| `CoreBenchmarks` | Pseudolegal generation, depth-three perft and FEN formatting for the start position and Kiwipete. |
+| `GameApiBenchmarks` | Legal moves, FEN, history, cloning, move + undo, records and JSON at 0 and 20 plies. |
+| `SanBenchmarks` | Span/string formatting, parsing and move + undo for opening, ambiguous and mating moves. |
+| `PgnBenchmarks` | Snapshot, export, import and restoration at 0, 20 and 120 plies. |
-## Allocation changes measured on 2026-10-19
+Setup and input preparation are outside measurements. PGN scenarios use prefixes
+of the same validated line. Move + undo returns to the same position, with
+collections already warmed; restoration creates a new game including replay
+and collection growth. Export to `TextWriter.Null` excludes output storage,
+encoding and I/O. Running without arguments starts the existing depth-six perft
+benchmark, which can take longer.
-The baseline engine was commit `1ea5cc4`, with the same benchmark harness added
-before changing the implementation. Both runs used Windows 11 x64, SDK
-10.0.402, .NET 10.0.13 and concurrent workstation GC on the same machine.
-The command above was used with separate `game-api-before` and `game-api-after`
-artifact directories.
+For longer measurements in isolated benchmark processes, omit `--inProcess`
+and the short iteration settings. Compare the same fixtures on the same machine
+and runtime, and inspect confidence intervals and benchmark warnings before
+drawing conclusions from timing differences.
-Allocated managed bytes per operation:
+## Allocation cross-check
-| Operation | Plies | Before | After |
-| --- | ---: | ---: | ---: |
-| Get legal moves | 0 | 648 | 208 |
-| Read FEN | 0 | 480 | 0 |
-| Make and undo a move | 0 | 2776 | 1048 |
-| Export record | 0 | 96 | 40 |
-| Restore record | 0 | 2728 | 2224 |
-| Serialize record | 0 | 664 | 664 |
-| Deserialize record | 0 | 3520 | 3016 |
-| Get legal moves | 20 | 1056 | 336 |
-| Read FEN | 20 | 704 | 0 |
-| Make and undo a move | 20 | 3952 | 1416 |
-| Export record | 20 | 1176 | 248 |
-| Restore record | 20 | 75918 | 40996 |
-| Serialize record | 20 | 1224 | 1224 |
-| Deserialize record | 20 | 77494 | 42572 |
+```sh
+dotnet run --project src/ChessRealms.Engine.Benchmark -c Release --no-build -- --allocation-probe
+```
-At 20 plies the mean restoration time changed from 145.4 to 100.1 microseconds,
-and deserialization from 149.3 to 104.8 microseconds. These are short local
-measurements, not production latency guarantees. BenchmarkDotNet could not set
-the power plan and reported some iterations below its recommended 100 ms
-minimum. Treat timing differences as indicative; FEN access is now close to
-measurement overhead, so no timing ratio is claimed for it.
+This emits CSV using `GC.GetAllocatedBytesForCurrentThread` around synchronous
+operations after warmup. Setup, delegates and output are excluded. It helps
+cross-check small allocation readings from the in-process benchmark harness;
+its different warmup duration means results need not match BDN byte for byte.
+It does not measure execution time, retained memory or peak live heap.
-## Implementation and ownership
+Keep generated reports and temporary audit notes under `artifacts/benchmarks/`,
+which Git ignores. Record the revision, runtime and command with results used
+in a PR. Rerun relevant scenarios after changes instead of treating historical
+numbers as current API guarantees.
-- Internal legal-move generation filters moves in a bounded, local span. The
- public `GetLegalMoves()` still returns an independently owned, read-only
- snapshot, including every promotion choice.
-- Record export copies coordinate moves directly from the owned history once.
- It avoids the public history snapshot and a second move-array copy. The
- constructor accepting caller-owned moves still copies them defensively.
-- `ToFen()` reuses the initial FEN or the last history entry's `FenAfter`.
- Moves already store that string; undo and branching select the correct
- history entry. No additional mutable cache is introduced.
-- Repetition keys format only the four position fields, excluding counters.
- En passant is still included only when a legal en passant move exists.
-- `HasLegalMoves` uses the already evaluated outcome: a position without legal
- moves has already finished. Claimed and automatic draws also prohibit play.
+## API usage
-Public history snapshots, game-record immutability, JSON schema version one and
-validated restoration keep their existing contracts. JSON serialization itself
-is unchanged in this pass. Its allocation count includes the result string and
-serializer overhead. Deserialization still validates by replaying; calling
-`ChessGame.FromRecord` afterwards performs a second replay.
+`History`, `GetLegalMoves()` and `Clone()` return independently owned snapshots
+or games. Read a history snapshot once before traversing it. `ToFen()` reuses an
+existing FEN string; `FenBefore` references the preceding position's string.
+The [SAN guide](san.md) covers span queries and text application; the
+[PGN guide](pgn.md) covers model ownership, export and restoration.
diff --git a/docs/pgn.md b/docs/pgn.md
new file mode 100644
index 0000000..09b85ee
--- /dev/null
+++ b/docs/pgn.md
@@ -0,0 +1,147 @@
+# Portable Game Notation
+
+PGN is a text exchange format for games. `PgnGame` supports one standard-chess
+game with tags, an initial position, the mainline and a declared result.
+It uses the existing [SAN API](san.md) to resolve each move against its preceding
+position. Board input and engine callers continue using `CoordinateMove`.
+
+## Model and export
+
+```csharp
+using ChessRealms.Engine;
+
+var game = new ChessGame();
+game.MakeSanMove("e4");
+game.MakeSanMove("e5");
+game.MakeSanMove("Nf3");
+
+var tags = new Dictionary
+{
+ ["Event"] = "Example",
+ ["White"] = "Alice",
+ ["Black"] = "Bob"
+};
+PgnGame document = PgnGame.FromGame(game, tags, PgnResult.WhiteWin);
+string text = document.ToPgn();
+// ... headers, then: 1. e4 e5 2. Nf3 1-0
+
+using var writer = new StreamWriter("game.pgn");
+document.WriteTo(writer); // Does not close or dispose the writer.
+```
+
+`InitialFen`, `Moves`, `Tags` and `Result` are immutable snapshots. Source tags
+are copied; later moves, undo and tag edits do not affect the document. Moves
+contain coordinate values, without a cached SAN string or FEN pair per entry.
+Only the current line is captured, including only moves played since the
+initial FEN. Moves preceding a FEN import cannot be recovered.
+
+Export uses canonical SAN, invariant move numbers and LF line endings. It
+orders the seven roster tags first, then other tags by ordinal name; quote and
+backslash characters in values are escaped. Missing roster values default to
+`?`, with `????.??.??` for Date and the selected result for Result. Movetext is
+wrapped at whitespace to 79 columns, except an individual longer move-number
+token. A blank line separates headers and moves and follows the final marker.
+
+Custom starting positions generate `SetUp "1"` and `FEN` automatically, using
+the initial position rather than the final board. A line starting with Black
+uses the FEN's fullmove number followed by three periods. Counters beyond
+`Int64` are preserved. Redundant starting-position FEN/SetUp tags are removed.
+Supplied setup tags must form a valid pair and match the game's initial FEN.
+
+Tag names are case sensitive: 1–255 ASCII letters, digits or underscores, with
+a letter or digit first. Values may contain at most 255 UTF-16 code units;
+controls, line separators and unpaired surrogates are rejected. Printable
+Unicode values are supported as an extension to traditional PGN character
+encoding. Use UTF-8 when writing files that contain them. Nonstructural metadata,
+including dates, player names and custom tags, is preserved without semantic
+interpretation. Duplicate tag names are rejected.
+
+## Result rules
+
+`PgnResult` maps `Unknown` to `*`, `WhiteWin` to `1-0`, `BlackWin` to `0-1` and
+`Draw` to `1/2-1/2`. Unknown covers both ongoing games and an unavailable result.
+
+| Source of result | Behavior |
+| --- | --- |
+| `FromGame` without an explicit result or Result tag | Uses `ChessGame.Outcome`. |
+| Supplied Result tag | Uses that result; an explicit argument must agree. |
+| Unfinished engine position | Any declared result is accepted, including resignation, timeout or an agreed draw. |
+| Finished engine game | The result must match its outcome, including a successful draw claim. |
+| Import | The mandatory final marker must agree with the Result tag, if present, and any ending established by replay. |
+
+`ToGame()` returns an independent replay with board, counters, history,
+repetition and undo. It does not apply the declared result as a new engine
+ending. For example, the exported `1-0` above produces an ongoing engine game
+because the moves alone do not establish White's win. Applications displaying
+a match should retain `PgnGame.Result` as well as the replayed board.
+
+A claimed draw exports as `1/2-1/2`, but PGN does not preserve its claim reason or
+unplayed intended move. Replaying such a mainline may therefore produce an
+ongoing engine game. For exact persistence of claims, use `GameRecord` and its
+versioned JSON format. Natural endings and automatic draws are reconstructed
+from the mainline under the engine's [rule boundaries](game-rules-api.md).
+
+## Import and diagnostics
+
+```csharp
+ReadOnlySpan input = "1. e4 e5 2. Nf3 *";
+if (PgnGame.TryParse(input, out PgnGame? document, out PgnDiagnostic? error))
+{
+ ChessGame replay = document.ToGame();
+}
+else
+{
+ Console.WriteLine($"{error.Code} at {error.Line}:{error.Column}: {error.Message}");
+}
+```
+
+`Parse` throws `PgnParseException`, a `FormatException` exposing the same
+`Diagnostic`. `TryParse` returns false with a null document on any format,
+legality or unsupported-feature error; it never exposes a partly imported game.
+Success returns a null diagnostic. Neither operation takes or mutates an
+existing game.
+
+Diagnostic offsets count zero-based UTF-16 code units relative to the supplied
+span. Line and column are one-based; CRLF counts as one newline, and a tab as
+one column. `PlyIndex` is the next zero-based mainline index, or null for header
+errors. Codes distinguish invalid tags, duplicate tags, setup/FEN errors,
+result mismatches, wrong move numbers, illegal/noncanonical SAN, moves after
+an engine ending, missing termination and unsupported input.
+
+Import accepts optional headers, an initial BOM, ASCII space/tab/newline/vertical
+tab separators, and optional move numbers. Supplied numbers must match the
+position; import allows zero or more periods with whitespace between them.
+Output normalizes this layout. Only canonical SAN is accepted: required check
+and mate suffixes, uppercase `O` for castling, explicit promotion and minimal
+legal disambiguation. Tags use only `\"` and `\\` escapes.
+
+The following inputs are explicitly rejected:
+
+- Comments (`{...}` or `;`), NAGs (`$1`), annotation suffixes (`!`, `?!`, etc.)
+ and recursive variations (`(...)`), including after the final result.
+- Escape lines (`%`), reserved angle-bracket constructs and extra content after
+ the game. Multiple games must be separated by the caller using a suitable PGN
+ database reader; this API does not provide one.
+- Nonstandard variants. A supplied Variant tag must be exactly `Standard`.
+- Missing result markers, malformed tags and unplayable mainlines.
+
+No annotations or variations are silently discarded. This is a defined subset
+of the [PGN specification](https://www.saremba.de/chessgml/standards/pgn/pgn-complete.htm),
+not a lossless editor for arbitrary PGN databases. Original whitespace, tag order
+and redundant setup tags are normalized on export.
+
+## Costs and validation
+
+PGN import consumes SAN from character spans, but processing a document still
+allocates model and replay data. `WriteTo` avoids creating the complete output
+string. `ToGame()` performs a replay each time, so keep the returned game when
+you need to continue using it. Coordinate-based move application pays no PGN
+processing cost.
+
+Tests cover model ownership, result conflicts, custom FEN/counters, escaping,
+canonical output, structured diagnostics, unsupported features, special moves,
+automatic endings and deterministic game round trips through undo. The package
+consumer smoke test exercises PGN through the built NuGet artifact.
+
+See the [benchmark guide](performance.md) to measure import, export, snapshots
+and restoration.
diff --git a/docs/san.md b/docs/san.md
index ec62ade..287fc7b 100644
--- a/docs/san.md
+++ b/docs/san.md
@@ -2,8 +2,8 @@
SAN describes a move relative to the position **before** that move. It is useful
for a human-readable move list, text entry and as a building block for PGN.
-This API handles one canonical SAN move at a time; it does not import or export
-PGN documents.
+The SAN methods handle one canonical move at a time. [PgnGame](pgn.md) builds on
+them to import and export a single game's tags and mainline.
`CoordinateMove` remains the move value used by board input, history and game
records. A drag from e2 to e4 already supplies a coordinate move and needs no SAN
@@ -74,8 +74,8 @@ accepted; `Ngf3`, `Ng1f3`, `nf3`, `Nf3+` and `Nf3!` are rejected.
There is no whitespace trimming, `0-0` alias, omitted capture marker, implicit
promotion, `e.p.` suffix, figurine notation, move number, result token, comment
-or annotation support. This is a strict SAN API, not a permissive PGN reader.
-PGN import normalization can be designed separately.
+or annotation support. PGN import handles move numbers and result markers
+separately and retains the same canonical SAN requirement.
## Failure and state guarantees
@@ -126,64 +126,18 @@ A draw claim's intended move was not played and is not in `record.Moves`. A
game loaded from FEN only has the history played since that FEN. Formatting
historical moves does not recover earlier moves or encode a game's result.
-## Implementation and costs
-
-SAN queries generate legal encoded moves into a bounded stack buffer. The
-parser reads the input span, filters candidates by the supplied move fields,
-then formats only the single selected candidate into a seven-character buffer
-to verify canonical spelling. It does not format every legal move.
-
-Check/mate detection applies the selected move to a value copy of `Position`
-without updating counters. It generates opponent replies only if that copy is
-in check. It does not clone game history, build FEN, update repetition keys or
-use public `GetLegalMoves()` snapshots. Mate detection uses board replies rather
-than the public move availability of a game ended by a draw.
+## Performance and validation
After initialization, successful `TryParseSan`, `ParseSan` and `TryFormatSan`
queries require no managed heap allocations. `FormatSan` allocates the returned
-string; throwing APIs allocate exceptions on failure. SAN still costs legal-move
-generation and sometimes reply generation, so zero allocation does not imply
-zero CPU cost.
-
-`MakeSanMove` avoids a second current-position search, but its canonical suffix
-verification is additional work. For checking moves, reply generation is done
-both for SAN verification and by normal move application. Applying a move still
-has the existing FEN/history/repetition allocations. Coordinate-based callers
+string; throwing APIs allocate exceptions on failure. SAN still requires legal
+move generation and check/mate detection.
+
+`MakeSanMove` reuses the resolved internal move for application. Applying it
+still has the normal game history and repetition costs. Coordinate-based callers
pay no SAN formatting or parsing cost.
`SanTests` covers canonical fixtures, strict rejection, state preservation,
undo, deterministic playout round trips and allocation checks after warmup.
-`SanBenchmarks` compares span/string formatting, parsing, coordinate application,
-parse-then-apply and direct SAN application. Run it with:
-
-```sh
-dotnet run --project src/ChessRealms.Engine.Benchmark -c Release -- --filter '*SanBenchmarks*' --inProcess --job short --warmupCount 3 --iterationCount 5 --iterationTime 100 --artifacts artifacts/benchmarks/san --exporters JSON
-```
-
-## Local measurements on 2026-10-20
-
-The command above was run against this implementation on Windows 11 x64,
-SDK 10.0.402 and .NET 10.0.13. All 18 benchmarks completed. Span formatting and
-parsing allocated **0 B** in all three scenarios; string formatting allocated
-**32 B** for the selected two-to-five-character outputs. Mean span formatting
-times ranged from 0.616 to 1.252 microseconds; parsing from 0.734 to 1.306
-microseconds.
-
-Application measurements include undo to return to the same starting position.
-Setup and warmup occur outside measurement, including collection growth.
-
-| Scenario | Coordinate + undo | Parse + coordinate + undo | Direct SAN + undo | Allocated by each application path |
-| --- | ---: | ---: | ---: | ---: |
-| Opening (`e4`) | 4.940 us | 5.796 us | 4.885 us | 1048 B |
-| Ambiguous (`Nb1d2`) | 3.588 us | 4.355 us | 3.782 us | 720 B |
-| Mate (`Qg7#`) | 4.397 us | 5.803 us | 4.801 us | 584 B |
-
-These are short in-process measurements, not latency guarantees. Processor
-metadata was unavailable through CIM, the benchmark could not set the power
-plan, and some iterations were below the recommended 100 ms minimum. Small
-differences, including the opening direct-SAN result versus coordinate input,
-are within measurement variability. Use longer isolated-process runs for
-performance decisions. Reports are written under `artifacts/benchmarks/san/`.
-
-Existing FEN history storage, JSON records and package versioning are unchanged
-by the SAN feature.
+`SanBenchmarks` compares formatting, parsing and application paths; see the
+[benchmark guide](performance.md) for commands and measurement guidance.
diff --git a/scripts/Test-PackageConsumer.ps1 b/scripts/Test-PackageConsumer.ps1
index 8346935..fe13d43 100644
--- a/scripts/Test-PackageConsumer.ps1
+++ b/scripts/Test-PackageConsumer.ps1
@@ -123,6 +123,36 @@ if (game.MakeSanMove("e4+") != MoveResult.None || game.MakeSanMove("e4") == Move
{
throw new InvalidOperationException("The package consumer could not apply and undo canonical SAN.");
}
+
+game.MakeSanMove("e4");
+var document = PgnGame.FromGame(game,
+ new Dictionary { ["Event"] = "Package smoke test" }, PgnResult.WhiteWin);
+using var writer = new StringWriter();
+document.WriteTo(writer);
+if (writer.ToString() != document.ToPgn()
+ || !PgnGame.TryParse(writer.ToString().AsSpan(), out var imported, out var diagnostic)
+ || diagnostic is not null || imported.Result != PgnResult.WhiteWin
+ || imported.Tags["Event"] != "Package smoke test"
+ || imported.Moves.Count != 1 || imported.ToGame().ToFen() != game.ToFen()
+ || imported.ToGame().IsFinished)
+{
+ throw new InvalidOperationException("The package consumer could not round-trip PGN.");
+}
+
+if (PgnGame.TryParse("1. e4 {comment} *", out var invalid, out var error)
+ || invalid is not null || error?.Code != PgnErrorCode.UnsupportedAnnotation || error.Offset != 6)
+{
+ throw new InvalidOperationException("The package consumer could not inspect a PGN diagnostic.");
+}
+
+try
+{
+ PgnGame.Parse("1. e5 *");
+ throw new InvalidOperationException("Invalid PGN did not throw.");
+}
+catch (PgnParseException exception) when (exception.Diagnostic.Code == PgnErrorCode.InvalidSan)
+{
+}
'@
[System.IO.File]::WriteAllText((Join-Path $consumerRoot "Program.cs"), $program)
diff --git a/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs b/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs
new file mode 100644
index 0000000..05f2f5f
--- /dev/null
+++ b/src/ChessRealms.Engine.Benchmark/AllocationProbe.cs
@@ -0,0 +1,86 @@
+using System.Globalization;
+
+namespace ChessRealms.Engine.Benchmark;
+
+// Complements BenchmarkDotNet's process-wide memory diagnoser. All work is synchronous;
+// delegates, fixture setup, output and result consumption are outside the measured region.
+internal static class AllocationProbe
+{
+ internal static void Run()
+ {
+ Console.WriteLine("Suite,Scenario,Operation,BytesPerOperation");
+ foreach (string scenario in new[] { "Start", "Kiwipete" })
+ {
+ var benchmark = new CoreBenchmarks { Scenario = scenario };
+ benchmark.Setup();
+ Measure("Core", scenario, "GeneratePseudoLegalMoves", benchmark.GeneratePseudoLegalMoves);
+ Measure("Core", scenario, "PerftDepth3", benchmark.PerftDepth3);
+ Measure("Core", scenario, "FormatFen", benchmark.FormatFen);
+ }
+
+ foreach (int plies in new[] { 0, 20 })
+ {
+ var benchmark = new GameApiBenchmarks { MoveCount = plies };
+ benchmark.Setup();
+ string scenario = plies.ToString(CultureInfo.InvariantCulture);
+ Measure("GameApi", scenario, "LegalMoves", benchmark.LegalMoves);
+ Measure("GameApi", scenario, "Fen", benchmark.Fen);
+ Measure("GameApi", scenario, "HistorySnapshot", benchmark.HistorySnapshot);
+ Measure("GameApi", scenario, "Clone", benchmark.Clone);
+ Measure("GameApi", scenario, "MakeAndUndoMove", benchmark.MakeAndUndoMove);
+ Measure("GameApi", scenario, "ExportRecord", benchmark.ExportRecord);
+ Measure("GameApi", scenario, "RestoreRecord", benchmark.RestoreRecord);
+ Measure("GameApi", scenario, "Serialize", benchmark.Serialize);
+ Measure("GameApi", scenario, "Deserialize", benchmark.Deserialize);
+ }
+
+ foreach (string scenario in new[] { "Opening", "Ambiguous", "Mate" })
+ {
+ var benchmark = new SanBenchmarks { Scenario = scenario };
+ benchmark.Setup();
+ Measure("SAN", scenario, "FormatString", benchmark.FormatString);
+ Measure("SAN", scenario, "FormatSpan", benchmark.FormatSpan);
+ Measure("SAN", scenario, "ParseSpan", benchmark.ParseSpan);
+ Measure("SAN", scenario, "CoordinateMakeAndUndo", benchmark.CoordinateMakeAndUndo);
+ Measure("SAN", scenario, "ParseMakeAndUndo", benchmark.ParseMakeAndUndo);
+ Measure("SAN", scenario, "SanMakeAndUndo", benchmark.SanMakeAndUndo);
+ }
+
+ foreach (int plies in new[] { 0, 20, 120 })
+ {
+ var benchmark = new PgnBenchmarks { MoveCount = plies };
+ benchmark.Setup();
+ string scenario = plies.ToString(CultureInfo.InvariantCulture);
+ Measure("PGN", scenario, "Snapshot", benchmark.Snapshot);
+ Measure("PGN", scenario, "ExportString", benchmark.ExportString);
+ Measure("PGN", scenario, "ExportToNullWriter", () =>
+ {
+ benchmark.ExportToNullWriter();
+ return 0;
+ });
+ Measure("PGN", scenario, "ImportSpan", benchmark.ImportSpan);
+ Measure("PGN", scenario, "Restore", benchmark.Restore);
+ Measure("PGN", scenario, "ImportAndRestore", benchmark.ImportAndRestore);
+ }
+ }
+
+ private static void Measure(string suite, string scenario, string operation, Func action)
+ {
+ T? last = default;
+ for (int i = 0; i < 32; ++i)
+ {
+ last = action();
+ }
+
+ const int samples = 128;
+ long start = GC.GetAllocatedBytesForCurrentThread();
+ for (int i = 0; i < samples; ++i)
+ {
+ last = action();
+ }
+
+ long bytes = GC.GetAllocatedBytesForCurrentThread() - start;
+ GC.KeepAlive(last);
+ Console.WriteLine($"{suite},{scenario},{operation},{(bytes / (double)samples).ToString(CultureInfo.InvariantCulture)}");
+ }
+}
diff --git a/src/ChessRealms.Engine.Benchmark/CoreBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/CoreBenchmarks.cs
new file mode 100644
index 0000000..ea61d08
--- /dev/null
+++ b/src/ChessRealms.Engine.Benchmark/CoreBenchmarks.cs
@@ -0,0 +1,42 @@
+using BenchmarkDotNet.Attributes;
+using ChessRealms.Engine.Core.Movements;
+using ChessRealms.Engine.Core.Types;
+using ChessRealms.Engine.Parsing;
+
+namespace ChessRealms.Engine.Benchmark;
+
+[MemoryDiagnoser]
+public class CoreBenchmarks
+{
+ private Position position;
+
+ [Params("Start", "Kiwipete")]
+ public string Scenario { get; set; } = "Start";
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ string fen = Scenario == "Start" ? ChessGame.StartingFen
+ : "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1";
+ position = ChessGame.FromFen(fen).Position;
+ ulong expected = Scenario == "Start" ? 8902UL : 97862UL;
+ if (Perft.PerftDriver.Test(position, 3, false).Nodes != expected)
+ {
+ throw new InvalidOperationException("Invalid perft benchmark fixture.");
+ }
+ }
+
+ [Benchmark]
+ public int GeneratePseudoLegalMoves()
+ {
+ var copy = position;
+ Span moves = stackalloc int[MoveGen.MaxMoves];
+ return MoveGen.WriteMoves(ref copy, copy.color, moves);
+ }
+
+ [Benchmark]
+ public ulong PerftDepth3() => Perft.PerftDriver.Test(position, 3, false).Nodes;
+
+ [Benchmark]
+ public string FormatFen() => FenStrings.FormatUnchecked(position);
+}
diff --git a/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs
index 04eadb2..edf5423 100644
--- a/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs
+++ b/src/ChessRealms.Engine.Benchmark/GameApiBenchmarks.cs
@@ -38,6 +38,12 @@ record = game.ExportRecord();
[Benchmark]
public string Fen() => game.ToFen();
+ [Benchmark]
+ public IReadOnlyList HistorySnapshot() => game.History;
+
+ [Benchmark]
+ public ChessGame Clone() => game.Clone();
+
[Benchmark]
public MoveResult MakeAndUndoMove()
{
diff --git a/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs
new file mode 100644
index 0000000..dc3dc49
--- /dev/null
+++ b/src/ChessRealms.Engine.Benchmark/PgnBenchmarks.cs
@@ -0,0 +1,79 @@
+using BenchmarkDotNet.Attributes;
+
+namespace ChessRealms.Engine.Benchmark;
+
+[MemoryDiagnoser]
+public class PgnBenchmarks
+{
+ private ChessGame game = null!;
+ private PgnGame document = null!;
+ private string text = null!;
+
+ [Params(0, 20, 120)]
+ public int MoveCount { get; set; }
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ game = new ChessGame();
+ foreach (var move in CreateLongLine().Take(MoveCount))
+ {
+ if (game.MakeMove(move) == MoveResult.None)
+ {
+ throw new InvalidOperationException("The benchmark fixture must contain every requested ply.");
+ }
+ }
+
+ document = PgnGame.FromGame(game);
+ text = document.ToPgn();
+ var imported = PgnGame.Parse(text);
+ if (imported.Moves.Count != MoveCount || imported.ToGame().ToFen() != game.ToFen())
+ {
+ throw new InvalidOperationException("The benchmark fixture must round-trip.");
+ }
+ }
+
+ private static CoordinateMove[] CreateLongLine()
+ {
+ // Use the same 120-ply fixture prefix for every size. Reject short games
+ // during setup instead of accidentally measuring fewer plies than advertised.
+ for (int seed = 0; seed < 128; ++seed)
+ {
+ var candidate = new ChessGame();
+ var random = new Random(seed);
+ int plies = 0;
+ while (plies < 120 && !candidate.IsFinished)
+ {
+ var moves = candidate.GetLegalMoves();
+ candidate.MakeMove(moves[random.Next(moves.Count)]);
+ ++plies;
+ }
+
+ if (plies == 120)
+ {
+ return candidate.CopyRecordedMoves();
+ }
+ }
+
+ throw new InvalidOperationException("Could not construct a 120-ply benchmark fixture.");
+ }
+
+ [Benchmark]
+ public PgnGame Snapshot() => PgnGame.FromGame(game);
+
+ [Benchmark]
+ public string ExportString() => document.ToPgn();
+
+ // Measures traversal/formatting without output buffering, encoding or I/O.
+ [Benchmark]
+ public void ExportToNullWriter() => document.WriteTo(TextWriter.Null);
+
+ [Benchmark]
+ public PgnGame ImportSpan() => PgnGame.Parse(text.AsSpan());
+
+ [Benchmark]
+ public ChessGame Restore() => document.ToGame();
+
+ [Benchmark]
+ public ChessGame ImportAndRestore() => PgnGame.Parse(text.AsSpan()).ToGame();
+}
diff --git a/src/ChessRealms.Engine.Benchmark/Program.cs b/src/ChessRealms.Engine.Benchmark/Program.cs
index 577279a..16614d6 100644
--- a/src/ChessRealms.Engine.Benchmark/Program.cs
+++ b/src/ChessRealms.Engine.Benchmark/Program.cs
@@ -4,6 +4,12 @@
using BenchmarkDotNet.Toolchains.InProcess.Emit;
using ChessRealms.Engine.Benchmark;
+if (args is ["--allocation-probe"])
+{
+ AllocationProbe.Run();
+ return;
+}
+
Console.WriteLine("ChessRealms benchmarks");
Console.WriteLine();
diff --git a/src/ChessRealms.Engine.Tests/PgnExportTests.cs b/src/ChessRealms.Engine.Tests/PgnExportTests.cs
new file mode 100644
index 0000000..1bea770
--- /dev/null
+++ b/src/ChessRealms.Engine.Tests/PgnExportTests.cs
@@ -0,0 +1,121 @@
+using System.Globalization;
+
+namespace ChessRealms.Engine.Tests;
+
+internal class PgnExportTests
+{
+ [Test]
+ public void StartingGame_ExportsCanonicalRosterAndTermination()
+ {
+ const string expected = "[Event \"?\"]\n[Site \"?\"]\n[Date \"????.??.??\"]\n[Round \"?\"]\n"
+ + "[White \"?\"]\n[Black \"?\"]\n[Result \"*\"]\n\n*\n\n";
+ Assert.That(PgnGame.FromGame(new ChessGame()).ToPgn(), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void Tags_AreEscapedAndSortedOrdinally_AndWriterRemainsOpen()
+ {
+ var tags = new Dictionary
+ {
+ ["z"] = "last", ["A"] = "first", ["Event"] = "Київ \"Open\" \\ 2026 ♟",
+ ["event"] = "case sensitive", ["Variant"] = "Standard"
+ };
+ var document = PgnGame.FromGame(new ChessGame(), tags);
+ using var writer = new StringWriter { NewLine = "\r\n" };
+ document.WriteTo(writer);
+ string text = writer.ToString();
+ Assert.That(text, Is.EqualTo(document.ToPgn()));
+ Assert.That(text, Does.StartWith("[Event \"Київ \\\"Open\\\" \\\\ 2026 ♟\"]\n"));
+ Assert.That(text, Does.Contain("[A \"first\"]\n[Variant \"Standard\"]\n[event \"case sensitive\"]\n[z \"last\"]"));
+ Assert.That(text, Does.Not.Contain("\r"));
+ Assert.That(PgnGame.Parse(text).Tags, Is.EquivalentTo(document.Tags));
+ writer.Write("still open");
+ Assert.That(writer.ToString(), Does.EndWith("still open"));
+ }
+
+ [Test]
+ public void BlackStartingPosition_UsesFenCountersBeyondInt64_AndInvariantFormatting()
+ {
+ const string number = "999999999999999999999999";
+ var game = ChessGame.FromFen($"7k/8/8/8/8/8/8/R6K b - - 0 {number}");
+ game.MakeSanMove("Kg8");
+ game.MakeSanMove("Ra2");
+ var culture = CultureInfo.CurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("ar-SA");
+ var document = PgnGame.FromGame(game);
+ string text = document.ToPgn();
+ Assert.That(text, Does.Contain($"[FEN \"7k/8/8/8/8/8/8/R6K b - - 0 {number}\"]\n[SetUp \"1\"]"));
+ Assert.That(text, Does.EndWith($"{number}... Kg8 1000000000000000000000000. Ra2 *\n\n"));
+ Assert.That(PgnGame.Parse(text).ToGame().ToFen(), Is.EqualTo(game.ToFen()));
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = culture;
+ }
+ }
+
+ [TestCase("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1", "O-O", "*")]
+ [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6 0 1", "exd6", "*")]
+ [TestCase("7k/P7/8/8/8/8/8/7K w - - 0 1", "a8=N", "1/2-1/2")]
+ [TestCase("1r5k/P7/8/8/8/8/8/7K w - - 0 1", "axb8=Q+", "*")]
+ [TestCase("7k/8/8/8/8/Q1p5/8/Q1Q4K w - - 0 1", "Qa1xc3+", "*")]
+ [TestCase("7k/5Q2/6K1/8/8/8/8/8 w - - 149 80", "Qg7#", "1-0")]
+ [TestCase("7k/8/8/8/8/8/8/R6K w - - 149 80", "Ra8+", "1/2-1/2")]
+ public void SpecialMovesAndEndings_RoundTrip(string fen, string san, string result)
+ {
+ var game = ChessGame.FromFen(fen);
+ Assert.That(game.MakeSanMove(san), Is.Not.EqualTo(MoveResult.None));
+ var document = PgnGame.FromGame(game);
+ string text = document.ToPgn();
+ Assert.That(text, Does.EndWith($". {san} {result}\n\n"));
+ var replay = PgnGame.Parse(text).ToGame();
+ Assert.That(replay.ToFen(), Is.EqualTo(game.ToFen()));
+ Assert.That(replay.Outcome, Is.EqualTo(game.Outcome));
+ Assert.That(replay.History, Is.EqualTo(game.History));
+ }
+
+ [TestCase(17)]
+ [TestCase(12345)]
+ public void FullGames_RoundTripMovesHistoryRepetitionAndUndo_AndWrapMovetext(int seed)
+ {
+ var game = new ChessGame();
+ var random = new Random(seed);
+ for (int i = 0; i < 160 && !game.IsFinished; ++i)
+ {
+ var moves = game.GetLegalMoves();
+ game.MakeMove(moves[random.Next(moves.Count)]);
+ }
+
+ var document = PgnGame.FromGame(game);
+ string text = document.ToPgn();
+ string movetext = text[(text.IndexOf("\n\n", StringComparison.Ordinal) + 2)..];
+ Assert.That(movetext.Split('\n').All(line => line.Length <= 79 && line == line.Trim()), Is.True);
+ Assert.That(movetext.Count(c => c == '\n'), Is.GreaterThan(2));
+ var imported = PgnGame.Parse(text);
+ Assert.That(imported.ToPgn(), Is.EqualTo(text));
+ Assert.That(imported.Moves, Is.EqualTo(document.Moves));
+ var replay = imported.ToGame();
+ Assert.That(replay.History, Is.EqualTo(game.History));
+ do
+ {
+ Assert.That(replay.ToFen(), Is.EqualTo(game.ToFen()));
+ Assert.That(replay.Outcome, Is.EqualTo(game.Outcome));
+ Assert.That(replay.RepetitionCount, Is.EqualTo(game.RepetitionCount));
+ Assert.That(replay.AvailableDrawClaims, Is.EqualTo(game.AvailableDrawClaims));
+ Assert.That(replay.UndoMove(), Is.EqualTo(game.UndoMove()));
+ } while (game.History.Count != 0);
+ }
+
+ [Test]
+ public void FivefoldRepetition_IsPreservedByMainlineReplay()
+ {
+ string line = string.Join(" ", Enumerable.Repeat("Nf3 Nf6 Ng1 Ng8", 4)) + " 1/2-1/2";
+ var document = PgnGame.Parse(line);
+ Assert.That(document.ToGame().Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition));
+ Assert.That(PgnGame.Parse(document.ToPgn()).ToGame().Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition));
+ Assert.That(PgnGame.TryParse(line.Replace("1/2-1/2", "Nf3 1/2-1/2"), out _, out var error), Is.False);
+ Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.MoveAfterGameEnd));
+ }
+}
diff --git a/src/ChessRealms.Engine.Tests/PgnImportTests.cs b/src/ChessRealms.Engine.Tests/PgnImportTests.cs
new file mode 100644
index 0000000..0340533
--- /dev/null
+++ b/src/ChessRealms.Engine.Tests/PgnImportTests.cs
@@ -0,0 +1,162 @@
+namespace ChessRealms.Engine.Tests;
+
+internal class PgnImportTests
+{
+ [TestCase("e4 e5 Nf3 Nc6 *")]
+ [TestCase("1.e4 e5 2.Nf3 Nc6 *")]
+ [TestCase("1. e4 1... e5 2. Nf3 2... Nc6 *")]
+ [TestCase("1 e4 1 e5 2 Nf3 2 Nc6 *")]
+ [TestCase("\uFEFF \t\r\n1 . . e4\n1. . . e5\r2. Nf3\tNc6 *\v")]
+ [TestCase("[Event\"Example\"][Result\"*\"]1.e4 e5 2.Nf3 Nc6*")]
+ public void ImportLayoutVariants_ResolveSameMoves(string text)
+ {
+ var document = PgnGame.Parse(text);
+ Assert.That(document.Moves, Is.EqualTo(new[] { "e2e4", "e7e5", "g1f3", "b8c6" }.Select(move => CoordinateMove.Parse(move))));
+ Assert.That(document.Result, Is.EqualTo(PgnResult.Unknown));
+ Assert.That(document.Tags.Count, Is.EqualTo(7));
+ }
+
+ [Test]
+ public void SpanSlice_UsesOnlySuppliedInput()
+ {
+ ReadOnlySpan input = "prefix1. e4 *suffix";
+ Assert.That(PgnGame.TryParse(input.Slice(6, 7), out var document, out var diagnostic), Is.True);
+ Assert.That(diagnostic, Is.Null);
+ Assert.That(document!.Moves.Count, Is.EqualTo(1));
+ }
+
+ [TestCase("", PgnErrorCode.MissingResult, 0, 0)]
+ [TestCase("1. e4", PgnErrorCode.MissingResult, 5, 1)]
+ [TestCase("2. e4 *", PgnErrorCode.InvalidMoveNumber, 0, 0)]
+ [TestCase("1. *", PgnErrorCode.InvalidMoveNumber, 0, 0)]
+ [TestCase("1. 1. e4 *", PgnErrorCode.InvalidMoveNumber, 3, 0)]
+ [TestCase("1. e5 *", PgnErrorCode.InvalidSan, 3, 0)]
+ [TestCase("1. e4 e4 *", PgnErrorCode.InvalidSan, 6, 1)]
+ [TestCase("1. e4+ *", PgnErrorCode.InvalidSan, 3, 0)]
+ [TestCase("1. Ng1f3 *", PgnErrorCode.InvalidSan, 3, 0)]
+ [TestCase("1. e4 \0 *", PgnErrorCode.UnexpectedToken, 6, 1)]
+ [TestCase("1. e4 \f *", PgnErrorCode.UnexpectedToken, 6, 1)]
+ [TestCase("1. e4 \u00a0 *", PgnErrorCode.UnexpectedToken, 6, 1)]
+ [TestCase("1. e4 ! *", PgnErrorCode.UnsupportedAnnotation, 6, 1)]
+ [TestCase("1. e4!? *", PgnErrorCode.UnsupportedAnnotation, 5, 0)]
+ [TestCase("1. e4 {comment} *", PgnErrorCode.UnsupportedAnnotation, 6, 1)]
+ [TestCase("1. e4 ;comment\n*", PgnErrorCode.UnsupportedAnnotation, 6, 1)]
+ [TestCase("1. e4 $1 *", PgnErrorCode.UnsupportedAnnotation, 6, 1)]
+ [TestCase("1. e4 (1. d4) *", PgnErrorCode.UnsupportedAnnotation, 6, 1)]
+ [TestCase("1. e4 ) *", PgnErrorCode.UnsupportedAnnotation, 6, 1)]
+ [TestCase("%escape\n*", PgnErrorCode.UnsupportedConstruct, 0, 0)]
+ [TestCase(" *", PgnErrorCode.UnsupportedConstruct, 0, 0)]
+ [TestCase("* {comment}", PgnErrorCode.UnsupportedAnnotation, 2, 0)]
+ [TestCase("* [Event \"second\"] *", PgnErrorCode.MultipleGames, 2, 0)]
+ [TestCase("* 1. e4 *", PgnErrorCode.UnexpectedToken, 2, 0)]
+ [TestCase("* *", PgnErrorCode.UnexpectedToken, 2, 0)]
+ [TestCase("1. f3 e5 2. g4 Qh4# *", PgnErrorCode.ResultMismatch, 20, 4)]
+ [TestCase("1. f3 e5 2. g4 Qh4# 1-0", PgnErrorCode.ResultMismatch, 20, 4)]
+ [TestCase("1. f3 e5 2. g4 Qh4# 3. e4 0-1", PgnErrorCode.MoveAfterGameEnd, 23, 4)]
+ public void Failures_AreAtomicAndHaveStructuredLocations(string text, PgnErrorCode code, int offset, int ply)
+ {
+ Assert.That(PgnGame.TryParse(text, out var document, out var error), Is.False);
+ Assert.That(document, Is.Null);
+ Assert.That(error, Is.Not.Null);
+ Assert.Multiple(() =>
+ {
+ Assert.That(error!.Code, Is.EqualTo(code));
+ Assert.That(error.Offset, Is.EqualTo(offset));
+ Assert.That(error.PlyIndex, Is.EqualTo(ply));
+ Assert.That(error.Message, Is.Not.Empty);
+ });
+ var exception = Assert.Throws(() => PgnGame.Parse(text));
+ Assert.That(exception!.Diagnostic, Is.EqualTo(error));
+ Assert.That(exception, Is.InstanceOf());
+ }
+
+ [TestCase("[Event \"a\"][Event \"b\"] *", PgnErrorCode.DuplicateTag)]
+ [TestCase("[_Event \"a\"] *", PgnErrorCode.InvalidTag)]
+ [TestCase("[Event bare] *", PgnErrorCode.InvalidTag)]
+ [TestCase("[Event \"a\\n\"] *", PgnErrorCode.InvalidTag)]
+ [TestCase("[Event \"a\nb\"] *", PgnErrorCode.InvalidTag)]
+ [TestCase("[Event \"a\"", PgnErrorCode.InvalidTag)]
+ [TestCase("[Event \"a", PgnErrorCode.InvalidTag)]
+ [TestCase("[Event \"a\" extra] *", PgnErrorCode.InvalidTag)]
+ [TestCase("[Result \"win\"] *", PgnErrorCode.InvalidResult)]
+ [TestCase("[Variant \"Chess960\"] *", PgnErrorCode.UnsupportedVariant)]
+ [TestCase("[SetUp \"1\"] *", PgnErrorCode.InvalidSetup)]
+ [TestCase("[SetUp \"2\"] *", PgnErrorCode.InvalidSetup)]
+ [TestCase("[FEN \"invalid\"] *", PgnErrorCode.InvalidSetup)]
+ [TestCase("[SetUp \"0\"][FEN \"invalid\"] *", PgnErrorCode.InvalidSetup)]
+ [TestCase("[SetUp \"1\"][FEN \"invalid\"] *", PgnErrorCode.InvalidFen)]
+ public void HeaderFailures_DoNotHavePlyIndex(string text, PgnErrorCode code)
+ {
+ Assert.That(PgnGame.TryParse(text, out var document, out var error), Is.False);
+ Assert.That(document, Is.Null);
+ Assert.That(error!.Code, Is.EqualTo(code));
+ Assert.That(error.PlyIndex, Is.Null);
+ Assert.That(error.Offset, Is.InRange(0, text.Length));
+ }
+
+ [TestCase("\r\n")]
+ [TestCase("\r")]
+ [TestCase("\n")]
+ public void Diagnostic_CountsUtf16OffsetsAndOneBasedLinesAndColumns(string newline)
+ {
+ string text = $"[Event \"Київ ♟\"]{newline}{newline}1. e4{newline}\te4 *";
+ Assert.That(PgnGame.TryParse(text, out _, out var error), Is.False);
+ Assert.That(error!.Offset, Is.EqualTo(text.LastIndexOf("e4", StringComparison.Ordinal)));
+ Assert.That(error.Line, Is.EqualTo(4));
+ Assert.That(error.Column, Is.EqualTo(2));
+ Assert.That(error.PlyIndex, Is.EqualTo(1));
+ }
+
+ [Test]
+ public void ResultTagAndMovetext_MustAgree()
+ {
+ Assert.That(PgnGame.TryParse("[Result \"1-0\"] 1. e4 0-1", out _, out var error), Is.False);
+ Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.ResultMismatch));
+ Assert.That(error.Offset, Is.EqualTo(21));
+ Assert.That(error.PlyIndex, Is.EqualTo(1));
+ Assert.That(PgnGame.Parse("[Result \"1-0\"] 1. e4 1-0").Result, Is.EqualTo(PgnResult.WhiteWin));
+ }
+
+ [Test]
+ public void TerminalStartingFen_RequiresItsResult()
+ {
+ const string header = "[SetUp \"1\"][FEN \"7k/8/8/8/8/8/8/7K w - - 0 1\"] ";
+ Assert.That(PgnGame.TryParse(header + "*", out _, out var error), Is.False);
+ Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.ResultMismatch));
+ Assert.That(PgnGame.Parse(header + "1/2-1/2").ToGame().IsFinished, Is.True);
+ Assert.That(PgnGame.TryParse(header + "1. Kg1 1/2-1/2", out _, out error), Is.False);
+ Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.MoveAfterGameEnd));
+ }
+
+ [Test]
+ public void TagAndTokenLimits_AreEnforcedWithoutTruncation()
+ {
+ string max = new('a', 255);
+ var valid = PgnGame.Parse($"[{max} \"{max}\"] *");
+ Assert.That(valid.Tags[max], Is.EqualTo(max));
+ string[] invalid = [$"[{max}a \"v\"] *", $"[Name \"{max}a\"] *", "[Name \"\ud800\"] *"];
+ foreach (string text in invalid)
+ {
+ Assert.That(PgnGame.TryParse(text, out _, out var error), Is.False);
+ Assert.That(error!.Code, Is.EqualTo(PgnErrorCode.InvalidTag));
+ }
+
+ Assert.That(PgnGame.TryParse(new string('a', 256) + " *", out _, out var tokenError), Is.False);
+ Assert.That(tokenError!.Code, Is.EqualTo(PgnErrorCode.UnexpectedToken));
+ }
+
+ [Test]
+ public void ArbitraryMalformedInput_ReturnsDiagnosticInsteadOfLeakingParserExceptions()
+ {
+ var random = new Random(2048);
+ const string alphabet = "[]\"\\{}();!?$*0123456789.e4x+#_ \t\r\n\uFEFF\ud800";
+ for (int sample = 0; sample < 500; ++sample)
+ {
+ string text = new(Enumerable.Range(0, random.Next(1, 80))
+ .Select(_ => alphabet[random.Next(alphabet.Length)]).ToArray());
+ bool success = PgnGame.TryParse(text, out var document, out var error);
+ Assert.That(document is not null, Is.EqualTo(success), text);
+ Assert.That(error is null, Is.EqualTo(success), text);
+ }
+ }
+}
diff --git a/src/ChessRealms.Engine.Tests/PgnModelTests.cs b/src/ChessRealms.Engine.Tests/PgnModelTests.cs
new file mode 100644
index 0000000..76f464b
--- /dev/null
+++ b/src/ChessRealms.Engine.Tests/PgnModelTests.cs
@@ -0,0 +1,135 @@
+namespace ChessRealms.Engine.Tests;
+
+internal class PgnModelTests
+{
+ [Test]
+ public void Snapshot_OwnsTagsAndCurrentLine_AndReplaysIndependentGames()
+ {
+ var game = new ChessGame();
+ game.MakeSanMove("e4");
+ game.MakeSanMove("e5");
+ game.UndoMove();
+ var tags = new Dictionary { ["Event"] = "Example", ["Custom"] = "value" };
+ var document = PgnGame.FromGame(game, tags, PgnResult.WhiteWin);
+ tags["Event"] = "Changed";
+ game.MakeSanMove("c5");
+ var replay = document.ToGame();
+ var secondReplay = document.ToGame();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(document.InitialFen, Is.EqualTo(ChessGame.StartingFen));
+ Assert.That(document.Moves, Is.EqualTo(new[] { CoordinateMove.Parse("e2e4") }));
+ Assert.That(document.Tags["Event"], Is.EqualTo("Example"));
+ Assert.That(document.Tags["Date"], Is.EqualTo("????.??.??"));
+ Assert.That(document.Tags["Result"], Is.EqualTo("1-0"));
+ Assert.That(document.Result, Is.EqualTo(PgnResult.WhiteWin));
+ Assert.That(replay.Outcome.Result, Is.EqualTo(GameResult.Ongoing));
+ });
+ Assert.Throws(() => ((IList)document.Moves)[0] = default);
+ Assert.Throws(() => ((IDictionary)document.Tags)["Event"] = "Changed");
+ Assert.That(replay.UndoMove(), Is.True);
+ Assert.That(secondReplay.History.Count, Is.EqualTo(1));
+ Assert.That(game.History.Count, Is.EqualTo(2));
+ Assert.That(PgnGame.Parse(document.ToPgn()).ToPgn(), Is.EqualTo(document.ToPgn()));
+ }
+
+ [TestCase("*", PgnResult.Unknown)]
+ [TestCase("1-0", PgnResult.WhiteWin)]
+ [TestCase("0-1", PgnResult.BlackWin)]
+ [TestCase("1/2-1/2", PgnResult.Draw)]
+ public void UnfinishedPosition_AllowsDeclaredResults(string marker, PgnResult result)
+ {
+ var game = new ChessGame();
+ var document = PgnGame.FromGame(game, new Dictionary { ["Result"] = marker });
+ Assert.That(document.Result, Is.EqualTo(result));
+ Assert.That(document.ToPgn(), Does.EndWith($"\n\n{marker}\n\n"));
+ Assert.That(document.ToGame().IsFinished, Is.False);
+ Assert.That(game.IsFinished, Is.False);
+ }
+
+ [TestCase("bad name", "text")]
+ [TestCase("_name", "text")]
+ [TestCase("", "text")]
+ [TestCase("Name", "line\nbreak")]
+ [TestCase("Name", "tab\t")]
+ [TestCase("Name", "\u007f")]
+ [TestCase("Name", "\u2028")]
+ [TestCase("Result", "draw")]
+ [TestCase("Variant", "Chess960")]
+ [TestCase("SetUp", "1")]
+ [TestCase("SetUp", "2")]
+ [TestCase("FEN", ChessGame.StartingFen)]
+ public void InvalidTags_AreRejected(string name, string value)
+ {
+ Assert.Throws(() =>
+ PgnGame.FromGame(new ChessGame(), new Dictionary { [name] = value }));
+ }
+
+ [Test]
+ public void DuplicateNullOversizedAndMalformedUnicodeTags_AreRejected()
+ {
+ KeyValuePair[] duplicate = [new("Event", "a"), new("Event", "b")];
+ Assert.Throws(() => PgnGame.FromGame(new ChessGame(), duplicate));
+ KeyValuePair[] nullName = [new(null!, "a")];
+ KeyValuePair[] nullValue = [new("Name", null!)];
+ Assert.Throws(() => PgnGame.FromGame(new ChessGame(), nullName));
+ Assert.Throws(() => PgnGame.FromGame(new ChessGame(), nullValue));
+ foreach (var pair in new[] { new KeyValuePair(new string('a', 256), "v"),
+ new("Name", new string('a', 256)), new("Name", "\ud800") })
+ {
+ Assert.Throws(() => PgnGame.FromGame(new ChessGame(), new[] { pair }));
+ }
+ }
+
+ [Test]
+ public void InvalidArgumentsAndConflictingResults_AreRejected()
+ {
+ Assert.Throws(() => PgnGame.FromGame(null!));
+ Assert.Throws(() => PgnGame.FromGame(new ChessGame()).WriteTo(null!));
+ Assert.Throws(() => new PgnParseException(null!));
+ Assert.Throws(() => PgnGame.FromGame(new ChessGame(), result: (PgnResult)99));
+ Assert.Throws(() => PgnGame.FromGame(new ChessGame(),
+ new Dictionary { ["Result"] = "1-0" }, PgnResult.BlackWin));
+ var mate = PgnGame.Parse("1. f3 e5 2. g4 Qh4# 0-1").ToGame();
+ Assert.That(PgnGame.FromGame(mate).Result, Is.EqualTo(PgnResult.BlackWin));
+ Assert.Throws(() => PgnGame.FromGame(mate, result: PgnResult.Unknown));
+ Assert.Throws(() => PgnGame.FromGame(mate, result: PgnResult.WhiteWin));
+ }
+
+ [Test]
+ public void SetupTags_MustMatchInitialPosition_AndNormalizeRedundantSetup()
+ {
+ const string fen = "7k/8/8/8/8/8/8/R6K b - - 0 23";
+ var custom = ChessGame.FromFen(fen);
+ custom.MakeSanMove("Kg8");
+ var document = PgnGame.FromGame(custom);
+ Assert.That(document.InitialFen, Is.EqualTo(fen));
+ Assert.That(document.Tags["FEN"], Is.EqualTo(fen));
+ Assert.That(document.Tags["SetUp"], Is.EqualTo("1"));
+ var tags = new Dictionary { ["SetUp"] = "1", ["FEN"] = ChessGame.StartingFen };
+ Assert.Throws(() => PgnGame.FromGame(custom, tags));
+ Assert.That(PgnGame.FromGame(new ChessGame(), tags).Tags.ContainsKey("FEN"), Is.False);
+ tags["FEN"] = "invalid";
+ Assert.Throws(() => PgnGame.FromGame(custom, tags));
+ Assert.Throws(() =>
+ PgnGame.FromGame(custom, new Dictionary { ["SetUp"] = "0" }));
+ Assert.That(PgnGame.FromGame(new ChessGame(),
+ new Dictionary { ["SetUp"] = "0" }).Tags.ContainsKey("SetUp"), Is.False);
+ }
+
+ [Test]
+ public void DrawClaim_ResultSurvives_ButReasonAndUnplayedIntendedMoveAreNotInvented()
+ {
+ var game = ChessGame.FromFen("4k3/8/8/8/8/8/8/R3K3 w - - 99 50");
+ Assert.That(game.ClaimDraw(DrawClaim.FiftyMoveRule, CoordinateMove.Parse("a1a2")), Is.True);
+ var document = PgnGame.FromGame(game);
+ var imported = PgnGame.Parse(document.ToPgn());
+ Assert.That(imported.Result, Is.EqualTo(PgnResult.Draw));
+ Assert.That(imported.Moves, Is.Empty);
+ Assert.That(imported.ToGame().Outcome.Result, Is.EqualTo(GameResult.Ongoing));
+ Assert.That(game.DrawIntendedMove, Is.EqualTo(CoordinateMove.Parse("a1a2")));
+ Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.FiftyMoveRule));
+ Assert.Throws(() => PgnGame.FromGame(game, result: PgnResult.WhiteWin));
+ }
+}
diff --git a/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs b/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs
index 94b338d..0922c6b 100644
--- a/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs
+++ b/src/ChessRealms.Engine.Tests/PublicApiSurfaceTests.cs
@@ -56,6 +56,11 @@ public void ExportedTypes_AreTheReviewedRootSurface()
"ChessRealms.Engine.GameState",
"ChessRealms.Engine.MoveHistoryEntry",
"ChessRealms.Engine.MoveResult",
+ "ChessRealms.Engine.PgnDiagnostic",
+ "ChessRealms.Engine.PgnErrorCode",
+ "ChessRealms.Engine.PgnGame",
+ "ChessRealms.Engine.PgnParseException",
+ "ChessRealms.Engine.PgnResult",
"ChessRealms.Engine.PieceColor",
"ChessRealms.Engine.PieceValue",
"ChessRealms.Engine.Square"
diff --git a/src/ChessRealms.Engine/Parsing/PgnReader.cs b/src/ChessRealms.Engine/Parsing/PgnReader.cs
new file mode 100644
index 0000000..0ffed6d
--- /dev/null
+++ b/src/ChessRealms.Engine/Parsing/PgnReader.cs
@@ -0,0 +1,326 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Numerics;
+
+namespace ChessRealms.Engine.Parsing;
+
+internal ref struct PgnReader(ReadOnlySpan text)
+{
+ private readonly ReadOnlySpan _text = text;
+ private readonly Dictionary _tags = new(StringComparer.Ordinal);
+ private readonly Dictionary _tagOffsets = new(StringComparer.Ordinal);
+ private int _position;
+ private PgnDiagnostic? _error;
+
+ internal static bool TryRead(ReadOnlySpan text, [NotNullWhen(true)] out PgnGame? document,
+ [NotNullWhen(false)] out PgnDiagnostic? diagnostic)
+ {
+ var reader = new PgnReader(text);
+ bool success = reader.Read(out document);
+ diagnostic = reader._error;
+ return success;
+ }
+
+ private bool Read([NotNullWhen(true)] out PgnGame? document)
+ {
+ document = null;
+ if (!_text.IsEmpty && _text[0] == '\uFEFF')
+ {
+ ++_position;
+ }
+
+ SkipWhiteSpace();
+ while (_position < _text.Length && _text[_position] == '[')
+ {
+ if (!ReadTag())
+ {
+ return false;
+ }
+
+ SkipWhiteSpace();
+ }
+
+ if (_tags.TryGetValue("Variant", out var variant) && variant != "Standard")
+ {
+ return Fail(PgnErrorCode.UnsupportedVariant, _tagOffsets["Variant"], null, "Only standard chess is supported.");
+ }
+
+ if (!PgnTags.TryReadSetup(_tags, out var fen))
+ {
+ int offset = _tagOffsets.GetValueOrDefault("SetUp", _tagOffsets.GetValueOrDefault("FEN"));
+ return Fail(PgnErrorCode.InvalidSetup, offset, null, "FEN requires SetUp \"1\", and SetUp \"1\" requires FEN.");
+ }
+
+ if (!ChessGame.TryCreateFromFen(fen ?? ChessGame.StartingFen, out var game))
+ {
+ return Fail(PgnErrorCode.InvalidFen, _tagOffsets["FEN"], null, "The starting FEN is invalid.");
+ }
+
+ PgnResult? headerResult = null;
+ if (_tags.TryGetValue("Result", out var marker))
+ {
+ if (!PgnTags.TryReadResult(marker, out var result))
+ {
+ return Fail(PgnErrorCode.InvalidResult, _tagOffsets["Result"], null, "Invalid Result tag.");
+ }
+
+ headerResult = result;
+ }
+
+ int ply = 0;
+ int? pendingNumber = null;
+ while (true)
+ {
+ SkipWhiteSpace();
+ if (_position == _text.Length)
+ {
+ return Fail(PgnErrorCode.MissingResult, _position, ply, "A game termination marker is required.");
+ }
+
+ int start = _position;
+ if (RejectUnsupported(ply))
+ {
+ return false;
+ }
+
+ if (_text[_position] == '*')
+ {
+ ++_position;
+ }
+ else
+ {
+ while (_position < _text.Length && IsSymbolCharacter(_text[_position]))
+ {
+ ++_position;
+ }
+ }
+
+ var token = _text[start.._position];
+ if (token.IsEmpty)
+ {
+ return Fail(PgnErrorCode.UnexpectedToken, start, ply, "Expected SAN, a move number or a result.");
+ }
+
+ if (token.Length > 255)
+ {
+ return Fail(PgnErrorCode.UnexpectedToken, start, ply, "A PGN symbol cannot exceed 255 characters.");
+ }
+
+ if (PgnTags.TryReadResult(token, out var result))
+ {
+ if (pendingNumber.HasValue)
+ {
+ return Fail(PgnErrorCode.InvalidMoveNumber, pendingNumber.Value, ply, "A move number must precede a move.");
+ }
+
+ if ((headerResult.HasValue && result != headerResult.Value)
+ || (game.IsFinished && result != PgnTags.FromOutcome(game.Outcome.Result)))
+ {
+ return Fail(PgnErrorCode.ResultMismatch, start, ply, "The result conflicts with the Result tag or finished position.");
+ }
+
+ SkipWhiteSpace();
+ if (_position < _text.Length)
+ {
+ if (RejectUnsupported(ply))
+ {
+ return false;
+ }
+
+ return Fail(_text[_position] == '[' ? PgnErrorCode.MultipleGames : PgnErrorCode.UnexpectedToken,
+ _position, ply, "Only one game is accepted; unexpected content follows its result.");
+ }
+
+ document = PgnGame.FromGame(game, _tags, result);
+ return true;
+ }
+
+ if (IsDigits(token))
+ {
+ if (pendingNumber.HasValue || !BigInteger.TryParse(token, NumberStyles.None, CultureInfo.InvariantCulture, out var number)
+ || number != game.FullmoveNumber)
+ {
+ return Fail(PgnErrorCode.InvalidMoveNumber, start, ply, "The move number does not match the position.");
+ }
+
+ pendingNumber = start;
+ SkipWhiteSpace();
+ // PGN import permits zero or more periods, separated by whitespace.
+ while (_position < _text.Length && _text[_position] == '.')
+ {
+ ++_position;
+ SkipWhiteSpace();
+ }
+
+ continue;
+ }
+
+ if (_position < _text.Length && _text[_position] is '!' or '?')
+ {
+ return Fail(PgnErrorCode.UnsupportedAnnotation, _position, ply, "Move annotations are not supported.");
+ }
+
+ if (game.IsFinished)
+ {
+ return Fail(PgnErrorCode.MoveAfterGameEnd, start, ply, "No move is legal after the game has finished.");
+ }
+
+ if (game.MakeSanMove(token) == MoveResult.None)
+ {
+ return Fail(PgnErrorCode.InvalidSan, start, ply, "Expected a legal move in canonical SAN for this position.");
+ }
+
+ pendingNumber = null;
+ ++ply;
+ }
+ }
+
+ private bool ReadTag()
+ {
+ int tagStart = _position++;
+ SkipWhiteSpace();
+ int nameStart = _position;
+ while (_position < _text.Length && (char.IsAsciiLetterOrDigit(_text[_position]) || _text[_position] == '_'))
+ {
+ ++_position;
+ }
+
+ int nameLength = _position - nameStart;
+ if (nameLength is 0 or > 255 || !char.IsAsciiLetterOrDigit(_text[nameStart]))
+ {
+ return Fail(PgnErrorCode.InvalidTag, nameStart, null, "Invalid tag name.");
+ }
+
+ string name = _text.Slice(nameStart, nameLength).ToString();
+ if (_tags.ContainsKey(name))
+ {
+ return Fail(PgnErrorCode.DuplicateTag, nameStart, null, "Tag names must be unique.");
+ }
+
+ SkipWhiteSpace();
+ if (_position == _text.Length || _text[_position] != '"')
+ {
+ return Fail(PgnErrorCode.InvalidTag, _position, null, "Expected a quoted tag value.");
+ }
+
+ int valueStart = ++_position;
+ Span value = stackalloc char[255];
+ int length = 0;
+ while (_position < _text.Length && _text[_position] != '"')
+ {
+ int characterStart = _position;
+ char c = _text[_position++];
+ if (c == '\\')
+ {
+ if (_position == _text.Length || _text[_position] is not ('\\' or '"'))
+ {
+ return Fail(PgnErrorCode.InvalidTag, characterStart, null, "Only quote and backslash escapes are supported.");
+ }
+
+ c = _text[_position++];
+ }
+
+ if (length == value.Length || char.IsControl(c) || c is '\u2028' or '\u2029')
+ {
+ return Fail(PgnErrorCode.InvalidTag, characterStart, null, "Tag values must contain at most 255 printable UTF-16 characters.");
+ }
+
+ value[length++] = c;
+ }
+
+ if (_position == _text.Length)
+ {
+ return Fail(PgnErrorCode.InvalidTag, _position, null, "Unterminated tag value.");
+ }
+
+ string decoded = value[..length].ToString();
+ if (!PgnTags.IsValidValue(decoded))
+ {
+ return Fail(PgnErrorCode.InvalidTag, valueStart, null, "Invalid Unicode in tag value.");
+ }
+
+ ++_position;
+ SkipWhiteSpace();
+ if (_position == _text.Length || _text[_position] != ']')
+ {
+ return Fail(PgnErrorCode.InvalidTag, _position, null, "Expected the closing tag bracket.");
+ }
+
+ ++_position;
+ _tags.Add(name, decoded);
+ _tagOffsets.Add(name, tagStart);
+ return true;
+ }
+
+ private bool RejectUnsupported(int ply)
+ {
+ char c = _text[_position];
+ if (c is '{' or '}' or ';' or '$' or '(' or ')' or '!' or '?')
+ {
+ Fail(PgnErrorCode.UnsupportedAnnotation, _position, ply, "Comments, NAGs and variations are not supported.");
+ return true;
+ }
+
+ if (c is '%' or '<' or '>')
+ {
+ Fail(PgnErrorCode.UnsupportedConstruct, _position, ply, "PGN escape lines and reserved constructs are not supported.");
+ return true;
+ }
+
+ return false;
+ }
+
+ private void SkipWhiteSpace()
+ {
+ while (_position < _text.Length && _text[_position] is ' ' or '\t' or '\r' or '\n' or '\v')
+ {
+ ++_position;
+ }
+ }
+
+ private static bool IsSymbolCharacter(char c) => char.IsAsciiLetterOrDigit(c) || c is '_' or '+' or '#' or '=' or ':' or '-' or '/';
+
+ private static bool IsDigits(ReadOnlySpan token)
+ {
+ foreach (char c in token)
+ {
+ if (!char.IsAsciiDigit(c))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private bool Fail(PgnErrorCode code, int offset, int? ply, string message)
+ {
+ int line = 1;
+ int column = 1;
+ for (int i = 0; i < offset; ++i)
+ {
+ if (_text[i] == '\r')
+ {
+ if (i + 1 < offset && _text[i + 1] == '\n')
+ {
+ ++i;
+ }
+
+ ++line;
+ column = 1;
+ }
+ else if (_text[i] == '\n')
+ {
+ ++line;
+ column = 1;
+ }
+ else
+ {
+ ++column;
+ }
+ }
+
+ _error = new PgnDiagnostic(code, offset, line, column, ply, message);
+ return false;
+ }
+}
diff --git a/src/ChessRealms.Engine/Parsing/PgnTags.cs b/src/ChessRealms.Engine/Parsing/PgnTags.cs
new file mode 100644
index 0000000..2757f88
--- /dev/null
+++ b/src/ChessRealms.Engine/Parsing/PgnTags.cs
@@ -0,0 +1,126 @@
+namespace ChessRealms.Engine.Parsing;
+
+internal static class PgnTags
+{
+ internal static readonly string[] Roster = ["Event", "Site", "Date", "Round", "White", "Black", "Result"];
+
+ internal static bool IsValidName(string? name)
+ {
+ if (string.IsNullOrEmpty(name) || name.Length > 255 || !char.IsAsciiLetterOrDigit(name[0]))
+ {
+ return false;
+ }
+
+ foreach (char c in name)
+ {
+ if (!char.IsAsciiLetterOrDigit(c) && c != '_')
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ internal static bool IsValidValue(string? value)
+ {
+ if (value is null || value.Length > 255)
+ {
+ return false;
+ }
+
+ for (int i = 0; i < value.Length; ++i)
+ {
+ char c = value[i];
+ if (char.IsControl(c) || c is '\u2028' or '\u2029')
+ {
+ return false;
+ }
+
+ if (char.IsSurrogate(c))
+ {
+ if (!char.IsHighSurrogate(c) || i + 1 == value.Length || !char.IsLowSurrogate(value[++i]))
+ {
+ return false;
+ }
+ }
+ }
+
+ return true;
+ }
+
+ internal static bool TryReadSetup(IReadOnlyDictionary tags, out string? fen)
+ {
+ bool hasFen = tags.TryGetValue("FEN", out fen);
+ bool hasSetup = tags.TryGetValue("SetUp", out var setup);
+ return hasFen ? hasSetup && setup == "1" : !hasSetup || setup == "0";
+ }
+
+ internal static Dictionary Normalize(Dictionary tags, string initialFen, PgnResult result)
+ {
+ var normalized = new Dictionary(tags, StringComparer.Ordinal);
+ foreach (string name in Roster)
+ {
+ normalized.TryAdd(name, name == "Date" ? "????.??.??" : "?");
+ }
+
+ normalized["Result"] = ResultText(result);
+ if (initialFen != ChessGame.StartingFen)
+ {
+ normalized["SetUp"] = "1";
+ normalized["FEN"] = initialFen;
+ }
+ else
+ {
+ normalized.Remove("SetUp");
+ normalized.Remove("FEN");
+ }
+
+ return normalized;
+ }
+
+ internal static string ResultText(PgnResult result) => result switch
+ {
+ PgnResult.WhiteWin => "1-0",
+ PgnResult.BlackWin => "0-1",
+ PgnResult.Draw => "1/2-1/2",
+ _ => "*"
+ };
+
+ internal static PgnResult FromOutcome(GameResult result) => result switch
+ {
+ GameResult.WhiteWin => PgnResult.WhiteWin,
+ GameResult.BlackWin => PgnResult.BlackWin,
+ GameResult.Draw => PgnResult.Draw,
+ _ => PgnResult.Unknown
+ };
+
+ internal static bool TryReadResult(ReadOnlySpan text, out PgnResult result)
+ {
+ result = PgnResult.Unknown;
+ if (text.SequenceEqual("*"))
+ {
+ return true;
+ }
+
+ if (text.SequenceEqual("1-0"))
+ {
+ result = PgnResult.WhiteWin;
+ return true;
+ }
+
+ if (text.SequenceEqual("0-1"))
+ {
+ result = PgnResult.BlackWin;
+ return true;
+ }
+
+ if (text.SequenceEqual("1/2-1/2"))
+ {
+ result = PgnResult.Draw;
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/src/ChessRealms.Engine/Parsing/PgnWriter.cs b/src/ChessRealms.Engine/Parsing/PgnWriter.cs
new file mode 100644
index 0000000..77331ab
--- /dev/null
+++ b/src/ChessRealms.Engine/Parsing/PgnWriter.cs
@@ -0,0 +1,86 @@
+using System.Globalization;
+
+namespace ChessRealms.Engine.Parsing;
+
+internal static class PgnWriter
+{
+ internal static void Write(PgnGame document, TextWriter writer)
+ {
+ foreach (string name in PgnTags.Roster)
+ {
+ WriteTag(writer, name, document.Tags[name]);
+ }
+
+ foreach (var pair in document.Tags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
+ {
+ if (!PgnTags.Roster.Contains(pair.Key, StringComparer.Ordinal))
+ {
+ WriteTag(writer, pair.Key, pair.Value);
+ }
+ }
+
+ writer.Write('\n');
+ var replay = ChessGame.FromFen(document.InitialFen);
+ int column = 0;
+ Span san = stackalloc char[7];
+ for (int i = 0; i < document.Moves.Count; ++i)
+ {
+ if (replay.CurrentColor == PieceColor.White || i == 0)
+ {
+ string number = replay.FullmoveNumber.ToString(CultureInfo.InvariantCulture)
+ + (replay.CurrentColor == PieceColor.White ? "." : "...");
+ WriteToken(writer, number, ref column);
+ }
+
+ CoordinateMove move = document.Moves[i];
+ if (!replay.TryFormatSan(move, san, out int written))
+ {
+ throw new InvalidOperationException("The validated PGN line could not be replayed.");
+ }
+
+ WriteToken(writer, san[..written], ref column);
+ replay.MakeMove(move);
+ }
+
+ WriteToken(writer, PgnTags.ResultText(document.Result), ref column);
+ writer.Write("\n\n");
+ }
+
+ private static void WriteTag(TextWriter writer, string name, string value)
+ {
+ writer.Write('[');
+ writer.Write(name);
+ writer.Write(" \"");
+ foreach (char c in value)
+ {
+ if (c is '\\' or '"')
+ {
+ writer.Write('\\');
+ }
+
+ writer.Write(c);
+ }
+
+ writer.Write("\"]\n");
+ }
+
+ private static void WriteToken(TextWriter writer, ReadOnlySpan token, ref int column)
+ {
+ if (column != 0)
+ {
+ if (column + 1 + token.Length > 79)
+ {
+ writer.Write('\n');
+ column = 0;
+ }
+ else
+ {
+ writer.Write(' ');
+ ++column;
+ }
+ }
+
+ writer.Write(token);
+ column += token.Length;
+ }
+}
diff --git a/src/ChessRealms.Engine/PgnDiagnostic.cs b/src/ChessRealms.Engine/PgnDiagnostic.cs
new file mode 100644
index 0000000..411e9e9
--- /dev/null
+++ b/src/ChessRealms.Engine/PgnDiagnostic.cs
@@ -0,0 +1,43 @@
+namespace ChessRealms.Engine;
+
+/// The category of a PGN import failure.
+public enum PgnErrorCode
+{
+ UnexpectedToken,
+ InvalidTag,
+ DuplicateTag,
+ InvalidSetup,
+ InvalidFen,
+ InvalidResult,
+ ResultMismatch,
+ InvalidMoveNumber,
+ InvalidSan,
+ MoveAfterGameEnd,
+ MissingResult,
+ UnsupportedAnnotation,
+ UnsupportedConstruct,
+ UnsupportedVariant,
+ MultipleGames
+}
+
+/// A PGN error location. Offset is zero-based UTF-16; line and column are one-based.
+/// PlyIndex is the zero-based next mainline ply, or null for header errors.
+public sealed record PgnDiagnostic(PgnErrorCode Code, int Offset, int Line, int Column, int? PlyIndex, string Message);
+
+/// A failed PGN import with a structured diagnostic.
+public sealed class PgnParseException : FormatException
+{
+ public PgnDiagnostic Diagnostic { get; }
+
+ public PgnParseException(PgnDiagnostic diagnostic)
+ : base(Describe(diagnostic))
+ {
+ Diagnostic = diagnostic;
+ }
+
+ private static string Describe(PgnDiagnostic diagnostic)
+ {
+ ArgumentNullException.ThrowIfNull(diagnostic);
+ return $"{diagnostic.Message} (line {diagnostic.Line}, column {diagnostic.Column}, offset {diagnostic.Offset}).";
+ }
+}
diff --git a/src/ChessRealms.Engine/PgnGame.cs b/src/ChessRealms.Engine/PgnGame.cs
new file mode 100644
index 0000000..5d96083
--- /dev/null
+++ b/src/ChessRealms.Engine/PgnGame.cs
@@ -0,0 +1,120 @@
+using ChessRealms.Engine.Parsing;
+using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+
+namespace ChessRealms.Engine;
+
+/// An immutable single-game PGN mainline, with tags and a declared result.
+public sealed class PgnGame
+{
+ public string InitialFen { get; }
+ public IReadOnlyList Moves { get; }
+ public IReadOnlyDictionary Tags { get; }
+ public PgnResult Result { get; }
+
+ private PgnGame(ChessGame game, Dictionary tags, PgnResult result)
+ {
+ InitialFen = game.InitialFen;
+ Moves = Array.AsReadOnly(game.CopyRecordedMoves());
+ Result = result;
+ Tags = new ReadOnlyDictionary(PgnTags.Normalize(tags, InitialFen, result));
+ }
+
+ /// Snapshots a game's current line. Supplied tags are copied; missing roster tags get defaults.
+ /// Result defaults to the supplied Result tag, or the game's outcome. An explicit result must
+ /// agree with a supplied Result tag and any finished engine outcome. Declared results do not modify the game.
+ /// PGN does not preserve the reason or intended move of a draw claim; use GameRecord for that purpose.
+ /// The game is null.
+ /// A tag, result, or starting-position declaration is invalid or conflicting.
+ public static PgnGame FromGame(ChessGame game, IEnumerable>? tags = null,
+ PgnResult? result = null)
+ {
+ ArgumentNullException.ThrowIfNull(game);
+ var copy = new Dictionary(StringComparer.Ordinal);
+ if (tags is not null)
+ {
+ foreach (var pair in tags)
+ {
+ if (!PgnTags.IsValidName(pair.Key) || !PgnTags.IsValidValue(pair.Value)
+ || !copy.TryAdd(pair.Key, pair.Value))
+ {
+ throw new ArgumentException("Tag names and values must be valid and names must be unique.", nameof(tags));
+ }
+ }
+ }
+
+ if (copy.TryGetValue("Variant", out var variant) && variant != "Standard")
+ {
+ throw new ArgumentException("Only standard chess PGN is supported.", nameof(tags));
+ }
+
+ if (!PgnTags.TryReadSetup(copy, out var declaredFen))
+ {
+ throw new ArgumentException("SetUp and FEN must describe a consistent starting position.", nameof(tags));
+ }
+
+ if (declaredFen is not null && (!ChessGame.TryCreateFromFen(declaredFen, out var declaredGame)
+ || declaredGame.ToFen() != game.InitialFen))
+ {
+ throw new ArgumentException("The FEN tag must match the game's initial position.", nameof(tags));
+ }
+
+ if (copy.TryGetValue("SetUp", out var setup) && setup == "0" && game.InitialFen != ChessGame.StartingFen)
+ {
+ throw new ArgumentException("SetUp 0 conflicts with the game's initial position.", nameof(tags));
+ }
+
+ if (!PgnTags.IsValidValue(game.InitialFen))
+ {
+ throw new ArgumentException("The initial FEN exceeds the PGN tag-value limit.", nameof(game));
+ }
+
+ PgnResult selected = result ?? PgnTags.FromOutcome(game.Outcome.Result);
+ if (copy.TryGetValue("Result", out var marker))
+ {
+ if (!PgnTags.TryReadResult(marker, out var taggedResult) || (result.HasValue && result.Value != taggedResult))
+ {
+ throw new ArgumentException("The Result tag is invalid or conflicts with the supplied result.", nameof(tags));
+ }
+
+ selected = taggedResult;
+ }
+
+ if (!Enum.IsDefined(selected) || (game.IsFinished && selected != PgnTags.FromOutcome(game.Outcome.Result)))
+ {
+ throw new ArgumentException("The declared result is invalid or contradicts the finished game.", nameof(result));
+ }
+
+ return new PgnGame(game, copy, selected);
+ }
+
+ /// Replays the line into an independent game. Does not infer resignation, timeout or draw claims
+ /// from the declared result; an ongoing engine outcome may accompany a decisive PGN result.
+ public ChessGame ToGame() => ChessGame.FromRecord(new GameRecord(InitialFen, Moves, null));
+
+ /// Exports ordered tags and canonical SAN with LF line endings.
+ public string ToPgn()
+ {
+ using var writer = new StringWriter(CultureInfo.InvariantCulture);
+ WriteTo(writer);
+ return writer.ToString();
+ }
+
+ /// Writes PGN without closing the supplied writer. Writer failures propagate.
+ public void WriteTo(TextWriter writer)
+ {
+ ArgumentNullException.ThrowIfNull(writer);
+ PgnWriter.Write(this, writer);
+ }
+
+ /// Imports exactly one mainline PGN game with canonical SAN.
+ /// The PGN is invalid or uses unsupported annotations or variants.
+ public static PgnGame Parse(ReadOnlySpan text)
+ => TryParse(text, out var game, out var diagnostic) ? game : throw new PgnParseException(diagnostic);
+
+ /// Imports atomically. On failure returns no game and a diagnostic; on success the diagnostic is null.
+ public static bool TryParse(ReadOnlySpan text, [NotNullWhen(true)] out PgnGame? game,
+ [NotNullWhen(false)] out PgnDiagnostic? diagnostic)
+ => PgnReader.TryRead(text, out game, out diagnostic);
+}
diff --git a/src/ChessRealms.Engine/PgnResult.cs b/src/ChessRealms.Engine/PgnResult.cs
new file mode 100644
index 0000000..d079f2a
--- /dev/null
+++ b/src/ChessRealms.Engine/PgnResult.cs
@@ -0,0 +1,11 @@
+namespace ChessRealms.Engine;
+
+/// The declared PGN result, independent of an engine's board-derived outcome.
+public enum PgnResult
+{
+ /// The marker *, covering ongoing games and unavailable results.
+ Unknown,
+ WhiteWin,
+ BlackWin,
+ Draw
+}