個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證Pawnstar - a computer chess engine by Jonny Reckless.
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗Pawnstar is a UCI chess engine written in
C++23. It uses a bitboard board representation, a parallel alpha-beta search (Lazy SMP),
a lockless transposition table, and a perspective NNUE evaluation. The engine is header-only — every
class lives entirely in its src/*.h header as inline definitions, so only main.cpp is compiled and
linked (each test/tool is a single self-contained translation unit), and the build needs no LTO.
Legal move generation runs at roughly 600 million moves per second on a modern laptop.
pext).setoption name EvalFile.Hash and Threads UCI options, and pondering (think on the opponent's clock).pawnstar.book).clang++ with C++23 support; use clang on Windows too (the recommended, CI-validated path). MSVC cl is not a wired-up build — the embedded net/book .S files (.incbin) can't be assembled by cl — but the engine source is otherwise cl-compatible: it compiles and runs bit-identically under cl /arch:AVX2 /std:c++latest (the [[gnu::target]] attributes are harmlessly ignored). Clang stays preferred and is ~5–15% faster.-mbmi2 -mavx2. AVX2 is required for the NNUE evaluation SIMD calculation; -mbmi2 only lets the compiler emit BMI1 tzcnt/blsr bit-scan instructions for the std::countr_zero bit iteration. Sliding move generation uses magic bitboards (a portable multiply), so the pext instruction is not required.make for the Linux build (also works on Windows under Git Bash — see below), or CMake ≥ 3.20 for the cross-platform / Windows build.doxygen (and optionally Graphviz dot) only if you want to build the API docs.Setting up a machine. tools/setup-dev.ps1 (Windows) and tools/setup-dev.sh (Linux, macOS) install what
building, testing, debugging and formatting need - clang, CMake and Ninja (Visual Studio's on Windows, its C++ tools
added behind a UAC prompt, and its clang component unless a standalone LLVM for the MSVC ABI is there to build with
instead - the report says which clang the build will use; apt's clang-18 on Linux; Xcode's clang and Homebrew's
CMake and Ninja on macOS), a bash >= 4 for the UCI suite (Git Bash on Windows, Homebrew's on macOS) and clang-format
22.1.8 through uv - plus the git hooks, checking each first, so they are safe to run again; -Check / --check only
reports. -Docs / --docs adds Doxygen and Graphviz (on Windows into the user's profile from their zips at pinned
SHA-256s, %LOCALAPPDATA%\Programs\doxygen-1.14.0 and %LOCALAPPDATA%\Programs\graphviz, no administrator), and
-Training / --training what NNUE training needs: Rust's
stable toolchain, a check for an NVIDIA GPU and the CUDA toolkit (which the scripts never install), and bullet at the
pinned commit in ~/pawnstar_nnue/bullet (%USERPROFILE%\pawnstar_nnue\bullet on Windows). In VS Code they are the
setup environment and check environment tasks.
CMake is the build system; a thin Makefile wrapper preserves the classic make UX on Unix-y shells (each
target just invokes CMake). Both need clang++ and CMake ≥ 3.20 with a generator (Ninja preferred; the
wrapper falls back to Unix Makefiles if Ninja is absent). Use whichever you prefer:
make # engine → build/pawnstar_<major>_<minor>_<build> (+ the build/pawnstar alias)
make check # build everything and run all test suites (ctest)
make tests # build the test executables without running them
make tools # build helper tools (gen_data, gen_openings, lichess_extract, lc0_extract, make_book, stamp_net, print_net_header, nnue_quant_study, dump_magics)
make doc # generate Doxygen HTML into doc/html
make clean # remove the build/ tree
or drive CMake directly (the canonical path, and how you build on Windows):
cmake -S . -B build -G Ninja -DCMAKE_CXX_COMPILER=clang++
cmake --build build
ctest --test-dir build --output-on-failure
The engine binary is version-named pawnstar_<major>_<minor>_<build> (e.g. build/pawnstar_1_0_0). The
version comes from git alone (cmake/version.cmake; "Versions" below) and is reported to the
UCI host as id name Pawnstar <major>.<minor>.<build>. Each build also
maintains a stable build/pawnstar alias (a symlink on Linux/macOS, a copy on Windows) pointing at the
newest version-named binary, so the commands throughout this README — and tooling such as the VS Code
debugger and tools/run_sprt.sh / tools/rate.sh — can use that fixed path regardless of the version.
Debug build with AddressSanitizer + UndefinedBehaviorSanitizer (DEBUG=1 → CMake -DCMAKE_BUILD_TYPE=Debug -DPAWNSTAR_SANITIZE=ON):
make DEBUG=1
Release build for benchmarking, which omits the DEBUGX diagnostic counters (RELEASE=1 →
-DPAWNSTAR_DEBUGX=OFF):
make RELEASE=1
The DEBUGX diagnostic counters (the dbg command's data) are compiled in by default and cost roughly
6% on the search hot path; RELEASE=1 leaves them out. It composes with DEBUG=1 independently, and
WERROR=1 (-DWERROR=ON) turns warnings into errors. make check runs ctest -V, so each suite's own
output (the [PASS]/[FAIL] lines, perft/bench numbers) is shown; pass CTEST_ARGS=--output-on-failure
for a terse summary that prints a suite's output only when it fails.
Code coverage (clang's source-based coverage, COVERAGE=1 → -DPAWNSTAR_COVERAGE=ON, which adds
-fprofile-instr-generate -fcoverage-mapping to compile and link):
make coverage # Windows: tools\build.ps1 coverage
builds everything instrumented, runs every ctest with LLVM_PROFILE_FILE=build/prof/%p-%m.profraw, merges the
profiles with llvm-profdata and has llvm-cov report src/'s line, function and region coverage over the
engine and the test binaries, writing build/coverage/coverage.lcov and an HTML report in build/coverage/html
(the LLVM tools are taken from beside the compiler first, so they match it). The target is
cmake/run_coverage.cmake. An instrumented tree is not for benchmarking: a later plain
make configures it back without the instrumentation.
The target is also the coverage gate: it fails when src/'s line coverage is under
PAWNSTAR_COVERAGE_MIN_LINES, 90 % - one number in CMakeLists.txt, which CI's coverage job and a developer's
make coverage both read (make coverage COVERAGE_MIN_LINES=95, tools\build.ps1 coverage -CoverageMinLines 95;
0 reports without gating). What is measured is src/: the engine and its UCI front end, every header and
main.cpp. The developer tools in tools/ (make_book, stamp_net, print_net_header, nnue_quant_study, dump_magics), which
no release ships and no ctest runs, and the tests themselves are not counted. The suites cover 98.0 % of src/'s
lines on Windows (clang 22) and 95.2 % in CI on Linux (clang 18, measured 2026-10-31: 3478 of 3654 lines); what is left is unreachable (the fallbacks for a corrupt embedded book or net, switch defaults,
the max-ply guards), machine-dependent (the AVX-VNNI kernel on a CPU without it) or timing-dependent.
The wrapper reconfigures on each invocation, so branch/knob changes are picked up automatically; if a build
ever looks stale, make clean and rebuild. Switching CXX for an existing tree needs make clean first
(CMake caches the compiler).
CMake builds the same engine, tools, and tests on Linux, macOS and Windows. On Windows,
tools/build.ps1 is the make wrapper's counterpart, with the same targets and knobs - tools\build.ps1,
tools\build.ps1 check, -Release (RELEASE=1), -Debug (DEBUG=1), -Coverage / coverage
(COVERAGE=1, make coverage), clean. It finds Visual Studio with vswhere, enters its x64 developer
environment and puts a clang first on the PATH - Visual Studio's own when it has the Clang component, else a
standalone LLVM that targets the MSVC ABI (C:\Program Files\LLVM, or clang++ on the PATH; CI's Windows job
builds with the hosted image's standalone LLVM) - so it runs from any shell, VS Code's tasks
among them. Underneath it is the CI-validated path, a clang toolchain
targeting the MSVC ABI with Ninja:
cmake -S . -B build -G Ninja -DCMAKE_CXX_COMPILER=clang++
cmake --build build
ctest --test-dir build --output-on-failure
This produces a self-contained, version-named build/pawnstar_<major>_<minor>_<build>.exe (plus a
build/pawnstar.exe alias): the NNUE net and the opening book are compiled into the binary (the same
.incbin embedding used on Linux), so it needs no external files — an on-disk nnue/…bin / pawnstar.book
is only an optional override. The Windows build is bit-identical to Linux (same bench node count and
perft) and passes the full test suite. The link writes a .pdb beside each binary, so the Visual Studio
debugger (VS Code's cppvsdbg, the Windows: configurations in .vscode/launch.json) has symbols. A
-Debug build runs AddressSanitizer and UBSan on the release C runtime, the only one the sanitizer runtime
supports on Windows; the build copies the sanitizer runtime (clang_rt.asan_dynamic-x86_64.dll, from the
compiler's -print-resource-dir) beside the executables, so ctest, a debugger and a hand-run binary need no
PATH change.
VS Code works the same on all three platforms: .vscode/tasks.json runs make on Linux and macOS and
tools/build.ps1 on Windows, .vscode/launch.json has a launch and an attach per platform (gdb, CodeLLDB,
the Visual Studio debugger), and IntelliSense reads build/compile_commands.json, which every configure writes.
Cross-compiling that .exe from Linux (clang + MinGW-w64, statically linked so it also needs no
runtime DLLs) uses the bundled toolchain file; with wine installed, ctest runs the .exe suites under
it:
cmake -S . -B build-win -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/mingw-clang-toolchain.cmake -DCMAKE_BUILD_TYPE=Release
cmake --build build-win # -> build-win/pawnstar_<…>.exe (+ pawnstar.exe alias)
ctest --test-dir build-win --output-on-failure
tools/build_windows.sh [release|debug] wraps the cross-compile above (release = -DPAWNSTAR_DEBUGX=OFF,
the build to distribute) and runs bench under wine as a smoke test.
The few platform-specific bits live in src/platform.h (CPU feature detection) and main.cpp
(executable-path lookup); everything else is portable C++23.
Every push to main, every pull request to main, and manual dispatch runs
.github/workflows/ci.yml (the status badge at the top of this README reflects
its latest result on main). Every job runs on a GitHub-hosted runner, on an image named by its version
(ubuntu-24.04, windows-2025), and installs what it needs with pinned versions. The jobs:
make WERROR=1 CXX=clang++-18 check drives the CMake
build through the wrapper and runs all nineteen suites via ctest, behind a fail-fast AVX2/BMI2 runner check.
(Exercising the wrapper also covers the plain CMake path; the Windows artifact job runs CMake directly.)clang-format-22). This is independent of the
compiler, which stays clang-18 — a clang-format of a different major restyles code that is already
correct, so the two are pinned separately and deliberately.make WERROR=1 CXX=clang++-18 coverage: every suite on a build
instrumented with clang's source-based coverage, red when src/'s line coverage is under
PAWNSTAR_COVERAGE_MIN_LINES (90 %), with the per-file table in the job summary. It installs clang-18 with
llvm-18 from apt, so llvm-profdata-18 and llvm-cov-18 match the compiler that instrumented the build.KING_MIRRORING=1 OUTPUT_BUCKETS=8), then tools/new-shape-signature.sh over a net
generated from nothing, and a last step that configures the retired v12 shape and runs the two suites that
read the shape (net_header, nnue_incremental) so the gated path cannot rot.pawnstar_<major>_<minor>_<build>, .exe on Windows) as a downloadable build artifact for that run. The
upload is the job's last step, so a failed build or test ends the job before anything is published. There
is no macOS job in CI: the macOS arm64 binary is built and tested by the release workflow (below), in a dry
run too.CI checks out full git history and the tags (fetch-depth: 0), from which the version is derived. main takes
pull requests only, merged by rebase onto a linear history, and the aggregate CI job — green only when every
other job is — is the one check its ruleset requires. The artifacts are for a look at a commit's build;
none is released.
The version is MAJOR.MINOR.BUILD, from git alone, the rule all of reckless-technology's projects follow:
MAJOR.MINOR is the nearest annotated tag vMAJOR.MINOR reachable from the commit built (a version mark, made by
hand on main);BUILD is the number of commits since that tag, the tagged commit itself being 0.cmake/version.cmake works it out at configure time
(git describe --tags --match 'v*.*' --exclude 'v*.*.*') and configures again by itself when HEAD moves. It writes
the version into a generated header force-included into main.cpp, and into the binary's name and the Doxygen
PROJECT_NUMBER. Nothing in the tree holds a version to edit. A tree with no mark reachable (a source tarball, a
shallow clone, a fork without the tags) builds as 0.0.0, with a warning. After tagging, re-run make, or reconfigure.
.github/workflows/release.yml makes a release:
git tag -a v1.1 -m "Pawnstar 1.1" && git push origin v1.1) releases 1.1.0.main (gh workflow run release.yml --ref main) releases main's head as
MAJOR.MINOR.BUILD. -f dry_run=true, from any branch, builds, tests and signs the macOS binary but publishes
nothing.It refuses a mark that isn't vMAJOR.MINOR, a commit not on main, and a version already released. Each platform is
built from nothing and runs the full suite, including the bench determinism signature, on GitHub-hosted runners:
Linux (ubuntu-24.04) through the make wrapper (clang-18, warnings as errors), Windows (windows-2025) through CMake
and clang, macOS (Apple silicon, macos-15) through the wrapper. Each shipped binary must report the version. The
workflow creates the release vMAJOR.MINOR.BUILD and its tag itself. It never runs for a pull request.
| File | What it is |
|---|---|
pawnstar-<version>-windows-x86_64.exe | the engine (AVX2), Authenticode-signed through SSL.com eSigner (Jonathan Reckless) |
pawnstar-<version>-macos-arm64.zip | the engine for Apple silicon, signed with a Developer ID (hardened runtime) and notarised by Apple |
pawnstar-<version>-linux-x86_64 | the engine for Linux (AVX2) |
pawnstar-<version>-docs-html.zip | the Doxygen HTML documentation |
No source archive is published. The source is this repository: clone or fork it. (GitHub attaches its own automatic "Source code" archives to every release, which are its doing and not the workflow's.)
The binaries are built with RELEASE=1 (the DEBUGX counters off). The macOS engine ships in a zip because a bare
binary can't carry a stapled notarisation ticket: Gatekeeper looks its ticket up online, and the zip keeps the
executable bit through a browser's download. The signing runs from tools/sign-macos.sh and tools/sign-windows.ps1,
with the organisation's signing secrets (the same ones as its other apps). The Windows binary is signed only in a run
that publishes, since every signing spends the eSigner account's allowance. A publishing run that would ship an
unsigned binary fails.
Run as a UCI engine (connect via Arena, Cute Chess, or any UCI-compatible GUI):
./build/pawnstar
In addition to the standard UCI commands, a few engine-specific commands are available at the prompt:
eval print the full evaluation the search uses for the current position
nnue print the raw NNUE network output for the current position
getboard print the FEN of the current position
bookmoves list opening-book moves for the current position
dbg dump internal diagnostic counters
bench search a fixed position set to a fixed depth; print "<nodes> nodes <nps> nps"
help list all commands
eval and nnue are not duplicates. eval is the value the search actually uses: the draw
short-circuit (0 for a material/fifty-move/repetition draw), then the NNUE forward pass (memoised in the
eval cache), then fifty-move scaling (ScaleByRule50, which fades the score toward 0 as the fifty-move
clock climbs past 20 plies). nnue is the bare network output with none of that wrapping. They agree in a
plain position but diverge on a draw or once the fifty-move clock is high.
Pawnstar advertises these setoption options in its uci response:
Hash (spin, default 64, 1–4096 MB) — transposition-table size; resized at runtime (setoption name Hash value <MB>). The default must stay equal to the Rust and Go ports': table size changes the collision pattern and so the node count at a fixed depth, so an unequal default breaks the three ports' bit-exactness at every go — and invisibly, since bench pins 64 in all three. test_uci_commands pins the advertised line.Clear Hash (button) — empties the transposition table without resizing it.Threads (spin, default = PAWNSTAR_THREADS or all cores, 1–256) — number of Lazy SMP search threads.Move Overhead (spin, default 30, 0–5000 ms) — time reserved from each move's deadline to cover GUI/network lag, so Pawnstar doesn't forfeit on time.OwnBook (check, default true) — whether to play from the built-in opening book; set false to let the GUI's book drive the opening.EvalFile (string) — path to an NNUE net to load at runtime (see below).Ponder (check) — advertised so GUIs know Pawnstar can ponder. Its value is declarative only — the
GUI's checkbox controls whether the GUI itself uses pondering; Pawnstar ignores the setoption … Ponder
value (per the UCI spec it only signals that pondering is allowed, e.g. to adjust time management, which
Pawnstar doesn't do). Pondering is driven entirely by the go ponder command: when the GUI sends it,
Pawnstar thinks on the opponent's clock and reports bestmove <move> ponder <reply>; on a ponder hit
(ponderhit) it keeps the work already done (warm TT, current depth) and converts to a normally-timed
search; on a miss the GUI sends stop and Pawnstar returns its move, then searches the real position
afresh.NNUE is Pawnstar's only evaluator, so a net is required. At startup the engine loads the shipped net
nnue/pawnstar-v20.bin (~12.6 MB; resolved relative to the working directory, like pawnstar.book). If that file
is not there (wrong cwd, missing/renamed file), the engine falls back to a copy of the net embedded
in the binary at build time, so it always has a working evaluator; it errors out if that copy is refused too.
A net file that is there and is read and refused — wrong architecture, wrong bucket map, corrupt
payload — is fatal and does not fall back: the engine prints the reason and exits non-zero, because an
engine that cannot play the net it was given must not play at all. To use a different net:
setoption name EvalFile value /path/to/net.bin # load a different net at runtime
or via an environment variable at launch:
PAWNSTAR_EVALFILE=/path/to/net.bin ./build/pawnstar # use a different net
See NNUE evaluation below for how it works and how to train a net.
Position (src/position.h) is the central immutable state class. It stores
per-piece-type bitboards in pieces_ and per-colour bitboards in colors_ (public members), a 64-entry square→piece
array for O(1) lookup, the incrementally-maintained Zobrist hash, castling rights, the en passant
square, the half-move clock, and a checkers_ bitboard. MakeMove and MakeNullMove return a brand
new Position by value (copy-make); there is no unmake. The half-move clock is the fifty-move rule's (FIDE
9.3) and FEN's: plies since the last pawn move or capture, so castling counts like any other piece move and
does not reset it.
pieces_ is a std::array<Bitboard, 7> indexed by piece type, but pieces_[0] (the kNone slot)
holds the occupied-squares set — the union of all pieces of both colours — rather than a piece
bitboard; the six real piece types occupy indices 1–6. This lets occupancy queries and the magic-bitboard
attack lookups read it directly without recomputing a union.
Bitboard (src/bitboard.h) wraps a uint64_t with the usual set operations plus a
range-based iterator that yields the Square of each set bit, enabling for (Square s : bb).
Move (src/move.h) packs everything into a single 64-bit word:
| Bits | Field |
|---|---|
| 0–5 | to square |
| 6–11 | from square |
| 12–14 | moving piece |
| 15–17 | captured piece |
| 18–21 | move type (capture, promotion, castling, en passant, double push, …) |
| 22 | gives-check flag |
| 23–24 | transposition node type (CUT / ALL / PV) |
| 25–32 | transposition depth (int8) |
| 33–40 | transposition age / generation (uint8) |
| 41–63 | score (signed 23-bit) — move-ordering score, and the stored TT score |
The upper bits (23–63) are scratch ordering space during search and carry the transposition metadata
once a move is stored in the table. Because all of that lives in the move, a transposition entry is
just a {hash, move} pair. MoveList is a StackList — a fixed-capacity, stack-allocated container —
so move generation does no heap allocation. (The search's principal variation and per-thread position
stack are std::vectors, the position stack reserved once up front; see the design notes.)
Sliding-piece attacks (bishop, rook, queen) use magic bitboards: the occupancy bits relevant to a
square are masked, multiplied by a precomputed magic and shifted down to a hash slot
(hash = (occupancy & mask) * magic >> shift). This is a plain 64-bit multiply — portable and fast on
every x86-64 CPU (the previous pext-based lookup is microcoded/slow on AMD Zen 1/2). The hash indexes a
1-byte-per-slot index array that in turn selects a de-duplicated attack set, which (since most occupancies
share an attack set) shrinks the tables ~5× to ~155 KB. The magic multipliers are baked-in constants found
offline by tools/dump_magics; only the index/attack tables are filled at startup. Knight, king and pawn
attacks are plain 64-entry lookup tables. All tables are computed once at program startup in
src/generated_data.h.
Pins (src/pins.h) computes absolute pins and the squares
each pinned piece may still move to, before move generation.
The search is a fail-soft negamax with alpha-beta pruning, driven by iterative deepening and parallelised with Lazy SMP: several threads each run an independent whole-tree search, sharing a single lockless transposition table rather than splitting the tree between them.
Game::SearchRootNode (declared in class Game and defined at the bottom of src/game.h, after
the search_state.h hub is included so SearchState is complete) runs on a dedicated worker
thread — started by Game::StartThinking — so the UCI stop command, which sets an atomic
cancellation flag polled throughout the tree, can interrupt it at any time. It searches the root move
list at increasing depths starting from kStartDepth (3) up to kMaxPly − 1 (63), emitting an
info depth … score … time … nodes … nps … hashfull … pv … line each time the best move improves
(score mate <±moves> once a forced mate is found, otherwise score cp <centipawns>). After every iteration the root
moves are re-sorted by their scores from the previous iteration, so the prior best move is tried first
next time. The root searches a full [kAlpha, kBeta] window (no aspiration windows); alpha is raised
in place as root moves beat it.
Under a standard (time-budgeted) clock the engine can stop before the allotted time is spent, using the stability of the result across iterations:
kScoreInstabilityThreshold (50 cp), it stops once it has used a quarter of the time budget;|score| > kWinThreshold).This avoids burning the whole clock on positions whose answer is already settled. Fixed-depth and fixed-time clocks bypass the heuristic.
The per-move soft budget is remaining_time / moves_to_go, and the configured Move Overhead is
subtracted from the hard deadline to leave headroom for GUI/network lag. (Folding the per-move increment
into the soft budget was SPRT-tested at 8+0.08 and 10+0.1 and proved neutral-to-slightly-negative in
equal-clock self-play, so winc/binc are parsed but not currently used in budgeting.)
ChessClock (src/chess_clock.h) is implemented with std::chrono on the monotonic
steady_clock: the soft/hard budgets are std::chrono::milliseconds durations and the hard stop is an
absolute time_point deadline, polled (every ~2k nodes) via HasReachedHardDeadline. On ponderhit the
clock restarts the budget from that instant (the deadline and search-start instant are std::atomic so the
UCI thread can retarget the running search). It is covered by the test_chess_clock unit suite.
SearchState::Search (src/search_state.h) applies, at each node:
[alpha, alpha+1]) scout search and are only re-searched with the full window if
the scout beats alpha.static_eval − 80·depth ≥ beta the node is cut immediately (returning static_eval)
without searching any move — when we are that far above beta a quiet move will hold it. The static eval
is computed once and shared with null-move pruning. SPRT-tested +114.8 ± 22.5 Elo at 8+0.08 — the
largest single search gain; pawnstar had previously lacked the static-eval pruning suite.depth − 4, i.e. R = 3); a resulting score ≥ beta prunes the
node. (Reducing less — depth − 3 — was SPRT-tested at 8+0.08 and lost ≈16 Elo over 1000 games
[−15.99 ± 16.16], so the depth − 4 cut stands.)depth > 2 is searched one ply shallower, with a second ply shaved off past the 7th
move at depth > 3, and a third ply past the 12th move (since the butterfly history table,
2026-07: pooled counts are rarely zero, so the old zero-history gate lost meaning; the ladder is now
purely lateness). A reduced search that beats alpha is re-searched at full
depth. The original zero-history third-ply reduction was SPRT-tested: roughly neutral at fast 8+0.08, but +11 Elo
(±18, 578 games) at 40 moves/minute — a time-control-dependent gain that deeper trees reward.3 + depth²-th move the remaining quiet, non-checking moves are skipped entirely (captures,
promotions and checks are never pruned). Where LMR late quiets, LMP them. SPRT-tested
on top of RFP.Quiescence search (SearchState::SearchQuiescent, src/search_state.h) extends the leaves with
captures only, so the static evaluation is never called on a position with a hanging capture available.
It does not use a transposition table (no probe, no store). It applies SEE pruning — the captures are
SEE-sorted, so the loop reads each move's precomputed ordering score and exits on the first losing
capture (move.score() < 0); every remaining move loses material too, so none are searched (and no
SEE is recomputed in the loop). Dropping the quiescence transposition table and switching to this sorted
early-exit was SPRT-tested +38.65 ± 13.78 Elo at 8+0.08 (1318 games, LLR-accepted against [-5, 0]).
Alpha-beta prunes far more when the best move is searched first, so SearchState::ScoreAndSortMoves
(src/search_state.h) assigns every move a 23-bit sort score (stored in the
Move itself) in strict descending bands. The TT move is searched before the list is even generated;
the remaining moves sort as:
| Band | Score | Moves |
|---|---|---|
| Winning captures | kWinningCaptureBase (1 << 20) + SEE | captures / promotions with SEE ≥ 0 |
| Killers | kKillerBase (1 << 19) +1 / +0 | the two killer moves for this ply |
| Countermove | kKillerBase − 1 | the best quiet refutation to the previous move |
| Quiet moves | min(main history + continuation history, kKillerBase − 2) | everything else |
| Losing captures | negative SEE | captures with SEE < 0, below the quiet moves |
The killer and main-history tables answer "which quiet moves are good at this ply / in
general?". The countermove and continuation-history tables refine that by conditioning on the
previous move — the one move the opponent played to reach this node. That move is threaded down the
tree as Search's prev_move parameter (Move::None() at the root and after a null move).
Both previous-move tables share a key that abstracts a move down to (piece, to-square) — the
Move::piece_to() method:
int Move::piece_to() const; // (piece() << 6) | to() → 7 × 64 = kContKeys (448) keys
This is the standard "a piece lands on a square" abstraction — it generalises across positions (a knight
reaching f5 is the same follow-up however it got there), and Move::None collapses to a harmless slot 0.
countermoves_, an array<Move, kContKeys> of 448) — a single best refutation per previous
move. When a quiet move causes a beta cutoff it is recorded keyed by prev_move.piece_to()
(RecordCountermove). In ordering it is a boolean signal — a move that equals the recorded
countermove gets the one fixed band just below the killers (kKillerBase − 1).continuation_history_, a kContKeys × kContKeys (448 × 448) int16_t table,
~0.4 MB, indexed [prev.piece_to() * kContKeys + move.piece_to()]) — a graded score for every (prev → move) pairing. It is a
saturating counter (caps at 16384, like the main HistoryTable) bumped whenever a quiet move proves
good as a follow-up to prev: on a beta cutoff, on an alpha-raise, and for the final PV best move
(RecordContinuationHistory). In ordering, an ordinary quiet move's score is main_history(ply, move) + continuation_history(prev, move), clamped to kKillerBase − 2 so a hot quiet can never jump into the
countermove or killer bands.Captures are excluded from both tables (every record site guards on Move::IsQuiet()) because captures
are already ordered by SEE — history signals only matter for quiets, which have no static score to sort
on. Both tables are per-thread (owned by SearchState), so there is no cross-thread contention; the
continuation_history_ buffer is heap-allocated once in the constructor, so the per-node hot path allocates nothing.
Parallelism is provided by Lazy SMP: Game::SearchRootNode launches Game::thread_count_ threads
(clamped to kMaxSearchThreads), each running its own complete iterative-deepening search of the root
position via SearchState::IterativeDeepen. There is no tree splitting and no work queue — the threads cooperate
purely through the shared lockless transposition table, where one thread's deeper or earlier results
prefill the entries another thread will probe. Each thread owns its own SearchState (position stack,
hash history, killers, node count) and its own ordering tables (history, countermove and
continuation history), so the only cross-thread sharing is the transposition tables and the
cancellation flag; there is no per-(ply, move) counter contention.
The thread count is set by the UCI Threads option (stored in Game::thread_count_); its default is
computed once at construction from the PAWNSTAR_THREADS environment variable if set (>0), else
hardware_concurrency(), clamped to [1, kMaxSearchThreads] (e.g. PAWNSTAR_THREADS=1 forces a
deterministic single-threaded search, used to regenerate Bratko-Kopec reference data).
To keep the helper threads from recomputing the same tree in lockstep, the main thread (thread 0)
searches every depth while each helper follows a Stockfish-style iteration-skip schedule
(kSkipSize / kSkipPhase tables in search_state.h) that makes it skip selected depths. The
threads therefore desynchronise and seed the shared table with a diverse mix of depths. Only the main
thread prints info lines, applies time management and produces the authoritative best move; helpers
run silently and stop as soon as the shared cancellation flag is set.
Because the helpers race ahead through the table, a search run to a nominal depth is non-deterministic
and the returned best move (and its exact score) can vary slightly between runs — a known and accepted
property of Lazy SMP. PAWNSTAR_THREADS=1 recovers determinism when that matters.
The knobs above live in src/constants.h:
| Constant | Value | Meaning |
|---|---|---|
kStartDepth | 3 | First (full-width) iterative-deepening depth |
kMaxPly | 64 | Hard ceiling on search depth / ply |
kHashtableMegabytes | 64 | Main transposition table size |
kScoreInstabilityThreshold | 50 | Centipawn window for the "score stable" time-management test |
kMaxSearchThreads | 256 | Upper bound on Lazy SMP search threads |
A 64 MB TT is used for the main search. Each entry is exactly 128 bits — a 64-bit Zobrist hash and a 64-bit move whose spare bits carry the score, depth, node type and age.
The table is lockless (Hyatt's XOR trick): each cell stores key = hash ^ data alongside data
(the packed move), as two std::atomic<uint64_t> accessed with relaxed ordering.
A reader accepts a
cell only if key ^ data equals the probe hash, so a torn pair written by two concurrent stores is
detected and treated as a miss.
Concurrent writers race benignly (last writer wins). Aging is O(1):
Age() bumps a generation counter and an entry is stale when its stamped age differs from it.
The probe is a random memory access, so once a child move has been made (its Zobrist hash known) the
search Prefetches the cell the recursive node will read, hiding the cache-miss latency behind the
intervening work. This is purely a latency optimization and does not change results.
EvaluatePosition (declared in src/evaluation.h, defined in the src/search_state.h hub) is NNUE-only: after a draw
short-circuit it returns the network's score for the (lazily-maintained) accumulator, clamped to ±30000 so
a pathological max-material eval can't overflow the int16 eval cache, memoised by Zobrist hash in that eval
cache. See NNUE evaluation below. Earlier versions also had a
tapered hand-crafted evaluation (material, piece-square tables, pawn structure, king safety, mobility);
it was removed once NNUE became the sole evaluator.
Static exchange evaluation (EvaluateStaticExchange,
src/static_exchange_evaluation.h) is independent of the NNUE eval: it
resolves a capture sequence on a square, is used to order captures and promotions during search (see
above) and to prune losing captures in quiescence, and is covered by test_see.
Pawnstar evaluates exclusively with an NNUE (Efficiently Updatable Neural Network), and has no
hand-crafted evaluation to fall back on. EvaluatePosition consults the net directly, after a draw
short-circuit. The shipped net is loaded at startup, with a copy embedded in the binary in case the file
is not there, and another can be loaded at run time — see The NNUE net above.
The shape. nnue/pawnstar-v20.bin is the shared Reckless NNUE shape, the same one zenith and the two ports carry:
768 inputs per (perspective, king bucket) -> 1024 feature transformer (x2 perspectives)
SCReLU, concat[side to move | opponent] = 2048 -> 8 material output heads -> 1 centipawn score
The 768 base inputs are (piece colour × piece type × square), replicated across 8 king buckets chosen
by the perspective's own king square — its file × board-half, with files e–h mirrored onto a–d so the
whole perspective flips horizontally and every bucket sees both wings. The score comes from one of 8
output heads selected by the number of pieces on the board, so the same hidden features are weighed
differently as material comes off. kKingBucketMap in src/nnue.h is the one place the
bucketing is written down; kNumKingBuckets is derived from it, and the trainer reads the same map.
Everything is integer: the feature weights and biases are int16 at scale QA, the output weights int16
at QB and the output biases int32 at QA·QB. That is what makes a search's node count a property of the
position and the depth alone, so the three ports are bit-exact with each other at a fixed depth. The
weights are heap-allocated — at 8 king buckets the array is about 12.6 MB and would overflow a default
thread stack — owned by the Game, read-only after load and shared by every search thread.
The accumulator is maintained incrementally and lazily. Each SearchState holds a per-ply stack of
accumulators, a copy-make scheme: playing a move pushes the child and marks its accumulator stale, and
undoing one is free, because every ancestor's accumulator is still valid for the next sibling. The work
happens only when an evaluation actually reads an accumulator and the eval cache misses: it walks back to
the deepest valid ancestor and, for each ply forward, copies the parent and applies that move's feature
delta — adding the arriving pieces' columns and subtracting the departing ones. A king crossing a bucket
boundary rebuilds that one perspective from scratch. So a node that cuts off before evaluating pays
nothing, and returning to a sibling after a deep sub-search costs one copy and one delta rather than a
replay. The stack is reserved once at construction, so the search performs no heap allocation.
Training data, and the notice it obliges. The shipped net is trained entirely on
Leela Chess Zero self-play data, published under the
Open Database License 1.0. A trained net is a Produced Work under that licence, so distributing one
obliges a notice naming the source — it does not make the net ODbL-licensed, and does not make it GPL
merely because this project's code trained it. The notice is in
THIRD-PARTY-NOTICES.md, and the engine prints it itself: send license over UCI.
Where the rest lives. nnue/README.md is the net's own record: the RNNUE1 container and its checks, how a net is trained, verified and stamped, what each net measured, and the attribution for the retired nets. The trainers and the data pipeline are shared across the engines and live in nnue-training.
OpeningBook (src/opening_book.h) lets the engine play known opening lines
instantly instead of searching. At startup the engine loads pawnstar.book from the working directory;
the shipped book (pawnstar.book) was built by
tools/make_book.cpp from the Lichess open database,
which is released under the Creative Commons
CC0 1.0 public-domain dedication - so the book carries
no third party's restriction. The book file's own # header records the monthly export it came from, every
filter used, the counts and the SHA-256 of exactly the bytes read. As with the NNUE net, if the file can't be loaded
(wrong cwd, missing/renamed file) the engine falls back to a copy of the book embedded in the binary, so a
relocated executable still has its book; the book is optional, so failing both is only a warning. The
built-in book can be disabled with the OwnBook UCI option.
The file is plain text, one opening line per row, with each move in coordinate notation
(from-square then to-square, e.g. e2e4 e7e5 g1f3). Tokens that begin with a digit are treated as
move numbers and ignored, and a # begins a comment to end of line, so PGN-style numbered lines are
also accepted. Each line is replayed from the standard start position and every move it visits is
recorded. A move may carry a trailing ?, marking it questionable: it is replayed so the rest of
its line still parses and the engine keeps its prepared reply to it, but it is never selected.
The shipped book was screened against the engine's own judgement at depth 12 with a 100 cp margin, which is where those marks come from: 555 of its 8,923 (position, move) pairs were marked, and 84 positions whose every move was marked were given the engine's own move, unmarked, so no position the book reaches is left with nothing to play. The screener lives in the public amphitheater-chess repository, drives any engine over UCI, and is run as:
amphitheater-cli screenbook -engine cmd=<engine> -book <book> [-out <book>] [-depth 12] [-margin 100] [-marks <file>]
-out defaults to the book that was read. src/match/BookScreener.h there records why a weak move is marked
rather than deleted and what makes one move's score comparable with another's.
Internally the book is a std::map from position Zobrist hash to a vector of legal Moves, with
repeats: a move that appears in many lines is stored many times, so picking uniformly at random
(GetMove) naturally weights selection by how often the move occurs. SearchRootNode consults the
book before searching and, on a hit, returns the chosen move immediately. The bookmoves UCI command
prints the available book moves and their frequencies for the current position.
make check
builds and runs fifteen standalone test executables (plus a shell-driven UCI integration test,
test/uci_test.sh, run against build/pawnstar, and three Python suites, test/pool_matches_test.py,
test/opening_icc_test.py and test/pgn_nps_test.py — nineteen
suites in all; the shell script also checks where the engine finds its net and book at startup, the embedded
copies it falls back to, PAWNSTAR_EVALFILE and a clean exit at the end of input):
| Executable | Description |
|---|---|
build/test_see | 24 static exchange evaluation cases |
build/test_perft | Move-generation regression on the standard PERFT positions |
build/test_bratko_kopec_nnue | 24 Bratko-Kopec positions searched with the NNUE net; reports moves solved |
build/test_nnue | NNUE inference cross-check against the trainer's reference evals |
build/test_nnue_incremental | Incremental accumulator vs full-refresh consistency check |
build/test_eval_symmetry | The net-independent evaluation check: 24 positions, each against its colour-flipped vertical mirror, which a perspective network must score exactly equally (the White-relative eval(P) == -eval(flip(P))) - a mirroring, perspective, colour-mapping or bucketing defect breaks it while the training loss stays perfect. It pins no expected score, so it gates every future net unchanged; the flip itself is checked against hand-computed FENs, for flip(flip(P)) == P and for the legal move count |
build/test_opening_book | Opening book: loading (file, memory, comments, an illegal move), weighting, display, ? moves |
build/test_chess_clock | ChessClock std::chrono timing logic (deadline / elapsed / ponderhit / reset) |
build/test_bench_golden | The bench signature: each of the 16 positions' best move and node count, and the 5,617,906 total |
build/test_primitives | Bitboards, the attack tables' generators rebuilt at run time, castling text, material draws, the TT |
build/test_uci_commands | Every UCI command, option and go parameter through ProcessInput, in process, with the net |
build/test_principal_variation | The principal variation the search publishes: every ply legal, replayable without touching the game history, and evaluating from its FEN exactly as from the position |
build/test_lichess_extract | tools/lichess_extract.h's extraction rules, driven against PGN written by hand |
test_bench_golden runs the bench recipe (RunBench in src/input_handling.h, the code the bench command
runs: depth 13, single-threaded, book off, hash cleared) with the shipped net and checks it against the golden
table in test/bench_golden_test.cpp - the cross-engine bit-parity signature that the Go and Rust ports
(pawnstar-go,
pawnstar-rs) pin too. Any change to the search, the move
ordering, the pruning, the TT or the eval moves it; update the table only for a deliberate, SPRT-gated change or
a new net, and re-port the change to both ports.
make check runs the NNUE tests against the shipped nnue/pawnstar-v20.bin: test_nnue against the
checked-in test/nnue_reference.txt (250 trainer evals; max |diff| 0 cp), test_nnue_incremental which
asserts the incremental accumulator matches a full refresh at every node, test_eval_symmetry which asserts
every position scores exactly as its colour-flipped vertical mirror (net-independent, so it gates a new net
where the reference evals cannot — they are the shipped net's own), and test_bratko_kopec_nnue
which searches the 24 BK positions with the net (single-threaded, deterministic) and must solve all 24
— each position's best move must be in its accepted-move set (the kCases array in test/bratko_kopec_nnue_test.cpp). The NNUE
ctest cases are registered only when the shipped net is present, so a checkout without it still configures
and those suites simply don't run. When you ship a new net, regenerate both test/nnue_reference.txt (bullet pawnstar_eval)
and the BK accepted moves (the net-specific union over depths 8–11) — see nnue/README.md
§7.
The Bratko-Kopec suite is the move-based test_bratko_kopec_nnue (the classic metric — did the engine
find a good move). It takes an optional depth argument, e.g.
./build/test_bratko_kopec_nnue nnue/pawnstar-v20.bin 10, and searches single-threaded for a reproducible
result; the accepted moves are recorded over depths 8–11, so it solves 24/24 at each of those depths.
There is no score-based Bratko-Kopec test: NNUE scores are non-deterministic under Lazy SMP
and on a different scale. The earlier score-based test_bratko_kopec and the test_pawn_structure suite
tested the removed hand-crafted evaluation and were deleted with it.
make doc # writes HTML to doc/html (open doc/html/index.html)
The codebase is fully Doxygen-commented; make doc generates the browsable API reference.
| Path | Contents |
|---|---|
src/ | Engine source and headers |
test/ | Standalone test programs |
tools/ | Bullet trainer sources, training/verify/SPRT scripts (pawnstar-match.sh to run a match, pool_matches.py to pool its chunks into one Elo with a correct 95% bar), the training-data generator (gen_data) and extractors (lichess_extract, lc0_extract), the opening-book builder (make_book), and helper tools (gen_openings, stamp_net, print_net_header, nnue_quant_study, dump_magics) |
nnue/ | The shipped NNUE net (pawnstar-v20.bin) plus training-pipeline documentation |
Doxyfile | Doxygen configuration |
LICENSE | GNU General Public License v3 |
A few deliberate choices shape the codebase:
Position is never mutated in place; MakeMove returns a new
one by value and there is no UnmakeMove. This trades a per-ply copy for far simpler code: no
state to restore, no aliasing between plies, and positions that are trivially safe to hand to other
threads. The class is kept small (160 bytes) so the copy stays cheap.StackList), so move generation allocates nothing. The per-thread position stack and the per-search
hash history are std::vectors reserved once when the SearchState is constructed (the position stack
to kMaxPly + 4, so a push_back during search never reallocates and invalidates the live position
reference); the principal-variation lists are std::vectors bounded by the search depth; and the game
history is a std::vector that grows only as real moves are played — so the node-by-node search loop
performs effectively no dynamic allocation.inline const globals in generated_data.h), so the hot paths are pure lookups.Pawnstar has no official CCRL rating. The figures currently tracked are:
test_bratko_kopec_nnue solves 24/24 at each depth 8–11 with the shipped net (depth 8 runs in make check).nnue/pawnstar-v20.bin measures roughly ~2900+ Elo (CCRL-ballpark, anchored
against reference engines at a fast time control) — far above the since-removed hand-crafted eval (~2350);
v20 is the strongest shipped net, each step in the lineage above adding measured Elo over its predecessor. At equal time, the incremental accumulator is
worth ~+80 Elo over a full refresh and the AVX2 SIMD kernels a further ~+180 Elo over the scalar
versions (both by SPRT).make check is the regression baseline; the perft suite verifies move-generation correctness against
the published node counts for the standard positions.
-mbmi2 -mavx2. Sliding-piece attacks use magic bitboards (a portable
multiply), so the pext instruction is no longer required — this notably helps early AMD Zen parts,
where pext is microcoded and comparatively slow. (The NNUE kernels keep scalar fallbacks behind
#if defined(__AVX2__), but the default build enables AVX2.)Threads UCI option (Lazy SMP, 1–256); its default comes from the
PAWNSTAR_THREADS environment variable if set, otherwise hardware_concurrency().ChessBucketsMirrored net with 8 mirrored king
buckets (file × board-half) and 8 material output heads (v20). The owning Game is heap-allocated, since the inline ~12.6 MB weight array would overflow the default stack.make clean && make check
.clang-format):
clang-format -i src/<file> # or your editor's format-on-save
src/generated_data.h holds the precomputed-table logic (the compute functions, in a gendata_detail
namespace, and the inline const globals they fill at startup); edit it when that logic changes.make doc stays complete.Pawnstar is free software, licensed under the GNU General Public License, version 3 (GPLv3). You may redistribute and/or modify it under the terms of that license; it comes with no warranty. See the LICENSE file for the full text, or https://www.gnu.org/licenses/gpl-3.0.html.
The shipped network carries a notice of its own. nnue/pawnstar-v20.bin was trained entirely on the
Leela Chess Zero self-play training data (https://storage.lczero.org/files/training_data/), a database
published under the Open Database License 1.0 with its contents under the Database Contents License
1.0. A trained net is an ODbL Produced Work, and distributing one obliges a notice naming that source:
the notice is in THIRD-PARTY-NOTICES.md, and the engine prints it in answer to the
license command. The obligation is the notice alone — the net is neither ODbL-licensed nor GPL-licensed by
having been trained with GPLv3 tooling.
The bundled opening book (pawnstar.book) was built from the
Lichess open database, which is released under the Creative Commons
CC0 1.0 public-domain dedication. CC0 waives
attribution, so the credit in THIRD-PARTY-NOTICES.md is a courtesy and not an
obligation Pawnstar carries - and nothing downstream inherits one either. The NNUE trainer sources under
tools/bullet/ are written for the bullet trainer and
adapted from it: pawnstar.rs from its simple example, and pawnstar_eval.rs from that example and from its
Chess768 and ChessBucketsMirrored input code. bullet is MIT-licensed, Copyright (c) 2023 Jamie Whiting, so those two
files are redistributed here with that attribution in their headers and bullet's licence text in
THIRD-PARTY-NOTICES.md. bullet itself is not redistributed: tools/setup_bullet.sh
fetches it at a pinned commit.
build/test_lc0_extracttools/lc0_extract.h's extraction rules, driven against Lc0 v6 records built from plane words copied out of a real test80 tar |
build/test_net_header | tools/net_header_report.h, the RNNUE1 header reader build/print_net_header wraps: every field decoded at its documented offset from hand-laid fixtures (8 and 4 king buckets, and a header with no two equal fields), both CRCs and the payload length verified, each kind of damage reported, and the build's own net decoded against its known values |
test/pool_matches_test.py | tools/pool_matches.py, the pentanomial pooling tool: three completed matches' Ptnml(0-2) vectors pooled to their measured Elo, 95% bar and LOS, the chunk-scatter diagnostics on a known case, and the two traps - a doubled 1.959964 in the bar, and a 95% bar compared against a one-sigma scatter. Run by ctest as pool_matches wherever a Python interpreter is found |
test/opening_icc_test.py | tools/opening_icc.py, the opening-replay correction: four hand-computed one-way ANOVA fixtures (perfect correlation, none at all, exactly a third, and an unbalanced case where the balanced-design shortcut understates the inflation), its PGN reader against a fixture holding one of each way a round fails to be a colour-reversed pair, and its nominal figures anchored to pool_matches' own match A. Run by ctest as opening_icc |
test/pgn_nps_test.py | tools/pgn_nps.py, the absolute-nps reader: hand-summed node and time totals, a Black-to-move opening, a book move carrying no node counts, and a game missing a comment altogether. Run by ctest as pgn_nps |