diff --git a/ChessRealms.Engine.slnx b/ChessRealms.Engine.slnx
index 4454c87..551ca3c 100644
--- a/ChessRealms.Engine.slnx
+++ b/ChessRealms.Engine.slnx
@@ -6,6 +6,7 @@
+
diff --git a/README.md b/README.md
index 87c9a50..58ea648 100644
--- a/README.md
+++ b/README.md
@@ -3,11 +3,12 @@
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, JSON game records, and standard checkmate, stalemate and supported draw
-rules. The repository also includes tests, a console game, a perft runner,
+undo, canonical SAN formatting and parsing, 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, SAN/PGN, Chess960 or tournament
+It does not provide an AI/search engine, UCI, 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)
@@ -57,6 +58,21 @@ promotions require a suffix such as `a7a8q`. The
describes ownership, FEN validation, draw claims, repetition and supported rule
boundaries.
+SAN is available at the text boundary, with the game supplying the position:
+
+```csharp
+var game = new ChessGame();
+CoordinateMove move = game.ParseSan("Nf3"); // Read-only; the move is not played.
+string notation = game.FormatSan(move); // Format before making the move.
+game.MakeMove(move);
+game.MakeSanMove("d5"); // Resolve and play text directly.
+```
+
+`TryParseSan(ReadOnlySpan, out CoordinateMove)` and
+`TryFormatSan(CoordinateMove, Span, out int)` support buffer-based use.
+See the [SAN guide](https://github.com/ChessRealms/Engine/blob/main/docs/san.md)
+for canonical spelling, state guarantees, historical moves and performance 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 3ec6227..a1953b1 100644
--- a/docs/game-rules-api.md
+++ b/docs/game-rules-api.md
@@ -45,6 +45,16 @@ destination of at least 64 entries, with `ChessPiece.Empty` for vacant squares.
The raw bitboard position is deliberately not exposed; use FEN, board inspection,
legal moves, history and `Clone()` for consumer scenarios.
+## Standard Algebraic Notation
+
+`FormatSan` and `TryFormatSan` format a playable coordinate move in the current
+position, before it is applied. `ParseSan` and `TryParseSan` resolve canonical
+SAN to a `CoordinateMove` without applying it. `MakeSanMove` resolves and plays
+the text through the same move application used by `MakeMove`, avoiding a second
+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.
+
## Game record export and restoration
`ExportRecord()` creates an independent, immutable `GameRecord` containing the
@@ -196,5 +206,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, SAN/PGN, Chess960, AI/search, UCI,
+clocks, tournament claim procedures, PGN, Chess960, AI/search, UCI,
networking or a graphical interface.
diff --git a/docs/san.md b/docs/san.md
new file mode 100644
index 0000000..ec62ade
--- /dev/null
+++ b/docs/san.md
@@ -0,0 +1,189 @@
+# Standard Algebraic Notation
+
+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.
+
+`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
+conversion. SAN is resolved at the text boundary. There is no separate public
+`SanMove`: the text needs a position to identify its source square, while the
+resolved value is already represented by `CoordinateMove`.
+
+## Reading, displaying and playing moves
+
+```csharp
+using ChessRealms.Engine;
+
+var game = new ChessGame();
+CoordinateMove move = game.ParseSan("Nf3"); // Does not play the move.
+string label = game.FormatSan(move); // "Nf3", before applying it.
+MoveResult result = game.MakeMove(move);
+
+// For text entry that should immediately play a move:
+MoveResult reply = game.MakeSanMove("d5");
+```
+
+The explicit parse-then-apply form is useful for previews and validation. For
+immediate application, `MakeSanMove` reuses the resolved internal move instead
+of searching the current position's legal moves again. Both application paths
+have identical history, repetition, draw, outcome and undo behavior.
+
+For caller-owned buffers:
+
+```csharp
+var game = new ChessGame();
+ReadOnlySpan input = "Nf3";
+Span output = stackalloc char[7];
+
+if (game.TryParseSan(input, out CoordinateMove move)
+ && game.TryFormatSan(move, output, out int written))
+{
+ ReadOnlySpan notation = output[..written];
+ // Consume notation here; allocating a string is optional.
+}
+```
+
+Seven characters suffice for any canonical standard-chess SAN move, including
+`Qa1xc3+` and `axb8=Q+`. The span API uses characters; there are no SAN UTF-8
+overloads. SAN output is ASCII and culture independent.
+
+## Canonical notation and strict parsing
+
+The notation follows [PGN specification section 8.2.3](https://www.saremba.de/chessgml/standards/pgn/pgn-complete.htm#c8.2.3):
+
+- Piece letters are `N`, `B`, `R`, `Q`, `K`; pawn moves omit a piece letter.
+- Captures use `x`. Pawn captures include the source file, such as `exd5`.
+- Castling is `O-O` or `O-O-O`, using uppercase letters O.
+- En passant uses ordinary pawn-capture notation, such as `exd6`.
+- Promotion uses `=N`, `=B`, `=R` or `=Q`, including underpromotion.
+- Source disambiguation considers only other **legal** moves of the same piece
+ type to the same target. Use the source file if sufficient, otherwise the
+ rank, otherwise both. An absolutely pinned alternative does not qualify.
+- A checking move ends in `+`, or `#` for mate. Discovered and double checks
+ also use `+`; a checking suffix cannot replace source disambiguation.
+- Stalemate and other drawn endings have no special SAN suffix. A move giving
+ check while triggering an automatic draw still uses `+` when legal replies
+ exist on the resulting board.
+
+Parsing accepts exactly the canonical spelling produced for that move in that
+position. Missing, false or incorrect check/mate suffixes and unnecessary source
+qualifiers are rejected. For example, from the starting position `Nf3` is
+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.
+
+## Failure and state guarantees
+
+| Operation | Failure behavior |
+| --- | --- |
+| `FormatSan(move)` | Throws `ArgumentException` for an invalid/unplayable move or a finished game. |
+| `TryFormatSan(move, destination, out written)` | Returns false, writes zero characters and leaves the entire destination unchanged for an unplayable move, a finished game or insufficient space. |
+| `ParseSan(text)` | Throws `FormatException` for invalid, ambiguous, noncanonical or unplayable SAN, including a finished game. |
+| `TryParseSan(text, out move)` | Returns false with `default(CoordinateMove)` for the same inputs. |
+| `MakeSanMove(text)` | Returns `MoveResult.None` without changing the game for the same inputs. |
+
+An empty/default character span, including a null string converted to a span,
+is invalid input. Parsing and formatting never change the board, counters,
+history, repetition counts, outcome, draw claim or undo stack. They analyze a
+value copy of the position. No move is temporarily applied to the original game.
+
+All operations use the current game's playable-move policy: a finished game
+rejects even a move that would otherwise be legal on the board. This includes
+claimed and automatic draws. A parsed coordinate move is not a reservation;
+`MakeMove` validates it again if applied later. Games remain mutable and are not
+thread-safe.
+
+## Formatting history
+
+The current position cannot generally format a move that has already been
+played. For a single entry, its `FenBefore` supplies the necessary board context:
+
+```csharp
+MoveHistoryEntry entry = game.History[0];
+string san = ChessGame.FromFen(entry.FenBefore).FormatSan(entry.Move);
+```
+
+For a whole line, replay once from the record's initial position and format
+each move before applying it:
+
+```csharp
+GameRecord record = game.ExportRecord();
+var replay = ChessGame.FromFen(record.InitialFen);
+var labels = new List(record.Moves.Count);
+foreach (CoordinateMove move in record.Moves)
+{
+ labels.Add(replay.FormatSan(move));
+ replay.MakeMove(move);
+}
+```
+
+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.
+
+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
+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.
diff --git a/scripts/Test-PackageConsumer.ps1 b/scripts/Test-PackageConsumer.ps1
index 4539c5f..8346935 100644
--- a/scripts/Test-PackageConsumer.ps1
+++ b/scripts/Test-PackageConsumer.ps1
@@ -104,6 +104,25 @@ if (piece != new ChessPiece(PieceColor.White, PieceValue.Pawn))
}
game.UndoMove();
+
+if (game.FormatSan(move) != "e4" || game.ParseSan("e4") != move)
+{
+ throw new InvalidOperationException("The package consumer could not format and parse SAN.");
+}
+
+Span san = stackalloc char[7];
+if (!game.TryFormatSan(move, san, out int written) || !san[..written].SequenceEqual("e4")
+ || !game.TryParseSan(san[..written], out var parsed) || parsed != move
+ || game.History.Count != 0)
+{
+ throw new InvalidOperationException("The package consumer could not query SAN through spans.");
+}
+
+if (game.MakeSanMove("e4+") != MoveResult.None || game.MakeSanMove("e4") == MoveResult.None
+ || !game.UndoMove() || game.ToFen() != ChessGame.StartingFen)
+{
+ throw new InvalidOperationException("The package consumer could not apply and undo canonical SAN.");
+}
'@
[System.IO.File]::WriteAllText((Join-Path $consumerRoot "Program.cs"), $program)
diff --git a/src/ChessRealms.Engine.Benchmark/SanBenchmarks.cs b/src/ChessRealms.Engine.Benchmark/SanBenchmarks.cs
new file mode 100644
index 0000000..7b80d5b
--- /dev/null
+++ b/src/ChessRealms.Engine.Benchmark/SanBenchmarks.cs
@@ -0,0 +1,65 @@
+using BenchmarkDotNet.Attributes;
+
+namespace ChessRealms.Engine.Benchmark;
+
+[MemoryDiagnoser]
+public class SanBenchmarks
+{
+ private ChessGame game = null!;
+ private CoordinateMove move;
+ private string san = null!;
+
+ [Params("Opening", "Ambiguous", "Mate")]
+ public string Scenario { get; set; } = "Opening";
+
+ [GlobalSetup]
+ public void Setup()
+ {
+ (string fen, string coordinate, string notation) = Scenario switch
+ {
+ "Opening" => (ChessGame.StartingFen, "e2e4", "e4"),
+ "Ambiguous" => ("7k/8/8/8/8/1N6/8/1N3N1K w - - 0 1", "b1d2", "Nb1d2"),
+ "Mate" => ("7k/5Q2/6K1/8/8/8/8/8 w - - 0 1", "f7g7", "Qg7#"),
+ _ => throw new InvalidOperationException("Unknown SAN benchmark scenario.")
+ };
+ game = ChessGame.FromFen(fen);
+ move = CoordinateMove.Parse(coordinate);
+ san = notation;
+ if (game.FormatSan(move) != san || game.ParseSan(san) != move)
+ {
+ throw new InvalidOperationException("Invalid SAN benchmark fixture.");
+ }
+ }
+
+ [Benchmark]
+ public string FormatString() => game.FormatSan(move);
+
+ [Benchmark]
+ public int FormatSpan()
+ {
+ Span destination = stackalloc char[7];
+ return game.TryFormatSan(move, destination, out int written) ? written : 0;
+ }
+
+ [Benchmark]
+ public CoordinateMove ParseSpan() => game.ParseSan(san.AsSpan());
+
+ [Benchmark]
+ public MoveResult CoordinateMakeAndUndo() => Undo(game.MakeMove(move));
+
+ [Benchmark]
+ public MoveResult ParseMakeAndUndo() => Undo(game.MakeMove(game.ParseSan(san)));
+
+ [Benchmark]
+ public MoveResult SanMakeAndUndo() => Undo(game.MakeSanMove(san));
+
+ private MoveResult Undo(MoveResult result)
+ {
+ if (result == MoveResult.None || !game.UndoMove())
+ {
+ throw new InvalidOperationException("The benchmark must return to its starting position.");
+ }
+
+ return result;
+ }
+}
diff --git a/src/ChessRealms.Engine.Tests/SanTests.cs b/src/ChessRealms.Engine.Tests/SanTests.cs
new file mode 100644
index 0000000..eb786ef
--- /dev/null
+++ b/src/ChessRealms.Engine.Tests/SanTests.cs
@@ -0,0 +1,369 @@
+namespace ChessRealms.Engine.Tests;
+
+internal class SanTests
+{
+ private const string ThreeKnights = "7k/8/8/8/8/1N6/8/1N3N1K w - - 0 1";
+ private const string PinnedKnight = "7k/8/8/8/1b6/2N5/8/4K1N1 w - - 0 1";
+ private const string Castles = "r3k2r/8/8/8/8/8/8/R3K2R";
+ private const string Promotion = "7k/P7/8/8/8/8/8/7K w - - 0 1";
+ private const string Mate = "7k/5Q2/6K1/8/8/8/8/8 w - - 149 80";
+
+ private static readonly string[][] CanonicalCases =
+ [
+ [ChessGame.StartingFen, "e2e4", "e4"],
+ [ChessGame.StartingFen, "g1f3", "Nf3"],
+ ["7k/8/8/3p4/4P3/8/8/7K w - - 0 1", "e4d5", "exd5"],
+ ["7k/8/8/3p4/4P3/8/8/7K b - - 0 1", "d5e4", "dxe4"],
+ ["7k/8/8/3p4/8/2N5/8/7K w - - 0 1", "c3d5", "Nxd5"],
+ ["7k/p7/8/8/8/8/8/2B4K w - - 0 1", "c1e3", "Be3"],
+ ["7k/8/8/8/8/8/8/R6K w - - 0 1", "h1g2", "Kg2"],
+ ["7k/8/8/8/8/8/8/R6K w - - 0 1", "a1a8", "Ra8+"],
+ [Mate, "f7g7", "Qg7#"],
+ [Mate, "f7e6", "Qe6"],
+ [Castles + " w KQkq - 0 1", "e1g1", "O-O"],
+ [Castles + " w KQkq - 0 1", "e1c1", "O-O-O"],
+ [Castles + " b KQkq - 0 1", "e8g8", "O-O"],
+ [Castles + " b KQkq - 0 1", "e8c8", "O-O-O"],
+ ["5k2/8/8/8/8/8/8/4K2R w K - 0 1", "e1g1", "O-O+"],
+ ["3k4/8/8/8/8/8/8/R3K3 w Q - 0 1", "e1c1", "O-O-O+"],
+ ["7k/8/8/3pP3/8/8/8/7K w - d6 0 1", "e5d6", "exd6"],
+ ["7k/8/8/8/3Pp3/8/8/7K b - d3 0 1", "e4d3", "exd3"],
+ ["8/8/8/R2pP2k/8/8/8/7K w - d6 0 1", "e5d6", "exd6+"],
+ ["4k3/p7/8/8/8/8/4B3/4R2K w - - 0 1", "e2b5", "Bb5+"],
+ [PinnedKnight, "g1e2", "Ne2"],
+ ["7k/8/8/8/8/8/8/1N3N1K w - - 0 1", "b1d2", "Nbd2"],
+ ["7k/8/8/8/8/8/8/1N3N1K w - - 0 1", "f1d2", "Nfd2"],
+ ["7k/8/8/8/8/5N2/8/1N5K w - - 0 1", "b1d2", "Nbd2"],
+ ["7k/8/8/8/8/1N6/8/1N5K w - - 0 1", "b1d2", "N1d2"],
+ ["7k/8/8/8/8/1N6/8/1N5K w - - 0 1", "b3d2", "N3d2"],
+ [ThreeKnights, "b1d2", "Nb1d2"],
+ [ThreeKnights, "b3d2", "N3d2"],
+ [ThreeKnights, "f1d2", "Nfd2"],
+ ["7k/8/8/8/8/Q1p5/8/Q1Q4K w - - 0 1", "a1c3", "Qa1xc3+"],
+ [Promotion, "a7a8q", "a8=Q+"],
+ [Promotion, "a7a8r", "a8=R+"],
+ [Promotion, "a7a8b", "a8=B"],
+ [Promotion, "a7a8n", "a8=N"],
+ ["1r5k/P7/8/8/8/8/8/7K w - - 0 1", "a7b8q", "axb8=Q+"],
+ ["7k/8/8/8/8/8/p7/1R5K b - - 0 1", "a2b1r", "axb1=R+"],
+ ["7k/8/8/8/8/8/p7/7K b - - 0 1", "a2a1n", "a1=N"],
+ ["8/P1k5/8/8/8/8/8/7K w - - 0 1", "a7a8n", "a8=N+"],
+ ["4k3/8/8/8/8/2N1N3/8/4R2K w - - 0 1", "e3d5", "Ned5+"],
+ ["4k3/8/8/8/8/2N1N3/8/4R2K w - - 0 1", "c3d5", "Ncd5"],
+ ["7k/8/8/8/8/8/8/R6K w - - 149 80", "a1a8", "Ra8+"],
+ ["7k/8/8/8/8/8/8/R6K b - - 0 999999999999999999999999", "h8g8", "Kg8"]
+ ];
+
+ [TestCaseSource(nameof(CanonicalCases))]
+ public void CanonicalNotation_FormatsParsesAndAppliesWithoutChangingQueries(string fen, string coordinate, string san)
+ {
+ var game = ChessGame.FromFen(fen);
+ var before = game.Clone();
+ var move = CoordinateMove.Parse(coordinate);
+ Assert.That(game.FormatSan(move), Is.EqualTo(san));
+
+ Span buffer = stackalloc char[9];
+ buffer.Fill('!');
+ Assert.That(game.TryFormatSan(move, buffer[..san.Length], out int written), Is.True);
+ Assert.That(written, Is.EqualTo(san.Length));
+ Assert.That(buffer[..written].ToString(), Is.EqualTo(san));
+ Assert.That(buffer[written..].ToString(), Is.EqualTo(new string('!', 9 - written)));
+ Assert.That(game.ParseSan(san), Is.EqualTo(move));
+ Assert.That(game.TryParseSan(san, out var parsed), Is.True);
+ Assert.That(parsed, Is.EqualTo(move));
+ AssertEquivalent(before, game);
+
+ MoveResult expected = before.MakeMove(move);
+ Assert.That(expected, Is.Not.EqualTo(MoveResult.None));
+ Assert.That(game.MakeSanMove(san), Is.EqualTo(expected));
+ AssertEquivalent(before, game);
+ Assert.That(game.UndoMove(), Is.True);
+ Assert.That(before.UndoMove(), Is.True);
+ AssertEquivalent(before, game);
+ }
+
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase("e4 ")]
+ [TestCase(" e4")]
+ [TestCase("e4\n")]
+ [TestCase("E4")]
+ [TestCase("nf3")]
+ [TestCase("Pe4")]
+ [TestCase("e2e4")]
+ [TestCase("Ng1f3")]
+ [TestCase("Ngf3")]
+ [TestCase("N1f3")]
+ [TestCase("Nxf3")]
+ [TestCase("Nf3+")]
+ [TestCase("e4#")]
+ [TestCase("e4!")]
+ [TestCase("e4?!")]
+ [TestCase("e4$1")]
+ [TestCase("e4 e5")]
+ [TestCase("1.e4")]
+ [TestCase("e5")]
+ [TestCase("e4=Q")]
+ [TestCase("Nf3=Q")]
+ [TestCase("0-0")]
+ [TestCase("O-O")]
+ [TestCase("O-O-O")]
+ [TestCase("♘f3")]
+ [TestCase("e4")]
+ [TestCase("e4\0")]
+ [TestCase("--")]
+ [TestCase("*")]
+ public void InvalidStartingNotation_IsRejectedWithoutMutation(string? text)
+ {
+ AssertRejected(new ChessGame(), text);
+ }
+
+ [TestCase(ThreeKnights, "Nd2")]
+ [TestCase(ThreeKnights, "Nbd2")]
+ [TestCase(ThreeKnights, "N1d2")]
+ [TestCase(ThreeKnights, "Nb3d2")]
+ [TestCase(PinnedKnight, "Nge2")]
+ [TestCase(PinnedKnight, "Nce2")]
+ [TestCase(Promotion, "a8")]
+ [TestCase(Promotion, "a8Q+")]
+ [TestCase(Promotion, "a8=q+")]
+ [TestCase(Promotion, "a8=P")]
+ [TestCase(Promotion, "a8=K")]
+ [TestCase(Promotion, "a8=Q")]
+ [TestCase(Promotion, "a8=Q#")]
+ [TestCase(Promotion, "a8=Q++")]
+ [TestCase(Mate, "Qg7")]
+ [TestCase(Mate, "Qg7+")]
+ [TestCase(Mate, "Qe6=")]
+ [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6 0 1", "ed6")]
+ [TestCase("7k/8/8/3pP3/8/8/8/7K w - d6 0 1", "exd6 e.p.")]
+ [TestCase("7k/8/8/r4pPK/8/8/8/8 w - f6 0 1", "gxf6")]
+ [TestCase("4kr2/8/8/8/8/8/8/R3K2R w KQ - 0 1", "O-O")]
+ // Both knights can play d5, but only e3d5 uncovers the rook's check.
+ [TestCase("4k3/8/8/8/8/2N1N3/8/4R2K w - - 0 1", "Nd5+")]
+ public void NoncanonicalAmbiguousOrIllegalNotation_IsRejected(string fen, string text)
+ {
+ AssertRejected(ChessGame.FromFen(fen), text);
+ }
+
+ [TestCaseSource(nameof(CanonicalCases))]
+ public void ShortDestinations_AreNeverPartiallyWritten(string fen, string coordinate, string san)
+ {
+ var game = ChessGame.FromFen(fen);
+ var before = game.Clone();
+ Span buffer = stackalloc char[7];
+ for (int length = 0; length < san.Length; ++length)
+ {
+ buffer.Fill('!');
+ Assert.That(game.TryFormatSan(CoordinateMove.Parse(coordinate), buffer[..length], out int written), Is.False);
+ Assert.That(written, Is.Zero);
+ Assert.That(buffer.ToString(), Is.EqualTo("!!!!!!!"));
+ }
+
+ AssertEquivalent(before, game);
+ }
+
+ [Test]
+ public void InvalidCoordinateMoves_CannotBeFormatted()
+ {
+ var game = new ChessGame();
+ var before = game.Clone();
+ CoordinateMove[] moves = [default, CoordinateMove.Parse("e2e5"), CoordinateMove.Parse("e2e4q"),
+ CoordinateMove.Parse("e7e5"), CoordinateMove.Parse("e1g1")];
+ Span buffer = stackalloc char[7];
+ foreach (var move in moves)
+ {
+ buffer.Fill('!');
+ Assert.That(game.TryFormatSan(move, buffer, out int written), Is.False);
+ Assert.That(written, Is.Zero);
+ Assert.That(buffer.ToString(), Is.EqualTo("!!!!!!!"));
+ Assert.Throws(() => game.FormatSan(move));
+ }
+
+ AssertEquivalent(before, game);
+ }
+
+ [TestCase("7k/6Q1/6K1/8/8/8/8/8 b - - 0 1", "h8g8", "Kg8")]
+ [TestCase("7k/5Q2/6K1/8/8/8/8/8 b - - 0 1", "h8g8", "Kg8")]
+ [TestCase("7k/8/8/8/8/8/8/7K w - - 0 1", "h1g1", "Kg1")]
+ [TestCase("7k/8/8/8/8/8/8/R6K w - - 150 1", "a1a2", "Ra2")]
+ public void FinishedPositions_RejectAllSanOperations(string fen, string coordinate, string san)
+ {
+ var game = ChessGame.FromFen(fen);
+ Assert.That(game.IsFinished, Is.True);
+ AssertFinished(game, coordinate, san);
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void Queries_PreserveRepetitionClaimsHistoryAndEveryUndo(bool claim)
+ {
+ var game = new ChessGame();
+ Play(game, "g1f3 g8f6 f3g1 f6g8 g1f3 g8f6 f3g1");
+ if (claim)
+ {
+ Assert.That(game.ClaimDraw(DrawClaim.ThreefoldRepetition, CoordinateMove.Parse("f6g8")), Is.True);
+ AssertFinished(game, "f6g8", "Ng8");
+ }
+ else
+ {
+ var move = CoordinateMove.Parse("f6g8");
+ var before = game.Clone();
+ Assert.That(game.ParseSan("Ng8"), Is.EqualTo(move));
+ Assert.That(game.FormatSan(move), Is.EqualTo("Ng8"));
+ AssertRejected(game, "Ng8+");
+ AssertEquivalent(before, game);
+ AssertUndoEquivalent(before, game);
+ }
+ }
+
+ [Test]
+ public void FivefoldRepetition_RejectsFurtherSanMoves()
+ {
+ var game = new ChessGame();
+ for (int i = 0; i < 4; ++i)
+ {
+ Play(game, "g1f3 g8f6 f3g1 f6g8");
+ }
+
+ Assert.That(game.Outcome.Reason, Is.EqualTo(FinishReason.FivefoldRepetition));
+ AssertFinished(game, "g1f3", "Nf3");
+ }
+
+ [Test]
+ public void ClaimWithoutHistory_RemainsIntactAfterQueriesAndFailedUndo()
+ {
+ 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);
+ AssertFinished(game, "a1a2", "Ra2");
+ }
+
+ [Test]
+ public void SpanInput_UsesOnlyTheSuppliedSlice()
+ {
+ var game = new ChessGame();
+ ReadOnlySpan input = "prefixNf3suffix";
+ Assert.That(game.ParseSan(input.Slice(6, 3)), Is.EqualTo(CoordinateMove.Parse("g1f3")));
+ Assert.That(game.History, Is.Empty);
+ }
+
+ [TestCase(17)]
+ [TestCase(12345)]
+ public void DeterministicGames_RoundTripEveryLegalMoveAndPreserveState(int seed)
+ {
+ var game = new ChessGame();
+ var random = new Random(seed);
+ for (int ply = 0; ply < 100 && !game.IsFinished; ++ply)
+ {
+ var before = game.Clone();
+ var moves = game.GetLegalMoves();
+ var notation = new HashSet(StringComparer.Ordinal);
+ foreach (var move in moves)
+ {
+ string san = game.FormatSan(move);
+ Assert.That(notation.Add(san), Is.True, san);
+ Assert.That(game.ParseSan(san), Is.EqualTo(move), san);
+ var copy = game.Clone();
+ var result = copy.MakeMove(move);
+ Assert.That(san.EndsWith('#'), Is.EqualTo(result.HasFlag(MoveResult.Checkmate)));
+ Assert.That(san.EndsWith('+'), Is.EqualTo(result.HasFlag(MoveResult.Check)
+ && !result.HasFlag(MoveResult.Checkmate)));
+ }
+
+ AssertEquivalent(before, game);
+ var chosen = moves[random.Next(moves.Count)];
+ string text = game.FormatSan(chosen);
+ Assert.That(game.MakeSanMove(text), Is.EqualTo(before.MakeMove(chosen)));
+ AssertEquivalent(before, game);
+ }
+ }
+
+ [TestCaseSource(nameof(CanonicalCases))]
+ public void SpanQueries_DoNotAllocateAfterWarmup(string fen, string coordinate, string san)
+ {
+ var game = ChessGame.FromFen(fen);
+ var move = CoordinateMove.Parse(coordinate);
+ Span buffer = stackalloc char[7];
+ for (int i = 0; i < 64; ++i)
+ {
+ game.TryFormatSan(move, buffer, out _);
+ game.TryParseSan(san, out _);
+ }
+
+ long start = GC.GetAllocatedBytesForCurrentThread();
+ bool success = true;
+ for (int i = 0; i < 128; ++i)
+ {
+ success &= game.TryFormatSan(move, buffer, out _);
+ success &= game.TryParseSan(san, out var parsed) && parsed == move;
+ success &= !game.TryParseSan("invalid", out _);
+ }
+
+ long allocated = GC.GetAllocatedBytesForCurrentThread() - start;
+ Assert.That(success, Is.True);
+ Assert.That(allocated, Is.Zero);
+ }
+
+ private static void AssertRejected(ChessGame game, string? text)
+ {
+ var before = game.Clone();
+ Assert.That(game.TryParseSan(text, out var move), Is.False);
+ Assert.That(move, Is.EqualTo(default(CoordinateMove)));
+ Assert.Throws(() => game.ParseSan(text));
+ Assert.That(game.MakeSanMove(text), Is.EqualTo(MoveResult.None));
+ AssertEquivalent(before, game);
+ }
+
+ private static void AssertFinished(ChessGame game, string coordinate, string san)
+ {
+ var before = game.Clone();
+ var move = CoordinateMove.Parse(coordinate);
+ Span buffer = stackalloc char[7];
+ buffer.Fill('!');
+ Assert.That(game.TryFormatSan(move, buffer, out int written), Is.False);
+ Assert.That(written, Is.Zero);
+ Assert.That(buffer.ToString(), Is.EqualTo("!!!!!!!"));
+ Assert.Throws(() => game.FormatSan(move));
+ AssertRejected(game, san);
+ AssertEquivalent(before, game);
+ AssertUndoEquivalent(before, game);
+ }
+
+ private static void AssertEquivalent(ChessGame expected, ChessGame actual)
+ {
+ Assert.Multiple(() =>
+ {
+ Assert.That(actual.Position, Is.EqualTo(expected.Position));
+ Assert.That(actual.ToFen(), Is.EqualTo(expected.ToFen()));
+ Assert.That(actual.Outcome, Is.EqualTo(expected.Outcome));
+ Assert.That(actual.State, Is.EqualTo(expected.State));
+ Assert.That(actual.History, Is.EqualTo(expected.History));
+ Assert.That(actual.RepetitionCount, Is.EqualTo(expected.RepetitionCount));
+ Assert.That(actual.AvailableDrawClaims, Is.EqualTo(expected.AvailableDrawClaims));
+ Assert.That(actual.DrawIntendedMove, Is.EqualTo(expected.DrawIntendedMove));
+ Assert.That(actual.GetLegalMoves(), Is.EqualTo(expected.GetLegalMoves()));
+ Assert.That(GameRecord.Serialize(actual.ExportRecord()), Is.EqualTo(GameRecord.Serialize(expected.ExportRecord())));
+ });
+ }
+
+ private static void AssertUndoEquivalent(ChessGame expected, ChessGame actual)
+ {
+ while (expected.UndoMove())
+ {
+ Assert.That(actual.UndoMove(), Is.True);
+ AssertEquivalent(expected, actual);
+ }
+
+ Assert.That(actual.UndoMove(), Is.False);
+ AssertEquivalent(expected, actual);
+ }
+
+ private static void Play(ChessGame game, string moves)
+ {
+ foreach (string move in moves.Split(' '))
+ {
+ Assert.That(game.MakeMove(CoordinateMove.Parse(move)), Is.Not.EqualTo(MoveResult.None));
+ }
+ }
+}
diff --git a/src/ChessRealms.Engine/ChessGame.San.cs b/src/ChessRealms.Engine/ChessGame.San.cs
new file mode 100644
index 0000000..e62979b
--- /dev/null
+++ b/src/ChessRealms.Engine/ChessGame.San.cs
@@ -0,0 +1,319 @@
+using ChessRealms.Engine.Core.Constants;
+using ChessRealms.Engine.Core.Math;
+using ChessRealms.Engine.Core.Movements;
+using ChessRealms.Engine.Core.Types;
+
+namespace ChessRealms.Engine;
+
+public sealed partial class ChessGame
+{
+ // Canonical SAN is at most seven ASCII characters, for example Qa6xb7#.
+ private const int MaxSanLength = 7;
+
+ /// Formats a playable move as canonical Standard Algebraic Notation in the current position.
+ /// Call before applying the move. Does not change the game.
+ /// The move is invalid, illegal, or the game is finished.
+ public string FormatSan(CoordinateMove move)
+ {
+ Span text = stackalloc char[MaxSanLength];
+ return TryFormatSan(move, text, out int written)
+ ? new string(text[..written])
+ : throw new ArgumentException("The move must be playable in the current game.", nameof(move));
+ }
+
+ /// Writes canonical SAN for a playable move into a character span without allocating a string.
+ /// Uses the position before the move and does not change the game. Returns false with zero
+ /// characters written and leaves the destination unchanged for an invalid or illegal move, a finished
+ /// game, or insufficient space. Seven characters suffice for any canonical SAN move.
+ public bool TryFormatSan(CoordinateMove move, Span destination, out int charsWritten)
+ {
+ charsWritten = 0;
+ if (IsFinished || !move.IsValid || destination.Length < 2)
+ {
+ return false;
+ }
+
+ Span legalMoves = stackalloc int[MoveGen.MaxMoves];
+ int count = WriteLegalMoves(position, legalMoves);
+ if (!TryFindMove(move, legalMoves[..count], out int encodedMove))
+ {
+ return false;
+ }
+
+ Span text = stackalloc char[MaxSanLength];
+ int length = WriteSan(position, encodedMove, legalMoves[..count], text);
+ if (destination.Length < length)
+ {
+ return false;
+ }
+
+ text[..length].CopyTo(destination);
+ charsWritten = length;
+ return true;
+ }
+
+ /// Resolves canonical SAN to a legal coordinate move without changing the game.
+ /// Requires exact case, minimal disambiguation, and the correct check or mate suffix.
+ /// Whitespace, annotations and alternative spellings are not accepted.
+ /// The text is not canonical SAN for a playable move in this game.
+ public CoordinateMove ParseSan(ReadOnlySpan text)
+ => TryParseSan(text, out var move) ? move : throw new FormatException("Expected canonical SAN for a playable move.");
+
+ /// Tries to resolve canonical SAN to a legal coordinate move without changing the game.
+ /// Returns false and a default move for malformed, ambiguous, noncanonical or illegal input,
+ /// or a finished game. The check or mate suffix must be exact. Does not allocate strings.
+ public bool TryParseSan(ReadOnlySpan text, out CoordinateMove move)
+ {
+ move = default;
+ if (!TryFindSanMove(text, out int encodedMove))
+ {
+ return false;
+ }
+
+ move = ToPublicMove(encodedMove);
+ return true;
+ }
+
+ /// Resolves and plays canonical SAN without repeating the current-position legal-move search.
+ /// Uses the same notation rules as ParseSan and the same history and outcome rules as MakeMove.
+ /// Returns MoveResult.None without changing the game for invalid input or a finished game.
+ public MoveResult MakeSanMove(ReadOnlySpan text)
+ => TryFindSanMove(text, out int encodedMove)
+ ? ApplyLegalMove(ToPublicMove(encodedMove), encodedMove)
+ : MoveResult.None;
+
+ private bool TryFindSanMove(ReadOnlySpan text, out int encodedMove)
+ {
+ encodedMove = 0;
+ if (IsFinished || !TryReadSanPattern(text, out var pattern))
+ {
+ return false;
+ }
+
+ Span legalMoves = stackalloc int[MoveGen.MaxMoves];
+ int count = WriteLegalMoves(position, legalMoves);
+ int selected = 0;
+ foreach (int candidate in legalMoves[..count])
+ {
+ if (!pattern.Matches(candidate))
+ {
+ continue;
+ }
+
+ // A check suffix cannot disambiguate two otherwise matching moves.
+ if (selected != 0)
+ {
+ return false;
+ }
+
+ selected = candidate;
+ }
+
+ if (selected == 0)
+ {
+ return false;
+ }
+
+ // Only the selected move needs check/mate evaluation. This also rejects
+ // unnecessary source qualifiers and all noncanonical spellings.
+ Span canonical = stackalloc char[MaxSanLength];
+ int length = WriteSan(position, selected, legalMoves[..count], canonical);
+ if (!text.SequenceEqual(canonical[..length]))
+ {
+ return false;
+ }
+
+ encodedMove = selected;
+ return true;
+ }
+
+ private static int WriteSan(Position before, int move, ReadOnlySpan legalMoves, Span text)
+ {
+ int length = 0;
+ int castling = BinaryMoveOps.DecodeCastling(move);
+ if (castling != Castlings.None)
+ {
+ ReadOnlySpan castle = (castling & (Castlings.WK | Castlings.BK)) != 0 ? "O-O" : "O-O-O";
+ castle.CopyTo(text);
+ length = castle.Length;
+ }
+ else
+ {
+ int piece = BinaryMoveOps.DecodeSrcPiece(move);
+ int source = BinaryMoveOps.DecodeSrc(move);
+ int target = BinaryMoveOps.DecodeTrg(move);
+ bool capture = BinaryMoveOps.DecodeCapture(move) != 0;
+ if (piece != Pieces.Pawn)
+ {
+ text[length++] = "PNBRQK"[piece];
+ bool alternative = false;
+ bool sameFile = false;
+ bool sameRank = false;
+ foreach (int candidate in legalMoves)
+ {
+ int otherSource = BinaryMoveOps.DecodeSrc(candidate);
+ if (otherSource == source || BinaryMoveOps.DecodeSrcPiece(candidate) != piece
+ || BinaryMoveOps.DecodeTrg(candidate) != target)
+ {
+ continue;
+ }
+
+ alternative = true;
+ sameFile |= otherSource % 8 == source % 8;
+ sameRank |= otherSource / 8 == source / 8;
+ }
+
+ if (alternative)
+ {
+ if (!sameFile || sameRank)
+ {
+ text[length++] = (char)('a' + source % 8);
+ }
+
+ if (sameFile)
+ {
+ text[length++] = (char)('1' + source / 8);
+ }
+ }
+ }
+ else if (capture)
+ {
+ text[length++] = (char)('a' + source % 8);
+ }
+
+ if (capture)
+ {
+ text[length++] = 'x';
+ }
+
+ text[length++] = (char)('a' + target % 8);
+ text[length++] = (char)('1' + target / 8);
+ int promotion = BinaryMoveOps.DecodePromotion(move);
+ if (promotion != Promotions.None)
+ {
+ text[length++] = '=';
+ text[length++] = "PNBRQK"[promotion];
+ }
+ }
+
+ // SAN needs board geometry, not counters or repetition history. In
+ // particular an automatic draw with check must not be mistaken for mate.
+ var after = before;
+ MoveDriver.MakeMove(ref after, move, updateCounters: false);
+ after.SwitchColor();
+ if (after.IsKingChecked(after.color))
+ {
+ Span replies = stackalloc int[MoveGen.MaxMoves];
+ text[length++] = WriteLegalMoves(after, replies) == 0 ? '#' : '+';
+ }
+
+ return length;
+ }
+
+ private static bool TryReadSanPattern(ReadOnlySpan text, out SanPattern pattern)
+ {
+ pattern = default;
+ if (text.Length is < 2 or > MaxSanLength)
+ {
+ return false;
+ }
+
+ if (text[^1] is '+' or '#')
+ {
+ text = text[..^1];
+ }
+
+ if (text.SequenceEqual("O-O") || text.SequenceEqual("O-O-O"))
+ {
+ int rights = text.Length == 3 ? Castlings.WK | Castlings.BK : Castlings.WQ | Castlings.BQ;
+ pattern = new(rights, Pieces.King, 0, Promotions.None, false, -1, -1);
+ return true;
+ }
+
+ int promotion = Promotions.None;
+ if (text.Length >= 2 && text[^2] == '=')
+ {
+ promotion = ReadSanPiece(text[^1]);
+ if (!Promotions.IsValid(promotion))
+ {
+ return false;
+ }
+
+ text = text[..^2];
+ }
+
+ if (text.Length < 2 || !Square.TryParse(text[^2..], out var target))
+ {
+ return false;
+ }
+
+ var prefix = text[..^2];
+ int piece = prefix.IsEmpty ? Pieces.None : ReadSanPiece(prefix[0]);
+ if (piece != Pieces.None)
+ {
+ prefix = prefix[1..];
+ }
+ else
+ {
+ piece = Pieces.Pawn;
+ }
+
+ bool capture = !prefix.IsEmpty && prefix[^1] == 'x';
+ if (capture)
+ {
+ prefix = prefix[..^1];
+ }
+
+ int file = -1;
+ int rank = -1;
+ if (!prefix.IsEmpty && prefix[0] is >= 'a' and <= 'h')
+ {
+ file = prefix[0] - 'a';
+ prefix = prefix[1..];
+ }
+
+ if (!prefix.IsEmpty && prefix[0] is >= '1' and <= '8')
+ {
+ rank = prefix[0] - '1';
+ prefix = prefix[1..];
+ }
+
+ if (!prefix.IsEmpty || (piece == Pieces.Pawn && (rank != -1 || capture != (file != -1)))
+ || (piece != Pieces.Pawn && promotion != Promotions.None))
+ {
+ return false;
+ }
+
+ pattern = new(Castlings.None, piece, target.Index, promotion, capture, file, rank);
+ return true;
+ }
+
+ private static int ReadSanPiece(char symbol) => symbol switch
+ {
+ 'N' => Pieces.Knight,
+ 'B' => Pieces.Bishop,
+ 'R' => Pieces.Rook,
+ 'Q' => Pieces.Queen,
+ 'K' => Pieces.King,
+ _ => Pieces.None
+ };
+
+ private readonly record struct SanPattern(int Castling, int Piece, int Target, int Promotion,
+ bool Capture, int File, int Rank)
+ {
+ public bool Matches(int move)
+ {
+ int castling = BinaryMoveOps.DecodeCastling(move);
+ if (Castling != Castlings.None)
+ {
+ return (castling & Castling) != 0;
+ }
+
+ int source = BinaryMoveOps.DecodeSrc(move);
+ return castling == Castlings.None && BinaryMoveOps.DecodeSrcPiece(move) == Piece
+ && BinaryMoveOps.DecodeTrg(move) == Target && BinaryMoveOps.DecodePromotion(move) == Promotion
+ && (BinaryMoveOps.DecodeCapture(move) != 0) == Capture
+ && (File == -1 || source % 8 == File) && (Rank == -1 || source / 8 == Rank);
+ }
+ }
+}
diff --git a/src/ChessRealms.Engine/ChessGame.cs b/src/ChessRealms.Engine/ChessGame.cs
index e033126..fd2aa78 100644
--- a/src/ChessRealms.Engine/ChessGame.cs
+++ b/src/ChessRealms.Engine/ChessGame.cs
@@ -9,7 +9,7 @@
namespace ChessRealms.Engine;
/// A mutable game with exclusive history ownership. Use Clone for independent analysis.
-public sealed class ChessGame
+public sealed partial class ChessGame
{
/// The FEN for the standard starting position.
public const string StartingFen = FenStrings.StartPosition;
@@ -186,6 +186,11 @@ public MoveResult MakeMove(CoordinateMove move)
return MoveResult.None;
}
+ return ApplyLegalMove(move, encodedMove);
+ }
+
+ private MoveResult ApplyLegalMove(CoordinateMove move, int encodedMove)
+ {
var nextPosition = position;
MoveDriver.MakeMove(ref nextPosition, encodedMove);
nextPosition.SwitchColor();
@@ -309,7 +314,13 @@ private bool TryFindMove(CoordinateMove move, out int encodedMove)
Span legalMoves = stackalloc int[MoveGen.MaxMoves];
int legalMoveCount = WriteLegalMoves(position, legalMoves);
- foreach (int candidate in legalMoves[..legalMoveCount])
+ return TryFindMove(move, legalMoves[..legalMoveCount], out encodedMove);
+ }
+
+ private static bool TryFindMove(CoordinateMove move, ReadOnlySpan legalMoves, out int encodedMove)
+ {
+ encodedMove = 0;
+ foreach (int candidate in legalMoves)
{
if (ToPublicMove(candidate) != move)
{