Bug fixes. Implement quiescent search, extensions and LMR. Needs SPRT

This commit is contained in:
Mattia Giambirtone 2024-04-27 09:49:45 +02:00
parent 3f283932d8
commit 4381298957
7 changed files with 253 additions and 102 deletions

View File

@ -153,7 +153,11 @@ const
-53, -34, -21, -11, -28, -14, -24, -43
]
MIDDLEGAME_TABLES: array[6, array[64, Score]] = [
# Bishop, King, Knight, Pawn, Queen, Rook
MIDDLEGAME_WEIGHTS: array[6, Score] = [365, 0, 337, 82, 1025, 477]
ENDGAME_WEIGHTS: array[6, Score] = [297, 0, 281, 94, 936, 512]
MIDDLEGAME_PSQ_TABLES: array[6, array[64, Score]] = [
BISHOP_MIDDLEGAME_SCORES,
KING_MIDDLEGAME_SCORES,
KNIGHT_MIDDLEGAME_SCORES,
@ -162,7 +166,7 @@ const
ROOK_MIDDLEGAME_SCORES
]
ENDGAME_TABLES: array[6, array[64, Score]] = [
ENDGAME_PSQ_TABLES: array[6, array[64, Score]] = [
BISHOP_ENDGAME_SCORES,
KING_ENDGAME_SCORES,
KNIGHT_ENDGAME_SCORES,
@ -171,33 +175,31 @@ const
ROOK_ENDGAME_SCORES
]
func lowestEval*: Score {.inline.} = Score(-20_000)
func highestEval*: Score {.inline.} = Score(20_000)
func mateScore*: Score {.inline.} = lowestEval()
DOUBLED_PAWNS_MALUS: array[9, Score] = [0, -5, -10, -20, -30, -30, -30, -30, -30]
ISOLATED_PAWN_MALUS: array[9, Score] = [0, -10, -25, -50, -75, -75, -75, -75, -75]
proc getPieceValue(kind: PieceKind): Score =
## Returns the absolute value of a piece
case kind:
of Pawn:
return Score(100)
of Bishop:
return Score(330)
of Knight:
return Score(280)
of Rook:
return Score(525)
of Queen:
return Score(950)
else:
discard
var
MIDDLEGAME_VALUE_TABLES: array[2, array[6, array[64, Score]]]
ENDGAME_VALUE_TABLES: array[2, array[6, array[64, Score]]]
proc getPieceScore*(board: Chessboard, square: Square): Score =
## Returns the value of the piece located at
## the given square
return board.getPiece(square).kind.getPieceValue()
proc initializeTables =
for kind in [Bishop, King, Knight, Pawn, Queen, Rook]:
for sq in 0..63:
MIDDLEGAME_VALUE_TABLES[White.int][kind.int][sq] = MIDDLEGAME_WEIGHTS[kind.int] + MIDDLEGAME_PSQ_TABLES[kind.int][sq]
ENDGAME_VALUE_TABLES[White.int][kind.int][sq] = ENDGAME_WEIGHTS[kind.int] + ENDGAME_PSQ_TABLES[kind.int][sq]
MIDDLEGAME_VALUE_TABLES[Black.int][kind.int][sq] = MIDDLEGAME_WEIGHTS[kind.int] + MIDDLEGAME_PSQ_TABLES[kind.int][sq xor 56]
ENDGAME_VALUE_TABLES[Black.int][kind.int][sq] = ENDGAME_WEIGHTS[kind.int] + ENDGAME_PSQ_TABLES[kind.int][sq xor 56]
initializeTables()
func lowestEval*: Score {.inline.} = Score(-25_000)
func highestEval*: Score {.inline.} = Score(25_000)
func mateScore*: Score {.inline.} = lowestEval() + 1
proc getGamePhase(board: Chessboard): int =
@ -219,9 +221,33 @@ proc getGamePhase(board: Chessboard): int =
result = min(24, result)
proc evaluatePiecePositions(board: ChessBoard): Score =
## Returns the evaluation of the current
## material's position relative to white
proc getPieceScore*(board: Chessboard, square: Square): Score =
## Returns the value of the piece located at
## the given square
let
piece = board.getPiece(square)
middleGameScore = MIDDLEGAME_VALUE_TABLES[piece.color.int][piece.kind.int][square.int]
endGameScore = ENDGAME_VALUE_TABLES[piece.color.int][piece.kind.int][square.int]
middleGamePhase = board.getGamePhase()
endGamePhase = 24 - middleGamePhase
result = Score((middleGameScore * middleGamePhase + endGameScore * endGamePhase) div 24)
proc getPieceScore*(board: Chessboard, piece: Piece, square: Square): Score =
## Returns the value the given piece would have if it
## were at the given square
let
middleGameScore = MIDDLEGAME_VALUE_TABLES[piece.color.int][piece.kind.int][square.int]
endGameScore = ENDGAME_VALUE_TABLES[piece.color.int][piece.kind.int][square.int]
middleGamePhase = board.getGamePhase()
endGamePhase = 24 - middleGamePhase
result = Score((middleGameScore * middleGamePhase + endGameScore * endGamePhase) div 24)
proc evaluateMaterial(board: ChessBoard): Score =
## Returns a material and position evaluation
## for the current side to move
let
middleGamePhase = board.getGamePhase()
endGamePhase = 24 - middleGamePhase
@ -232,8 +258,8 @@ proc evaluatePiecePositions(board: ChessBoard): Score =
for sq in board.getOccupancy():
let piece = board.getPiece(sq)
middleGameScores[piece.color.int] += MIDDLEGAME_TABLES[piece.kind.int][sq.int]
endGameScores[piece.color.int] += ENDGAME_TABLES[piece.kind.int][sq.int]
middleGameScores[piece.color.int] += MIDDLEGAME_VALUE_TABLES[piece.color.int][piece.kind.int][sq.int]
endGameScores[piece.color.int] += ENDGAME_VALUE_TABLES[piece.color.int][piece.kind.int][sq.int]
let
sideToMove = board.position.sideToMove
@ -244,27 +270,35 @@ proc evaluatePiecePositions(board: ChessBoard): Score =
result = Score((middleGameScore * middleGamePhase + endGameScore * endGamePhase) div 24)
proc evaluateMaterial(board: ChessBoard): Score =
## Returns the material evaluation of the
## current position relative to white (positive
## if in white's favor, negative otherwise)
var
whiteScore: Score
blackScore: Score
proc evaluatePawnStructure(board: Chessboard): Score =
## Evaluates the pawn structure of the current
## position for the side to move
let
sideToMove = board.position.sideToMove
friendlyPawns = board.getOccupancyFor(sideToMove)
for sq in board.getOccupancyFor(White):
whiteScore += board.getPieceScore(sq)
for sq in board.getOccupancyFor(Black):
blackScore += board.getPieceScore(sq)
result = whiteScore - blackScore
if board.position.sideToMove == Black:
result *= -1
# Doubled pawns are a bad idea
var doubledPawns = 0
for file in 0..7:
if (getFileMask(file) and friendlyPawns).countSquares() > 1:
inc(doubledPawns)
# Isolated pawns are also a bad idea
var isolatedPawns = 0
for pawn in friendlyPawns:
let file = fileFromSquare(pawn)
var fileMask = getFileMask(file)
if file - 1 in 0..7:
fileMask = fileMask or getFileMask(file - 1)
if file + 1 in 0..7:
fileMask = fileMask or getFileMask(file + 1)
if (friendlyPawns and fileMask) == 0:
inc(isolatedPawns)
return DOUBLED_PAWNS_MALUS[doubledPawns] + ISOLATED_PAWN_MALUS[isolatedPawns]
proc evaluate*(board: Chessboard): Score =
## Evaluates the current position
result = board.evaluateMaterial()
result += board.evaluatePiecePositions()
result += board.evaluatePawnStructure()

View File

@ -226,7 +226,7 @@ proc generateBishopMoves(self: Chessboard, moves: var MoveList, destinationMask:
moves.add(createMove(square, target, Capture))
proc generateKingMoves(self: Chessboard, moves: var MoveList) =
proc generateKingMoves(self: Chessboard, moves: var MoveList, capturesOnly=false) =
let
sideToMove = self.position.sideToMove
king = self.getBitboard(King, sideToMove)
@ -235,9 +235,10 @@ proc generateKingMoves(self: Chessboard, moves: var MoveList) =
enemyPieces = self.getOccupancyFor(nonSideToMove) and not self.getBitboard(King, nonSideToMove)
bitboard = getKingAttacks(king.toSquare())
noKingOccupancy = occupancy and not king
for square in bitboard and not occupancy:
if not self.isOccupancyAttacked(square, noKingOccupancy):
moves.add(createMove(king, square))
if not capturesOnly:
for square in bitboard and not occupancy:
if not self.isOccupancyAttacked(square, noKingOccupancy):
moves.add(createMove(king, square))
for square in bitboard and enemyPieces:
if not self.isOccupancyAttacked(square, noKingOccupancy):
moves.add(createMove(king, square, Capture))
@ -271,12 +272,15 @@ proc generateCastling(self: Chessboard, moves: var MoveList) =
moves.add(createMove(kingSquare, kingPiece.queenSideCastling(), Castle))
proc generateMoves*(self: Chessboard, moves: var MoveList) =
proc generateMoves*(self: Chessboard, moves: var MoveList, capturesOnly: bool = false) =
## Generates the list of all possible legal moves
## in the current position
## in the current position. If capturesOnly is
## true, only capture moves are generated
let sideToMove = self.position.sideToMove
self.generateKingMoves(moves)
let
sideToMove = self.position.sideToMove
nonSideToMove = sideToMove.opposite()
self.generateKingMoves(moves, capturesOnly)
if self.position.checkers.countSquares() > 1:
# King is in double check: no need to generate any more
# moves
@ -304,8 +308,8 @@ proc generateMoves*(self: Chessboard, moves: var MoveList) =
let
checker = self.position.checkers.lowestSquare()
checkerBB = checker.toBitboard()
epTarget = self.position.enPassantSquare
checkerPiece = self.getPiece(checker)
# epTarget = self.position.enPassantSquare
# checkerPiece = self.getPiece(checker)
destinationMask = getRayBetween(checker, self.getBitboard(King, sideToMove).toSquare()) or checkerBB
# TODO: This doesn't really work. I've addressed the issue for now, but it's kinda ugly. Find a better
# solution
@ -315,6 +319,10 @@ proc generateMoves*(self: Chessboard, moves: var MoveList) =
# # for most pieces, because the move generators won't allow them to move there, but it does matter
# # for pawns
# destinationMask = destinationMask or epTarget.toBitboard()
if capturesOnly:
# Note: This does not cover en passant (which is good because it's a capture,
# but the "fix" stands on flimsy ground)
destinationMask = destinationMask and self.getOccupancyFor(nonSideToMove)
self.generatePawnMoves(moves, destinationMask)
self.generateKnightMoves(moves, destinationMask)
self.generateRookMoves(moves, destinationMask)
@ -427,7 +435,7 @@ proc doMove*(self: Chessboard, move: Move) =
proc isLegal*(self: Chessboard, move: Move): bool {.inline.} =
## Returns whether the given move is legal
var moves = MoveList()
var moves = newMoveList()
self.generateMoves(moves)
return move in moves

View File

@ -193,4 +193,10 @@ func toAlgebraic*(self: Move): string =
of PromoteToRook:
result &= "r"
else:
discard
discard
proc newMoveList*: MoveList =
new(result)
for i in 0..result.data.high():
result.data[i] = nullMove()

View File

@ -30,7 +30,7 @@ type
# Number of half-moves that were performed
# to reach this position starting from the
# root of the tree
plyFromRoot*: int8
plyFromRoot*: uint8
# Number of half moves since
# last piece capture or pawn movement.
# Used for the 50-move rule

View File

@ -33,6 +33,7 @@ type
stopFlag*: Atomic[bool] # Can be used to cancel the search from another thread
board: Chessboard
bestMoveRoot: Move
bestRootScore: Score
searchStart: MonoTime
searchDeadline: MonoTime
nodeCount: uint64
@ -40,6 +41,7 @@ type
searchMoves: seq[Move]
previousBestMove: Move
transpositionTable: TTable
currentExtensionCount: uint8
proc newSearchManager*(board: Chessboard, transpositions: TTable): SearchManager =
@ -52,17 +54,37 @@ proc newSearchManager*(board: Chessboard, transpositions: TTable): SearchManager
proc getEstimatedMoveScore(self: SearchManager, move: Move): Score =
## Returns an estimated static score for the move
result = Score(0)
let
sideToMove = self.board.position.sideToMove
nonSideToMove = sideToMove.opposite()
if self.previousBestMove != nullMove() and move == self.previousBestMove:
result = highestEval() + 1
elif move.isCapture():
return highestEval() + 1
if move.isCapture():
# Implementation of MVVLVA: Most Valuable Victim Least Valuable Attacker
# We prioritize moves that capture the most valuable pieces, and as a
# second goal we want to use our least valuable pieces to do so (this
# is why we multiply the score of the captured piece by 100, to give
# it priority)
result = 100 * self.board.getPieceScore(move.targetSquare) -
self.board.getPieceScore(move.startSquare)
result += 100 * self.board.getPieceScore(move.targetSquare) - self.board.getPieceScore(move.startSquare)
if move.isPromotion():
# Promotions are a good idea to search first
var piece: Piece
case move.getPromotionType():
of PromoteToBishop:
piece = Piece(kind: Bishop, color: sideToMove)
of PromoteToKnight:
piece = Piece(kind: Knight, color: sideToMove)
of PromoteToRook:
piece = Piece(kind: Rook, color: sideToMove)
of PromoteToQueen:
piece = Piece(kind: Queen, color: sideToMove)
else:
discard # Unreachable
result += self.board.getPieceScore(piece, move.targetSquare)
if (self.board.getPawnAttacks(move.targetSquare, nonSideToMove) and self.board.getBitboard(Pawn, nonSideToMove)) != 0:
# Moving on a square attacked by an enemy pawn is _usually_ a very bad
# idea. Assume the piece is lost
result -= self.board.getPieceScore(move.startSquare)
proc reorderMoves(self: SearchManager, moves: var MoveList) =
@ -71,8 +93,9 @@ proc reorderMoves(self: SearchManager, moves: var MoveList) =
proc orderer(a, b: Move): int {.closure.} =
return cmp(self.getEstimatedMoveScore(a), self.getEstimatedMoveScore(b))
moves.data.sort(orderer, SortOrder.Descending)
# Ignore null moves beyond the lenght of the movelist
sort(moves.data.toOpenArray(0, moves.len - 1), orderer, SortOrder.Descending)
proc timedOut(self: SearchManager): bool = getMonoTime() >= self.searchDeadline
@ -86,7 +109,7 @@ proc log(self: SearchManager, depth: int) =
nps = 1000 * (self.nodeCount div max(elapsedMsec, 1))
var logMsg = &"info depth {depth} time {elapsedMsec} nodes {self.nodeCount} nps {nps}"
if self.bestMoveRoot != nullMove():
logMsg &= &" pv {self.bestMoveRoot.toAlgebraic()}"
logMsg &= &" bestmove {self.bestMoveRoot.toAlgebraic()} score {self.bestRootScore}"
echo logMsg
@ -104,7 +127,74 @@ proc shouldStop(self: SearchManager): bool =
return true
proc search*(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {.discardable.} =
proc getSearchExtension(self: SearchManager, move: Move): int =
## Returns the number of extensions that should be performed
## when exploring the given move
if self.currentExtensionCount == 16:
return 0
if self.board.inCheck():
# Opponent is in check: extend the search to see
# if we can do other interesting things!
inc(self.currentExtensionCount)
return 1
let piece = self.board.getPiece(move.targetSquare)
# If a pawn has just moved to its second-last rank, extend to
# see if a promotion would yield some good position
if piece.kind == Pawn:
let rank = if piece.color == White: getRankMask(1) else: getRankMask(6)
if (move.targetSquare.toBitboard() and rank) != 0:
inc(self.currentExtensionCount, 1)
return 1
proc qsearch(self: SearchManager, ply: uint8, alpha, beta: Score): Score =
## Negamax search with a/b pruning that is restricted to
## capture moves (commonly called quiescent search). The
## purpose of this extra search step is to mitigate the
## so called horizon effect that stems from the fact that,
## at some point, the engine will have to stop searching, possibly
## thinking a bad move is good because it couldn't see far enough
## ahead (this usually results in the engine blundering captures
## or sacking pieces for apparently no reason: the reason is that it
## did not look at the opponent's responses, because it stopped earlier.
## That's the horizon). To address this, we look at all possible captures
## in the current position and make sure that a position is not evaluated as
## bad if only bad capture moves exist, if good non-capture moves do
if self.shouldStop():
return
if ply == 127:
return Score(0)
let score = self.board.evaluate()
if score >= beta:
# Same as with the regular alpha-beta search
return score
var moves = newMoveList()
self.board.generateMoves(moves, capturesOnly=true)
self.reorderMoves(moves)
var bestScore = score
var alpha = max(alpha, score)
for move in moves:
self.board.doMove(move)
inc(self.nodeCount)
# Find the best move for us (worst move
# for our opponent, hence the negative sign)
var score = -self.qsearch(ply + 1, -beta, -alpha)
self.board.unmakeMove()
bestScore = max(score, bestScore)
if score >= beta:
# This move was too good for us, opponent will not search it
break
# When a search is cancelled or times out, we need
# to make sure the entire call stack unwinds back
# to the root move. This is why the check is duplicated
if self.shouldStop():
return
if score > alpha:
alpha = score
return bestScore
proc search(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {.discardable.} =
## Simple negamax search with alpha-beta pruning
if self.shouldStop():
return
@ -120,8 +210,8 @@ proc search*(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {.
if query.entry.score <= alpha:
return query.entry.score
if depth == 0:
return self.board.evaluate()
var moves = MoveList()
return self.qsearch(0, alpha, beta)
var moves = newMoveList()
var depth = depth
self.board.generateMoves(moves)
self.reorderMoves(moves)
@ -136,46 +226,49 @@ proc search*(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {.
# Stalemate
return Score(0)
var bestScore = lowestEval()
var alpha = alpha
var alpha = alpha
for i, move in moves:
if ply == 0 and self.searchMoves.len() > 0 and move notin self.searchMoves:
continue
self.board.doMove(move)
var extension = self.getSearchExtension(move)
let zobrist = self.board.position.zobristKey
inc(self.nodeCount)
# Find the best move for us (worst move
# for our opponent, hence the negative sign)
var score = -self.search(depth - 1, ply + 1, -beta, -alpha)
if self.board.position.repetitionDraw:
var score: Score
var fullDepth = true
if extension == 0 and i >= 3 and not move.isCapture():
# Late Move Reduction: assume our move orderer did a good job,
# so it is not worth to look at all moves at the same depth equally.
# If this move turns out to be better than we expected, we'll re-search
# it at full depth
const reduction = 1
score = -self.search(depth - 1 - reduction, ply + 1, -beta, -alpha)
fullDepth = score > alpha
if fullDepth:
score = -self.search(depth - 1 + extension, ply + 1, -beta, -alpha)
if self.board.position.halfMoveClock >= 100 or self.board.position.repetitionDraw:
# Drawing by repetition is *bad*
score = lowestEval() div 2
score = Score(0)
self.board.unmakeMove()
# When a search is cancelled or times out, we need
# to make sure the entire call stack unwindss back
# to make sure the entire call stack unwinds back
# to the root move. This is why the check is duplicated
if self.shouldStop():
return
bestScore = max(score, bestScore)
if score >= beta:
# If we meet this position again, mark the fact that this score is a
# lower bound for the actual true score of the node (i.e. its score
# will NOT be lower than this)
self.transpositionTable.store(depth.uint8, score, zobrist, LowerBound)
let nodeType = if score >= beta: LowerBound elif score <= alpha: UpperBound else: Exact
self.transpositionTable.store(depth.uint8, score, zobrist, nodeType)
if nodeType == LowerBound:
# score >= beta
# This move was too good for us, opponent will not search it
break
if score <= alpha:
# If we meet this position again, mark the fact that this score is an
# upper bound for the actual true score of the node (i.e. its score
# will NOT be higher than this)
self.transpositionTable.store(depth.uint8, score, zobrist, UpperBound)
else:
# The position didn't cause any cutoffs, so the score stored here is
# the actual true score of the position
self.transpositionTable.store(depth.uint8, score, zobrist, Exact)
if score > alpha:
alpha = score
if ply == 0:
self.bestMoveRoot = move
self.bestRootScore = bestScore
return bestScore
@ -202,16 +295,16 @@ proc findBestMove*(self: SearchManager, maxSearchTime, maxDepth: int, maxNodes:
# Search the previous best move first
self.previousBestMove = self.bestMoveRoot
self.search(i, 0, lowestEval(), highestEval())
let shouldStop = self.shouldStop()
if shouldStop:
self.log(i - 1)
else:
self.log(i)
# Since we always search the best move from the
# previous iteration, we can use partial search
# results: the engine will either not have changed
# its mind, or it will have found an even better move
# in the meantime, which we should obviously use!
result = self.bestMoveRoot
let shouldStop = self.shouldStop()
if shouldStop:
break
self.log(i - 1)
else:
self.log(i)
if shouldStop:
break

View File

@ -48,7 +48,8 @@ proc newTranspositionTable*(size: uint64): TTable =
## Initializes a new transposition table of
## size bytes
new(result)
result.data = newSeq[TTEntry](size)
let numEntries = size div sizeof(TTEntry).uint64
result.data = newSeq[TTEntry](numEntries)
func getIndex(self: TTable, key: ZobristKey): uint64 =

View File

@ -13,6 +13,7 @@
# limitations under the License.
import movegen
import eval
import uci
@ -28,12 +29,12 @@ type
CountData = tuple[nodes: uint64, captures: uint64, castles: uint64, checks: uint64, promotions: uint64, enPassant: uint64, checkmates: uint64]
proc perft*(board: Chessboard, ply: int, verbose: bool = false, divide: bool = false, bulk: bool = false): CountData =
proc perft*(board: Chessboard, ply: int, verbose = false, divide = false, bulk = false, capturesOnly = false): CountData =
## Counts (and debugs) the number of legal positions reached after
## the given number of ply
var moves = MoveList()
board.generateMoves(moves)
var moves = newMoveList()
board.generateMoves(moves, capturesOnly=capturesOnly)
if not bulk:
if len(moves) == 0 and board.inCheck():
result.checkmates = 1
@ -146,6 +147,7 @@ proc handleGoCommand(board: Chessboard, command: seq[string]) =
args = command[2].splitWhitespace()
bulk = false
verbose = false
captures = false
if args.len() > 1:
var ok = true
for arg in args[1..^1]:
@ -154,6 +156,8 @@ proc handleGoCommand(board: Chessboard, command: seq[string]) =
bulk = true
of "verbose":
verbose = true
of "captures":
captures = true
else:
echo &"Error: go: perft: invalid argument '{args[1]}'"
ok = false
@ -164,13 +168,13 @@ proc handleGoCommand(board: Chessboard, command: seq[string]) =
let ply = parseInt(args[0])
if bulk:
let t = cpuTime()
let nodes = board.perft(ply, divide=true, bulk=true, verbose=verbose).nodes
let nodes = board.perft(ply, divide=true, bulk=true, verbose=verbose, capturesOnly=captures).nodes
let tot = cpuTime() - t
echo &"\nNodes searched (bulk-counting: on): {nodes}"
echo &"Time taken: {round(tot, 3)} seconds\nNodes per second: {round(nodes / tot).uint64}"
else:
let t = cpuTime()
let data = board.perft(ply, divide=true, verbose=verbose)
let data = board.perft(ply, divide=true, verbose=verbose, capturesOnly=captures)
let tot = cpuTime() - t
echo &"\nNodes searched (bulk-counting: off): {data.nodes}"
echo &" - Captures: {data.captures}"
@ -327,6 +331,7 @@ const HELP_TEXT = """Nimfish help menu:
Options:
- bulk: Enable bulk-counting (significantly faster, gives less statistics)
- verbose: Enable move debugging (for each and every move, not recommended on large searches)
- captures: Only generate capture moves
Example: go perft 5 bulk
- position: Get/set board position
Subcommands:
@ -362,6 +367,8 @@ const HELP_TEXT = """Nimfish help menu:
- uci: enter UCI mode
- quit: exit
- zobrist: Print the zobrist key for the current position
- eval: Evaluate the current position
- rep: Show whether this position is a draw by repetition
"""
@ -450,6 +457,8 @@ proc commandLoop*: int =
echo board.position.zobristKey.uint64
of "rep":
echo board.position.repetitionDraw
of "eval":
echo &"Eval: {board.evaluate()}"
else:
echo &"Unknown command '{cmd[0]}'. Type 'help' for more information."
except IOError: