Refactor directory structure. Fix magic bitboard generation and add utilities to dump them to disk

This commit is contained in:
Mattia Giambirtone 2024-04-19 13:40:31 +02:00
parent 6a548bf372
commit 82cef11cc4
19 changed files with 254 additions and 151 deletions

24
Chess/nimfish.nimble Normal file
View File

@ -0,0 +1,24 @@
# Package
version = "0.1.0"
author = "nocturn9x"
description = "A chess engine written in nim"
license = "Apache-2.0"
srcDir = "src"
binDir = "bin"
installExt = @["nim"]
bin = @["nimfish"]
# Dependencies
requires "nim >= 2.1.1"
requires "jsony >= 1.1.5"
after build:
exec "nimble test"
task test, "Runs the test suite":
exec "python tests/suite.py -d 5 --bulk"

View File

@ -18,9 +18,11 @@ import std/times
import std/math
import std/bitops
import bitboards
import pieces
import moves
import src/bitboards
import src/magics
import src/pieces
import src/moves
type
@ -648,9 +650,49 @@ proc generatePawnMoves(self: ChessBoard, moves: var MoveList) =
self.generatePawnPromotions(moves)
proc generateRookMovements(self: ChessBoard, moves: var MoveList) =
## Helper of generateRookMoves to generate all non-capture
## rook moves
let
sideToMove = self.getSideToMove()
occupancy = self.getOccupancy()
friendlyPieces = self.getOccupancyFor(sideToMove)
rooks = self.getBitboard(Rook, sideToMove)
for square in rooks:
let blockers = occupancy and Rook.getRelevantBlockers(square)
var moveset = getRookMoves(square, blockers)
# Can't capture our own pieces
moveset = moveset and not friendlyPieces
for target in moveset:
moves.add(createMove(square, target))
proc generateRookCaptures(self: ChessBoard, moves: var MoveList) =
## Helper of generateRookMoves to generate all capture
## rook moves
let
sideToMove = self.getSideToMove()
occupancy = self.getOccupancy()
enemyPieces = self.getCapturablePieces(sideToMove.opposite())
rooks = self.getBitboard(Rook, sideToMove)
for square in rooks:
let blockers = occupancy and Rook.getRelevantBlockers(square)
var moveset = getRookMoves(square, blockers)
# Can only cature enemy pieces
moveset = moveset and not enemyPieces
for target in moveset:
moves.add(createMove(square, target))
proc generateRookMoves(self: ChessBoard, moves: var MoveList) =
## Helper of generateSlidingMoves to generate rook moves
self.generateRookMovements(moves)
self.generateRookCaptures(moves)
proc generateSlidingMoves(self: ChessBoard, moves: var MoveList) =
## Generates all legal sliding moves for the side to move
self.generateRookMoves(moves)
proc generateKingMoves(self: ChessBoard, moves: var MoveList) =
## Generates all legal king moves for the side to move
@ -758,6 +800,7 @@ proc generateMoves*(self: ChessBoard, moves: var MoveList) =
self.generatePawnMoves(moves)
self.generateKingMoves(moves)
self.generateKnightMoves(moves)
self.generateRookMoves(moves)
# TODO: all pieces
@ -962,25 +1005,26 @@ proc doMove(self: ChessBoard, move: Move) =
# Castling check: have the rooks moved?
if piece.kind == Rook:
case piece.color:
of White:
if rowFromSquare(move.startSquare) == piece.getStartRank():
if colFromSquare(move.startSquare) == 0:
# Queen side
castlingAvailable.white.queen = false
elif colfromSquare(move.startSquare) == 7:
# King side
castlingAvailable.white.king = false
of Black:
if rowFromSquare(move.startSquare) == piece.getStartRank():
if colFromSquare(move.startSquare) == 0:
# Queen side
castlingAvailable.black.queen = false
elif colFromSquare(move.startSquare) == 7:
# King side
castlingAvailable.black.king = false
else:
discard
discard
# case piece.color:
# of White:
# if rowFromSquare(move.startSquare) == piece.getStartRank():
# if columnFromSquare(move.startSquare) == 0:
# # Queen side
# castlingAvailable.white.queen = false
# elif columnfromSquare(move.startSquare) == 7:
# # King side
# castlingAvailable.white.king = false
# of Black:
# if rowFromSquare(move.startSquare) == piece.getStartRank():
# if columnFromSquare(move.startSquare) == 0:
# # Queen side
# castlingAvailable.black.queen = false
# elif columnFromSquare(move.startSquare) == 7:
# # King side
# castlingAvailable.black.king = false
# else:
# discard
# Has a rook been captured?
if move.isCapture():
let captured = self.grid[move.targetSquare]
@ -1475,8 +1519,8 @@ proc handleMoveCommand(board: ChessBoard, command: seq[string]): Move {.discarda
if board.grid[targetSquare].kind != Empty:
flags.add(Capture)
elif board.grid[startSquare].kind == Pawn and abs(rowFromSquare(startSquare) - rowFromSquare(targetSquare)) == 2:
flags.add(DoublePush)
#elif board.grid[startSquare].kind == Pawn and abs(rowFromSquare(startSquare) - rowFromSquare(targetSquare)) == 2:
# flags.add(DoublePush)
if len(moveString) == 5:
# Promotion
@ -1794,13 +1838,15 @@ when isMainModule:
testPieceBitboard(blackRooks, blackRookSquares)
testPieceBitboard(blackQueens, blackQueenSquares)
testPieceBitboard(blackKing, blackKingSquares)
b = newChessboardFromFEN("8/5R2/8/8/7k/8/5K2/8 w - - 0 1")
var m = MoveList()
b.generateMoves(m)
b.generateRookMovements(m)
echo &"There are {len(m)} legal moves for {b.getSideToMove()} at {b.toFEN()}: "
for move in m:
echo " - ", move.startSquare, move.targetSquare, " ", move.getFlags()
echo b.pretty()
echo b.getAttacksTo("f3".toSquare(), White)
# setControlCHook(proc () {.noconv.} = quit(0))
# quit(main())

View File

@ -68,6 +68,8 @@ func createMove*(startSquare, targetSquare: Bitboard, flags: varargs[MoveFlag]):
func toBin*(x: Bitboard, b: Positive = 64): string = toBin(BiggestInt(x), b)
func toBin*(x: uint64, b: Positive = 64): string = toBin(Bitboard(x), b)
func contains*(self: Bitboard, square: Square): bool = (self and square.toBitboard()) != 0
iterator items*(self: Bitboard): Square =
## Iterates ove the given bitboard

View File

@ -8,6 +8,11 @@ import pieces
import std/random
import std/bitops
import std/tables
import std/os
import jsony
export pieces
@ -23,9 +28,8 @@ type
# Yeah uh, don't look too closely at this...
proc generateRookMasks(blockers: bool = false): array[64, Bitboard] {.compileTime.} =
## Generates all movement masks for rooks (only generates
## blocker masks if blockers equals true)
proc generateRookBlockers: array[64, Bitboard] {.compileTime.} =
## Generates all blocker masks for rooks
for rank in 0..7:
for file in 0..7:
let
@ -37,37 +41,36 @@ proc generateRookMasks(blockers: bool = false): array[64, Bitboard] {.compileTim
last = makeSquare(rank, 7).toBitboard()
while true:
current = current.rightRelativeTo(White)
if (current == last and blockers) or current == 0:
if current == last or current == 0:
break
result[i] = result[i] or current
current = bitboard
last = makeSquare(rank, 0).toBitboard()
while true:
current = current.leftRelativeTo(White)
if (current == last and blockers) or current == 0:
if current == last or current == 0:
break
result[i] = result[i] or current
current = bitboard
last = makeSquare(0, file).toBitboard()
while true:
current = current.forwardRelativeTo(White)
if (current == last and blockers) or current == 0:
if current == last or current == 0:
break
result[i] = result[i] or current
current = bitboard
last = makeSquare(7, file).toBitboard()
while true:
current = current.backwardRelativeTo(White)
if (current == last and blockers) or current == 0:
if current == last or current == 0:
break
result[i] = result[i] or current
# Okay this is fucking clever tho. Which is obvious, considering I didn't come up with it.
# Or, well, the trick at the end isn't mine
func generateBishopMasks(blockers = false): array[64, Bitboard] {.compileTime.} =
## Generates all movement masks for bishops (only generates
## blocker masks if blockers equals true)
func generateBishopBlockers: array[64, Bitboard] {.compileTime.} =
## Generates all blocker masks for bishops
for rank in 0..7:
for file in 0..7:
# Generate all possible movement masks
@ -100,37 +103,36 @@ func generateBishopMasks(blockers = false): array[64, Bitboard] {.compileTime.}
if current == 0:
break
result[i] = result[i] or current
if blockers:
# Mask off the edges
# Mask off the edges
# Yeah, this is the trick. I know, not a big deal, but
# I'm an idiot so what do I know. Credit to @__arandomnoob
# on the engine programming discord server for the tip!
result[i] = result[i] and not getFileMask(0)
result[i] = result[i] and not getFileMask(7)
result[i] = result[i] and not getRankMask(0)
result[i] = result[i] and not getRankMask(7)
# Yeah, this is the trick. I know, not a big deal, but
# I'm an idiot so what do I know. Credit to @__arandomnoob
# on the engine programming discord server for the tip!
result[i] = result[i] and not getFileMask(0)
result[i] = result[i] and not getFileMask(7)
result[i] = result[i] and not getRankMask(0)
result[i] = result[i] and not getRankMask(7)
func getIndex(magic: MagicEntry, blockers: Bitboard): uint {.inline.} =
func getIndex*(magic: MagicEntry, blockers: Bitboard): uint {.inline.} =
## Computes an index into the magic bitboard table using
## the given magic entry and the blockers bitboard
let
blockers = blockers and magic.mask
hash = blockers * magic.value
index = hash shr (64 - magic.indexBits)
index = hash shr (64'u8 - magic.indexBits)
return index.uint
# Magic number tables and their corresponding moves
var
ROOK_MAGICS: seq[MagicEntry]
ROOK_MAGICS: array[64, MagicEntry]
ROOK_MOVES: array[64, seq[Bitboard]]
BISHOP_MAGICS: seq[MagicEntry]
BISHOP_MAGICS: array[64, MagicEntry]
BISHOP_MOVES: array[64, seq[Bitboard]]
proc getRookMoves(square: Square, blockers: Bitboard): Bitboard =
proc getRookMoves*(square: Square, blockers: Bitboard): Bitboard =
## Returns the move bitboard for the rook at the given
## square with the given blockers bitboard
let
@ -139,7 +141,7 @@ proc getRookMoves(square: Square, blockers: Bitboard): Bitboard =
return moves[getIndex(magic, blockers)]
proc getBishopMoves(square: Square, blockers: Bitboard): Bitboard =
proc getBishopMoves*(square: Square, blockers: Bitboard): Bitboard =
## Returns the move bitboard for the bishop at the given
## square with the given blockers bitboard
let
@ -153,13 +155,11 @@ proc getBishopMoves(square: Square, blockers: Bitboard): Bitboard =
# regardless of color
const
# mfw Nim's compile time VM *graciously* allows me to call perfectly valid code: :D
ROOK_BLOCKERS = generateRookMasks(blockers=true)
BISHOP_BLOCKERS = generateBishopMasks(blockers=true)
ROOK_MOVEMENTS = generateRookMasks()
BISHOP_MOVEMENTS = generateBishopMasks()
ROOK_BLOCKERS = generateRookBlockers()
BISHOP_BLOCKERS = generateBishopBlockers()
func getRelevantBlockers(kind: PieceKind, square: Square): Bitboard =
func getRelevantBlockers*(kind: PieceKind, square: Square): Bitboard =
## Returns the relevant blockers mask for the given piece
## type at the given square
case kind:
@ -169,7 +169,40 @@ func getRelevantBlockers(kind: PieceKind, square: Square): Bitboard =
return BISHOP_BLOCKERS[square.uint]
else:
discard
# Thanks analog :D
const
ROOK_DELTAS = [(1, 0), (0, -1), (-1, 0), (0, 1)]
BISHOP_DELTAS = [(1, 1), (1, -1), (-1, -1), (-1, 1)]
# These are technically (file, rank), but it's all symmetric anyway
func tryOffset(square: Square, df, dr: SomeInteger): Square =
let
file = fileFromSquare(square)
rank = rankFromSquare(square)
if file + df notin 0..7:
return nullSquare()
if rank + dr notin 0..7:
return nullSquare()
return makeSquare(rank + dr, file + df)
proc getMoveSet*(kind: PieceKind, square: Square, blocker: Bitboard): Bitboard =
## A naive implementation of sliding attacks. Returns the moves that can
## be performed from the given piece at the given square with the given
## blocker mask
result = Bitboard(0)
let deltas = if kind == Rook: ROOK_DELTAS else: BISHOP_DELTAS
for (file, rank) in deltas:
var ray = square
while not blocker.contains(ray):
if (let shifted = ray.tryOffset(file, rank); shifted) != nullSquare():
ray = shifted
result = result or ray.toBitboard()
else:
break
proc attemptMagicTableCreation(kind: PieceKind, square: Square, entry: MagicEntry): tuple[success: bool, table: seq[Bitboard]] =
## Tries to create a magic bitboard table for the given piece
@ -185,19 +218,16 @@ proc attemptMagicTableCreation(kind: PieceKind, square: Square, entry: MagicEntr
for blocker in entry.mask.subsets():
let index = getIndex(entry, blocker)
# Get the moves the piece can make from the given
# square with this specific blocker configuration
var moves: Bitboard
case kind:
of Rook:
moves = ROOK_MOVEMENTS[square.uint]
of Bishop:
moves = BISHOP_MOVEMENTS[square.uint]
else:
discard
# square with this specific blocker configuration.
# Note that this will return the same set of moves
# for several different blocker configurations, as
# many of them (while different) produce the same
# results
var moves = kind.getMoveSet(square, blocker)
if result.table[index] == 0:
# No entry here, yet, so no problem!
result.table[index] = moves
elif (result.table[index] or blocker) != moves:
elif result.table[index] != moves:
# We found a non-constructive collision, fail :(
# Notes for future self: A "constructive" collision
# is one which doesn't affect the result, because some
@ -214,14 +244,14 @@ proc attemptMagicTableCreation(kind: PieceKind, square: Square, entry: MagicEntr
# direction to find what pieces are actually blocking the
# the slider's path and which aren't for every single lookup,
# which is the whole thing we're trying to avoid by doing all
# this magic bitboard stuff and it is basically how the old mailbox
# this magic bitboard stuff, and it is basically how the old mailbox
# move generator worked anyway (thanks to Sebastian Lague on YouTube
# for the insight)
return (false, @[])
# We have found a constructive collision: all good
proc findMagic(kind: PieceKind, square: Square, indexBits: uint8): tuple[entry: MagicEntry, table: seq[Bitboard]] =
proc findMagic(kind: PieceKind, square: Square, indexBits: uint8): tuple[entry: MagicEntry, table: seq[Bitboard], iterations: int] =
## Constructs a (sort of) perfect hash function that fits all
## the possible blocking configurations for the given piece at
## the given square into a table of size 2^indexBits
@ -229,32 +259,92 @@ proc findMagic(kind: PieceKind, square: Square, indexBits: uint8): tuple[entry:
# The best way to find a good magic number? Literally just
# bruteforce the shit out of it!
var rand = initRand()
result.iterations = 0
while true:
inc(result.iterations)
# Again, this is stolen from the article. A magic number
# is only useful if it is small (i.e. has a low number of
# bits set), so we AND together 3 random numbers to get a
# number with (hopefully) not that many bits set
# is only useful if it has high bit sparsity, so we AND
# together a bunch of random values to get a number that's
# hopefully better
let
magic = rand.next() and rand.next() and rand.next()
entry = MagicEntry(mask: mask, value: magic, indexBits: indexBits)
var attempt = attemptMagicTableCreation(kind, square, entry)
if attempt.success:
return (entry, attempt.table)
# Huzzah! Our search for the mighty magic number is complete
# (for this square)
result.entry = entry
result.table = attempt.table
return
# Not successful? No problem, we'll just try again until
# the heat death of the universe!
# the heat death of the universe! (Not reallty though: finding
# magics is pretty fast even if you're unlucky)
import std/strformat
proc computeMagics*: int {.discardable.} =
## Fills in our magic number tables and returns
## the total number of iterations that were performed
## to find them
for i in 0..63:
let square = Square(i)
var magic = findMagic(Rook, square, Rook.getRelevantBlockers(square).uint64.countSetBits().uint8)
inc(result, magic.iterations)
ROOK_MAGICS[i] = magic.entry
ROOK_MOVES[i] = magic.table
magic = findMagic(Bishop, square, Bishop.getRelevantBlockers(square).uint64.countSetBits().uint8)
inc(result, magic.iterations)
BISHOP_MAGICS[i] = magic.entry
BISHOP_MOVES[i] = magic.table
when isMainModule:
import std/strformat
import std/strutils
import std/times
import std/math
for i in 0..63:
let square = Square(i)
var result = findMagic(Rook, square, Rook.getRelevantBlockers(square).uint64.countSetBits().uint8)
echo &"Found magic bitboard for rooks at {square}"
ROOK_MAGICS.add(result.entry)
ROOK_MOVES[i] = result.table
result = findMagic(Bishop, square, Bishop.getRelevantBlockers(square).uint64.countSetBits().uint8)
echo &"Found magic bitboard for bishops at {square}"
BISHOP_MAGICS.add(result.entry)
BISHOP_MOVES[i] = result.table
echo "Generating magic bitboards"
let start = cpuTime()
let it {.used.} = computeMagics()
let tot = round(cpuTime() - start, 3)
echo &"Generated magic bitboards in {tot} seconds with {it} iterations"
var
rookTableSize = 0
rookTableCount = 0
bishopTableSize = 0
bishopTableCount = 0
for i in 0..63:
inc(rookTableCount, len(ROOK_MOVES[i]))
inc(bishopTableCount, len(BISHOP_MOVES[i]))
inc(rookTableSize, len(ROOK_MOVES[i]) * sizeof(Bitboard) + sizeof(seq[Bitboard]))
inc(bishopTableSize, len(BISHOP_MOVES[i]) * sizeof(Bitboard) + sizeof(seq[Bitboard]))
echo &"There are {rookTableCount} entries in the move table for rooks (total size: ~{round(rookTableSize / 1024, 3)} KiB)"
echo &"There are {bishopTableCount} entries in the move table for bishops (total size: ~{round(bishopTableSize / 1024, 3)} KiB)"
var magics = newTable[string, array[64, MagicEntry]]()
var moves = newTable[string, array[64, seq[Bitboard]]]()
magics["rooks"] = ROOK_MAGICS
magics["bishops"] = BISHOP_MAGICS
moves["rooks"] = ROOK_MOVES
moves["bishops"] = BISHOP_MOVES
let
magicsJson = magics.toJSON()
movesJson = moves.toJSON()
var path = joinPath(getCurrentDir(), "src/resources")
if path.lastPathPart() == "nimfish":
path = joinPath("src", path)
writeFile(joinPath(path, "magics.json"), magicsJson)
writeFile(joinPath(path, "movesets.json"), movesJson)
echo &"Dumped data to disk (approx. {round(((len(movesJson) + len(magicsJson)) / 1024) / 1024, 2)} MiB)"
else:
var path = joinPath(getCurrentDir(), "src/resources")
if path.lastPathPart() == "nimfish":
path = joinPath("src", path)
var magics = readFile(joinPath(path, "magics.json")).fromJson(TableRef[string, array[64, MagicEntry]])
var moves = readFile(joinPath(path, "movesets.json")).fromJson(TableRef[string, array[64, seq[Bitboard]]])
ROOK_MAGICS = magics["rooks"]
BISHOP_MAGICS = magics["bishops"]
ROOK_MOVES = moves["rooks"]
BISHOP_MOVES = moves["bishops"]

View File

@ -43,9 +43,8 @@ func `+`*(a, b: Square): Square {.inline.} = Square(a.int8 + b.int8)
func `+`*(a: Square, b: SomeInteger): Square {.inline.} = Square(a.int8 + b.int8)
func `+`*(a: SomeInteger, b: Square): Square {.inline.} = Square(a.int8 + b.int8)
func colFromSquare*(square: Square): int8 = square.int8 mod 8 + 1
func rowFromSquare*(square: Square): int8 = square.int8 div 8 + 1
func fileFromSquare*(square: Square): int8 = square.int8 mod 8
func rankFromSquare*(square: Square): int8 = square.int8 div 8
func makeSquare*(rank, file: SomeInteger): Square = Square((rank * 8) + file)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -12,6 +12,6 @@ in semiconductor technology smart enough to play tic tac toe.
- Chess -> WIP
All of these games will be played using decision trees searched using the minimax algorithm (maybe a bit of neural networks too, who knows).
All of these games will be played using decision trees searched using the minimax algorithm or variations thereof (maybe a bit of neural networks too, who knows).
Ideally I'd like to implement a bunch of stuff such as move reordering, alpha-beta pruning and transpositions in order to improve both
processing time and decision quality. Very much WIP.

View File

@ -1,60 +0,0 @@
import board as chess
import std/strformat
import std/strutils
when isMainModule:
setControlCHook(proc () {.noconv.} = echo ""; quit(0))
const fen = "rnbqkbnr/2p/8/8/8/8/P7/RNBQKBNR w KQkq - 0 1"
var
board = newChessboardFromFEN(fen)
canCastle: tuple[queen, king: bool]
data: string
move: Move
echo "\x1Bc"
while true:
canCastle = board.canCastle()
echo &"{board.pretty()}"
echo &"Turn: {board.getSideToMove()}"
echo &"Moves: {board.getMoveCount()} full, {board.getHalfMoveCount()} half"
echo &"Can castle:\n - King side: {(if canCastle.king: \"yes\" else: \"no\")}\n - Queen side: {(if canCastle.queen: \"yes\" else: \"no\")}"
stdout.write(&"En passant target: ")
if board.getEnPassantTarget() != emptyLocation():
echo board.getEnPassantTarget().locationToAlgebraic()
else:
echo "None"
stdout.write(&"Check: ")
if board.inCheck():
echo &"Yes"
else:
echo "No"
stdout.write("\nMove(s) -> ")
try:
data = readLine(stdin).strip(chars={'\0', ' '})
except IOError:
echo ""
break
if data == "undo":
echo &"\x1BcUndo: {board.undoLastMove()}"
continue
if data == "reset":
echo &"\x1BcBoard reset"
board = newChessboardFromFEN(fen)
continue
for moveChars in data.split(" "):
if len(moveChars) != 4:
echo "\x1BcError: invalid move"
break
try:
move = board.makeMove(moveChars[0..1], moveChars[2..3])
except ValueError:
echo &"\x1BcError: {getCurrentExceptionMsg()}"
if move == emptyMove():
echo &"\x1BcError: move '{moveChars}' is illegal"
break
else:
echo "\x1Bc"