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 @@ -5,6 +5,7 @@
<Folder Name="/docs/" Id="a576d511-a7dd-48da-89d2-a534742656e6">
<File Path="docs/game-rules-api.md" />
<File Path="docs/performance.md" />
<File Path="docs/pgn.md" />
<File Path="docs/releasing.md" />
<File Path="docs/san.md" />
</Folder>
Expand Down
24 changes: 20 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
15 changes: 14 additions & 1 deletion docs/game-rules-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
105 changes: 42 additions & 63 deletions docs/performance.md
Original file line number Diff line number Diff line change
@@ -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.
147 changes: 147 additions & 0 deletions docs/pgn.md
Original file line number Diff line number Diff line change
@@ -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<string, string>
{
["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<char> 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.
Loading
Loading