Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ChessRealms.Engine.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<File Path="docs/game-rules-api.md" />
<File Path="docs/performance.md" />
<File Path="docs/releasing.md" />
<File Path="docs/san.md" />
</Folder>
<Folder Name="/scripts/" Id="9f3eeaa3-c0d2-4d93-af81-f93234779361">
<File Path="scripts/Resolve-ReleaseVersion.ps1" />
Expand Down
22 changes: 19 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<char>, out CoordinateMove)` and
`TryFormatSan(CoordinateMove, Span<char>, 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
Expand Down
12 changes: 11 additions & 1 deletion docs/game-rules-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
189 changes: 189 additions & 0 deletions docs/san.md
Original file line number Diff line number Diff line change
@@ -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<char> input = "Nf3";
Span<char> output = stackalloc char[7];

if (game.TryParseSan(input, out CoordinateMove move)
&& game.TryFormatSan(move, output, out int written))
{
ReadOnlySpan<char> 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<string>(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.
19 changes: 19 additions & 0 deletions scripts/Test-PackageConsumer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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<char> 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)

Expand Down
65 changes: 65 additions & 0 deletions src/ChessRealms.Engine.Benchmark/SanBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -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<char> 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;
}
}
Loading
Loading