perftcheck
CLI linux · macOS · windows
Cross-platform CLI that points at a UCI chess engine and
validates its
go perft N
output against
142,953 known-correct positions
(depths 1–7) bundled inside the binary. When a
mismatch is found, optionally drills down move-by-move until
it surfaces the exact leaf position where the engine's
move-generator diverges from truth.
Install
Self-contained single-file binary, ~84 MB (the corpus is embedded). Published to GitHub Releases on every tagged build. No .NET runtime required on the target machine. The downloads below point at perftcheck-0.3.0.
| Platform | Architecture | Asset |
|---|---|---|
| Linux | x86_64 | perftcheck-linux-x64 |
| Linux | aarch64 | perftcheck-linux-arm64 |
| macOS | Intel | perftcheck-osx-x64 |
| macOS | Apple Silicon | perftcheck-osx-arm64 |
| Windows | x86_64 | perftcheck-win-x64.exe |
macOS / Linux
$ curl -L -o perftcheck \ https://github.com/Timmoth/grandchesstree/releases/download/perftcheck-0.3.0/perftcheck-<rid> $ chmod +x perftcheck $ ./perftcheck --engine ./your-engine
On macOS, Gatekeeper may quarantine the binary on first run.
Right-click → Open, or run
xattr -d com.apple.quarantine perftcheck.
Windows (PowerShell)
PS> Invoke-WebRequest ` https://github.com/Timmoth/grandchesstree/releases/download/perftcheck-0.3.0/perftcheck-win-x64.exe ` -OutFile perftcheck.exe PS> .\perftcheck.exe --engine .\your-engine.exe
Usage
One required flag —
--engine,
pointing at a UCI executable. The default run walks every
bundled position to depth 4 and writes
perft-report.json.
Source & full docs:
Perft-Checker/.
$ ./perftcheck --engine ./stockfish
| Flag | Default | Description |
|---|---|---|
-e, --engine <PATH> | required | UCI engine executable |
-s, --static-analysis <FILE> | embedded corpus | Override the bundled corpus. Accepts corpus.gctc.gz (binary) or static_analysis.jsonl. |
--depth-cap <N> | 4 | Skip cases deeper than this |
--depth-min <N> | 1 | Skip cases shallower than this |
--timeout <SECS> | 30 | Per-case timeout; engine is killed and restarted on exceed |
--filter <SUBSTR> | — | Only run cases whose FEN contains this substring |
--quality <TIER> | all | Restrict by context-quality tier: high, medium, low, both (high+med), all |
--tag <NAME> | — | Only run cases whose tags contain this tag. Repeatable; AND across all flags. |
--limit <N> | — | Take only the first N matching cases |
--report <PATH> | perft-report.json | Path to write the JSON report |
--fail-fast | off | Stop on the first non-pass |
--quiet | off | Suppress live console output (CI mode) |
--perft-command <CMD> | go perft | UCI verb sent before the depth number. Set to perft for engines (Potential, Stormphrax) that accept the bare form. |
--bare-number-total | off | Accept a bare integer on its own line as the perft total — needed by Stormphrax (and a few others) that print just the number with no Nodes: prefix. Off by default so numeric debug spew doesn't get mis-parsed. |
--drill-down | off | On every mismatch, walk divides depth-by-depth until the leaf position where move-gen first diverges. Requires --ref-engine. |
--ref-engine <PATH> | — | GrandChessTree.Engine binary used as the divide+apply oracle for --drill-down. |
--include-edge-cases | off | Include 13 positions tagged edge_case_engine_disagreement — FENs where production engines historically diverge from the Stockfish/TGCT oracle (en-passant blocks check, near-50-move-rule, pathological piece counts). Off by default to keep clean runs clean. |
Exit codes:
0 all passed,
1 at least one failure,
2 bad args.
The corpus
142,953 positions scraped from forum posts, GitHub issues,
EPDs, and chessprogramming.org — each carrying its
source URLs, deterministic feature tags
(en_passant_capture_possible,
castling_white_kingside, etc.),
full
d1–d7 perft node counts,
and the
per-move root divides
used by drill-down.
Binary format — bundled
corpus.gctc.gz —
custom gzipped binary, ~46 MB. URL
dedup, packed 16-bit UCI moves, varint counts. Embedded
in the perftcheck binary. Format spec lives at the top
of
fen_corpus/scripts/09_pack.py.
JSONL — authoring
static_analysis.jsonl —
one JSON row per FEN, ~388 MB. Greppable; used as
the input that 09_pack.py compresses. Pass either format
to --static-analysis
and perftcheck transparently detects which one.
The pipeline that builds the corpus (search → fetch → extract → describe → perft → pack) lives in Perft-Checker/fen_corpus. d1–d6 totals are computed by GrandChessTree.Engine divide; d7 stragglers come from MPerft with nullmove counting on a small set of pathological multi-queen / mass-promotion positions.
Failure aggregation
After every run, the summary cross-tabulates the deterministic feature tags across the failure set. Tags present in ≥50% of failures are highlighted as strong root-cause signals.
Tags across the 17 failure(s) (top 20 of 23, ≥50% highlighted): ╭────────────────────────────────┬──────────┬──────────╮ │ tag │ failures │ fraction │ ├────────────────────────────────┼──────────┼──────────┤ │ en_passant_capture_possible │ 16 │ 94 % │ ← strong signal │ side_to_move_black │ 14 │ 82 % │ ← strong signal │ castling_white_kingside │ 9 │ 53 % │ ← strong signal │ material_equal │ 6 │ 35 % │ │ middlegame_phase │ 5 │ 29 % │ …
Same data lands in
failureTags.countByTag
/
fractionByTag
in the JSON report for programmatic analysis.
Drill-down: find the exact buggy move
When the engine's perft total disagrees with truth at depth
N, the bug is usually one move in one sub-tree.
Manually narrowing that down is the classic "perft divide
→ pick wrong root → apply → divide again"
loop, repeated until depth 1 where the divide is the legal
move list and the diff is the bug.
--drill-down
automates that loop.
Point it at a trusted reference engine — the
GrandChessTree.Engine in this repo, which accepts the
extended
divide:<d>:<mb>:<fen>:<moves>
command (per-move divide
and the resulting FEN after applying an optional
move list, in one round-trip):
$ ./perftcheck \
--engine ./your-buggy-engine \
--drill-down --ref-engine ./GrandChessTree.Engine
For every mismatch the runner:
- Compares the test engine's divide at the failing position to the corpus's
divide_d<N>. - Picks the first move whose count differs (missing → extra → wrong-count; missing moves are usually the actionable signal).
- Applies that move via the reference engine, descends to depth N−1, asks the reference for the new expected divide, and compares again.
- Stops at depth 1, where the diff between the test engine's and the reference's move lists is the bug — which moves the engine illegally generates, and which it failed to generate, at a single specific position.
Each failure in the JSON report gains a
drillDown
block:
{
"drillDown": {
"bugDepth": 1,
"moveSequence": ["e2e4", "g8f6", "f1c4"],
"leafFen": "rnbqkb1r/pppp1ppp/5n2/4p3/2B1P3/8/PPPP1PPP/RNBQK1NR b KQkq - 2 2",
"missingMoves": ["d8e7"],
"extraMoves": [],
"wrongCount": {}
}
}
A bug-depth histogram is printed at the end of the run.
A tall d=1 column
means a pure move-generator issue; deeper bottom-outs point
at make-move / unmake bugs (where the engine's d=1 divide
after a fresh
position fen
agrees with truth, but its internal state after applying the
same moves doesn't).
Cost: each mismatch adds roughly N reference-engine round-trips. For ~400 failures at depth 4, drill-down typically adds 5–15 minutes.
Common recipes
CI gate — quiet output
$ perftcheck -e ./engine \
--quiet --report perft.json
Just the curated subset
$ perftcheck -e ./engine --quality high
Only en-passant positions
$ perftcheck -e ./engine \
--tag en_passant_capture_possible
Bisection — halt on first divergence
$ perftcheck -e ./engine --fail-fast
Engines using bare perft N
$ perftcheck -e ./potential \
--perft-command perft
Potential and a few others omit the go prefix.
Stormphrax — bare total & bare verb
$ perftcheck -e ./stormphrax \
--perft-command perft \
--bare-number-total
Stormphrax prints the perft total as a lone integer.
Stress-test exotic positions
$ perftcheck -e ./engine \
--include-edge-cases
Adds 13 positions where production engines historically diverge (EP-blocks-check, near-50-move-rule, pathological piece counts).
Diagnose every mismatch to a leaf
$ perftcheck -e ./buggy \
--drill-down \
--ref-engine ./GrandChessTree.Engine
JSON report
Every run writes a single self-describing JSON file
(default
perft-report.json).
Structured for programmatic ingestion — CI systems,
spreadsheets, comparison scripts.
{
"tool": "perftcheck",
"version": "0.3.0",
"engine": "/abs/path/to/engine",
"engineId": "Stockfish 18",
"startedUtc": "2026-05-30T12:00:00Z",
"durationSeconds": 21.94,
"options": {
"depthMin": 1, "depthCap": 4, "timeoutSeconds": 30,
"staticAnalysisFile": "embedded:corpus.gctc.gz"
},
"totals": { "cases": 570293, "passed": 569869, "failed": 424 },
"failures": [
{
"kind": "mismatch",
"fen": "…",
"depth": 3,
"expected": 12345,
"actual": 12344,
"diff": -1,
"source": "<embedded:corpus.gctc.gz>:8721",
"tags": ["en_passant_capture_possible", "side_to_move_black", …],
"sourceUrls": ["https://github.com/X/Y/issues/123", …],
"drillDown": { … } // when --drill-down is on
}
],
"failureTags": {
"failuresWithTags": 17,
"countByTag": { "en_passant_capture_possible": 16, … },
"fractionByTag": { "en_passant_capture_possible": 0.94, … }
}
}
Failure kinds:
"mismatch" (node-count
differs —
expected/actual/diff
populated),
"timeout"
(engine didn't respond within
--timeout
seconds),
"error"
(engine pipe closed or output unparseable; raw output
captured in engineOutput,
truncated to ~2 KB).
Engine compatibility
-
Stockfish —
works out of the box. Uses
Nodes searched: Nfor the total ande2e4: 600for divide lines. - Komodo, Ethereal, Berserk, Lc0 — UCI-standard, work out of the box.
-
Stormphrax —
needs both
--perft-command perftand--bare-number-total. Total prints as a lone integer on its own line; thego perftverb isn't recognised. Has a small known-divergence pocket (EP-blocks-check, pathological piece counts) covered by--include-edge-cases. -
StockDory —
works out of the box; total uses comma thousands
separators
(
Nodes searched: 1,234,567) and parses correctly. -
Viridithas —
works out of the box; total is embedded inside a UCI info
line
(
info depth N nodes K time T nps M) and parses correctly. Strictly rejectsfullmove=0andhalfmove≥100— pass FENs through a normaliser before testing if those quirks are in scope. -
Pawnocchio —
works for most positions; total has trailing timing info
(
Nodes searched: N in Tms (M nps)) and parses correctly. Hangs on a small set of pathological positions — raise--timeoutif you see timeouts. -
Jet — works
out of the box; total is the bare
Nodes: Nform. -
Potential — accepts
only the bare
perft Nform (nogoprefix). Pass--perft-command perft. Divide lines aree2e4 600(space, no colon) and parse correctly. -
GrandChessTree.Engine
(this repo) — has a minimal UCI shim so it works as
a test engine, and serves as the drill-down
oracle via its colon-delimited extension command
divide:<depth>:<mb>:<fen>[:<moves>](apply moves + divide + return resulting FEN in one round-trip). -
MoveGen
(this repo,
MoveGen/) — adds UCI mode to the demo Program.cs.
A divide line is matched by the regex
^\s*([a-h][1-8][a-h][1-8][rnbqRNBQ]?)\s*[:\s]\s*(\d+)$,
so both
e2e4: 600
and
e2e4 600
work, lowercase or uppercase promotion characters welcomed.
Build from source
You'll need the
.NET 10 SDK.
One NuGet dep
(Spectre.Console.Cli)
is restored automatically. The corpus
(corpus.gctc.gz, ~46 MB)
is embedded as a resource in the assembly — no extra
files to fetch.
Run straight from the repo
$ git clone https://github.com/Timmoth/grandchesstree.git $ cd grandchesstree/Perft-Checker $ dotnet run -c Release -- --engine /path/to/your-engine
Publish a single-file binary
Same incantation the
release workflow
uses. Substitute the
runtime identifier
for your host:
linux-x64,
linux-arm64,
osx-x64,
osx-arm64, or
win-x64.
$ dotnet publish Perft-Checker/PerftSuite.csproj \ -c Release \ -r <rid> \ --self-contained true \ -p:PublishSingleFile=true \ -p:EnableCompressionInSingleFile=true \ -p:DebugType=embedded
Resulting binary:
Perft-Checker/bin/Release/net10.0/<rid>/publish/perftcheck
(or
perftcheck.exe
on Windows). Strip the
--self-contained
flag for a framework-dependent build if .NET 10 will be
present on the target host.