From e011d9e5cbc80383e3fd6956cf19abdbd753d83a Mon Sep 17 00:00:00 2001 From: Jakob Date: Sun, 1 Mar 2026 20:04:49 +0100 Subject: [PATCH 1/4] Feat: add zobrist and tt --- src/bitboard.zig | 4 + src/evaluation.zig | 111 +++++++++-- src/move_generation.zig | 63 +++++- src/score_moves.zig | 15 +- src/search.zig | 411 ++++++++++++++++++++++++++++++++++++---- src/test.zig | 56 ++++++ src/tt.zig | 136 +++++++++++++ src/types.zig | 9 +- src/uci.zig | 146 +++++++------- src/zobrist.zig | 91 +++++++++ 10 files changed, 905 insertions(+), 137 deletions(-) create mode 100644 src/tt.zig create mode 100644 src/zobrist.zig diff --git a/src/bitboard.zig b/src/bitboard.zig index aa406f5..6880a1e 100644 --- a/src/bitboard.zig +++ b/src/bitboard.zig @@ -4,6 +4,7 @@ const types = @import("types.zig"); const attacks = @import("attacks.zig"); const tables = @import("tabeles.zig"); const eval = @import("evaluation.zig"); +const zobrist = @import("zobrist.zig"); const print = std.debug.print; pub fn print_board(bitboard: types.Bitboard) void { @@ -287,6 +288,9 @@ pub fn fan_pars(fen: []const u8, board: *types.Board) !void { } else { return FenError.InvalidEnPassant; } + + // Compute Zobrist hash from scratch + board.hash = zobrist.compute_hash(board); } pub inline fn get_all_attackers(board: *const types.Board, square: u6, occupied: u64) u64 { diff --git a/src/evaluation.zig b/src/evaluation.zig index 429d7cd..bd94ef8 100644 --- a/src/evaluation.zig +++ b/src/evaluation.zig @@ -475,6 +475,8 @@ pub const Evaluat = struct { var king_score = [_]i32{ 0, 0 }; var additional_material_score = [_]i32{ 0, 0 }; var mobility_score = [_]i32{ 0, 0 }; + var white_passed_bb: u64 = 0; + var black_passed_bb: u64 = 0; // check if pawn is passed const isPassedPawn = struct { @@ -576,7 +578,9 @@ pub const Evaluat = struct { } // Passed pawn evaluation - if (isPassedPawn(sq, types.Color.White, black_pawns, white_pawns)) { + const is_passed_w = isPassedPawn(sq, types.Color.White, black_pawns, white_pawns); + if (is_passed_w) { + white_passed_bb |= types.squar_bb[sq]; var tmp_sc = get_passed_pawn_score(sq); pawn_structure_score[0] += tmp_sc[0]; pawn_structure_score[1] += tmp_sc[1]; @@ -590,19 +594,28 @@ pub const Evaluat = struct { } // Pawn is supported? - if ((white_pawn_attacks & types.squar_bb[sq]) != 0) { + const is_supported_w = (white_pawn_attacks & types.squar_bb[sq]) != 0; + if (is_supported_w) { const tmp_sc = get_supported_pawn_bonus(rank); pawn_structure_score[0] += tmp_sc[0]; pawn_structure_score[1] += tmp_sc[1]; } // Pawn phalanx (adjacent pawns on same rank) - if (file < 7 and (white_pawns & types.squar_bb[sq + 1]) != 0) { + const has_phalanx_w = file < 7 and (white_pawns & types.squar_bb[sq + 1]) != 0; + if (has_phalanx_w) { const tmp_sc = get_phalanx_score(rank); pawn_structure_score[0] += tmp_sc[0]; pawn_structure_score[1] += tmp_sc[1]; } + // Connected passed pawn bonus: passed + supported or phalanx + if (is_passed_w and (is_supported_w or has_phalanx_w)) { + const tmp_sc = get_connected_passer_bonus(rank); + pawn_structure_score[0] += tmp_sc[0]; + pawn_structure_score[1] += tmp_sc[1]; + } + // Threats var b1 = att & black_knight; if (b1 != 0) { @@ -659,7 +672,9 @@ pub const Evaluat = struct { } // Passed pawn evaluation - if (isPassedPawn(sq, types.Color.Black, white_pawns, black_pawns)) { + const is_passed_b = isPassedPawn(sq, types.Color.Black, white_pawns, black_pawns); + if (is_passed_b) { + black_passed_bb |= types.squar_bb[sq]; var tmp_sc = get_passed_pawn_score(sq ^ 56); // Flip for black pawn_structure_score[0] -= tmp_sc[0]; pawn_structure_score[1] -= tmp_sc[1]; @@ -673,19 +688,28 @@ pub const Evaluat = struct { } // Pawn is supported? - if ((black_pawn_attacks & types.squar_bb[sq]) != 0) { + const is_supported_b = (black_pawn_attacks & types.squar_bb[sq]) != 0; + if (is_supported_b) { const tmp_sc = get_supported_pawn_bonus(7 - rank); pawn_structure_score[0] -= tmp_sc[0]; pawn_structure_score[1] -= tmp_sc[1]; } // Pawn phalanx - if (file < 7 and (black_pawns & types.squar_bb[sq + 1]) != 0) { + const has_phalanx_b = file < 7 and (black_pawns & types.squar_bb[sq + 1]) != 0; + if (has_phalanx_b) { const tmp_sc = get_phalanx_score(7 - rank); pawn_structure_score[0] -= tmp_sc[0]; pawn_structure_score[1] -= tmp_sc[1]; } + // Connected passed pawn bonus: passed + supported or phalanx + if (is_passed_b and (is_supported_b or has_phalanx_b)) { + const tmp_sc = get_connected_passer_bonus(7 - rank); + pawn_structure_score[0] -= tmp_sc[0]; + pawn_structure_score[1] -= tmp_sc[1]; + } + // Threats var b1 = att & white_knight; if (b1 != 0) { @@ -1043,6 +1067,29 @@ pub const Evaluat = struct { additional_material_score[1] += seventh_rank_bonus[1]; } + // Rook behind passed pawn + { + const passers_on_file = (white_passed_bb | black_passed_bb) & file_mask; + if (passers_on_file != 0) { + const own_passers = white_passed_bb & file_mask; + if (own_passers != 0) { + const lowest_passer_sq: u6 = @intCast(util.lsb_index(own_passers)); + if (sq < lowest_passer_sq) { + additional_material_score[0] += 15; + additional_material_score[1] += 25; + } + } + const enemy_passers = black_passed_bb & file_mask; + if (enemy_passers != 0) { + const highest_passer_sq: u6 = @intCast(63 - @clz(enemy_passers)); + if (sq > highest_passer_sq) { + additional_material_score[0] += 15; + additional_material_score[1] += 25; + } + } + } + } + // Threats var b1 = mobility & black_pawns; if (b1 != 0) { @@ -1122,6 +1169,29 @@ pub const Evaluat = struct { additional_material_score[1] -= second_rank_bonus[1]; } + // Rook behind passed pawn + { + const passers_on_file = (white_passed_bb | black_passed_bb) & file_mask; + if (passers_on_file != 0) { + const own_passers = black_passed_bb & file_mask; + if (own_passers != 0) { + const highest_passer_sq: u6 = @intCast(63 - @clz(own_passers)); + if (sq > highest_passer_sq) { + additional_material_score[0] -= 15; + additional_material_score[1] -= 25; + } + } + const enemy_passers = white_passed_bb & file_mask; + if (enemy_passers != 0) { + const lowest_passer_sq: u6 = @intCast(util.lsb_index(enemy_passers)); + if (sq < lowest_passer_sq) { + additional_material_score[0] -= 15; + additional_material_score[1] -= 25; + } + } + } + } + // Threats var b1 = mobility & white_pawns; if (b1 != 0) { @@ -1301,14 +1371,12 @@ pub const Evaluat = struct { const black_bishop_count = util.popcount(black_bishop); if (white_bishop_count >= 2) { - const tmp_sc = [_]i32{ 12, 46 }; - additional_material_score[0] += tmp_sc[0]; - additional_material_score[1] += tmp_sc[1]; + additional_material_score[0] += mg_bishop_pair[0]; + additional_material_score[1] += eg_bishop_pair[0]; } if (black_bishop_count >= 2) { - const tmp_sc = [_]i32{ 12, 46 }; - additional_material_score[0] -= tmp_sc[0]; - additional_material_score[1] -= tmp_sc[1]; + additional_material_score[0] -= mg_bishop_pair[0]; + additional_material_score[1] -= eg_bishop_pair[0]; } // Doubled pawns @@ -1396,16 +1464,14 @@ pub const Evaluat = struct { // King + Bishop vs King + Knight if (total_minors == 2 and - ((white_bishop_count == 1 and black_knight_count == 1) or - (white_knight_count == 1 and black_bishop_count == 1))) + ((white_bishop_count == 1 and black_knight_count == 1) or (white_knight_count == 1 and black_bishop_count == 1))) { return true; } // King + two Knights vs King if (total_minors == 2 and - ((white_knight_count == 2 and black_bishop_count == 0 and black_knight_count == 0) or - (black_knight_count == 2 and white_bishop_count == 0 and white_knight_count == 0))) + ((white_knight_count == 2 and black_bishop_count == 0 and black_knight_count == 0) or (black_knight_count == 2 and white_bishop_count == 0 and white_knight_count == 0))) { return true; } @@ -1512,8 +1578,13 @@ const eg_queen_attacking: [6]i32 = .{ 0, -3, 8, 3, 0, 0 }; const mg_doubled_pawns: [1]i32 = .{-2}; const eg_doubled_pawns: [1]i32 = .{-13}; -const mg_bishop_pair: [1]i32 = .{12}; -const eg_bishop_pair: [1]i32 = .{46}; +// Connected passed pawn bonus by rank: passed pawns that are supported or in phalanx +// These pawns protect each other while advancing, making them especially dangerous +const mg_connected_passer: [8]i32 = .{ 0, 5, 7, 12, 20, 35, 0, 0 }; +const eg_connected_passer: [8]i32 = .{ 0, 7, 10, 17, 30, 50, 0, 0 }; + +const mg_bishop_pair: [1]i32 = .{30}; +const eg_bishop_pair: [1]i32 = .{50}; pub inline fn get_passed_pawn_score(sq: u6) [2]i32 { return .{ mg_passed_score[sq], eg_passed_score[sq] }; @@ -1560,6 +1631,10 @@ pub inline fn get_phalanx_score(rank: u6) [2]i32 { return .{ mg_pawn_phalanx[rank], eg_pawn_phalanx[rank] }; } +pub inline fn get_connected_passer_bonus(rank: u6) [2]i32 { + return .{ mg_connected_passer[rank], eg_connected_passer[rank] }; +} + pub inline fn get_knight_mobility_score(idx: u7) [2]i32 { return .{ mg_knight_mobility[idx], eg_knight_mobility[idx] }; } diff --git a/src/move_generation.zig b/src/move_generation.zig index 1682e66..0c59301 100644 --- a/src/move_generation.zig +++ b/src/move_generation.zig @@ -5,6 +5,7 @@ const lists = @import("lists.zig"); const util = @import("util.zig"); const bitboard = @import("bitboard.zig"); const eval = @import("evaluation.zig"); +const zobrist = @import("zobrist.zig"); const print = std.debug.print; // Define a move @@ -707,6 +708,15 @@ pub fn make_move(board: *types.Board, move: Move) bool { // Determine the moving side based on the piece found moving_side = if (@intFromEnum(piece_type) < 6) types.Color.White else types.Color.Black; + // Save old castling rights and en passant for hash update + const old_castle = board.castle; + const old_ep = board.enpassant; + + // Zobrist: remove piece from source, add to target + const pi = zobrist.piece_index(piece_type); + board.hash ^= zobrist.piece_keys[pi][source_square]; + board.hash ^= zobrist.piece_keys[pi][target_square]; + // Move the piece board.pieces[@intFromEnum(piece_type)] = util.clear_bit(board.pieces[@intFromEnum(piece_type)], @enumFromInt(source_square)); board.pieces[@intFromEnum(piece_type)] = util.set_bit(board.pieces[@intFromEnum(piece_type)], @enumFromInt(target_square)); @@ -723,6 +733,7 @@ pub fn make_move(board: *types.Board, move: Move) bool { if (util.get_bit(board.pieces[captured_piece_idx], target_square)) { board.pieces[captured_piece_idx] = util.clear_bit(board.pieces[captured_piece_idx], @enumFromInt(target_square)); const captured_piece: types.Piece = @enumFromInt(captured_piece_idx); + board.hash ^= zobrist.piece_keys[zobrist.piece_index(captured_piece)][target_square]; eval.global_evaluator.remove_piece_phase(captured_piece); eval.global_evaluator.remove_piece_material(captured_piece); break; @@ -739,6 +750,9 @@ pub fn make_move(board: *types.Board, move: Move) bool { const promoted_piece = get_promoted_piece(move_flags, moving_side); board.pieces[@intFromEnum(promoted_piece)] = util.set_bit(board.pieces[@intFromEnum(promoted_piece)], @enumFromInt(target_square)); + board.hash ^= zobrist.piece_keys[zobrist.piece_index(pawn_piece)][target_square]; + board.hash ^= zobrist.piece_keys[zobrist.piece_index(promoted_piece)][target_square]; + eval.global_evaluator.remove_piece_phase(pawn_piece); eval.global_evaluator.remove_piece_material(pawn_piece); eval.global_evaluator.put_piece_phase(promoted_piece); @@ -754,6 +768,7 @@ pub fn make_move(board: *types.Board, move: Move) bool { const captured_pawn = if (moving_side == types.Color.White) types.Piece.BLACK_PAWN else types.Piece.WHITE_PAWN; board.pieces[@intFromEnum(captured_pawn)] = util.clear_bit(board.pieces[@intFromEnum(captured_pawn)], @enumFromInt(captured_pawn_square)); + board.hash ^= zobrist.piece_keys[zobrist.piece_index(captured_pawn)][captured_pawn_square]; eval.global_evaluator.remove_piece_phase(captured_pawn); eval.global_evaluator.remove_piece_material(captured_pawn); } @@ -771,19 +786,65 @@ pub fn make_move(board: *types.Board, move: Move) bool { // Handle castling moves if (move_flags == types.MoveFlags.OO or move_flags == types.MoveFlags.OOO) { + const rook_piece = if (moving_side == types.Color.White) types.Piece.WHITE_ROOK else types.Piece.BLACK_ROOK; + const rook_pi = zobrist.piece_index(rook_piece); + switch (target_square) { + @intFromEnum(types.square.g1) => { + board.hash ^= zobrist.piece_keys[rook_pi][@intFromEnum(types.square.h1)]; + board.hash ^= zobrist.piece_keys[rook_pi][@intFromEnum(types.square.f1)]; + }, + @intFromEnum(types.square.c1) => { + board.hash ^= zobrist.piece_keys[rook_pi][@intFromEnum(types.square.a1)]; + board.hash ^= zobrist.piece_keys[rook_pi][@intFromEnum(types.square.d1)]; + }, + @intFromEnum(types.square.g8) => { + board.hash ^= zobrist.piece_keys[rook_pi][@intFromEnum(types.square.h8)]; + board.hash ^= zobrist.piece_keys[rook_pi][@intFromEnum(types.square.f8)]; + }, + @intFromEnum(types.square.c8) => { + board.hash ^= zobrist.piece_keys[rook_pi][@intFromEnum(types.square.a8)]; + board.hash ^= zobrist.piece_keys[rook_pi][@intFromEnum(types.square.d8)]; + }, + else => {}, + } handle_castling(board, target_square, moving_side); } // Update castling rights using bitboard masks update_castling_rights(board, source_square, target_square); + // Zobrist: update castling rights (XOR out old, XOR in new) + if (old_castle != board.castle) { + // XOR out old castling keys + if (old_castle & @intFromEnum(types.Castle.WK) != 0) board.hash ^= zobrist.castle_keys[0]; + if (old_castle & @intFromEnum(types.Castle.WQ) != 0) board.hash ^= zobrist.castle_keys[1]; + if (old_castle & @intFromEnum(types.Castle.BK) != 0) board.hash ^= zobrist.castle_keys[2]; + if (old_castle & @intFromEnum(types.Castle.BQ) != 0) board.hash ^= zobrist.castle_keys[3]; + // XOR in new castling keys + if (board.castle & @intFromEnum(types.Castle.WK) != 0) board.hash ^= zobrist.castle_keys[0]; + if (board.castle & @intFromEnum(types.Castle.WQ) != 0) board.hash ^= zobrist.castle_keys[1]; + if (board.castle & @intFromEnum(types.Castle.BK) != 0) board.hash ^= zobrist.castle_keys[2]; + if (board.castle & @intFromEnum(types.Castle.BQ) != 0) board.hash ^= zobrist.castle_keys[3]; + } + + // Zobrist: update en passant + if (old_ep != types.square.NO_SQUARE) { + board.hash ^= zobrist.ep_keys[@intFromEnum(old_ep) % 8]; + } + if (board.enpassant != types.square.NO_SQUARE) { + board.hash ^= zobrist.ep_keys[@intFromEnum(board.enpassant) % 8]; + } + + // Zobrist: flip side to move + board.hash ^= zobrist.side_key; + // Check if move leaves our king in check (illegal move) const our_king_piece = if (moving_side == types.Color.White) types.Piece.WHITE_KING else types.Piece.BLACK_KING; const our_king_square: u6 = @intCast(util.lsb_index(board.pieces[@intFromEnum(our_king_piece)])); const opponent_side = if (moving_side == types.Color.White) types.Color.Black else types.Color.White; if (bitboard.is_square_attacked(board, our_king_square, opponent_side)) { - // Restore board state - illegal move + // Restore board state - illegal move (hash is restored via BoardState) board.restore_state(saved_state); eval.global_evaluator = saved_evaluator; return false; diff --git a/src/score_moves.zig b/src/score_moves.zig index a62ed0c..76a9b51 100644 --- a/src/score_moves.zig +++ b/src/score_moves.zig @@ -39,6 +39,7 @@ const SCORE_QUIET = 0; const SCORE_BAD_CAPTURE = -1000000; const SCORE_KILLER = 90000; const SCORE_KILLER_2 = 80000; +const SCORE_COUNTERMOVE = 50000; const MAX_LOOP_COUNT = 32; const SCORE_PV_MOVE = 10000000; @@ -81,7 +82,7 @@ pub inline fn get_next_best_move(move_list: *lists.MoveList, score_list: *lists. return move_list.moves[i]; } -pub inline fn score_move(board: *types.Board, move_list: *lists.MoveList, score_list: *lists.ScoreList,pv_move: move_gen.Move) void { +pub inline fn score_move(board: *types.Board, move_list: *lists.MoveList, score_list: *lists.ScoreList, pv_move: move_gen.Move, countermove: move_gen.Move) void { score_list.count = 0; for (0..move_list.count) |i| { @@ -154,8 +155,11 @@ pub inline fn score_move(board: *types.Board, move_list: *lists.MoveList, score_ // score second killer move } else if (std.meta.eql(move_list.moves[i],search.global_search.killer_moves[1][search.global_search.ply])) { score = SCORE_KILLER_2; + // score countermove + } else if (!countermove.is_empty() and moves_equal(move, countermove)) { + score = SCORE_COUNTERMOVE; // score history move - } else{ + } else{ score = search.global_search.history_moves[move.from][move.to]; } } @@ -280,13 +284,8 @@ pub fn see(board: *const types.Board, move: move_generation.Move, threshold: i32 // Update the value (negamax style) value = -value - 1 - attacker_piece_val; - // Pruning - if this capture is good enough, stop + // Pruning - if this side can stand pat with value >= 0, the exchange is decided if (value >= 0) { - // Check if the opponent still has attackers - const opp_side_mask = if (side == types.Color.White) board.set_white() else board.set_black(); - if ((attackers & opp_side_mask) != 0) { - return side != board.get_piece_color_at(move.from).?; - } return side != board.get_piece_color_at(move.from).?; } diff --git a/src/search.zig b/src/search.zig index 2186397..a522702 100644 --- a/src/search.zig +++ b/src/search.zig @@ -7,23 +7,60 @@ const bitboard = @import("bitboard.zig"); const util = @import("util.zig"); const print = std.debug.print; const move_scores = @import("score_moves.zig"); +const tt_mod = @import("tt.zig"); +const zobrist = @import("zobrist.zig"); const Move = move_generation.Move; pub var global_search: Search = undefined; +pub var global_tt: ?tt_mod.TT = null; -pub fn search_position(board: *types.Board, max_depth: ?u8, time_ms: u64, comptime color: types.Color) void { - global_search.search_position(board, max_depth, time_ms, color); +pub fn search_position(board: *types.Board, max_depth: ?u8, soft_limit: u64, hard_limit: u64, comptime color: types.Color) void { + global_search.search_position(board, max_depth, soft_limit, hard_limit, color); } pub fn init_search() void { global_search = Search.new(); } +pub fn init_tt(allocator: std.mem.Allocator, size_mb: usize) void { + if (global_tt) |*existing| { + existing.deinit(); + } + global_tt = tt_mod.TT.init(allocator, size_mb) catch { + print("info string Failed to allocate TT ({} MB)\n", .{size_mb}); + global_tt = null; + return; + }; +} + +pub fn deinit_tt() void { + if (global_tt) |*existing| { + existing.deinit(); + global_tt = null; + } +} + const INFINITY: i32 = 50000; const MATE_VALUE: i32 = 49000; const MAX_PLY: usize = 128; const MAX_QUIESCENCE_DEPTH: i8 = 16; +// Precomputed Late Move Reduction table +// Formula: R = 1 + ln(depth) * ln(moveNumber) / 2.0 +const lmr_reductions: [64][64]u8 = init: { + @setEvalBranchQuota(10000); + var table: [64][64]u8 = .{[_]u8{0} ** 64} ** 64; + for (1..64) |d| { + for (1..64) |m| { + const df: f64 = @floatFromInt(d); + const mf: f64 = @floatFromInt(m); + const r: f64 = 1.0 + @log(df) * @log(mf) / 2.0; + table[d][m] = @intFromFloat(@min(@max(r, 0.0), 63.0)); + } + } + break :init table; +}; + pub const Search = struct { best_move: Move = undefined, stop_on_time: bool = false, @@ -43,7 +80,12 @@ pub const Search = struct { // history moves history_moves: [64][64]i32 = undefined, - time_limit: u64 = 0, + // countermove heuristic: countermoves[prev_from][prev_to] = refutation move + countermoves: [64][64]Move = undefined, + + // Time management + soft_limit: u64 = 0, // Target time + hard_limit: u64 = 0, // Absolute limit pub fn new() Search { var search = Search{}; @@ -52,6 +94,10 @@ pub const Search = struct { for (&search.history_moves) |*row| { @memset(row, 0); } + const empty_move = move_generation.Move.empty(); + for (&search.countermoves) |*row| { + @memset(row, empty_move); + } return search; } @@ -88,9 +134,9 @@ pub const Search = struct { } inline fn check_time(self: *Search) void { - if (self.time_limit > 0 and (self.nodes & 2047) == 0) { + if (self.hard_limit > 0 and (self.nodes & 2047) == 0) { const elapsed = self.timer.read() / std.time.ns_per_ms; - if (elapsed >= self.time_limit) { + if (elapsed >= self.hard_limit) { self.stop = true; } } @@ -179,7 +225,7 @@ pub const Search = struct { // Score moves for move ordering var score_list: lists.ScoreList = .{}; - move_scores.score_move(board, &move_list, &score_list, pv_move); + move_scores.score_move(board, &move_list, &score_list, pv_move, Move.empty()); const piece_values = [_]i32{ 100, 320, 330, 500, 900, 10000 }; // P, N, B, R, Q, K @@ -246,9 +292,24 @@ pub const Search = struct { return best_score; } + // Adjust mate score for TT storage: convert ply-relative to position-relative + inline fn score_to_tt(score: i32, ply: u16) i32 { + if (score > MATE_VALUE - 100) return score + @as(i32, @intCast(ply)); + if (score < -MATE_VALUE + 100) return score - @as(i32, @intCast(ply)); + return score; + } + + // Adjust mate score from TT: convert position-relative to ply-relative + inline fn score_from_tt(score: i16, ply: u16) i32 { + const s: i32 = score; + if (s > MATE_VALUE - 100) return s - @as(i32, @intCast(ply)); + if (s < -MATE_VALUE + 100) return s + @as(i32, @intCast(ply)); + return s; + } + // negamax alpha beta search with PVS (Principal Variation Search) // PVS optimizes search by using null-window searches for non-PV nodes - pub fn negamax(self: *Search, board: *types.Board, depth: u8, mut_alpha: i32, beta: i32, comptime color: types.Color) i32 { + pub fn negamax(self: *Search, board: *types.Board, depth: u8, mut_alpha: i32, beta: i32, do_null: bool, prev_move: Move, comptime color: types.Color) i32 { // Clear PV length for this ply if (self.ply < MAX_PLY) { self.pv_length[self.ply] = 0; @@ -265,26 +326,136 @@ pub const Search = struct { if (self.stop) return 0; var alpha = mut_alpha; + var adj_beta = beta; + const is_root = (self.ply == 0); + const is_pv_node = (adj_beta - alpha > 1); + + // Mate distance pruning: if we already found a shorter mate, prune + if (!is_root) { + alpha = @max(alpha, -MATE_VALUE + @as(i32, @intCast(self.ply))); + adj_beta = @min(adj_beta, MATE_VALUE - @as(i32, @intCast(self.ply)) - 1); + if (alpha >= adj_beta) return alpha; + } + + // TT probe + var tt_move: Move = Move.empty(); + if (!is_root) { + if (global_tt) |*tt| { + if (tt.probe(board.hash)) |entry| { + tt_move = entry.best_move; + + // Use TT score for cutoffs at non-PV nodes with sufficient depth + if (!is_pv_node and entry.depth >= depth) { + const tt_score = score_from_tt(entry.score, self.ply); + + switch (entry.flag) { + .EXACT => return tt_score, + .LOWER => { + if (tt_score >= adj_beta) return tt_score; + }, + .UPPER => { + if (tt_score <= alpha) return tt_score; + }, + .NONE => {}, + } + } + } + } + } + var legal_moves: u32 = 0; - var best_so_far: move_generation.Move = undefined; + var best_so_far: move_generation.Move = Move.empty(); + var best_score: i32 = -INFINITY; const old_alpha = alpha; - const is_root = (self.ply == 0); const in_check = self.is_king_in_check(board, color); const opponent = if (color == types.Color.White) types.Color.Black else types.Color.White; - var found_pv: bool = false; // Track if we found a PV move + + // Static eval for pruning decisions (only when not in check, not PV) + const can_static_prune = !is_pv_node and !in_check; + var static_eval: i32 = 0; + if (can_static_prune) { + static_eval = eval.global_evaluator.eval(board.*, color); + + // Reverse Futility Pruning (RFP) + // If static eval is far above beta, this node is likely to fail high + if (depth <= 6) { + if (static_eval - @as(i32, 80) * @as(i32, depth) >= adj_beta) { + return static_eval; + } + } + } + + // Null Move Pruning (NMP) + if (do_null and !is_pv_node and !in_check and depth >= 3) { + const has_non_pawn = if (color == .White) + (board.pieces[types.Piece.WHITE_KNIGHT.toU4()] | + board.pieces[types.Piece.WHITE_BISHOP.toU4()] | + board.pieces[types.Piece.WHITE_ROOK.toU4()] | + board.pieces[types.Piece.WHITE_QUEEN.toU4()]) != 0 + else + (board.pieces[types.Piece.BLACK_KNIGHT.toU4()] | + board.pieces[types.Piece.BLACK_BISHOP.toU4()] | + board.pieces[types.Piece.BLACK_ROOK.toU4()] | + board.pieces[types.Piece.BLACK_QUEEN.toU4()]) != 0; + + if (has_non_pawn) { + const nm_state = board.save_state(); + const nm_eval = eval.global_evaluator; + + // Make null move: clear en passant, flip side, update hash + if (board.enpassant != types.square.NO_SQUARE) { + board.hash ^= zobrist.ep_keys[@intFromEnum(board.enpassant) % 8]; + } + board.enpassant = types.square.NO_SQUARE; + board.hash ^= zobrist.side_key; + board.side = opponent; + + self.ply += 1; + + // Adaptive reduction: R = 3 + depth/6 + const R: u8 = 3 + depth / 6; + const null_depth: u8 = if (depth > R) depth - R else 0; + + const null_score = -self.negamax(board, null_depth, -adj_beta, -adj_beta + 1, false, Move.empty(), opponent); + + self.ply -= 1; + board.restore_state(nm_state); + eval.global_evaluator = nm_eval; + + if (self.stop) return 0; + + if (null_score >= adj_beta) { + return adj_beta; + } + } + } + + // Futility pruning flag: at shallow depths, skip quiet moves + const futility_margins = [4]i32{ 0, 200, 400, 600 }; + const futility_pruning = can_static_prune and depth >= 1 and depth <= 3 and + static_eval + futility_margins[@as(usize, depth)] < alpha; // Generate moves var move_list: lists.MoveList = .{}; move_generation.generate_moves(board, &move_list, color); - const pv_move = if (self.pv_length[self.ply] > 0) + // Use TT move for ordering if available, otherwise PV move + const order_move = if (!tt_move.is_empty()) + tt_move + else if (self.pv_length[self.ply] > 0) self.pv_table[self.ply][0] else move_generation.Move.empty(); + // Look up countermove for the previous move + const countermove = if (!prev_move.is_empty()) + self.countermoves[prev_move.from][prev_move.to] + else + Move.empty(); + // Generate move scores var score_list: lists.ScoreList = .{}; - move_scores.score_move(board, &move_list, &score_list, pv_move); + move_scores.score_move(board, &move_list, &score_list, order_move, countermove); // loop over moves within a movelist for (0..move_list.count) |i| { @@ -305,22 +476,67 @@ pub const Search = struct { legal_moves += 1; - // PVS (Principal Variation Search) + // Futility pruning: skip quiet moves that can't raise alpha + if (futility_pruning and legal_moves > 1) { + const is_capture_fp = move_generation.Print_move_list.is_capture(move); + const is_promotion_fp = move_generation.Print_move_list.is_promotion(move); + if (!is_capture_fp and !is_promotion_fp) { + const gives_check_fp = self.is_king_in_check(board, opponent); + if (!gives_check_fp) { + self.ply -= 1; + board.restore_state(board_state); + eval.global_evaluator = saved_eval; + continue; + } + } + } + + // Check extension: extend search by 1 ply when this move gives check + const gives_check = self.is_king_in_check(board, opponent); + const extension: u8 = if (gives_check) 1 else 0; + const new_depth = depth - 1 + extension; + + // PVS + LMR (Late Move Reductions) var score: i32 = undefined; - if (found_pv) { - // PVS: After the first move (PV node), search with null window - // to prove remaining moves are worse - score = -self.negamax(board, depth - 1, -alpha - 1, -alpha, opponent); + if (legal_moves == 1) { + // First legal move: always full depth, full window + score = -self.negamax(board, new_depth, -adj_beta, -alpha, true, move, opponent); + } else { + // Determine LMR reduction for non-first moves + var reduction: u8 = 0; + const is_capture_move = move_generation.Print_move_list.is_capture(move); + const is_promotion_move = move_generation.Print_move_list.is_promotion(move); + + if (depth >= 3 and legal_moves >= 4 and !in_check and !is_capture_move and !is_promotion_move) { + // Check if move is a killer at the parent ply + const parent_ply = self.ply - 1; + const is_killer = (move.from == self.killer_moves[0][parent_ply].from and + move.to == self.killer_moves[0][parent_ply].to) or + (move.from == self.killer_moves[1][parent_ply].from and + move.to == self.killer_moves[1][parent_ply].to); + + if (!gives_check and !is_killer) { + reduction = lmr_reductions[@min(@as(usize, depth), 63)][@min(legal_moves, 63)]; + // Reduce less in PV nodes + if (is_pv_node and reduction > 0) reduction -= 1; + // Don't reduce below depth 1 + if (reduction >= new_depth) reduction = if (new_depth >= 2) new_depth - 1 else 0; + } + } + + // LMR or PVS null window search (possibly at reduced depth) + score = -self.negamax(board, new_depth - reduction, -alpha - 1, -alpha, true, move, opponent); - // If the null window search failed high (score > alpha), - // we need to do a full re-search with the normal window - if (score > alpha and score < beta) { - score = -self.negamax(board, depth - 1, -beta, -alpha, opponent); + // If reduced search failed high, re-search at full depth null window + if (reduction > 0 and score > alpha) { + score = -self.negamax(board, new_depth, -alpha - 1, -alpha, true, move, opponent); + } + + // If null window failed high, re-search with full window (PVS) + if (score > alpha and score < adj_beta) { + score = -self.negamax(board, new_depth, -adj_beta, -alpha, true, move, opponent); } - } else { - // First move or when we haven't found PV yet - use full window - score = -self.negamax(board, depth - 1, -beta, -alpha, opponent); } self.ply -= 1; @@ -330,15 +546,41 @@ pub const Search = struct { if (self.stop) return 0; + // Track best score and move + if (score > best_score) { + best_score = score; + best_so_far = move; + } + // fail-hard beta cutoff - if (score >= beta) { + if (score >= adj_beta) { if (!move_generation.Print_move_list.is_capture(move)) { self.killer_moves[1][self.ply] = self.killer_moves[0][self.ply]; self.killer_moves[0][self.ply] = move; - self.history_moves[move.from][move.to] += @as(i32, depth) * @as(i32, depth); + // Gravity-style history update: prevents overflow and ages old entries + const bonus: i32 = @as(i32, depth) * @as(i32, depth); + const entry = &self.history_moves[move.from][move.to]; + entry.* += bonus - @divTrunc(entry.* * bonus, 16384); + + // Countermove heuristic: this move refutes the previous move + if (!prev_move.is_empty()) { + self.countermoves[prev_move.from][prev_move.to] = move; + } + } + + // Store in TT as lower bound (beta cutoff) + if (global_tt) |*tt| { + tt.store( + board.hash, + depth, + score_to_tt(score, self.ply), + .LOWER, + move, + ); } - return beta; + + return adj_beta; } // found a better move @@ -346,10 +588,6 @@ pub const Search = struct { // PV node (move) alpha = score; - best_so_far = move; - - // Enable PVS for subsequent moves - found_pv = true; // Update PV if (self.ply < MAX_PLY) { @@ -380,30 +618,81 @@ pub const Search = struct { self.best_move = best_so_far; } + // Store in TT + if (global_tt) |*tt| { + const tt_flag: tt_mod.TTFlag = if (alpha > old_alpha) .EXACT else .UPPER; + tt.store( + board.hash, + depth, + score_to_tt(alpha, self.ply), + tt_flag, + best_so_far, + ); + } + // node fails low return alpha; } // Main search function - pub fn search_position(self: *Search, board: *types.Board, max_depth: ?u8, time_ms: u64, comptime color: types.Color) void { + pub fn search_position(self: *Search, board: *types.Board, max_depth: ?u8, soft_limit_ms: u64, hard_limit_ms: u64, comptime color: types.Color) void { self.nodes = 0; self.stop = false; self.timer = std.time.Timer.start() catch unreachable; - self.time_limit = time_ms; + self.soft_limit = soft_limit_ms; + self.hard_limit = hard_limit_ms; self.ply = 0; // Reset ply counter self.clear_pv_table(); var best_move_found: ?move_generation.Move = null; var best_completed_depth: u8 = 0; + // Signal new search to TT for age-based replacement + if (global_tt) |*tt| { + tt.new_search(); + } + const depth_limit = max_depth orelse 64; - // Iterative deepening + // Stability tracking for time management + var prev_best_move: Move = Move.empty(); + var best_move_changes: u32 = 0; + + // Iterative deepening with aspiration windows var current_depth: u8 = 1; + var prev_score: i32 = 0; while (current_depth <= depth_limit) : (current_depth += 1) { // Reset ply for each iteration self.ply = 0; - const score = self.negamax(board, current_depth, -INFINITY, INFINITY, color); + var score: i32 = undefined; + + if (current_depth >= 4) { + // Aspiration windows: search with narrow window around previous score + // Widening sequence: ±25 → ±100 → ±400 → full window + var delta: i32 = 25; + var asp_alpha: i32 = @max(prev_score - delta, -INFINITY); + var asp_beta: i32 = @min(prev_score + delta, INFINITY); + + while (true) { + self.ply = 0; + score = self.negamax(board, current_depth, asp_alpha, asp_beta, true, Move.empty(), color); + if (self.stop) break; + + if (score <= asp_alpha) { + // Fail low: widen alpha + delta = if (delta <= 25) @as(i32, 100) else if (delta <= 100) @as(i32, 400) else INFINITY; + asp_alpha = if (delta >= INFINITY) -INFINITY else @max(prev_score - delta, -INFINITY); + } else if (score >= asp_beta) { + // Fail high: widen beta + delta = if (delta <= 25) @as(i32, 100) else if (delta <= 100) @as(i32, 400) else INFINITY; + asp_beta = if (delta >= INFINITY) INFINITY else @min(prev_score + delta, INFINITY); + } else { + break; + } + } + } else { + score = self.negamax(board, current_depth, -INFINITY, INFINITY, true, Move.empty(), color); + } // Check if search was interrupted if (self.stop) { @@ -413,8 +702,17 @@ pub const Search = struct { // This iteration completed successfully - save the best move if (self.pv_length[0] > 0) { - best_move_found = self.pv_table[0][0]; + const iter_best = self.pv_table[0][0]; + best_move_found = iter_best; best_completed_depth = current_depth; + + // Track move stability: did the best move change? + if (current_depth >= 2) { + if (prev_best_move.from != iter_best.from or prev_best_move.to != iter_best.to) { + best_move_changes += 1; + } + } + prev_best_move = iter_best; } const elapsed = self.timer.read() / std.time.ns_per_ms; @@ -435,7 +733,14 @@ pub const Search = struct { print("score cp {} ", .{score}); } - print("nodes {} time {} ", .{ self.nodes, elapsed }); + // NPS calculation + const nps: u64 = if (elapsed > 0) self.nodes * 1000 / elapsed else self.nodes; + print("nodes {} time {} nps {} ", .{ self.nodes, elapsed, nps }); + + // Hashfull from TT + if (global_tt) |*tt_ref| { + print("hashfull {} ", .{tt_ref.hashfull()}); + } // Print principal variation if (self.pv_length[0] > 0) { @@ -457,16 +762,38 @@ pub const Search = struct { print("\n", .{}); - // Stop if we found a mate if (score > MATE_VALUE - 100 or score < -MATE_VALUE + 100) { break; } - // Simple time management - don't start new iteration if we've used too much time - if (self.time_limit > 0 and elapsed > self.time_limit / 2) { - print("info string Time management: stopping after depth {} (used {}ms of {}ms)\n", .{ current_depth, elapsed, self.time_limit }); - break; + //time management + if (self.soft_limit > 0) { + var time_scale: u64 = 100; + + if (best_move_changes >= 3) { + time_scale = 180; + } else if (best_move_changes >= 2) { + time_scale = 150; + } else if (best_move_changes >= 1) { + time_scale = 130; + } + + const score_diff = if (score > prev_score) score - prev_score else prev_score - score; + if (current_depth >= 3 and score_diff > 50) { + time_scale = @min(time_scale + 30, 200); + } + + const adjusted_limit = self.soft_limit * time_scale / 100; + // Never exceed hard limit + const effective_limit = @min(adjusted_limit, self.hard_limit); + + if (elapsed > effective_limit) { + print("info string Time management: stopping after depth {} ({}ms, soft={}ms, scale={}%)\n", .{ current_depth, elapsed, self.soft_limit, time_scale }); + break; + } } + + prev_score = score; } // Output best move diff --git a/src/test.zig b/src/test.zig index 8c23dd6..58e62fa 100644 --- a/src/test.zig +++ b/src/test.zig @@ -7,6 +7,7 @@ const util = @import("util.zig"); const eval = @import("evaluation.zig"); const move_gen = @import("move_generation.zig"); const lists = @import("lists.zig"); +const zobrist = @import("zobrist.zig"); const print = std.debug.print; const expect = std.testing.expect; @@ -586,6 +587,61 @@ test "test phase calculation" { } } +test "Zobrist hash consistency after moves" { + attacks.init_attacks(); + + const test_fens = [_][]const u8{ + types.start_position, + types.tricky_position, + "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1", + "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", + "rnbqkb1r/pp1p1pPp/8/2p1pP2/1P1P4/3P3P/P1P1P3/RNBQKBNR w KQkq e6 0 1", + }; + + for (test_fens) |fen| { + var board = types.Board.new(); + try bitboard.fan_pars(fen, &board); + + // Verify initial hash matches full computation + const initial_hash = zobrist.compute_hash(&board); + try std.testing.expectEqual(initial_hash, board.hash); + + // Generate all legal moves and verify hash after each + var move_list: lists.MoveList = .{}; + if (board.side == types.Color.White) { + move_gen.generate_moves(&board, &move_list, types.Color.White); + } else { + move_gen.generate_moves(&board, &move_list, types.Color.Black); + } + + for (0..move_list.count) |i| { + const move = move_list.moves[i]; + const saved_state = board.save_state(); + const saved_eval = eval.global_evaluator; + + if (move_gen.make_move(&board, move)) { + const expected_hash = zobrist.compute_hash(&board); + if (board.hash != expected_hash) { + const from_str = types.SquareString.getSquareToString(@enumFromInt(move.from)); + const to_str = types.SquareString.getSquareToString(@enumFromInt(move.to)); + print("Hash mismatch after move {s}{s} (flags={}) in FEN: {s}\n", .{ + from_str, to_str, @intFromEnum(move.flags), fen, + }); + print(" Incremental: 0x{x}\n Recomputed: 0x{x}\n", .{ board.hash, expected_hash }); + } + try std.testing.expectEqual(expected_hash, board.hash); + + board.restore_state(saved_state); + eval.global_evaluator = saved_eval; + try std.testing.expectEqual(initial_hash, board.hash); + } else { + board.restore_state(saved_state); + eval.global_evaluator = saved_eval; + } + } + } +} + test "test evaluation end games" { attacks.init_attacks(); diff --git a/src/tt.zig b/src/tt.zig new file mode 100644 index 0000000..260bdfc --- /dev/null +++ b/src/tt.zig @@ -0,0 +1,136 @@ +const std = @import("std"); +const types = @import("types.zig"); +const move_gen = @import("move_generation.zig"); +const Move = move_gen.Move; + +// Transposition Table entry flags +pub const TTFlag = enum(u2) { + NONE = 0, + EXACT = 1, // PV node — score is exact + LOWER = 2, // Fail-high — score is a lower bound (beta cutoff) + UPPER = 3, // Fail-low — score is an upper bound +}; + +// TT Entry: 16 bytes (2 entries per cache line) +pub const TTEntry = struct { + key: u32 = 0, + best_move: Move = Move.empty(), + score: i16 = 0, + depth: u8 = 0, + flag: TTFlag = .NONE, + age: u8 = 0, + + pub inline fn is_empty(self: *const TTEntry) bool { + return self.flag == .NONE; + } +}; + +pub const TT = struct { + entries: []TTEntry, + mask: usize, + age: u8 = 0, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator, size_mb: usize) !TT { + const entry_size = @sizeOf(TTEntry); + const num_entries_raw = (size_mb * 1024 * 1024) / entry_size; + + var num_entries: usize = 1; + while (num_entries * 2 <= num_entries_raw) { + num_entries *= 2; + } + + const entries = try allocator.alloc(TTEntry, num_entries); + @memset(entries, TTEntry{}); + + return TT{ + .entries = entries, + .mask = num_entries - 1, + .age = 0, + .allocator = allocator, + }; + } + + pub fn deinit(self: *TT) void { + self.allocator.free(self.entries); + } + + pub fn clear(self: *TT) void { + @memset(self.entries, TTEntry{}); + self.age = 0; + } + + pub fn new_search(self: *TT) void { + self.age +%= 1; + } + + inline fn index(self: *const TT, hash: u64) usize { + return @as(usize, @truncate(hash)) & self.mask; + } + + inline fn verification_key(hash: u64) u32 { + return @truncate(hash >> 32); + } + + pub fn probe(self: *const TT, hash: u64) ?*const TTEntry { + const idx = self.index(hash); + const entry = &self.entries[idx]; + if (entry.flag != .NONE and entry.key == verification_key(hash)) { + return entry; + } + return null; + } + + pub fn store( + self: *TT, + hash: u64, + depth: u8, + score: i32, + flag: TTFlag, + best_move: Move, + ) void { + const idx = self.index(hash); + const entry = &self.entries[idx]; + const vkey = verification_key(hash); + + // Replacement policy: replace if + // 1. Empty slot + // 2. Same position (update with potentially deeper/better info) + // 3. Old age (from previous search) + // 4. Shallower depth + if (entry.flag == .NONE or + entry.key == vkey or + entry.age != self.age or + entry.depth <= depth) + { + // Clamp score to i16 range + const clamped_score: i16 = if (score > std.math.maxInt(i16)) + std.math.maxInt(i16) + else if (score < std.math.minInt(i16)) + std.math.minInt(i16) + else + @intCast(score); + + entry.* = TTEntry{ + .key = vkey, + .depth = depth, + .score = clamped_score, + .flag = flag, + .best_move = best_move, + .age = self.age, + }; + } + } + + // Get the approximate usage of the TT (per mille, 0-1000) + pub fn hashfull(self: *const TT) u32 { + var used: u32 = 0; + const sample = @min(self.entries.len, 1000); + for (0..sample) |i| { + if (self.entries[i].flag != .NONE and self.entries[i].age == self.age) { + used += 1; + } + } + return used * 1000 / @as(u32, @intCast(sample)); + } +}; diff --git a/src/types.zig b/src/types.zig index 737187d..fdba9fc 100644 --- a/src/types.zig +++ b/src/types.zig @@ -172,6 +172,7 @@ pub const BoardState = struct { side: Color, enpassant: square, castle: u8, + hash: u64, pub fn save(board: *const Board) BoardState { return BoardState{ @@ -180,6 +181,7 @@ pub const BoardState = struct { .side = board.side, .enpassant = board.enpassant, .castle = board.castle, + .hash = board.hash, }; } @@ -189,6 +191,7 @@ pub const BoardState = struct { board.side = self.side; board.enpassant = self.enpassant; board.castle = self.castle; + board.hash = self.hash; } }; @@ -198,7 +201,8 @@ pub const Board = struct { side: Color, enpassant: square, castle: u8, // bitmask of Castle - + hash: u64 = 0, // Zobrist hash + pub const PieceCount = @intFromEnum(Piece.NO_PIECE) + 1; @@ -230,12 +234,13 @@ pub const Board = struct { var b: Board = undefined; @memset(b.pieces[0..], 0); - + @memset(b.board[0..], Piece.NO_PIECE); b.side = Color.White; b.enpassant = square.NO_SQUARE; b.castle = 0; + b.hash = 0; return b; } diff --git a/src/uci.zig b/src/uci.zig index af56071..4723422 100644 --- a/src/uci.zig +++ b/src/uci.zig @@ -25,6 +25,7 @@ pub const UCI = struct { pub fn new(allocator: std.mem.Allocator) UCI { attacks.init_attacks(); search.init_search(); + search.init_tt(std.heap.page_allocator, 64); // Default 64 MB TT var board = types.Board.new(); bitboard.fan_pars(types.start_position, &board) catch { print("Error parsing fen in the new uci function\n", .{}); @@ -178,13 +179,16 @@ pub const UCI = struct { } } - // Calculate time for move - var calculated_time: u64 = 1000; + // Calculate time for move (0 = no time limit) + var soft_limit: u64 = 0; + var hard_limit: u64 = 0; if (movetime) |mt| { - calculated_time = mt; - } else if (infinite) { - calculated_time = 1000000; // Very long time for infinite + soft_limit = mt; + hard_limit = mt; + } else if (infinite or depth != null) { + soft_limit = 0; + hard_limit = 0; } else { // Calculate time based on remaining time and increment var my_time: ?u64 = null; @@ -200,30 +204,43 @@ pub const UCI = struct { if (my_time) |time| { const inc = my_inc orelse 0; + if (movestogo) |moves| { - // Fixed number of moves - calculated_time = (time / moves) + inc; + const m = @max(moves, 1); + soft_limit = time / m + inc * 3 / 4; + } else { + const total_phase: u64 = @as(u64, eval.global_evaluator.phase[0]) + + @as(u64, eval.global_evaluator.phase[1]); + const estimated_moves: u64 = if (total_phase >= 24) + 40 // Opening + else if (total_phase >= 16) + 35 // Middlegame + else if (total_phase >= 8) + 30 // Late middlegame + else + 25; // Endgame + + soft_limit = time / estimated_moves + inc * 3 / 4; + } + + // Hard limit: never use more than 50% of remaining time + hard_limit = @min(soft_limit * 3, time / 2); + // Safety buffer: always leave at least 50ms + if (time > 50) { + hard_limit = @min(hard_limit, time - 50); } else { - // Sudden death or increment - calculated_time = (time / 30) + inc; // Assume 30 moves left + hard_limit = 1; } - calculated_time = @min(calculated_time, time - 100); // Leave 100ms buffer - calculated_time = @max(calculated_time, 100); // Minimum 100ms + // Minimum limits + soft_limit = @max(soft_limit, 10); + hard_limit = @max(hard_limit, 10); } } - // // Start search in a separate thread - // self.search_thread = std.Thread.spawn(.{}, searchWrapper, .{ self, depth, calculated_time }) catch |err| { - // print("Error starting search thread: {}\n", .{err}); - // return; - // }; - // - // - searchWrapper(self, depth, calculated_time); + searchWrapper(self, depth, soft_limit, hard_limit); } fn run_bench(self: *UCI, stdout: anytype) !void { - // Benchmark positions (use diverse positions) const bench_positions = [_][]const u8{ "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", @@ -233,70 +250,60 @@ pub const UCI = struct { }; var total_nodes: u64 = 0; - const start_time = std.time.milliTimestamp(); + var timer = std.time.Timer.start() catch unreachable; - // Run perft depth 5 on each position for (bench_positions) |fen| { - // Parse position try bitboard.fan_pars(fen, &self.board); - // Run perft (you'll need to modify perft to return nodes) - const nodes = self.count_nodes(4); - total_nodes += nodes; - } + search.init_search(); + if (search.global_tt) |*tt| { + tt.clear(); + } - const end_time = std.time.milliTimestamp(); - const elapsed_ms = @as(u64, @intCast(end_time - start_time)); - const elapsed_s = @as(f64, @floatFromInt(elapsed_ms)) / 1000.0; - const nps: u64 = if (elapsed_s > 0) @intFromFloat(@as(f64, @floatFromInt(total_nodes)) / elapsed_s) else total_nodes; + if (self.board.side == types.Color.White) { + search.search_position(&self.board, 11, 0, 0, types.Color.White); + } else { + search.search_position(&self.board, 11, 0, 0, types.Color.Black); + } - // CRITICAL: Output in EXACTLY this format for OpenBench - try stdout.print("Nodes: {d}\n", .{total_nodes}); - try stdout.print("NPS: {d}\n", .{nps}); - } + total_nodes += search.global_search.nodes; + } - // Helper function to count nodes (wrapper around perft) - fn count_nodes(self: *UCI, depth: u8) u64 { - if (depth == 0) return 1; + const elapsed_ns = @max(1, timer.read()); + const nps = @as(u128, total_nodes) * std.time.ns_per_s / elapsed_ns; - var move_list: lists.MoveList = .{}; - var nodes: u64 = 0; + try stdout.print("{d} nodes {d} nps\n", .{ total_nodes, nps }); + } - if (self.board.side == types.Color.White) { - move_gen.generate_moves(&self.board, &move_list, types.Color.White); - } else { - move_gen.generate_moves(&self.board, &move_list, types.Color.Black); - } + fn parse_setoption(self: *UCI, command: []const u8) void { + // Format: setoption name value + _ = self; + var tokens = std.mem.tokenizeScalar(u8, command, ' '); + _ = tokens.next(); // "setoption" - for (0..move_list.count) |i| { - // Save evaluator state before making move - const saved_evaluator = eval.global_evaluator; + const name_kw = tokens.next() orelse return; + if (!std.mem.eql(u8, name_kw, "name")) return; - var board_copy = self.board; - _ = move_gen.make_move(&board_copy, move_list.moves[i]); + const name = tokens.next() orelse return; - var temp_uci = UCI{ - .board = board_copy, - .allocator = self.allocator, - .is_searching = false, - .stop_search = false, - .search_thread = null, - }; + const value_kw = tokens.next() orelse return; + if (!std.mem.eql(u8, value_kw, "value")) return; - nodes += temp_uci.count_nodes(depth - 1); + const value_str = tokens.next() orelse return; - // Restore evaluator state after recursive call - eval.global_evaluator = saved_evaluator; + if (std.mem.eql(u8, name, "Hash")) { + const size_mb = std.fmt.parseUnsigned(usize, value_str, 10) catch return; + const clamped = @max(1, @min(size_mb, 4096)); + search.init_tt(std.heap.page_allocator, clamped); + print("info string Hash set to {} MB\n", .{clamped}); } - - return nodes; } - fn searchWrapper(self: *UCI, depth: ?u8, time_ms: u64) void { + fn searchWrapper(self: *UCI, depth: ?u8, soft_limit: u64, hard_limit: u64) void { if (self.board.side == types.Color.White) { - search.search_position(&self.board, depth, time_ms, types.Color.White); + search.search_position(&self.board, depth, soft_limit, hard_limit, types.Color.White); } else { - search.search_position(&self.board, depth, time_ms, types.Color.Black); + search.search_position(&self.board, depth, soft_limit, hard_limit, types.Color.Black); } } // main loop @@ -324,6 +331,7 @@ pub const UCI = struct { } else if (std.mem.eql(u8, command, "uci")) { try stdout.print("id name {s} {s}\n", .{ ENGINE_NAME, VERSION }); try stdout.print("id author {s}\n", .{AUTHOR}); + try stdout.print("option name Hash type spin default 64 min 1 max 4096\n", .{}); try stdout.print("uciok\n", .{}); } else if (std.mem.eql(u8, command, "isready")) { try stdout.print("readyok\n", .{}); @@ -331,11 +339,17 @@ pub const UCI = struct { // Reset board to starting position self.board = types.Board.new(); try bitboard.fan_pars(types.start_position, &self.board); + search.init_search(); // Reset search state + if (search.global_tt) |*tt| { + tt.clear(); + } self.stop_search = true; if (self.search_thread) |thread| { thread.join(); self.search_thread = null; } + } else if (std.mem.eql(u8, command, "setoption")) { + self.parse_setoption(trimmed); } else if (std.mem.eql(u8, command, "position")) { try self.parse_position(trimmed); } else if (std.mem.eql(u8, command, "go")) { @@ -358,7 +372,6 @@ pub const UCI = struct { try self.run_bench(stdout); } else { try stdout.print("Unknown command: {s}\n", .{command}); - break; } } else |err| { print("Error reading input: {}\n", .{err}); @@ -371,5 +384,6 @@ pub const UCI = struct { self.stop_search = true; thread.join(); } + search.deinit_tt(); } }; diff --git a/src/zobrist.zig b/src/zobrist.zig new file mode 100644 index 0000000..6e11394 --- /dev/null +++ b/src/zobrist.zig @@ -0,0 +1,91 @@ +const std = @import("std"); +const types = @import("types.zig"); + +fn generate_keys() struct { + piece_keys: [12][64]u64, + side_key: u64, + castle_keys: [4]u64, + ep_keys: [8]u64, +} { + @setEvalBranchQuota(1 << 30); + // Fixed seed for deterministic comptime generation + var prng = std.Random.DefaultCsprng.init(.{ + 0x53, 0x08, 0x7C, 0x3E, 0xD1, 0xE4, 0x66, 0x5A, + 0x8B, 0x5E, 0xF7, 0xEA, 0x17, 0xED, 0xE3, 0x53, + 0xB9, 0xBB, 0xF9, 0xAA, 0xBB, 0xA8, 0x83, 0x74, + 0x28, 0xA0, 0x79, 0xEF, 0x58, 0x36, 0xB9, 0x53, + }); + const rng = prng.random(); + + var pk: [12][64]u64 = undefined; + for (0..12) |piece| { + for (0..64) |sq| { + pk[piece][sq] = rng.int(u64); + } + } + + const sk = rng.int(u64); + + var ck: [4]u64 = undefined; + for (0..4) |i| { + ck[i] = rng.int(u64); + } + + var ek: [8]u64 = undefined; + for (0..8) |i| { + ek[i] = rng.int(u64); + } + + return .{ + .piece_keys = pk, + .side_key = sk, + .castle_keys = ck, + .ep_keys = ek, + }; +} + +const keys = generate_keys(); + +pub const piece_keys: [12][64]u64 = keys.piece_keys; +pub const side_key: u64 = keys.side_key; +pub const castle_keys: [4]u64 = keys.castle_keys; +pub const ep_keys: [8]u64 = keys.ep_keys; + +pub inline fn piece_index(piece: types.Piece) usize { + const raw = @intFromEnum(piece); + return if (raw < 6) raw else raw - 2; +} + +pub fn compute_hash(board: *const types.Board) u64 { + var hash: u64 = 0; + + const piece_list = [_]types.Piece{ + .WHITE_PAWN, .WHITE_KNIGHT, .WHITE_BISHOP, .WHITE_ROOK, .WHITE_QUEEN, .WHITE_KING, + .BLACK_PAWN, .BLACK_KNIGHT, .BLACK_BISHOP, .BLACK_ROOK, .BLACK_QUEEN, .BLACK_KING, + }; + + for (piece_list) |piece| { + var bb = board.pieces[@intFromEnum(piece)]; + while (bb != 0) { + const sq: u6 = @intCast(@ctz(bb)); + hash ^= piece_keys[piece_index(piece)][sq]; + bb &= bb - 1; + } + } + + if (board.side == types.Color.Black) { + hash ^= side_key; + } + + if (board.castle & @intFromEnum(types.Castle.WK) != 0) hash ^= castle_keys[0]; + if (board.castle & @intFromEnum(types.Castle.WQ) != 0) hash ^= castle_keys[1]; + if (board.castle & @intFromEnum(types.Castle.BK) != 0) hash ^= castle_keys[2]; + if (board.castle & @intFromEnum(types.Castle.BQ) != 0) hash ^= castle_keys[3]; + + if (board.enpassant != types.square.NO_SQUARE) { + const file = @intFromEnum(board.enpassant) % 8; + hash ^= ep_keys[file]; + } + + return hash; +} From 98b00d041ad6db57f80e012907e81b413b9bfa99 Mon Sep 17 00:00:00 2001 From: Jakob Date: Sun, 1 Mar 2026 21:47:08 +0100 Subject: [PATCH 2/4] Chore: add thread option --- src/uci.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/uci.zig b/src/uci.zig index 4723422..4f34d75 100644 --- a/src/uci.zig +++ b/src/uci.zig @@ -332,6 +332,7 @@ pub const UCI = struct { try stdout.print("id name {s} {s}\n", .{ ENGINE_NAME, VERSION }); try stdout.print("id author {s}\n", .{AUTHOR}); try stdout.print("option name Hash type spin default 64 min 1 max 4096\n", .{}); + try stdout.print("option name Threads type spin default 1 min 1 max 1\n", .{}); try stdout.print("uciok\n", .{}); } else if (std.mem.eql(u8, command, "isready")) { try stdout.print("readyok\n", .{}); From dd04e0c0963b7dcfb8be6609df70c445afd5e0b5 Mon Sep 17 00:00:00 2001 From: Jakob Date: Mon, 2 Mar 2026 22:20:55 +0100 Subject: [PATCH 3/4] Fix: use std.io.getstdout --- src/search.zig | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/search.zig b/src/search.zig index a522702..3e74d7c 100644 --- a/src/search.zig +++ b/src/search.zig @@ -5,7 +5,6 @@ const move_generation = @import("move_generation.zig"); const types = @import("types.zig"); const bitboard = @import("bitboard.zig"); const util = @import("util.zig"); -const print = std.debug.print; const move_scores = @import("score_moves.zig"); const tt_mod = @import("tt.zig"); const zobrist = @import("zobrist.zig"); @@ -22,6 +21,11 @@ pub fn init_search() void { global_search = Search.new(); } +fn print(comptime fmt: []const u8, args: anytype) void { + const w = std.io.getStdOut().writer(); + w.print(fmt, args) catch {}; +} + pub fn init_tt(allocator: std.mem.Allocator, size_mb: usize) void { if (global_tt) |*existing| { existing.deinit(); @@ -514,7 +518,7 @@ pub const Search = struct { const is_killer = (move.from == self.killer_moves[0][parent_ply].from and move.to == self.killer_moves[0][parent_ply].to) or (move.from == self.killer_moves[1][parent_ply].from and - move.to == self.killer_moves[1][parent_ply].to); + move.to == self.killer_moves[1][parent_ply].to); if (!gives_check and !is_killer) { reduction = lmr_reductions[@min(@as(usize, depth), 63)][@min(legal_moves, 63)]; From 1ea5437fa4769465eb26e4abe3ff8dd1c0f5418b Mon Sep 17 00:00:00 2001 From: Jakob Date: Mon, 2 Mar 2026 22:54:56 +0100 Subject: [PATCH 4/4] Fix: fmt src/search.zig --- src/search.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/search.zig b/src/search.zig index 3e74d7c..dc77d6e 100644 --- a/src/search.zig +++ b/src/search.zig @@ -518,7 +518,7 @@ pub const Search = struct { const is_killer = (move.from == self.killer_moves[0][parent_ply].from and move.to == self.killer_moves[0][parent_ply].to) or (move.from == self.killer_moves[1][parent_ply].from and - move.to == self.killer_moves[1][parent_ply].to); + move.to == self.killer_moves[1][parent_ply].to); if (!gives_check and !is_killer) { reduction = lmr_reductions[@min(@as(usize, depth), 63)][@min(legal_moves, 63)];