diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1dc9e2b..49a282e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,7 +14,9 @@ concurrency: jobs: build: - runs-on: ubuntu-latest + # Pinned (not ubuntu-latest) to match the prod server's Ubuntu/glibc so the + # compiled libchess_engine.so loads there. Keep this == the server's release. + runs-on: ubuntu-24.04 steps: - name: Checkout uses: actions/checkout@v4 @@ -24,9 +26,16 @@ jobs: with: dotnet-version: '8.0.x' # adjust if needed - name: Restore - run: dotnet restore + run: dotnet restore JoshHeaps.Net/JoshHeaps.Net.csproj + + - name: Build native chess engine (libchess_engine.so) + run: | + cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release + cmake --build native/chess_engine/build + cp native/chess_engine/build/libchess_engine.so JoshHeaps.Net/Resources/ + - name: Publish - run: dotnet publish -c Release -o ./publish + run: dotnet publish JoshHeaps.Net/JoshHeaps.Net.csproj -c Release -o ./publish - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 54abbd2..edcd0a9 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -19,6 +19,6 @@ jobs: with: dotnet-version: 8.0.x - name: Restore dependencies - run: dotnet restore + run: dotnet restore JoshHeaps.Net.slnf - name: Build - run: dotnet build --no-restore + run: dotnet build JoshHeaps.Net.slnf --no-restore diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index 023f8e4..6a6fc01 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -19,10 +19,10 @@ jobs: dotnet-version: 8.0.x - name: Restore dependencies - run: dotnet restore + run: dotnet restore JoshHeaps.Net.slnf - name: Build solution - run: dotnet build --no-restore + run: dotnet build JoshHeaps.Net.slnf --no-restore - name: Install Playwright browsers run: | diff --git a/.gitignore b/.gitignore index 9491a2f..e8379fe 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,7 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd + +# Native chess engine CMake build output (the compiled .dll/.so are committed under Resources/) +native/**/build/ \ No newline at end of file diff --git a/JoshHeaps.Net.sln b/JoshHeaps.Net.sln index 270423a..866e59e 100644 --- a/JoshHeaps.Net.sln +++ b/JoshHeaps.Net.sln @@ -1,26 +1,60 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.9.34728.123 +# Visual Studio Version 18 +VisualStudioVersion = 18.6.11822.322 stable MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}" EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "chess_engine", "native\chess_engine\chess_engine\chess_engine.vcxproj", "{2579BBBC-1830-4342-BC10-0A4182DC84C7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.ActiveCfg = Debug|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x64.Build.0 = Debug|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.ActiveCfg = Debug|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|x86.Build.0 = Debug|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.ActiveCfg = Release|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x64.Build.0 = Release|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.ActiveCfg = Release|Any CPU + {9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|x86.Build.0 = Release|Any CPU {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.ActiveCfg = Debug|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x64.Build.0 = Debug|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.ActiveCfg = Debug|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Debug|x86.Build.0 = Debug|Any CPU {360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU {360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.ActiveCfg = Release|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Release|x64.Build.0 = Release|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.ActiveCfg = Release|Any CPU + {360264F4-8292-4EB3-B67D-98376C13438B}.Release|x86.Build.0 = Release|Any CPU + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.ActiveCfg = Debug|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|Any CPU.Build.0 = Debug|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.ActiveCfg = Debug|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x64.Build.0 = Debug|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.ActiveCfg = Debug|Win32 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Debug|x86.Build.0 = Debug|Win32 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.ActiveCfg = Release|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|Any CPU.Build.0 = Release|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.ActiveCfg = Release|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x64.Build.0 = Release|x64 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.ActiveCfg = Release|Win32 + {2579BBBC-1830-4342-BC10-0A4182DC84C7}.Release|x86.Build.0 = Release|Win32 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/JoshHeaps.Net.slnf b/JoshHeaps.Net.slnf new file mode 100644 index 0000000..c6a1fc3 --- /dev/null +++ b/JoshHeaps.Net.slnf @@ -0,0 +1,9 @@ +{ + "solution": { + "path": "JoshHeaps.Net.sln", + "projects": [ + "JoshHeaps.Net\\JoshHeaps.Net.csproj", + "JoshHeaps.Net.UiTests\\JoshHeaps.Net.UiTests.csproj" + ] + } +} diff --git a/JoshHeaps.Net/Controllers/ChessController.cs b/JoshHeaps.Net/Controllers/ChessController.cs index 87a2042..29bf494 100644 --- a/JoshHeaps.Net/Controllers/ChessController.cs +++ b/JoshHeaps.Net/Controllers/ChessController.cs @@ -1,8 +1,6 @@ -using JoshHeaps.Net.Hubs; -using JoshHeaps.Net.Models; +using JoshHeaps.Net.Models; using JoshHeaps.Net.Services.Interfaces; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.SignalR; using System.Collections.Concurrent; namespace JoshHeaps.Net.Controllers; @@ -11,8 +9,9 @@ namespace JoshHeaps.Net.Controllers; [Route("api/[controller]")] public class ChessController( IChessService chessService, - IHubContext chessHub, - IBackgroundTaskQueue queue) : ControllerBase + IBackgroundTaskQueue queue, + IChessEngineFactory engineFactory, + IComputerMoveOrchestrator orchestrator) : ControllerBase { /// /// Store of ongoing games. @@ -42,7 +41,7 @@ public class ChessController( Guid computerId = Guid.NewGuid(); var isWhite = Random.Shared.Next(2) == 0; - gameState.Computer = new(difficulty); + gameState.Computer = engineFactory.Create(difficulty); if (isWhite) { @@ -57,7 +56,7 @@ public class ChessController( { // Give user's browser time to connect to signalR and such. await Task.Delay(TimeSpan.FromSeconds(1)); - await gameState.Computer.MakeMove(gameState, chessHub, chessService); + await orchestrator.PlayAsync(gameState, gameState.Computer!); }); } @@ -189,7 +188,7 @@ public class ChessController( ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout); if (gameState.IsVsComputer && gameState.Computer is not null) - queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService)); + queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!)); return Ok(result); } diff --git a/JoshHeaps.Net/JoshHeaps.Net.csproj b/JoshHeaps.Net/JoshHeaps.Net.csproj index 51f7fd2..a2064f5 100644 --- a/JoshHeaps.Net/JoshHeaps.Net.csproj +++ b/JoshHeaps.Net/JoshHeaps.Net.csproj @@ -5,6 +5,7 @@ enable enable 53ed685c-bdff-4306-8cc2-9fbe55c85713 + true @@ -28,4 +29,16 @@ + + + + false + false + + + diff --git a/JoshHeaps.Net/Models/GameState.cs b/JoshHeaps.Net/Models/GameState.cs index 6e8f468..63579f9 100644 --- a/JoshHeaps.Net/Models/GameState.cs +++ b/JoshHeaps.Net/Models/GameState.cs @@ -1,4 +1,4 @@ -using JoshHeaps.Net.Services.Implementations; +using JoshHeaps.Net.Services.Interfaces; namespace JoshHeaps.Net.Models; @@ -43,7 +43,7 @@ public class GameState public bool IsVsComputer { get; set; } = false; - public Stockfish? Computer { get; set; } + public IChessEngine? Computer { get; set; } // optional: convenience public bool IsOpen => !WhiteJoined || !BlackJoined; diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index d38fd23..4c0e0c8 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -22,6 +22,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.Configure(configuration.GetSection(ChessEngineOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + if (!builder.Environment.IsDevelopment()) builder.Services.AddHostedService(); diff --git a/JoshHeaps.Net/Properties/launchSettings.json b/JoshHeaps.Net/Properties/launchSettings.json index 99ea902..9acb43f 100644 --- a/JoshHeaps.Net/Properties/launchSettings.json +++ b/JoshHeaps.Net/Properties/launchSettings.json @@ -11,6 +11,7 @@ "profiles": { "http": { "commandName": "Project", + "nativeDebugging": true, "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "http://localhost:5200", @@ -20,6 +21,7 @@ }, "https": { "commandName": "Project", + "nativeDebugging": true, "dotnetRunMessages": true, "launchBrowser": true, "applicationUrl": "https://localhost:7118;http://localhost:5200", diff --git a/JoshHeaps.Net/Resources/chess_engine.dll b/JoshHeaps.Net/Resources/chess_engine.dll new file mode 100644 index 0000000..5167dea Binary files /dev/null and b/JoshHeaps.Net/Resources/chess_engine.dll differ diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs new file mode 100644 index 0000000..34b7c55 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineFactory.cs @@ -0,0 +1,32 @@ +using JoshHeaps.Net.Services.Interfaces; +using Microsoft.Extensions.Options; + +namespace JoshHeaps.Net.Services.Implementations; + +/// The available chess engine implementations. +public enum ChessEngineKind +{ + Stockfish, + Custom +} + +/// Configuration selecting which to use. +public sealed class ChessEngineOptions +{ + public const string SectionName = "ChessEngine"; + + public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish; +} + +/// Creates the configured per game. +public sealed class ChessEngineFactory(IOptions options) : IChessEngineFactory +{ + private readonly ChessEngineKind _kind = options.Value.Engine; + + public IChessEngine Create(int skill) => _kind switch + { + ChessEngineKind.Custom => new CustomChessEngine(skill), + ChessEngineKind.Stockfish => new Stockfish(skill), + _ => throw new InvalidOperationException($"Unknown chess engine '{_kind}'.") + }; +} diff --git a/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs b/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs new file mode 100644 index 0000000..0845864 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/ChessEngineHelpers.cs @@ -0,0 +1,130 @@ +using JoshHeaps.Net.Models; +using System.Text; + +namespace JoshHeaps.Net.Services.Implementations; + +public static class ChessEngineHelpers +{ + public static MoveDto ToMoveDto( + this string uci, + GameState gameState, + Guid playerId) + { + int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1'); + int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1'); + + var piece = gameState.Board[fRow, fCol] + ?? throw new Exception("No piece at source square"); + + PieceType? promo = uci.Length == 5 ? uci[4] switch + { + 'q' => PieceType.Queen, + 'r' => PieceType.Rook, + 'b' => PieceType.Bishop, + 'n' => PieceType.Knight, + _ => null + } : null; + + return new MoveDto + { + GameId = gameState.GameId, + PlayerId = playerId, + PieceId = piece.Id, + TargetRow = tRow, + TargetCol = tCol, + PromotionChoice = promo, + SourceCol = fCol, + SourceRow = fRow, + }; + } + + /// + /// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string. + /// Only piece placement + active colour + castling are computed; the rest use + /// safe defaults (-, 0, 1). That is all the engine needs. + /// + public static string ToFen(this GameState gs) + { + var sb = new StringBuilder(64); + + /* 1) piece placement */ + for (int row = 0; row < 8; row++) + { + int empty = 0; + + for (int col = 0; col < 8; col++) + { + var p = gs.Board[row, col]; + + if (p is null) + { + empty++; + } + else + { + if (empty > 0) { sb.Append(empty); empty = 0; } + sb.Append(ToFenChar(p)); // ← unchanged helper + } + } + + if (empty > 0) sb.Append(empty); + if (row < 7) sb.Append('/'); + } + + /* 2) active colour */ + sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b "); + + /* 3) castling rights (from GameState flags) */ + sb.Append(GetCastlingFlags(gs)); + + /* 4) en-passant target square */ + sb.Append(' '); + sb.Append(gs.EnPassantTarget.HasValue + ? Alg(gs.EnPassantTarget.Value) + : "-"); + + /* 5-6) half-move clock + full-move number */ + int fullMoves = gs.MoveHistory.Count / 2 + 1; + sb.Append(" 0 ").Append(fullMoves); + + return sb.ToString(); + } + + /* ---------- helpers ---------- */ + + private static string GetCastlingFlags(GameState gs) + { + var flags = new StringBuilder(4); + + if (gs.WhiteCanCastleKingside) flags.Append('K'); + if (gs.WhiteCanCastleQueenside) flags.Append('Q'); + if (gs.BlackCanCastleKingside) flags.Append('k'); + if (gs.BlackCanCastleQueenside) flags.Append('q'); + + return flags.Length == 0 ? "-" : flags.ToString(); + } + + private static string Alg(Position p) + { + char file = (char)('a' + p.Col); + int rank = 8 - p.Row; + return $"{file}{rank}"; + } + + private static char ToFenChar(ChessPiece p) => p switch + { + { Type: PieceType.Pawn, Color: PieceColor.White } => 'P', + { Type: PieceType.Pawn, Color: PieceColor.Black } => 'p', + { Type: PieceType.Knight, Color: PieceColor.White } => 'N', + { Type: PieceType.Knight, Color: PieceColor.Black } => 'n', + { Type: PieceType.Bishop, Color: PieceColor.White } => 'B', + { Type: PieceType.Bishop, Color: PieceColor.Black } => 'b', + { Type: PieceType.Rook, Color: PieceColor.White } => 'R', + { Type: PieceType.Rook, Color: PieceColor.Black } => 'r', + { Type: PieceType.Queen, Color: PieceColor.White } => 'Q', + { Type: PieceType.Queen, Color: PieceColor.Black } => 'q', + { Type: PieceType.King, Color: PieceColor.White } => 'K', + { Type: PieceType.King, Color: PieceColor.Black } => 'k', + _ => throw new ArgumentOutOfRangeException(nameof(p)) + }; +} diff --git a/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs new file mode 100644 index 0000000..45d24df --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/ComputerMoveOrchestrator.cs @@ -0,0 +1,33 @@ +using JoshHeaps.Net.Hubs; +using JoshHeaps.Net.Models; +using JoshHeaps.Net.Services.Interfaces; +using Microsoft.AspNetCore.SignalR; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// Engine-agnostic glue for computer moves: get a move from the engine, apply it +/// through the rules service, and broadcast it to the game's clients. +/// +public sealed class ComputerMoveOrchestrator( + IHubContext chessHub, + IChessService chessService) : IComputerMoveOrchestrator +{ + public async Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine) + { + var uci = await engine.GetBestMoveAsync(state.ToFen()); + + var move = uci.ToMoveDto( + state, + state.CurrentPlayer == PieceColor.White + ? state.WhitePlayerId + : state.BlackPlayerId); + + var result = chessService.MakeMove(state, move); + + await chessHub.Clients.Group(state.GameId.ToString()) + .SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result); + + return (move, result); + } +} diff --git a/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs b/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs new file mode 100644 index 0000000..64e94a7 --- /dev/null +++ b/JoshHeaps.Net/Services/Implementations/CustomChessEngine.cs @@ -0,0 +1,118 @@ +using JoshHeaps.Net.Services.Interfaces; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace JoshHeaps.Net.Services.Implementations; + +/// +/// Middleman wrapper over the native custom chess engine (chess_engine.dll / libchess_engine.so). +/// Shares with so the two are swappable. +/// The boundary contract is FEN string in, UCI move string out — identical to Stockfish. +/// +public sealed partial class CustomChessEngine : IChessEngine +{ + private readonly EngineSafeHandle _handle; + + public int Skill { get; } + + public CustomChessEngine(int skill = 20) + { + Skill = skill; + + var handle = NativeMethods.engine_create($"skill={skill}"); + + if (handle == IntPtr.Zero) + throw new InvalidOperationException("Native chess engine failed to initialize (engine_create returned null)."); + + _handle = new EngineSafeHandle(handle); + } + + public Task GetBestMoveAsync(string fen) => Task.Run(() => GetBestMove(fen)); + + private unsafe string GetBestMove(string fen) + { + const int bufferLength = 16; // longest UCI move is 5 chars ("e7e8q") + NUL + byte* buffer = stackalloc byte[bufferLength]; + + var added = false; + + try + { + _handle.DangerousAddRef(ref added); + var code = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, buffer, bufferLength); + + if (code != 0) + throw new InvalidOperationException($"Native chess engine failed to produce a move for FEN '{fen}' (engine_best_move returned {code})."); + + return Marshal.PtrToStringUTF8((IntPtr)buffer) + ?? throw new InvalidOperationException("Native chess engine returned an empty move."); + } + finally + { + if (added) + _handle.DangerousRelease(); + } + } + + public ValueTask DisposeAsync() + { + _handle.Dispose(); + return ValueTask.CompletedTask; + } + + /// Guarantees the native handle is released exactly once via engine_destroy. + private sealed class EngineSafeHandle : SafeHandle + { + public EngineSafeHandle(IntPtr handle) : base(IntPtr.Zero, ownsHandle: true) => SetHandle(handle); + + public override bool IsInvalid => handle == IntPtr.Zero; + + protected override bool ReleaseHandle() + { + NativeMethods.engine_destroy(handle); + return true; + } + } + + /// + /// P/Invoke surface for chess_engine.(dll|so). The resolver maps the logical name + /// "chess_engine" to the platform binary in the Resources folder (mirrors Stockfish). + /// + private static partial class NativeMethods + { + private const string LibName = "chess_engine"; + + static NativeMethods() => + NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve); + + private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) + { + if (libraryName != LibName) + return IntPtr.Zero; + + var fileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "chess_engine.dll" + : "libchess_engine.so"; + + var path = Path.Combine(AppContext.BaseDirectory, "Resources", fileName); + + if (NativeLibrary.TryLoad(path, out var handle)) + return handle; + + return NativeLibrary.Load(libraryName, assembly, searchPath); + } + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial IntPtr engine_create(string? options); + + [LibraryImport(LibName, StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static unsafe partial int engine_best_move(IntPtr engine, string fen, byte* outBuffer, int outLength); + + [LibraryImport(LibName)] + [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] + internal static partial void engine_destroy(IntPtr engine); + } +} diff --git a/JoshHeaps.Net/Services/Implementations/Stockfish.cs b/JoshHeaps.Net/Services/Implementations/Stockfish.cs index 0f68109..82e9b43 100644 --- a/JoshHeaps.Net/Services/Implementations/Stockfish.cs +++ b/JoshHeaps.Net/Services/Implementations/Stockfish.cs @@ -1,22 +1,19 @@ -using JoshHeaps.Net.Hubs; -using JoshHeaps.Net.Models; using JoshHeaps.Net.Services.Interfaces; -using Microsoft.AspNetCore.SignalR; using System.Diagnostics; -using System.Reflection; using System.Runtime.InteropServices; -using System.Text; using System.Threading.Channels; namespace JoshHeaps.Net.Services.Implementations; -public sealed class Stockfish : IAsyncDisposable +public sealed class Stockfish : IChessEngine { private readonly Process _p; private readonly StreamWriter _stdin; private readonly Channel _stdout = Channel.CreateUnbounded(); private readonly int _skill; + public int Skill => _skill; + public Stockfish(int skill = 20, int hash = 256) { _skill = skill; @@ -28,13 +25,13 @@ public sealed class Stockfish : IAsyncDisposable : "stockfish-ubuntu-x86-64-sse41-popcnt"; // default: Linux string exePath = Path.Combine(baseDir, "Resources", fileName); - + if (!File.Exists(exePath)) { throw new FileNotFoundException($"Stockfish executable not found at {exePath}. " + "Ensure the file is present in the Resources folder of your project."); } - + Console.Write(exePath); _p = new Process @@ -109,145 +106,4 @@ public sealed class Stockfish : IAsyncDisposable await _p.WaitForExitAsync(); _p.Dispose(); } - - public async Task MakeMove(GameState state, IHubContext chessHub, IChessService chessService) - { - var move = await GetBestMoveAsync(state.ToFen()); - - var moveDto = move.ToMoveDto( - state, - state.CurrentPlayer == PieceColor.White - ? state.WhitePlayerId - : state.BlackPlayerId); - - var result = chessService.MakeMove(state, moveDto); - - await chessHub.Clients.Group(state.GameId.ToString()).SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), moveDto, result); - } } - -public static class StockfishHelpers -{ - public static MoveDto ToMoveDto( - this string uci, - GameState gameState, - Guid playerId) - { - int fCol = uci[0] - 'a', fRow = 7 - (uci[1] - '1'); - int tCol = uci[2] - 'a', tRow = 7 - (uci[3] - '1'); - - var piece = gameState.Board[fRow, fCol] - ?? throw new Exception("No piece at source square"); - - PieceType? promo = uci.Length == 5 ? uci[4] switch - { - 'q' => PieceType.Queen, - 'r' => PieceType.Rook, - 'b' => PieceType.Bishop, - 'n' => PieceType.Knight, - _ => null - } : null; - - return new MoveDto - { - GameId = gameState.GameId, - PlayerId = playerId, - PieceId = piece.Id, - TargetRow = tRow, - TargetCol = tCol, - PromotionChoice = promo, - SourceCol = fCol, - SourceRow = fRow, - }; - } - - /// - /// Convert a 2-D board array (rank 8 = row 0, file a = col 0) to a FEN string. - /// Only piece placement + active colour + castling are computed; the rest use - /// safe defaults (-, 0, 1). That is all Stockfish needs. - /// - public static string ToFen(this GameState gs) - { - var sb = new StringBuilder(64); - - /* 1) piece placement */ - for (int row = 0; row < 8; row++) - { - int empty = 0; - - for (int col = 0; col < 8; col++) - { - var p = gs.Board[row, col]; - - if (p is null) - { - empty++; - } - else - { - if (empty > 0) { sb.Append(empty); empty = 0; } - sb.Append(ToFenChar(p)); // ← unchanged helper - } - } - - if (empty > 0) sb.Append(empty); - if (row < 7) sb.Append('/'); - } - - /* 2) active colour */ - sb.Append(gs.CurrentPlayer == PieceColor.White ? " w " : " b "); - - /* 3) castling rights (from GameState flags) */ - sb.Append(GetCastlingFlags(gs)); - - /* 4) en-passant target square */ - sb.Append(' '); - sb.Append(gs.EnPassantTarget.HasValue - ? Alg(gs.EnPassantTarget.Value) - : "-"); - - /* 5-6) half-move clock + full-move number */ - int fullMoves = gs.MoveHistory.Count / 2 + 1; - sb.Append(" 0 ").Append(fullMoves); - - return sb.ToString(); - } - - /* ---------- helpers ---------- */ - - private static string GetCastlingFlags(GameState gs) - { - var flags = new StringBuilder(4); - - if (gs.WhiteCanCastleKingside) flags.Append('K'); - if (gs.WhiteCanCastleQueenside) flags.Append('Q'); - if (gs.BlackCanCastleKingside) flags.Append('k'); - if (gs.BlackCanCastleQueenside) flags.Append('q'); - - return flags.Length == 0 ? "-" : flags.ToString(); - } - - private static string Alg(Position p) - { - char file = (char)('a' + p.Col); - int rank = 8 - p.Row; - return $"{file}{rank}"; - } - - private static char ToFenChar(ChessPiece p) => p switch - { - { Type: PieceType.Pawn, Color: PieceColor.White } => 'P', - { Type: PieceType.Pawn, Color: PieceColor.Black } => 'p', - { Type: PieceType.Knight, Color: PieceColor.White } => 'N', - { Type: PieceType.Knight, Color: PieceColor.Black } => 'n', - { Type: PieceType.Bishop, Color: PieceColor.White } => 'B', - { Type: PieceType.Bishop, Color: PieceColor.Black } => 'b', - { Type: PieceType.Rook, Color: PieceColor.White } => 'R', - { Type: PieceType.Rook, Color: PieceColor.Black } => 'r', - { Type: PieceType.Queen, Color: PieceColor.White } => 'Q', - { Type: PieceType.Queen, Color: PieceColor.Black } => 'q', - { Type: PieceType.King, Color: PieceColor.White } => 'K', - { Type: PieceType.King, Color: PieceColor.Black } => 'k', - _ => throw new ArgumentOutOfRangeException(nameof(p)) - }; -} \ No newline at end of file diff --git a/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs b/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs new file mode 100644 index 0000000..4dc5d69 --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/IChessEngine.cs @@ -0,0 +1,19 @@ +namespace JoshHeaps.Net.Services.Interfaces; + +/// +/// An AI move-selection engine (e.g. Stockfish or the custom native engine). +/// Selects a move for a position; it does not enforce rules or broadcast updates. +/// Implementations own unmanaged resources, hence . +/// +public interface IChessEngine : IAsyncDisposable +{ + /// The engine's playing strength / search depth. + int Skill { get; } + + /// + /// Returns the engine's chosen move in UCI long-algebraic form (e.g. "e2e4", "e7e8q"). + /// + /// The current position as a FEN string. + /// The selected move as a UCI string. + Task GetBestMoveAsync(string fen); +} diff --git a/JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs b/JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs new file mode 100644 index 0000000..3f87e24 --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/IChessEngineFactory.cs @@ -0,0 +1,14 @@ +namespace JoshHeaps.Net.Services.Interfaces; + +/// +/// Creates instances, choosing the implementation from configuration. +/// +public interface IChessEngineFactory +{ + /// + /// Creates a new engine instance for a single game. The caller owns and disposes it. + /// + /// The desired playing strength / search depth. + /// A new, owned . + IChessEngine Create(int skill); +} diff --git a/JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs b/JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs new file mode 100644 index 0000000..6fcd34d --- /dev/null +++ b/JoshHeaps.Net/Services/Interfaces/IComputerMoveOrchestrator.cs @@ -0,0 +1,18 @@ +using JoshHeaps.Net.Models; + +namespace JoshHeaps.Net.Services.Interfaces; + +/// +/// Drives a computer move: asks the engine for a move, applies it through the rules +/// service, and broadcasts the result to the game's clients. +/// +public interface IComputerMoveOrchestrator +{ + /// + /// Has the engine pick a move for the current position, applies it, and broadcasts it. + /// + /// The game to play a move in. + /// The engine that selects the move. + /// The applied move and its result. + Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine); +} diff --git a/JoshHeaps.Net/appsettings.Development.json b/JoshHeaps.Net/appsettings.Development.json index 770d3e9..7c24bd5 100644 --- a/JoshHeaps.Net/appsettings.Development.json +++ b/JoshHeaps.Net/appsettings.Development.json @@ -5,5 +5,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "ChessEngine": { + "Engine": "Custom" } } diff --git a/JoshHeaps.Net/appsettings.json b/JoshHeaps.Net/appsettings.json index 90f577d..1b4fd6f 100644 --- a/JoshHeaps.Net/appsettings.json +++ b/JoshHeaps.Net/appsettings.json @@ -9,5 +9,8 @@ "BlogApi": { "BaseUrl": "https://media.joshheaps.net", "InvalidateKey": "CHANGE_ME" + }, + "ChessEngine": { + "Engine": "Custom" } } diff --git a/docs/custom-chess-engine-rnd.md b/docs/custom-chess-engine-rnd.md new file mode 100644 index 0000000..2cd1806 --- /dev/null +++ b/docs/custom-chess-engine-rnd.md @@ -0,0 +1,759 @@ +# R&D Findings: Swappable C++ Chess Engine via P/Invoke + +**Team:** Architect, Backend Engineer, Performance Engineer, DevOps Engineer (orchestrated by team lead) +**Date:** 2026-05-28 +**Status:** Complete — engine *infrastructure* design only; the C++ search logic is intentionally left as an empty, compilable stub for the user to implement. + +--- + +## Executive Summary + +We investigated how to host a custom chess engine written in C++ (compiled to `chess_engine.dll` for local Windows debugging and `libchess_engine.so` for the Linux server) behind a C# "middleman" wrapper that shares an interface with the existing `Stockfish.cs`, so the two are swappable by config. The key conclusion: the existing engine already uses a **string contract (FEN in → UCI move out)**, so the cleanest design mirrors it exactly — an opaque-handle `extern "C"` ABI, a `[LibraryImport]` P/Invoke wrapper, and a new `IChessEngine` interface that both `Stockfish` and the new `CustomChessEngine` implement, selected by a factory reading `appsettings`. The recommended next step is to land the **infrastructure** (interface, factory, P/Invoke wrapper, CMake project, empty C++ stub, build/packaging) and verify the round trip end-to-end with the stub returning a placeholder move — *before* any chess logic is written. + +> **Naming reconciliation:** the specialists used varying names for the native lib (`ChessEngine`, `chessengine`, `chess_engine`). This document standardizes on **logical name `chess_engine`** → `chess_engine.dll` (Windows) / `libchess_engine.so` (Linux), matching the Backend and DevOps proposals. Adjust the snippets from the Architect/Performance sections accordingly. + +### High-Level Architecture + +```mermaid +graph TD + subgraph Managed["C# / .NET 8"] + CTRL[ChessController] --> FAC[IChessEngineFactory] + FAC -->|reads appsettings| OPT[ChessEngineOptions] + FAC -->|Stockfish| SF[Stockfish : IChessEngine] + FAC -->|Custom| CE[CustomChessEngine : IChessEngine] + CTRL --> ORCH[IComputerMoveOrchestrator] + ORCH -->|GetBestMoveAsync fen| IFACE[/IChessEngine/] + SF -.implements.-> IFACE + CE -.implements.-> IFACE + ORCH --> RULES[IChessService rules] + ORCH --> HUB[ChessHub SignalR] + end + subgraph Native["C++ shared library"] + CE -->|P/Invoke once per move| ABI["extern C ABI
engine_best_move(fen, out)"] + ABI --> SEARCH["self-contained search
bitboards, zero managed callbacks"] + end + SF -->|child process stdio| SFEXE[stockfish-*.exe] +``` + +--- + +## Findings by Area + +### Architect: Engine Abstraction & Swap Mechanism + +#### Current State + +Two distinct concerns are easy to confuse: + +- **`IChessService` / `ChessService`** — the *rules engine* (move legality, check/mate). NOT the thing being swapped. `JoshHeaps.Net\Services\Interfaces\IChessService.cs:5-11`, `ChessService.cs:6`. +- **`Stockfish`** — the *AI move-selection engine* (the thing being swapped). `Services\Implementations\Stockfish.cs:13`. + +`Stockfish` is a concrete `sealed` class with **no interface**, carrying three mixed responsibilities: + +1. **Engine lifecycle/IO** — spawns a child process, UCI handshake, `GetBestMoveAsync(fen)`. `Stockfish.cs:13-111`. +2. **Orchestration** — `MakeMove(GameState, IHubContext, IChessService)` gets a move, converts it, calls the rules service, broadcasts over SignalR. `Stockfish.cs:113-126`. Engine-agnostic glue. +3. **FEN/UCI translation** — `StockfishHelpers.ToFen(...)` / `ToMoveDto(...)`. `Stockfish.cs:129-253`. Also engine-agnostic. + +Coupling points where the concrete type leaks: + +- `GameState.Computer` typed as concrete `Stockfish?` — domain model → implementation (inverted dependency). `Models\GameState.cs:46`, `using ...Implementations;` at `GameState.cs:1`. +- Controller hand-constructs the engine: `gameState.Computer = new(difficulty);` `Controllers\ChessController.cs:45`. No DI, no abstraction. +- Orchestration via concrete instance: `gameState.Computer.MakeMove(...)` at `ChessController.cs:60` and `:192`. +- Disposal via concrete type: `await game.Computer.DisposeAsync();` `ChessController.cs:248`. +- `Stockfish` is **not** registered in DI; `Program.cs:21-23` registers other services but the engine is `new`-ed inline per game. + +```mermaid +classDiagram + class GameState { + +Stockfish? Computer + +bool IsVsComputer + } + class ChessController + class Stockfish { + +GetBestMoveAsync(fen) Task~string~ + +MakeMove(state, hub, chessService) Task + +DisposeAsync() ValueTask + } + class StockfishHelpers { + +ToFen(GameState)$ string + +ToMoveDto(uci, state, playerId)$ MoveDto + } + class IChessService { <> } + class ChessService + + IChessService <|.. ChessService + ChessController --> GameState : new()s Stockfish into + GameState *-- Stockfish : owns concrete + ChessController ..> Stockfish : MakeMove() / DisposeAsync() + Stockfish ..> StockfishHelpers + Stockfish ..> IChessService + Stockfish ..> ChessHub : broadcasts + note for GameState "Models depends on\nServices.Implementations — inverted" + note for Stockfish "Orchestration lives inside engine —\nwould duplicate across 2 engines" +``` + +#### Findings + +1. **No interface → cannot swap.** `Stockfish` is `sealed`, concrete-only (`Stockfish.cs:13`). Adding `CustomChessEngine` today means touching `GameState`, the controller, and disposal. *Impact:* an `IChessEngine` abstraction is the core requirement. +2. **Domain model depends on a concrete service.** `GameState.cs:46` + `using ...Implementations` at `GameState.cs:1`. *Impact:* must become `IChessEngine?` (or leave the model entirely). +3. **Engine hand-constructed in controller, no DI.** `ChessController.cs:45`. *Impact:* engine selection can't be config-driven; this is the seam for a factory. +4. **Orchestration lives in the engine.** `Stockfish.MakeMove(...)` (`Stockfish.cs:113-126`) is not Stockfish-specific. *Impact:* copying it into `CustomChessEngine` duplicates rules-call + broadcast wiring; lift it to an orchestrator. +5. **FEN/UCI translation is engine-agnostic** (`Stockfish.cs:169`, `:131`). *Impact:* keep shared (rename to `ChessEngineHelpers`), don't duplicate. +6. **Engine lifecycle is game-scoped, not DI-scoped.** Created in `CreateGame` (`ChessController.cs:45`), disposed in the game-removal timer (`ChessController.cs:247-248`); the constructor spawns a process and blocks on UCI handshake (`Stockfish.cs:54,72,77`). *Impact:* a plain singleton won't fit — use a **per-game factory** producing disposable instances. +7. **`MakeMove` is fire-and-forget returning non-generic `Task`** (`Stockfish.cs:113`). *Impact:* when lifted, the orchestrator should return the `(MoveDto, MoveResultDto)` it produced (per CodingStyle "Void Avoidance"). + +#### Suggested Approach + +Three roles: `IChessEngine` (swappable contract), `IChessEngineFactory` (per-game creation from config), `IComputerMoveOrchestrator` (lifted glue). + +```csharp +// Services/Interfaces/IChessEngine.cs +public interface IChessEngine : IAsyncDisposable +{ + int Skill { get; } + // UCI long-algebraic, e.g. "e2e4", "e7e8q" + Task GetBestMoveAsync(string fen, CancellationToken cancellationToken = default); +} +``` + +```csharp +// Services/Implementations/Stockfish.cs (minimal change) +public sealed class Stockfish : IChessEngine // was: IAsyncDisposable +{ + public int Skill => _skill; + public Task GetBestMoveAsync(string fen, CancellationToken ct = default) { /* existing body */ } + public ValueTask DisposeAsync() { /* unchanged */ } + // DELETE MakeMove(...) -> moves to IComputerMoveOrchestrator +} +// StockfishHelpers -> rename to ChessEngineHelpers in Services/Implementations/ChessEngineHelpers.cs (body unchanged) +``` + +```csharp +// Services/Interfaces/IChessEngineFactory.cs +public interface IChessEngineFactory { IChessEngine Create(int skill); } + +// Services/Implementations/ChessEngineFactory.cs +public sealed class ChessEngineFactory(IOptions options) : IChessEngineFactory +{ + private readonly ChessEngineKind _kind = options.Value.Engine; + public IChessEngine Create(int skill) => _kind switch + { + ChessEngineKind.Custom => new CustomChessEngine(skill), + ChessEngineKind.Stockfish => new Stockfish(skill), + _ => throw new InvalidOperationException($"Unknown engine '{_kind}'.") + }; +} +public enum ChessEngineKind { Stockfish, Custom } +public sealed class ChessEngineOptions +{ + public const string SectionName = "ChessEngine"; + public ChessEngineKind Engine { get; set; } = ChessEngineKind.Stockfish; +} +``` + +```csharp +// Program.cs (near :21-23) +builder.Services.Configure(configuration.GetSection(ChessEngineOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +``` + +```jsonc +// appsettings.json — flip to "Custom" to swap (override per-env in appsettings.Development.json) +{ "ChessEngine": { "Engine": "Stockfish" } } +``` + +```csharp +// Services/Implementations/ComputerMoveOrchestrator.cs (lifted from Stockfish.MakeMove, engine-agnostic) +public interface IComputerMoveOrchestrator +{ + Task<(MoveDto move, MoveResultDto result)> PlayAsync(GameState state, IChessEngine engine, CancellationToken ct = default); +} +public sealed class ComputerMoveOrchestrator(IHubContext chessHub, IChessService chessService) : IComputerMoveOrchestrator +{ + public async Task<(MoveDto, MoveResultDto)> PlayAsync(GameState state, IChessEngine engine, CancellationToken ct = default) + { + var uci = await engine.GetBestMoveAsync(state.ToFen(), ct); + var move = uci.ToMoveDto(state, state.CurrentPlayer == PieceColor.White ? state.WhitePlayerId : state.BlackPlayerId); + var result = chessService.MakeMove(state, move); + await chessHub.Clients.Group(state.GameId.ToString()) + .SendAsync("ReceiveMoveUpdate", state.GameId.ToString(), move, result, ct); + return (move, result); + } +} +``` + +**`GameState.Computer` recommendation:** keep the reference on `GameState` but retype to `IChessEngine?` (fixes Finding 2) — do *not* go full service-injection. Rationale: the engine is a stateful, per-game, disposable resource whose lifetime is managed by `ScheduleRemoveGame` (`ChessController.cs:247-248`); live games live in the controller's static `ConcurrentDictionary` (`ChessController.cs:20`), not DI. Only the engine's *construction* (factory) and *orchestration* (orchestrator) move out. + +```csharp +// ChessController changes (inject factory + orchestrator into the primary ctor at :12-15) +gameState.Computer = engineFactory.Create(difficulty); // was :45 new(difficulty) +await orchestrator.PlayAsync(gameState, gameState.Computer); // was :60 +if (gameState.IsVsComputer && gameState.Computer is not null) // was :192 + queue.Queue(() => orchestrator.PlayAsync(gameState, gameState.Computer!)); +await game.Computer.DisposeAsync(); // :248 unchanged (IChessEngine : IAsyncDisposable) +``` + +```mermaid +classDiagram + class IChessEngine { + <> + +int Skill + +GetBestMoveAsync(fen, ct) Task~string~ + +DisposeAsync() ValueTask + } + class Stockfish + class CustomChessEngine { -P/Invoke chess_engine (.dll/.so) } + class IChessEngineFactory { <> +Create(skill) IChessEngine } + class ChessEngineFactory + class ChessEngineOptions { +ChessEngineKind Engine } + class IComputerMoveOrchestrator { <> +PlayAsync(state, engine, ct) } + class ComputerMoveOrchestrator + class GameState { +IChessEngine? Computer } + class ChessController + class ChessEngineHelpers + + IChessEngine <|.. Stockfish + IChessEngine <|.. CustomChessEngine + IChessEngineFactory <|.. ChessEngineFactory + IComputerMoveOrchestrator <|.. ComputerMoveOrchestrator + ChessEngineFactory ..> ChessEngineOptions : reads config + ChessEngineFactory ..> Stockfish : creates + ChessEngineFactory ..> CustomChessEngine : creates + ChessController ..> IChessEngineFactory + ChessController ..> IComputerMoveOrchestrator + GameState o-- IChessEngine : holds abstraction + ComputerMoveOrchestrator ..> IChessEngine + ComputerMoveOrchestrator ..> ChessEngineHelpers + ComputerMoveOrchestrator ..> IChessService + note for GameState "Now depends on Interfaces (DIP fixed)" + note for CustomChessEngine "C++ body empty; P/Invoke contract only" +``` + +#### Open Questions + +1. Does the custom engine speak FEN-in / UCI-out? (Assumed yes — confirm before locking the interface.) +2. Per-game instance vs shared singleton for the in-process native engine (a `.dll`/`.so` may be cheap enough to share, unlike a Stockfish process). +3. Skill/options surface — `int skill` vs an `EngineOptions` object if tunables diverge from Stockfish's `(skill, hash)` (`Stockfish.cs:20`). +4. Cancellation/timeout — interface has a `CancellationToken` but nothing wires it today (`BackgroundTaskQueue.cs:11` fire-and-forgets). +5. Concurrency — `GetBestMoveAsync` is not re-entrant per instance (single stdout `Channel`, `Stockfish.cs:17`); per-game instances make this moot today. + +--- + +### Backend Engineer: Native Interop & I/O Contract + +#### Current State + +The load-bearing method is `GetBestMoveAsync(string fen)` at `Stockfish.cs:80-95`: writes `position fen ` + `go depth N` to stdin (`:82-83`), reads stdout until a line starts with `bestmove` (`:88`), returns the second token — a raw UCI string like `e2e4` / `e7e8q` (`:90`). Input is produced by `ToFen()` (`Stockfish.cs:169-214`); output is consumed by `ToMoveDto()` (`Stockfish.cs:131-162`, parses chars `uci[0..4]` into `MoveDto`, `MoveDto.cs:3-20`). Consumer: `ChessController.cs:191-192` queues `gameState.Computer.MakeMove(...)`. + +**There is no native interop today.** A repo-wide grep for `DllImport|LibraryImport|NativeLibrary|Marshal|extern` returns only an unrelated hit in vendored `jquery.js`. Project targets `net8.0`, `Nullable=enable`, `ImplicitUsings=enable` (`JoshHeaps.Net.csproj:3-7`). + +```mermaid +sequenceDiagram + participant C as ChessController + participant SF as Stockfish (C#) + participant P as stockfish.exe (subprocess) + C->>SF: MakeMove(state) / GetBestMoveAsync(fen) + Note over SF: state.ToFen() builds FEN + SF->>P: stdin "position fen " + SF->>P: stdin "go depth N" + P-->>SF: stdout "info ..." + P-->>SF: stdout "bestmove e2e4" + Note over SF: Split(' ')[1] => "e2e4" + SF-->>C: "e2e4" (UCI) + Note over C: uci.ToMoveDto(state) => MoveDto +``` + +#### Findings + +1. **The boundary is already a pure string pair** (`Stockfish.cs:80,90`). *Impact:* the native ABI should mirror it exactly — `const char* fen` in, `char*` UCI out. No struct marshalling needed. +2. **No `IChessEngine` abstraction; consumers bind the concrete type** (`GameState.cs:46`, `ChessController.cs:191-192`). *Impact:* the middleman implements the Architect's interface; the `GameState` retype is a cross-cutting dependency. +3. **`MakeMove` mixes engine + SignalR/board concerns** (`Stockfish.cs:113-126`). *Impact:* keep it shared (orchestrator), not per-engine. The native-specific surface of `CustomChessEngine` is only `GetBestMoveAsync`. +4. **The UCI string is the contract anchor.** As long as the native engine emits a 4-or-5-char UCI move, the whole downstream pipeline (`MoveDto` → `IChessService.MakeMove` → SignalR) is unchanged (`Stockfish.cs:131,169`). +5. **Resource-shipping pattern is established** (`csproj:14-18` copies `Resources/**`). *Impact:* the `.dll`/`.so` ship the same way. + +#### Suggested Approach + +**Contract: strings (FEN in / UCI out), not a binary struct.** It is byte-identical to today's contract (so `ToFen`/`ToMoveDto` are untouched); FEN/UCI are stable ASCII (no layout/packing/endianness/enum-width to keep in sync); the user only writes string parsing in C++; per-move data is tiny. **Buffer-ownership rule:** the C# caller owns the output buffer; the engine only writes into it and never allocates returned strings — sidesteps cross-allocator free bugs. + +```c +// native/chess_engine/include/chess_engine.h +#ifndef CHESS_ENGINE_H +#define CHESS_ENGINE_H +#include + +#if defined(_WIN32) + #ifdef CHESS_ENGINE_BUILD + #define CHESS_API __declspec(dllexport) + #else + #define CHESS_API __declspec(dllimport) + #endif + #define CHESS_CALL __cdecl +#else + #define CHESS_API __attribute__((visibility("default"))) + #define CHESS_CALL +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct ChessEngine* EngineHandle; // opaque; host never dereferences + +enum { + CHESS_OK = 0, CHESS_ERR_NULL_HANDLE = -1, CHESS_ERR_BAD_FEN = -2, + CHESS_ERR_NO_MOVE = -3, CHESS_ERR_BUFFER = -4, CHESS_ERR_INTERNAL = -5 +}; + +CHESS_API EngineHandle CHESS_CALL engine_create(const char* options); // options e.g. "skill=20;hash=256" or NULL +CHESS_API int CHESS_CALL engine_set_option(EngineHandle, const char* name, const char* value); +CHESS_API int CHESS_CALL engine_best_move(EngineHandle, const char* fen, char* out_buf, int out_len); // writes "e2e4\0" +CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len); +CHESS_API void CHESS_CALL engine_destroy(EngineHandle); // safe with NULL + +#ifdef __cplusplus +} +#endif +#endif +``` + +```cpp +// native/chess_engine/src/chess_engine.cpp — EMPTY stub; compiles, returns placeholder +#define CHESS_ENGINE_BUILD +#include "chess_engine.h" +#include +#include + +struct ChessEngine { std::string options; }; // put TT, tables, etc. here later + +static int copy_out(const char* src, char* out, int cap) { + if (!out || cap <= 0) return CHESS_ERR_BUFFER; + const size_t need = std::strlen(src) + 1; + if (need > (size_t)cap) return CHESS_ERR_BUFFER; + std::memcpy(out, src, need); + return CHESS_OK; +} + +extern "C" { +CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) { + auto* e = new (std::nothrow) ChessEngine(); + if (e && options) e->options = options; + return e; +} +CHESS_API int CHESS_CALL engine_set_option(EngineHandle e, const char*, const char*) { + return e ? CHESS_OK : CHESS_ERR_NULL_HANDLE; +} +CHESS_API int CHESS_CALL engine_best_move(EngineHandle e, const char* fen, char* out, int cap) { + if (!e) return CHESS_ERR_NULL_HANDLE; + if (!fen || !*fen) return CHESS_ERR_BAD_FEN; + // TODO: parse fen, search, produce a real UCI move. + return copy_out("e2e4", out, cap); // placeholder +} +CHESS_API int CHESS_CALL engine_version(char* out, int cap) { return copy_out("custom-engine 0.0.1-stub", out, cap); } +CHESS_API void CHESS_CALL engine_destroy(EngineHandle e) { delete e; } +} +``` + +**ABI notes:** `extern "C"` kills name mangling; `CHESS_API` = `__declspec(dllexport)` (MSVC, when `CHESS_ENGINE_BUILD` defined) or `__attribute__((visibility("default")))` (GCC/Clang, pair with `-fvisibility=hidden`); `CHESS_CALL` pins `__cdecl` on Windows, empty (SysV default) on Linux. + +```csharp +// Services/Implementations/CustomChessEngine.cs +public sealed class CustomChessEngine : IChessEngine // IAsyncDisposable via IChessEngine +{ + private readonly EngineSafeHandle _handle; + + public int Skill { get; } + public CustomChessEngine(int skill = 20) + { + Skill = skill; + var raw = NativeMethods.engine_create($"skill={skill}"); + if (raw == IntPtr.Zero) throw new InvalidOperationException("engine_create returned null."); + _handle = new EngineSafeHandle(raw); + } + + public Task GetBestMoveAsync(string fen, CancellationToken ct = default) + => Task.Run(() => BestMove(fen), ct); // native call is sync + CPU-bound; offload off request thread + + private string BestMove(string fen) + { + Span outBuf = stackalloc byte[16]; // UCI <= 5 chars + NUL + bool added = false; + try + { + _handle.DangerousAddRef(ref added); + int rc; + unsafe { fixed (byte* p = outBuf) rc = NativeMethods.engine_best_move(_handle.DangerousGetHandle(), fen, p, outBuf.Length); } + ThrowIfError(rc); + int nul = outBuf.IndexOf((byte)0); + return System.Text.Encoding.ASCII.GetString(outBuf[..(nul < 0 ? outBuf.Length : nul)]); + } + finally { if (added) _handle.DangerousRelease(); } + } + + private static void ThrowIfError(int rc) { if (rc != 0) throw rc switch { + -2 => new ArgumentException("CHESS_ERR_BAD_FEN"), + -3 => new InvalidOperationException("No move (mate/stalemate)"), + -4 => new InvalidOperationException("Output buffer too small"), + _ => new InvalidOperationException($"Native engine error {rc}") }; } + + public ValueTask DisposeAsync() { _handle.Dispose(); return ValueTask.CompletedTask; } + + private sealed class EngineSafeHandle : SafeHandle + { + public EngineSafeHandle(IntPtr h) : base(IntPtr.Zero, true) => SetHandle(h); + public override bool IsInvalid => handle == IntPtr.Zero; + protected override bool ReleaseHandle() { NativeMethods.engine_destroy(handle); return true; } + } + + private static partial class NativeMethods + { + private const string Lib = "chess_engine"; // -> chess_engine.dll / libchess_engine.so + static NativeMethods() => NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve); + private static IntPtr Resolve(string name, Assembly asm, DllImportSearchPath? path) + { + if (name != Lib) return IntPtr.Zero; + string file = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "chess_engine.dll" : "libchess_engine.so"; + string probe = Path.Combine(AppContext.BaseDirectory, "Resources", file); + return File.Exists(probe) && NativeLibrary.TryLoad(probe, out var h) ? h : NativeLibrary.Load(name, asm, path); + } + + [LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + internal static partial IntPtr engine_create(string? options); + + [LibraryImport(Lib, StringMarshalling = StringMarshalling.Utf8)] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + internal static unsafe partial int engine_best_move(IntPtr engine, string fen, byte* outBuf, int outLen); + + [LibraryImport(Lib)] + [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] + internal static partial void engine_destroy(IntPtr engine); + } +} +``` + +**Why these C# choices:** `[LibraryImport]` (source-generated, AOT/trim-safe, no runtime IL stub, compile-time diagnostics) over `[DllImport]`; `StringMarshalling.Utf8` matches `const char*`; a `DllImportResolver` probes `Resources/` first then falls back to default search; `SafeHandle` guarantees `engine_destroy` runs exactly once; return codes map to typed exceptions; `Task.Run` adapts the sync native call to the async interface (for a real long search, prefer one dedicated long-running thread per instance over thread-pool churn). + +```mermaid +sequenceDiagram + participant C as ChessController + participant CE as CustomChessEngine (C#) + participant TP as Task.Run (threadpool) + participant N as chess_engine.dll/.so + C->>CE: GetBestMoveAsync(state.ToFen()) + CE->>TP: offload sync native call + Note over TP: stackalloc byte[16] out_buf (host-owned) + TP->>N: engine_best_move(handle, fen, out_buf, 16) + Note over N: parse FEN, search, write "e2e4\0" + N-->>TP: CHESS_OK; out_buf filled + TP-->>CE: "e2e4" + CE-->>C: "e2e4" (same UCI as Stockfish) + Note over C: uci.ToMoveDto(state) => MoveDto (unchanged) +``` + +#### Open Questions + +1. Should `MakeMove`/orchestration be on the interface or shared via the orchestrator? (Recommend orchestrator — aligns with Architect.) +2. Who owns the `GameState.Computer` retype (Architect vs Backend)? Cross-cutting. +3. Build/packaging: CMake + MSBuild copy vs commit binaries (see DevOps). +4. Search timeout/cancellation: add `engine_stop(handle)` + token-aware wrapper, or fixed-depth like Stockfish's `go depth N` (`Stockfish.cs:83`)? +5. `options` string format (`"key=value;..."`) vs per-option `engine_set_option`? +6. Concurrency model — one move per instance at a time (current queue suggests yes)? + +--- + +### Performance Engineer: Interop Boundary & Hot-Path Strategy + +#### Current State + +Two distinct things are being conflated: + +1. **The Stockfish path is already fast and well-structured** — one warm process (`Stockfish.cs:40-54`), two text commands per move, block on stdout (`:82-93`). The cost is the *search* (`go depth {_skill}`, `:83`), not the pipe. **Stockfish is not slow.** It is also already a self-contained native search — the model to preserve. +2. **The C# `ChessService` is the genuinely slow thing** and the real motivation: + - **Object-graph board:** `GameState.Board` is `ChessPiece?[,]` (`GameState.cs:11`) of heap `ChessPiece` objects each with a `string Id` (`ChessPiece.cs:5`) — pointer chase / cache miss per square touch. + - **Allocation per pseudo-move:** legality calls `CloneGameState` (`ChessService.cs:319,453-483`) allocating a new `GameState`, `ChessPiece[8,8]`, `List`, and a `ChessPiece` per piece — *every candidate move*. + - **LINQ in the inner loop:** `IsSquareAttacked` does `.Where(...).ToList()` + regenerates enemy moves (`ChessService.cs:396-407`); lookups by string id via `FirstOrDefault` (`:110,126`). + - Fine for validating one human move; orders of magnitude away from a search loop. Correct thing to move to C++. + +```mermaid +flowchart TD + A["ChessController.MakeMove :192"] --> B["queue.Queue (IBackgroundTaskQueue)"] + B --> C["Stockfish.MakeMove :113"] + C --> D["state.ToFen() ~70B :169"] + D --> E["stdin 'position fen' + 'go depth N' :82-83"] + E --> F["stdin pipe -> stockfish.exe"] + F --> G["NATIVE SEARCH (seconds)
millions of nodes, zero managed calls"] + G --> H["stdout 'bestmove e2e4' :86-92"] + H --> I["parse ~5B UCI -> MoveDto :117,136"] + style G fill:#2d6a2d,color:#fff + style F fill:#7a5c00,color:#fff + style H fill:#7a5c00,color:#fff +``` + +#### Findings + +1. **The move boundary is provably not the hot path.** Per move: ~70-byte FEN in, ~5-byte UCI out — sub-microsecond marshalling vs a multi-second search; the boundary is ~6+ orders of magnitude cheaper than the work it gates. The string contract is correct and will never bottleneck. Production already proves it via a *heavier* transport (OS pipes). +2. **Forbidden anti-pattern: a chatty per-node boundary.** A `LibraryImport` P/Invoke transition is ~1–2 ns, but a search visits millions of nodes/sec. A managed callback per node (move-gen/eval) adds a GC-tracked frame, write-barrier exposure, and loss of native inlining on the hottest loop — defeating the whole point. **Rule: the native search owns move-gen, make/unmake, and eval; zero managed callbacks below the once-per-move boundary.** +3. **Board representation is native-internal, not a marshalling concern.** Use **bitboards** inside C++ (~12 `uint64_t` + occupancy/flags); make/unmake and attacks become `&`/`|`/shifts/`popcnt`/`tzcnt` instead of pointer-chasing + LINQ (`ChessService.cs:392-410`). None of it crosses the boundary — C# keeps its `GameState` graph for rendering/human-move validation; the engine rebuilds bitboards from the FEN. +4. **Threading/async.** The search is CPU-bound/synchronous; the controller already enqueues on a background queue (`ChessController.cs:192`) so the SignalR thread isn't blocked. Wrap the blocking P/Invoke in `Task.Run`; results push back over `IHubContext` as today (`Stockfish.cs:125`). **Parallel search (Lazy SMP) stays 100% native.** **Cancellation = one atomic flag:** `engine_stop()` sets `std::atomic` polled between nodes — crosses the boundary once on cancel, never per node. + +| Operation | Approx. cost | Frequency | +|---|---|---| +| P/Invoke transition (blittable, `LibraryImport`) | ~1–2 ns | once per move | +| Marshal ~70B FEN in + ~5B UCI out | < 1 µs | once per move | +| `ToFen()` string build (`Stockfish.cs:169`) | low µs | once per move | +| **Native search (`go depth N`)** | **~0.1–several s** | **once per move** | +| Hypothetical managed eval callback **per node** | ~tens of ns × millions/sec | ❌ never — forbidden | + +#### Suggested Approach + +Cross the boundary **once per move**; keep the loop fully native. + +```cpp +// RECOMMENDED: self-contained native search +extern "C" int engine_best_move(ChessEngine* e, const char* fen, char* out, int cap) { + Position pos = parse_fen(fen); // build bitboards ONCE + g_stop.store(false); + Move best = search(pos, e->depth); // millions of nodes, NO callbacks out + return write_uci(best, out, cap); // ~5 bytes back +} +``` +```cpp +// FORBIDDEN: chatty boundary — do NOT do this +int search(Position& pos, int depth) { + for (Move m : managed_generate_moves(pos)) // P/Invoke OUT per node + eval += managed_eval_callback(pos); // managed frame per node — death +} +``` + +**Optional blittable-struct contract — recommend DEFER.** If profiling ever showed FEN parse dominating (it won't at one call/move), you *could* pass a `[StructLayout(LayoutKind.Sequential)]` `NativePosition` (12 bitboards + flags) by `in`/`ref` (fully blittable, no marshalling). But it adds a second board-layout source-of-truth and couples C# to the engine's internals. **Ship the FEN/UCI string contract; don't build the struct path until a profiler proves it's needed.** + +```mermaid +flowchart TD + A["ChessController.MakeMove :192"] --> B["queue.Queue -> Task.Run (SignalR thread freed)"] + B --> C["state.ToFen() ~70B"] + C --> D{{"engine_best_move(handle, fen, buf)
ONE P/Invoke crossing (~1-2ns + <1us marshal)"}} + D --> E["NATIVE: parse_fen -> bitboards (x12 ulong)"] + E --> F["NATIVE SEARCH LOOP
make/unmake on bitboards, native eval
Lazy SMP threads, poll atomic g_stop
ZERO managed callbacks"] + F --> G{{"return ~5B UCI move
ONE crossing back"}} + G --> H["parse UCI -> MoveDto -> SignalR (Stockfish.cs:117-125)"] + I["engine_stop(handle)"] -. "once on cancel, NOT per node" .-> F + style D fill:#1f4e79,color:#fff + style G fill:#1f4e79,color:#fff + style E fill:#2d6a2d,color:#fff + style F fill:#2d6a2d,color:#fff +``` + +**Build flags (coordinate with DevOps):** MSVC `/O2 /GL` + `/LTCG`, `/arch:AVX2` (matches the shipped AVX2 Stockfish, `Stockfish.cs:25`); GCC/Clang `-O3 -flto` with a baseline `-march` the server supports (the Linux Stockfish targets `sse41-popcnt`, `Stockfish.cs:28`) — avoid `-march=native` on a build host differing from the server. + +#### Open Questions + +1. **Deploy CPU baseline** — server's CPU floor dictates safe `-march`/`/arch` and whether `popcnt`/AVX2 bitboard intrinsics are guaranteed (Stockfish picks `avx2` Win / `sse41-popcnt` Linux, `Stockfish.cs:24-28`). +2. Will the native engine fully replace or coexist with Stockfish? (Recommend a shared `IChessEngine`.) +3. Engine lifetime/concurrency — one handle per game (like Stockfish today) vs reused across games (needs re-entrancy)? +4. Search termination — fixed depth, nodes, or wall-clock (time-based makes `engine_stop` most useful)? +5. Move-legality ownership — human moves validated by `ChessService.MakeMove` (`ChessController.cs:179`); two move generators risk divergence. + +--- + +### DevOps Engineer: Cross-Platform Native Build & Packaging + +#### Current State + +- **Packaging:** `JoshHeaps.Net.csproj:14-18` copies the whole Resources folder as `Content` with `CopyToOutputDirectory=PreserveNewest` → lands in `bin\\net8.0\Resources\` and `publish\Resources\`. Binaries are **committed** (`Resources\stockfish-windows-x86-64-avx2.exe`, `Resources\stockfish-ubuntu-x86-64-sse41-popcnt`). +- **Runtime load:** `Stockfish.cs:23` `AppContext.BaseDirectory`; `:24-28` OS filename switch; `:30` `Path.Combine(baseDir,"Resources",fileName)`; `:32-36` existence check. Stockfish is a **child process** (`:40-54`), so there is no native-library load path today. +- **CI/deploy is Linux-only:** `.github\workflows\deploy.yml:17` `runs-on: ubuntu-latest`, `dotnet publish -c Release` (`:29`), `rsync -az --delete publish/` to the server (`:55-58`), systemd restart (`:60-64`). PR build also `ubuntu-latest` (`dotnet.yml:13`). **A Windows `.dll` can never be produced on the runner — it must be committed.** + +```mermaid +flowchart TD + A["Resources/stockfish-windows-*.exe (committed)"] --> C + B["Resources/stockfish-ubuntu-* (committed)"] --> C + C["csproj Content Resources/** PreserveNewest (csproj:14-18)"] + C --> D["dotnet publish (ubuntu-latest) deploy.yml:29"] + D --> E["publish/Resources/*"] + E --> F["rsync to Linux server deploy.yml:55-58"] + F --> G["Runtime Stockfish.cs:23,30 BaseDirectory + Resources/fileName"] + G --> H["Process.Start(exePath) — child process, not P/Invoke :40-54"] +``` + +#### Findings + +1. **The existing Content glob already covers new Resources files** (`csproj:14-18`) — dropping the native libs in `Resources/` flows them to output/publish with zero csproj change required (explicit items optional for clarity). +2. **Build host is Linux-only** (`deploy.yml:17`, `dotnet.yml:13`) — the Windows `.dll` MUST be committed; an MSBuild→CMake target only helps on a developer's Windows box. +3. **Stockfish uses a child process, not P/Invoke** (`Stockfish.cs:40-54`) — no existing `DllImport`/`NativeLibrary` precedent; the resolver story is net-new. +4. **Resources path is hardcoded `Path.Combine(baseDir,"Resources",...)`** (`Stockfish.cs:30`), but P/Invoke's default search does NOT look in a `Resources` subfolder. *Impact:* register a `DllImportResolver` pointing at `Resources/` **or** place the lib at the output root. Biggest divergence from the Stockfish pattern. +5. **No CMake/C++ scaffolding exists** — greenfield; recommend `native/chess_engine/` at repo root, outside the csproj compile globs. +6. **glibc/libstdc++ ABI risk** — the committed `.so` is built on a dev/CI machine but runs on the rsync'd server (`deploy.yml:55`); a newer build-host libstdc++/glibc → runtime load failure. Build against a server-matching baseline or static-link libstdc++. + +#### Suggested Approach + +**Recommendation: build natively per-platform and commit both artifacts into `Resources/`, mirroring Stockfish.** The repo already commits platform binaries, the build host is Linux-only, and committing keeps deploy a pure `dotnet publish`. An optional opt-in MSBuild target can rebuild the matching-platform artifact locally, but must never be the deploy's source of truth. + +```cmake +# native/chess_engine/CMakeLists.txt +cmake_minimum_required(VERSION 3.20) +project(chess_engine LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +add_library(chess_engine SHARED src/chess_engine.cpp) +target_include_directories(chess_engine PUBLIC include) +target_compile_definitions(chess_engine PRIVATE CHESS_ENGINE_BUILD) + +# Windows -> chess_engine.dll ; Linux -> libchess_engine.so +set_target_properties(chess_engine PROPERTIES OUTPUT_NAME chess_engine POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) + +if (MSVC) + target_compile_options(chess_engine PRIVATE + $<$:/O2 /GL /DNDEBUG /arch:AVX2> + $<$:/Od /Zi>) # /Zi => .pdb for mixed-mode debugging + target_link_options(chess_engine PRIVATE $<$:/LTCG> $<$:/DEBUG>) +else() + # Portable server baseline; do NOT use -march=native (build host may differ -> SIGILL). + target_compile_options(chess_engine PRIVATE + $<$:-O3 -flto -DNDEBUG -march=x86-64-v2> # ~SSE4.2; confirm server floor + $<$:-O0 -g>) +endif() +``` + +The export macro (`CHESS_API`, defined under `CHESS_ENGINE_BUILD`) and `extern "C"` live in the Backend Engineer's `chess_engine.h`. + +```bash +# Windows (Developer PowerShell, MSVC) +cmake -S native/chess_engine -B native/chess_engine/build -A x64 +cmake --build native/chess_engine/build --config Release # -> build/Release/chess_engine.dll (+ .pdb) +# Linux (ideally in an ubuntu:22.04 container matching the server) +cmake -S native/chess_engine -B native/chess_engine/build -DCMAKE_BUILD_TYPE=Release +cmake --build native/chess_engine/build # -> build/libchess_engine.so +``` +Copy each artifact into `JoshHeaps.Net/Resources/` and commit — same lifecycle as the Stockfish binaries. + +**Optional local-only CMake build target** (gated `BuildNativeEngine=true`, off by default, OS-conditioned, never gates deploy): +```xml + + + + + + + + + + +``` + +**Runtime load — recommendation: `DllImportResolver` pointing at `Resources/`** (co-locates with Stockfish, survives single-file publish). This matches the Backend Engineer's `NativeMethods` resolver. Keep the native libs **only** in `Resources/` (rely on the existing `Content` glob) — do not also `` them to the output root, to avoid two copies that drift. + +**Local debugging (mixed-mode):** CMake `Debug` emits `/Zi` + `/DEBUG` → `chess_engine.pdb`; ship it next to the `.dll` for local debug builds only (Debug-only `None` item, never committed/deployed). In Visual Studio enable **Project Properties → Debug → Enable native code debugging** to step from C# P/Invoke into C++. + +**Linux deployment:** `libchess_engine.so` ships via `Resources/**` → `publish/` → rsync automatically. It's `dlopen`'d (no `chmod +x` needed) but must be world-readable. It links `libstdc++`/`glibc` — build against a baseline ≤ the server's, or **static-link** (`-static-libstdc++ -static-libgcc`) to remove the version coupling (safest for a committed binary). + +```mermaid +flowchart TD + subgraph Build["Build (per-platform)"] + W["Windows dev: cmake --build Release
=> chess_engine.dll (+pdb in Debug)"] + L["Linux (ubuntu:22.04 container): cmake --build Release
=> libchess_engine.so (-static-libstdc++)"] + end + W --> R["Commit into JoshHeaps.Net/Resources/ (as stockfish-* today)"] + L --> R + R --> CSP["csproj Content Resources/** (csproj:14-18) -> output/Resources/"] + CSP --> PUB["dotnet publish (ubuntu-latest) deploy.yml:29 — pure, no toolchain"] + PUB --> RS["rsync publish/ to server deploy.yml:55-58"] + RS --> RES["DllImportResolver: BaseDirectory + Resources/ + OS filename switch (mirrors Stockfish.cs:23-30)"] + RES --> PI["P/Invoke [LibraryImport(chess_engine)] -> NativeLibrary.Load"] + style W fill:#1d4ed8,color:#fff + style L fill:#15803d,color:#fff + style PI fill:#92400e,color:#fff +``` + +#### Open Questions + +1. **Server glibc/libstdc++ version unknown** — need `ldd --version` + `strings libstdc++.so.6 | grep GLIBCXX`; otherwise build in `ubuntu:22.04` or static-link libstdc++. +2. **Commit binaries vs build `.so` in CI?** Recommend commit both (consistent, pure deploy); alternative is a Linux native-build step in `deploy.yml` for a reproducible/ABI-correct `.so`. +3. **Target CPU baseline** — assumed `-march=x86-64-v2` to match `sse41-popcnt`; confirm the server floor; `-march=native` unsafe for committed/CI binaries. +4. **`IChessEngine` doesn't exist yet** — prerequisite from the Architect (out of scope for build, flagged). +5. **`.pdb` policy** — local-only Debug symbols recommended; confirm. + +--- + +## Cross-Cutting Concerns + +```mermaid +graph TD + subgraph Arch["Architect: abstraction"] + IFACE[/IChessEngine/] --> FAC[Factory + appsettings] + end + subgraph Backend["Backend: contract"] + ABI["extern C ABI
FEN -> UCI"] + end + subgraph Perf["Performance"] + ONCE["once-per-move boundary
native search"] + end + subgraph DevOps["DevOps"] + PKG["commit .dll/.so in Resources
DllImportResolver"] + end + IFACE -.->|"GetBestMoveAsync(fen) shape
must match ABI"| ABI + ABI -.->|"string contract = thin boundary"| ONCE + ONCE -.->|"build flags -O3/AVX2/LTO"| PKG + PKG -.->|"resolver finds lib for CustomChessEngine"| IFACE +``` + +1. **The string contract ties all four areas together.** `IChessEngine.GetBestMoveAsync(string fen) → string` (Architect) is the exact shape of `engine_best_move(const char* fen, char* out)` (Backend), which is what makes the boundary thin (Performance) and keeps `ToFen`/`ToMoveDto` untouched. If anyone changes to a struct contract, all four must change. Evidence: `Stockfish.cs:80,90,131,169`. +2. **`GameState.Computer` retype is owned by the Architect but unblocks Backend.** `GameState.cs:46` must become `IChessEngine?` before `CustomChessEngine` can be slotted in. Both specialists flagged it. +3. **Native-lib naming must be consistent end to end.** The logical name `chess_engine` (C# `[LibraryImport]`/resolver), the CMake `OUTPUT_NAME chess_engine`, and the committed filenames `chess_engine.dll` / `libchess_engine.so` must all agree. The specialists used different names — standardized here. +4. **`Resources/` placement + resolver is the load contract.** DevOps's `DllImportResolver` (probing `BaseDirectory/Resources`) and Backend's `NativeMethods.Resolve` are the same mechanism and must be written once (in `CustomChessEngine`/`NativeMethods`). Evidence: `Stockfish.cs:23,30`, `csproj:14-18`. +5. **Build optimization is a shared Performance/DevOps concern.** `-O3 -flto` / `/O2 /GL /LTCG`, `/arch:AVX2`, and a safe Linux `-march` baseline live in the CMakeLists (DevOps) but are motivated by the hot-loop requirement (Performance). +6. **Move-generation single-source-of-truth.** Human moves stay validated by C# `ChessService` (`ChessController.cs:179`); the native engine has its own generator. Two generators risk divergence — consider exposing native `perft` later for cross-validation. + +## Risk Assessment + +| Risk | Severity | Likelihood | Mitigation | Related Files | +|---|---|---|---|---| +| Linux `.so` fails to load (glibc/libstdc++ mismatch) | High | Med | Build in `ubuntu:22.04` container or static-link libstdc++; capture server `ldd --version` | `deploy.yml:55-58`, CMakeLists | +| Native crash takes down the ASP.NET process | High | Med | The empty stub returns codes, never throws across the boundary; validate FEN in C# first; consider process isolation if instability appears | `chess_engine.cpp`, `CustomChessEngine.cs` | +| Windows `.dll` can't be built in CI (Linux runner) | Med | High (by design) | Commit the `.dll` like the Stockfish `.exe`; optional opt-in local MSBuild target | `deploy.yml:17`, `csproj:14-18` | +| `-march`/`/arch` too aggressive → SIGILL on server | Med | Med | Use a confirmed server baseline; never `-march=native` for committed/CI binaries | CMakeLists, `Stockfish.cs:24-28` | +| P/Invoke can't find the lib (Resources subfolder not searched) | Med | High without resolver | `DllImportResolver` probing `BaseDirectory/Resources` | `CustomChessEngine.cs`, `Stockfish.cs:30` | +| Handle leak / double-free across boundary | Med | Low | `SafeHandle` + caller-owned out buffers + `delete nullptr`-safe `engine_destroy` | `CustomChessEngine.cs`, `chess_engine.cpp` | +| Concurrent `GetBestMoveAsync` on one non-reentrant handle | Low | Low | One handle per game (as today); document non-reentrancy | `GameState.cs:46`, `ChessController.cs:192` | +| Two move generators (C# rules vs native) diverge | Med | Med | Keep C# as legality authority; add native `perft` for cross-check later | `ChessService.cs`, native | + +## Recommendations + +Ordered by priority: + +1. **Introduce `IChessEngine` and retrofit `Stockfish`** (Architect §). Add the interface, make `Stockfish` implement it, rename `StockfishHelpers` → `ChessEngineHelpers`, retype `GameState.Computer` to `IChessEngine?`. Low effort, unblocks everything. *Supported by Findings A1–A2, B2.* +2. **Lift orchestration out of the engine** into `IComputerMoveOrchestrator` (Architect §; Backend Finding 3). Removes duplication before a second engine exists. Low effort. +3. **Add the factory + `appsettings` swap** (Architect §). `IChessEngineFactory` + `ChessEngineOptions`, registered in `Program.cs`. The swap mechanism the mission asks for. Low effort. +4. **Stand up the native project + empty stub** (Backend + DevOps §). `native/chess_engine/` with `chess_engine.h`, the compilable `chess_engine.cpp` placeholder, and `CMakeLists.txt`. Medium effort (build setup, not logic). +5. **Write `CustomChessEngine` P/Invoke wrapper** (Backend §) implementing `IChessEngine`, with `[LibraryImport]`, `DllImportResolver` → `Resources/`, `SafeHandle`, and `Task.Run` async adaptation. Medium effort. +6. **Build + commit both artifacts; verify the round trip** (DevOps §). Build `.dll` on Windows / `.so` on Linux (container), commit to `Resources/`, flip `appsettings` to `Custom`, confirm the stub's `e2e4` flows through `ToMoveDto` → SignalR end to end. Medium effort. **Do this before writing any chess logic.** +7. **Then implement the C++ engine** (user) — bitboards, search, eval, all native, honoring the "zero managed callbacks per node" rule (Performance §). The infrastructure above makes this a pure C++ task behind a stable contract. + +## Appendix: All Referenced Files + +| File | Referenced By | Context | +|---|---|---| +| `Services/Implementations/Stockfish.cs` | Architect, Backend, Performance, DevOps | The engine to mirror; `GetBestMoveAsync`/`MakeMove`/`ToFen`/`ToMoveDto`, process launch, Resources path | +| `Services/Interfaces/IChessService.cs` | Architect, Backend | Rules engine (separate concern, not swapped) | +| `Services/Implementations/ChessService.cs` | Architect, Performance | C# move-gen/legality — the genuinely slow code being replaced | +| `Models/GameState.cs` | Architect, Backend, Performance | `Computer` coupling (`:46`), board model (`:11`) | +| `Models/ChessPiece.cs`, `Position.cs`, `MoveDto.cs`, `Enums.cs` | Backend, Performance | Data shapes; UCI→`MoveDto` parsing | +| `Controllers/ChessController.cs` | Architect, Backend, Performance | Engine construction (`:45`), invocation (`:60,192`), disposal (`:248`), game registry (`:20`) | +| `Program.cs` | Architect | DI registration site (`:21-23`) | +| `appsettings.json` | Architect | Swap config section | +| `Services/Interfaces/IBackgroundTaskQueue.cs` | Performance | Background move execution | +| `JoshHeaps.Net.csproj` | Backend, DevOps | `Content Resources/**` copy (`:14-18`), TFM/Nullable (`:3-7`) | +| `Resources/stockfish-*` | DevOps | Committed-binary precedent for `.dll`/`.so` | +| `.github/workflows/deploy.yml`, `dotnet.yml` | DevOps | Linux-only CI, publish + rsync deploy | +| `native/chess_engine/include/chess_engine.h` (new) | Backend, DevOps | extern "C" ABI + export macro | +| `native/chess_engine/src/chess_engine.cpp` (new) | Backend | Empty compilable stub | +| `native/chess_engine/CMakeLists.txt` (new) | DevOps, Performance | Shared-lib build + optimization flags | +| `Services/Implementations/CustomChessEngine.cs` (new) | Backend, Architect | P/Invoke middleman implementing `IChessEngine` | +| `Services/Interfaces/IChessEngine.cs` + `IChessEngineFactory.cs` (new) | Architect | Swappable contract + factory | +| `Services/Implementations/ChessEngineFactory.cs` + `ComputerMoveOrchestrator.cs` (new) | Architect | Config-driven selection + lifted orchestration | diff --git a/native/chess_engine/CMakeLists.txt b/native/chess_engine/CMakeLists.txt new file mode 100644 index 0000000..af664b1 --- /dev/null +++ b/native/chess_engine/CMakeLists.txt @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.20) +project(chess_engine LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Shared library: chess_engine.dll (Windows) / libchess_engine.so (Linux). +add_library(chess_engine SHARED + src/chess_engine.cpp + src/bitboard.cpp + src/zobrist.cpp + src/position.cpp + src/movegen.cpp + src/uci.cpp + src/perft.cpp) +target_include_directories(chess_engine PUBLIC include) +target_compile_definitions(chess_engine PRIVATE CHESS_ENGINE_BUILD) + +set_target_properties(chess_engine PROPERTIES + OUTPUT_NAME chess_engine + POSITION_INDEPENDENT_CODE ON) # -fPIC on Linux (required for .so) + +# Export only the symbols marked with the CHESS_API macro. +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) + +if (MSVC) + target_compile_options(chess_engine PRIVATE + $<$:/O2 /GL /DNDEBUG /arch:AVX2> + $<$:/Od /Zi>) # /Zi => .pdb for mixed-mode debugging + target_link_options(chess_engine PRIVATE + $<$:/LTCG> + $<$:/DEBUG>) +else() + # Portable server baseline. Do NOT use -march=native: the build host may have + # instructions the server lacks (SIGILL at runtime). Bump only once the server + # CPU floor is confirmed. + target_compile_options(chess_engine PRIVATE + $<$:-O3 -flto -DNDEBUG -march=x86-64-v2> + $<$:-O0 -g>) +endif() diff --git a/native/chess_engine/chess_engine/chess_engine.vcxproj b/native/chess_engine/chess_engine/chess_engine.vcxproj new file mode 100644 index 0000000..5f99245 --- /dev/null +++ b/native/chess_engine/chess_engine/chess_engine.vcxproj @@ -0,0 +1,179 @@ + + + + + Debug + Win32 + + + Release + Win32 + + + Debug + x64 + + + Release + x64 + + + + 18.0 + Win32Proj + {2579bbbc-1830-4342-bc10-0a4182dc84c7} + chessengine + 10.0 + + + + DynamicLibrary + true + v145 + Unicode + + + DynamicLibrary + false + v145 + true + Unicode + + + DynamicLibrary + true + v145 + Unicode + + + DynamicLibrary + false + v145 + true + Unicode + + + + + + + + + + + + + + + + + + + + + + Level3 + true + WIN32;_DEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + stdcpp20 + NotUsing + $(ProjectDir)..\include;%(AdditionalIncludeDirectories) + + + Windows + true + false + + + true + + + + + Level3 + true + true + true + WIN32;NDEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + stdcpp20 + NotUsing + $(ProjectDir)..\include;%(AdditionalIncludeDirectories) + + + Windows + true + false + + + true + + + + + Level3 + true + _DEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + stdcpp20 + NotUsing + $(ProjectDir)..\include;%(AdditionalIncludeDirectories) + + + Windows + true + false + + + true + + + + + Level3 + true + true + true + NDEBUG;CHESS_ENGINE_BUILD;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + stdcpp20 + NotUsing + $(ProjectDir)..\include;%(AdditionalIncludeDirectories) + + + Windows + true + false + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/native/chess_engine/chess_engine/chess_engine.vcxproj.filters b/native/chess_engine/chess_engine/chess_engine.vcxproj.filters new file mode 100644 index 0000000..1e1cac6 --- /dev/null +++ b/native/chess_engine/chess_engine/chess_engine.vcxproj.filters @@ -0,0 +1,66 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + Source Files + + + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + Header Files + + + \ No newline at end of file diff --git a/native/chess_engine/include/chess_engine.h b/native/chess_engine/include/chess_engine.h new file mode 100644 index 0000000..0d494c6 --- /dev/null +++ b/native/chess_engine/include/chess_engine.h @@ -0,0 +1,80 @@ +/* chess_engine.h - C ABI for a swappable chess engine. + * + * Contract: FEN string in, UCI move string out (e.g. "e2e4", "e7e8q"). + * The C# host (CustomChessEngine) owns all buffers. The engine NEVER allocates + * memory that the host must free. Functions are thread-compatible per-handle only: + * do NOT call two functions on the SAME handle concurrently. Different handles + * are independent. + */ +#ifndef CHESS_ENGINE_H +#define CHESS_ENGINE_H + +#include + +/* ---- Export / calling-convention macro (MSVC + GCC/Clang) ---- */ +#if defined(_WIN32) + #ifdef CHESS_ENGINE_BUILD + #define CHESS_API __declspec(dllexport) + #else + #define CHESS_API __declspec(dllimport) + #endif + #define CHESS_CALL __cdecl /* explicit; matches C# CallingConvention.Cdecl */ +#else + #define CHESS_API __attribute__((visibility("default"))) + #define CHESS_CALL /* SysV default; no decoration needed */ +#endif + +#ifdef __cplusplus +extern "C" { /* prevent C++ name mangling */ +#endif + +/* Opaque handle. The host treats this as a token and never dereferences it. + * Internally it points to your engine state object. */ +typedef struct ChessEngine* EngineHandle; + +/* Return codes. 0 == success; negative == error. Keep these values stable. */ +enum { + CHESS_OK = 0, + CHESS_ERR_NULL_HANDLE = -1, /* handle was null/invalid */ + CHESS_ERR_BAD_FEN = -2, /* fen failed to parse */ + CHESS_ERR_NO_MOVE = -3, /* no legal move (mate/stalemate) */ + CHESS_ERR_BUFFER = -4, /* out_buf too small for the move + NUL */ + CHESS_ERR_INTERNAL = -5 /* unexpected engine failure */ +}; + +/* Create an engine instance. + * options: optional null-terminated UTF-8 config string (may be NULL), + * e.g. "skill=20;hash=256". Parse however you like; ignore for now. + * returns: a valid EngineHandle, or NULL on allocation failure. */ +CHESS_API EngineHandle CHESS_CALL engine_create(const char* options); + +/* Set a single option by name (optional; may no-op for now). + * returns CHESS_OK or a negative code. */ +CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, + const char* name, + const char* value); + +/* Compute the best move for the given position. + * engine : handle from engine_create. + * fen : null-terminated UTF-8 FEN of the position to move from. + * out_buf : host-owned buffer the engine writes the UCI move into, + * as a null-terminated ASCII string (e.g. "e2e4\0"). + * out_len : capacity of out_buf in bytes (host passes >= 8). + * returns CHESS_OK on success (out_buf now holds the move), else negative. + * MUST NOT write more than out_len bytes including the NUL terminator. */ +CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, + const char* fen, + char* out_buf, + int out_len); + +/* Write the engine version string into out_buf (null-terminated). + * returns CHESS_OK or CHESS_ERR_BUFFER. */ +CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len); + +/* Destroy an instance created by engine_create. Safe to call with NULL. */ +CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine); + +#ifdef __cplusplus +} +#endif +#endif /* CHESS_ENGINE_H */ diff --git a/native/chess_engine/src/bitboard.cpp b/native/chess_engine/src/bitboard.cpp new file mode 100644 index 0000000..2681695 --- /dev/null +++ b/native/chess_engine/src/bitboard.cpp @@ -0,0 +1,146 @@ +#include "bitboard.h" + +#include + +namespace chess { + +Bitboard PawnAttacks[COLOR_NB][SQUARE_NB]; +Bitboard KnightAttacks[SQUARE_NB]; +Bitboard KingAttacks[SQUARE_NB]; + +Magic BishopMagics[SQUARE_NB]; +Magic RookMagics[SQUARE_NB]; + +// Backing storage the magics index into (fancy-magic sizes). +static Bitboard RookTable[102400]; +static Bitboard BishopTable[5248]; + +namespace { + +int file_distance(Square a, Square b) { + return std::abs(int(file_of(a)) - int(file_of(b))); +} + +// Slow, edge-aware ray attack used only to build the tables. +Bitboard sliding_attack(const int* dirs, Square sq, Bitboard occ) { + Bitboard attacks = 0; + for (int i = 0; i < 4; ++i) { + Square prev = sq; + int t = int(sq) + dirs[i]; + while (t >= 0 && t < 64 && file_distance(Square(t), prev) <= 1) { + attacks |= square_bb(Square(t)); + if (occ & square_bb(Square(t))) break; + prev = Square(t); + t += dirs[i]; + } + } + return attacks; +} + +// Relevant-occupancy mask: the ray squares excluding board edges. +Bitboard slider_mask(const int* dirs, Square sq) { + Bitboard edges = ((RANK_1_BB | RANK_8_BB) & ~rank_bb(rank_of(sq))) + | ((FILE_A_BB | FILE_H_BB) & ~file_bb(file_of(sq))); + return sliding_attack(dirs, sq, 0) & ~edges; +} + +// Deterministic xorshift PRNG (fixed seed -> reproducible magics). +struct PRNG { + uint64_t s; + explicit PRNG(uint64_t seed) : s(seed) {} + uint64_t next() { + s ^= s >> 12; s ^= s << 25; s ^= s >> 27; + return s * 2685821657736338717ULL; + } + // Few set bits -> better magic candidates. + uint64_t sparse() { return next() & next() & next(); } +}; + +void init_magics(const int* dirs, Magic magics[], Bitboard table[]) { + PRNG rng(0x9E3779B97F4A7C15ull); // fixed seed -> reproducible magics + + Bitboard occupancy[4096]; + Bitboard reference[4096]; + int epoch[4096] = {}; + int currentEpoch = 0; + + size_t offset = 0; + for (int sq = 0; sq < 64; ++sq) { + Magic& m = magics[sq]; + m.mask = slider_mask(dirs, Square(sq)); + m.shift = 64 - popcount(m.mask); + m.attacks = table + offset; + + // Enumerate every subset of the mask (Carry-Rippler). + Bitboard b = 0; + int size = 0; + do { + occupancy[size] = b; + reference[size] = sliding_attack(dirs, Square(sq), b); + ++size; + b = (b - m.mask) & m.mask; + } while (b); + + // Search for a magic that maps subsets to indices collision-free + // (collisions are fine only when the attack set is identical). + for (;;) { + Bitboard magic; + do { + magic = rng.sparse(); + } while (popcount((m.mask * magic) >> 56) < 6); + + m.magic = magic; + ++currentEpoch; + bool ok = true; + for (int i = 0; i < size; ++i) { + unsigned idx = m.index(occupancy[i]); + if (epoch[idx] < currentEpoch) { + epoch[idx] = currentEpoch; + m.attacks[idx] = reference[i]; + } else if (m.attacks[idx] != reference[i]) { + ok = false; + break; + } + } + if (ok) break; + } + + offset += size; + } +} + +} // namespace + +void init_bitboards() { + for (int s = 0; s < 64; ++s) { + Bitboard b = square_bb(Square(s)); + + PawnAttacks[WHITE][s] = ((b & ~FILE_H_BB) << 9) | ((b & ~FILE_A_BB) << 7); + PawnAttacks[BLACK][s] = ((b & ~FILE_A_BB) >> 9) | ((b & ~FILE_H_BB) >> 7); + + const int knightDirs[8] = { 17, 15, 10, 6, -6, -10, -15, -17 }; + Bitboard kn = 0; + for (int d : knightDirs) { + int t = s + d; + if (t >= 0 && t < 64 && file_distance(Square(t), Square(s)) <= 2) + kn |= square_bb(Square(t)); + } + KnightAttacks[s] = kn; + + const int kingDirs[8] = { 8, -8, 1, -1, 9, 7, -7, -9 }; + Bitboard kg = 0; + for (int d : kingDirs) { + int t = s + d; + if (t >= 0 && t < 64 && file_distance(Square(t), Square(s)) <= 1) + kg |= square_bb(Square(t)); + } + KingAttacks[s] = kg; + } + + const int rookDirs[4] = { 8, -8, 1, -1 }; + const int bishopDirs[4] = { 9, 7, -7, -9 }; + init_magics(rookDirs, RookMagics, RookTable); + init_magics(bishopDirs, BishopMagics, BishopTable); +} + +} // namespace chess diff --git a/native/chess_engine/src/bitboard.h b/native/chess_engine/src/bitboard.h new file mode 100644 index 0000000..7b41652 --- /dev/null +++ b/native/chess_engine/src/bitboard.h @@ -0,0 +1,87 @@ +// Bitboard utilities and precomputed attack tables. Sliders use magic +// bitboards; the tables are built once by init_bitboards() (called from +// engine_create) and are read-only afterwards. +#ifndef CHESS_BITBOARD_H +#define CHESS_BITBOARD_H + +#include "types.h" + +#if defined(_MSC_VER) +#include +#endif + +namespace chess { + +constexpr Bitboard FILE_A_BB = 0x0101010101010101ULL; +constexpr Bitboard FILE_H_BB = 0x8080808080808080ULL; +constexpr Bitboard RANK_1_BB = 0x00000000000000FFULL; +constexpr Bitboard RANK_8_BB = 0xFF00000000000000ULL; + +inline Bitboard square_bb(Square s) { return 1ULL << s; } +inline Bitboard file_bb(File f) { return FILE_A_BB << f; } +inline Bitboard rank_bb(Rank r) { return RANK_1_BB << (8 * int(r)); } + +inline int popcount(Bitboard b) { +#if defined(_MSC_VER) + return int(__popcnt64(b)); +#else + return __builtin_popcountll(b); +#endif +} + +inline Square lsb(Bitboard b) { +#if defined(_MSC_VER) + unsigned long i; + _BitScanForward64(&i, b); + return Square(i); +#else + return Square(__builtin_ctzll(b)); +#endif +} + +// Returns the least-significant square and clears it from b. +inline Square pop_lsb(Bitboard& b) { + Square s = lsb(b); + b &= b - 1; + return s; +} + +inline bool more_than_one(Bitboard b) { return b & (b - 1); } + +// Precomputed leaper attacks (filled by init_bitboards). +extern Bitboard PawnAttacks[COLOR_NB][SQUARE_NB]; +extern Bitboard KnightAttacks[SQUARE_NB]; +extern Bitboard KingAttacks[SQUARE_NB]; + +struct Magic { + Bitboard mask; + Bitboard magic; + Bitboard* attacks; + unsigned shift; + + unsigned index(Bitboard occ) const { + return unsigned(((occ & mask) * magic) >> shift); + } +}; + +extern Magic BishopMagics[SQUARE_NB]; +extern Magic RookMagics[SQUARE_NB]; + +inline Bitboard bishop_attacks(Square s, Bitboard occ) { + const Magic& m = BishopMagics[s]; + return m.attacks[m.index(occ)]; +} +inline Bitboard rook_attacks(Square s, Bitboard occ) { + const Magic& m = RookMagics[s]; + return m.attacks[m.index(occ)]; +} +inline Bitboard queen_attacks(Square s, Bitboard occ) { + return bishop_attacks(s, occ) | rook_attacks(s, occ); +} + +// Must be called once before any attack query (engine_create does this). +void init_bitboards(); + +} // namespace chess + +#endif // CHESS_BITBOARD_H diff --git a/native/chess_engine/src/chess_engine.cpp b/native/chess_engine/src/chess_engine.cpp new file mode 100644 index 0000000..242f52a --- /dev/null +++ b/native/chess_engine/src/chess_engine.cpp @@ -0,0 +1,283 @@ +/* chess_engine.cpp - the DLL boundary (extern "C" ABI). + * + * The rules layer (board, move generation, make/unmake, hashing, perft) lives in + * the other src/*.cpp files and is ready to use. engine_best_move is intentionally + * left for YOU: that is where your search/evaluation goes. Everything below the + * FEN-in / UCI-out boundary should stay native — the managed side crosses it once + * per move. + */ +#ifndef CHESS_ENGINE_BUILD +#define CHESS_ENGINE_BUILD /* fallback when not building via CMake (which defines it) */ +#endif +#pragma once + +#include "chess_engine.h" +#include "bitboard.h" +#include "zobrist.h" +#include "position.h" +#include "movegen.h" +#include "uci.h" + +#include +#include +#include +#include +#include +#include + + +/* Internal engine state. Put your search tables, transposition table, etc. here. */ +struct ChessEngine { + int skill = 20; /* 1..20 from the UI; controls search depth */ +}; + +static int copy_out(const char* src, char* out_buf, int out_len) { + if (!out_buf || out_len <= 0) return CHESS_ERR_BUFFER; + const size_t need = std::strlen(src) + 1; /* + NUL */ + if (need > static_cast(out_len)) return CHESS_ERR_BUFFER; + std::memcpy(out_buf, src, need); + return CHESS_OK; +} + +/* Attack tables and Zobrist keys are global and read-only after this runs. */ +static void ensure_initialized() { + static bool done = false; + if (done) return; + chess::init_bitboards(); + chess::Zobrist::init(); + done = true; +} + +/* Pulls "skill=N" out of the engine_create options string; clamps to the UI's 1..20. */ +static int parse_skill(const char* options, int fallback) { + if (!options) return fallback; + const char* p = std::strstr(options, "skill="); + if (!p) return fallback; + int v = std::atoi(p + 6); + return v < 1 ? 1 : v > 20 ? 20 : v; +} + +/* Maps the 1..20 difficulty to a search depth. Kept modest: the search has no move + * ordering or quiescence yet, so deep fixed-depth runs get expensive quickly. */ +static int depth_for_skill(int skill) { + return skill; /* skill 1 -> 2 plies ... skill 20 -> 7 plies */ +} + +/* Positional multiplier in [0.5, 2.0] based on a square's distance from the four + * center squares (d4/e4/d5/e5): 2.0 dead center, 0.5 in a corner, scaling linearly. + * Multiply a piece's base value by this to reward central placement. */ +static double center_multiplier(chess::Square s) { + /* |2*coord - 7| is the distance from center in half-squares: 1 (center) .. 7 (edge). */ + int fileDist = std::abs(2 * int(chess::file_of(s)) - 7); + int rankDist = std::abs(2 * int(chess::rank_of(s)) - 7); + int dist = fileDist > rankDist ? fileDist : rankDist; /* Chebyshev distance, 1 .. 7 */ + + return dist * 20; /* 1 -> 2.0, 7 -> 0.5 */ +} + +static int piece_mobility(const chess::Position& pos, chess::Square s, chess::Piece pc, chess::Color c) { + chess::Bitboard occ = pos.pieces(); + chess::Bitboard targets; + + switch (chess::type_of(pc)) { + case chess::KNIGHT: targets = chess::KnightAttacks[s]; break; + case chess::BISHOP: targets = chess::bishop_attacks(s, occ); break; + case chess::ROOK: targets = chess::rook_attacks(s, occ); break; + case chess::QUEEN: targets = chess::queen_attacks(s, occ); break; + case chess::KING: targets = chess::KingAttacks[s]; break; + default: return 0; // pawns: mobility usually handled via push/attack separately + } + + return chess::popcount(targets & ~pos.pieces(c)); // exclude squares blocked by own pieces +} + +static chess::Bitboard front_span(chess::Color c, chess::Square s) { + chess::File f = file_of(s); + chess::Bitboard files = file_bb(f); + if (f > chess::FILE_A) files |= chess::file_bb(chess::File(f - 1)); + if (f < chess::FILE_H) files |= chess::file_bb(chess::File(f + 1)); + + // Pawns never sit on rank 1 or 8, so rank is 1..6 and these shifts + // are always in [8,56] — no shift-by-64 UB to guard against. + chess::Rank r = rank_of(s); + chess::Bitboard ahead = (c == chess::WHITE) ? (~0ULL << (8 * (r + 1))) // ranks > r + : ((1ULL << (8 * r)) - 1); // ranks < r + return files & ahead; +} + +static chess::Bitboard front_span_file_only(chess::Color c, chess::Square s) { + chess::File f = file_of(s); + chess::Bitboard files = file_bb(f); + + // Pawns never sit on rank 1 or 8, so rank is 1..6 and these shifts + // are always in [8,56] — no shift-by-64 UB to guard against. + chess::Rank r = rank_of(s); + chess::Bitboard ahead = (c == chess::WHITE) ? (~0ULL << (8 * (r + 1))) // ranks > r + : ((1ULL << (8 * r)) - 1); // ranks < r + return files & ahead; +} + +static int evaluatePawn(const chess::Position& pos, const chess::Color c, const chess::Square s) { + chess::Bitboard span = front_span(c, s); + chess::Bitboard file_span = front_span_file_only(c, s); + chess::Rank r = rank_of(s); + int squaresToPromotion = (c == chess::WHITE) ? (chess::RANK_8 - r) : (r - chess::RANK_1);; + bool isPassed = !(span & pos.pieces(~c, chess::PAWN)); + bool isBlocked = (file_span & pos.pieces(c, chess::PAWN)) | (file_span & pos.pieces(~c, chess::PAWN)); + bool isDoubled = (file_span & pos.pieces(c, chess::PAWN)); + + int score = 100; + + if (isPassed && !isBlocked) + score += squaresToPromotion * 10; // Bonus for passed pawns, more as they get closer to promotion + if (isDoubled) + score -= 20; // Penalty for doubled pawns + if (isBlocked) + score -= 20; // Penalty for blocked pawns + + return score; +} + +static int evaluatePiece(const chess::Position& pos, const chess::Square& s, const chess::Piece& pc, const chess::Color& c) { + int score = 0; + switch (chess::type_of(pc)) { + case chess::PAWN: score = evaluatePawn(pos, c, s); break; + case chess::KNIGHT: score = 320; break; + case chess::BISHOP: score = 330; break; + case chess::ROOK: score = 500; break; + case chess::QUEEN: score = 900; break; + default: return 0; + } + + score += center_multiplier(s); + score += piece_mobility(pos, s, pc, c) * 10; + + return score; +} + +static int evaluate(const chess::Position& pos) { + int score = 0; + chess::Bitboard white = pos.pieces(chess::WHITE); + + while (white) { + chess::Square s = chess::pop_lsb(white); + chess::Piece pc = pos.piece_on(s); + chess::Color c = chess::color_of(pc); + score += evaluatePiece(pos, s, pc, c); + } + + chess::Bitboard black = pos.pieces(chess::BLACK); + + while (black) { + chess::Square s = chess::pop_lsb(black); + chess::Piece pc = pos.piece_on(s); + chess::Color c = chess::color_of(pc); + score -= evaluatePiece(pos, s, pc, c); + } + + return score; +} + +extern "C" { + +CHESS_API EngineHandle CHESS_CALL engine_create(const char* options) { + ensure_initialized(); + auto* e = new (std::nothrow) ChessEngine(); + if (!e) return nullptr; + e->skill = parse_skill(options, e->skill); + return e; +} + +CHESS_API int CHESS_CALL engine_set_option(EngineHandle engine, + const char* /*name*/, + const char* /*value*/) { + if (!engine) return CHESS_ERR_NULL_HANDLE; + return CHESS_OK; /* TODO: store options */ +} + +static int alpha_beta(chess::Position& pos, int depth, int maxDepth, int bestForWhite, int bestForBlack, bool whiteToMove) { + if (depth == maxDepth) + return evaluate(pos); + + chess::MoveList moves; + pos.generate_legal(moves); + + if (moves.size() == 0) + return pos.is_draw() ? 0 : whiteToMove ? -200000 + depth : 200000 - depth; + + for (int i = 0; i < moves.size(); i++) { + chess::Move move = moves.moves[i]; + pos.do_move(move); + int moveScore = alpha_beta(pos, depth + 1, maxDepth, bestForWhite, bestForBlack, !whiteToMove); + if (whiteToMove) { + if (moveScore >= bestForBlack) { + pos.undo_move(move); + return bestForBlack; + } + if (moveScore > bestForWhite) + bestForWhite = moveScore; + } + else { + if (moveScore <= bestForWhite) { + pos.undo_move(move); + return bestForWhite; + } + if (moveScore < bestForBlack) + bestForBlack = moveScore; + } + + pos.undo_move(move); + } + + return whiteToMove ? bestForWhite : bestForBlack; +} + +CHESS_API int CHESS_CALL engine_best_move(EngineHandle engine, + const char* fen, + char* out_buf, + int out_len) { + if (!engine) return CHESS_ERR_NULL_HANDLE; + if (!fen || !*fen) return CHESS_ERR_BAD_FEN; + + auto held = std::make_unique(chess::Position::from_fen(fen)); + chess::Position& pos = *held; + bool whiteToMove = pos.side_to_move() == chess::WHITE; + chess::MoveList moves; + pos.generate_legal(moves); + if (moves.size() == 0) + return CHESS_ERR_NO_MOVE; + + int maxDepth = depth_for_skill(engine->skill); + + int bestForWhite = std::numeric_limits::min(); + int bestForBlack = std::numeric_limits::max(); + chess::Move bestMove = moves.moves[0]; + + for (int i = 0; i < moves.size(); i++) { + chess::Move move = moves.moves[i]; + pos.do_move(move); + int score = alpha_beta(pos, 1, maxDepth, bestForWhite, bestForBlack, !whiteToMove); + pos.undo_move(move); + + if (whiteToMove && score > bestForWhite) { + bestForWhite = score; + bestMove = move; + } + else if (!whiteToMove && score < bestForBlack) { + bestForBlack = score; + bestMove = move; + } + } + + return copy_out(chess::move_to_uci(bestMove).c_str(), out_buf, out_len); +} + +CHESS_API int CHESS_CALL engine_version(char* out_buf, int out_len) { + return copy_out("custom-engine 0.1.0", out_buf, out_len); +} + +CHESS_API void CHESS_CALL engine_destroy(EngineHandle engine) { + delete engine; /* delete nullptr is safe */ +} + +} /* extern "C" */ diff --git a/native/chess_engine/src/movegen.cpp b/native/chess_engine/src/movegen.cpp new file mode 100644 index 0000000..b2759ab --- /dev/null +++ b/native/chess_engine/src/movegen.cpp @@ -0,0 +1,134 @@ +#include "movegen.h" + +namespace chess { + +namespace { + +void add_promotions(MoveList& list, Square from, Square to) { + list.add(Move::make(from, to, PROMOTION, QUEEN)); + list.add(Move::make(from, to, PROMOTION, ROOK)); + list.add(Move::make(from, to, PROMOTION, BISHOP)); + list.add(Move::make(from, to, PROMOTION, KNIGHT)); +} + +void generate_castling(const Position& pos, MoveList& list) { + if (pos.in_check()) return; + + Color us = pos.side_to_move(), them = ~us; + Bitboard occ = pos.pieces(); + auto attacked = [&](Square sq) { return (pos.attackers_to(sq) & pos.pieces(them)) != 0; }; + + if (us == WHITE) { + if (pos.can_castle(WHITE, KINGSIDE) && + !(occ & (square_bb(F1) | square_bb(G1))) && !attacked(F1) && !attacked(G1)) + list.add(Move::make(E1, G1, CASTLING)); + if (pos.can_castle(WHITE, QUEENSIDE) && + !(occ & (square_bb(B1) | square_bb(C1) | square_bb(D1))) && !attacked(D1) && !attacked(C1)) + list.add(Move::make(E1, C1, CASTLING)); + } else { + if (pos.can_castle(BLACK, KINGSIDE) && + !(occ & (square_bb(F8) | square_bb(G8))) && !attacked(F8) && !attacked(G8)) + list.add(Move::make(E8, G8, CASTLING)); + if (pos.can_castle(BLACK, QUEENSIDE) && + !(occ & (square_bb(B8) | square_bb(C8) | square_bb(D8))) && !attacked(D8) && !attacked(C8)) + list.add(Move::make(E8, C8, CASTLING)); + } +} + +} // namespace + +void generate_pseudo(const Position& pos, MoveList& list) { + Color us = pos.side_to_move(), them = ~us; + Bitboard occ = pos.pieces(); + Bitboard targets = ~pos.pieces(us); // empty squares or enemy pieces + Bitboard theirs = pos.pieces(them); + + // Pawns + int push = (us == WHITE) ? 8 : -8; + Rank promoRank = (us == WHITE) ? RANK_8 : RANK_1; + Rank startRank = (us == WHITE) ? RANK_2 : RANK_7; + Bitboard b = pos.pieces(us, PAWN); + while (b) { + Square s = pop_lsb(b); + Square t = Square(int(s) + push); + if (!(occ & square_bb(t))) { + if (rank_of(t) == promoRank) { + add_promotions(list, s, t); + } else { + list.add(Move::make(s, t)); + if (rank_of(s) == startRank) { + Square t2 = Square(int(t) + push); + if (!(occ & square_bb(t2))) list.add(Move::make(s, t2)); + } + } + } + Bitboard caps = PawnAttacks[us][s] & theirs; + while (caps) { + Square c = pop_lsb(caps); + if (rank_of(c) == promoRank) add_promotions(list, s, c); + else list.add(Move::make(s, c)); + } + if (pos.ep_square() != SQ_NONE && (PawnAttacks[us][s] & square_bb(pos.ep_square()))) + list.add(Move::make(s, pos.ep_square(), EN_PASSANT)); + } + + // Knights + b = pos.pieces(us, KNIGHT); + while (b) { + Square s = pop_lsb(b); + Bitboard a = KnightAttacks[s] & targets; + while (a) list.add(Move::make(s, pop_lsb(a))); + } + + // Bishops + b = pos.pieces(us, BISHOP); + while (b) { + Square s = pop_lsb(b); + Bitboard a = bishop_attacks(s, occ) & targets; + while (a) list.add(Move::make(s, pop_lsb(a))); + } + + // Rooks + b = pos.pieces(us, ROOK); + while (b) { + Square s = pop_lsb(b); + Bitboard a = rook_attacks(s, occ) & targets; + while (a) list.add(Move::make(s, pop_lsb(a))); + } + + // Queens + b = pos.pieces(us, QUEEN); + while (b) { + Square s = pop_lsb(b); + Bitboard a = queen_attacks(s, occ) & targets; + while (a) list.add(Move::make(s, pop_lsb(a))); + } + + // King (non-castling) + { + Square s = pos.king_square(us); + Bitboard a = KingAttacks[s] & targets; + while (a) list.add(Move::make(s, pop_lsb(a))); + } +} + +void Position::generate_legal(MoveList& list) { + list.count = 0; + + MoveList pseudo; + generate_pseudo(*this, pseudo); + + Color us = sideToMove; + for (Move m : pseudo) { + do_move(m); + // After do_move, sideToMove is the opponent; the move is legal iff the + // side that just moved did not leave its own king attacked. + bool legal = (attackers_to(king_square(us)) & pieces(sideToMove)) == 0; + undo_move(m); + if (legal) list.add(m); + } + + generate_castling(*this, list); // already fully legal +} + +} // namespace chess diff --git a/native/chess_engine/src/movegen.h b/native/chess_engine/src/movegen.h new file mode 100644 index 0000000..33069eb --- /dev/null +++ b/native/chess_engine/src/movegen.h @@ -0,0 +1,17 @@ +// Legal move generation. generate_legal is a method on Position (declared +// there); this header exists so other translation units can pull in the +// pseudo-legal generator if they ever want it. +#ifndef CHESS_MOVEGEN_H +#define CHESS_MOVEGEN_H + +#include "position.h" + +namespace chess { + +// Generates pseudo-legal moves (ignores leaving your own king in check). +// Position::generate_legal filters these. Castling is generated fully-legal. +void generate_pseudo(const Position& pos, MoveList& list); + +} // namespace chess + +#endif // CHESS_MOVEGEN_H diff --git a/native/chess_engine/src/perft.cpp b/native/chess_engine/src/perft.cpp new file mode 100644 index 0000000..9386b00 --- /dev/null +++ b/native/chess_engine/src/perft.cpp @@ -0,0 +1,22 @@ +#include "perft.h" + +namespace chess { + +uint64_t perft(Position& pos, int depth) { + if (depth == 0) return 1; + + MoveList list; + pos.generate_legal(list); + + if (depth == 1) return uint64_t(list.size()); + + uint64_t nodes = 0; + for (Move m : list) { + pos.do_move(m); + nodes += perft(pos, depth - 1); + pos.undo_move(m); + } + return nodes; +} + +} // namespace chess diff --git a/native/chess_engine/src/perft.h b/native/chess_engine/src/perft.h new file mode 100644 index 0000000..da6f20b --- /dev/null +++ b/native/chess_engine/src/perft.h @@ -0,0 +1,14 @@ +// Perft: counts the leaf nodes of the legal move tree to a given depth. +// The standard correctness test for move generation + make/unmake. +#ifndef CHESS_PERFT_H +#define CHESS_PERFT_H + +#include "position.h" + +namespace chess { + +uint64_t perft(Position& pos, int depth); + +} // namespace chess + +#endif // CHESS_PERFT_H diff --git a/native/chess_engine/src/position.cpp b/native/chess_engine/src/position.cpp new file mode 100644 index 0000000..3330b05 --- /dev/null +++ b/native/chess_engine/src/position.cpp @@ -0,0 +1,347 @@ +#include "position.h" +#include "zobrist.h" + +#include +#include +#include +#include + + +namespace chess { + +namespace { + +// Bits of castling rights that are revoked when a piece leaves/arrives a square +// (covers king moves, rook moves, and rook captures uniformly). +int castling_mask(Square s) { + switch (s) { + case E1: return WHITE_OO | WHITE_OOO; + case A1: return WHITE_OOO; + case H1: return WHITE_OO; + case E8: return BLACK_OO | BLACK_OOO; + case A8: return BLACK_OOO; + case H8: return BLACK_OO; + default: return 0; + } +} + +char piece_to_char(Piece p) { + const char* w = " PNBRQK"; + char c = w[type_of(p)]; + return color_of(p) == BLACK ? char(std::tolower(c)) : c; +} + +} // namespace + +void Position::put_piece(Piece pc, Square s) { + board[s] = pc; + byTypeBB[type_of(pc)] |= square_bb(s); + byColorBB[color_of(pc)] |= square_bb(s); + zkey ^= Zobrist::psq[pc][s]; +} + +void Position::remove_piece(Square s) { + Piece pc = board[s]; + byTypeBB[type_of(pc)] ^= square_bb(s); + byColorBB[color_of(pc)] ^= square_bb(s); + board[s] = NO_PIECE; + zkey ^= Zobrist::psq[pc][s]; +} + +void Position::move_piece(Square from, Square to) { + Piece pc = board[from]; + Bitboard fromTo = square_bb(from) | square_bb(to); + byTypeBB[type_of(pc)] ^= fromTo; + byColorBB[color_of(pc)] ^= fromTo; + board[from] = NO_PIECE; + board[to] = pc; + zkey ^= Zobrist::psq[pc][from] ^ Zobrist::psq[pc][to]; +} + +bool Position::can_castle(Color c, CastlingSide side) const { + int r = (c == WHITE) ? (side == KINGSIDE ? WHITE_OO : WHITE_OOO) + : (side == KINGSIDE ? BLACK_OO : BLACK_OOO); + return (castlingRights & r) != 0; +} + +Position Position::from_fen(std::string_view fen) { + auto held = std::make_unique(); + Position& p = *held; + std::memset(p.byTypeBB, 0, sizeof(p.byTypeBB)); + std::memset(p.byColorBB, 0, sizeof(p.byColorBB)); + for (int s = 0; s < SQUARE_NB; ++s) p.board[s] = NO_PIECE; + p.sideToMove = WHITE; + p.castlingRights = NO_CASTLING; + p.epSquare = SQ_NONE; + p.rule50 = 0; + p.gamePly = 0; + p.zkey = 0; + p.undoCount = 0; + + size_t i = 0; + int rank = 7, file = 0; + + // 1) piece placement + for (; i < fen.size() && fen[i] != ' '; ++i) { + char c = fen[i]; + if (c == '/') { --rank; file = 0; } + else if (std::isdigit((unsigned char)c)) { file += c - '0'; } + else { + Color col = std::isupper((unsigned char)c) ? WHITE : BLACK; + PieceType pt = NO_PIECE_TYPE; + switch (std::tolower((unsigned char)c)) { + case 'p': pt = PAWN; break; + case 'n': pt = KNIGHT; break; + case 'b': pt = BISHOP; break; + case 'r': pt = ROOK; break; + case 'q': pt = QUEEN; break; + case 'k': pt = KING; break; + } + if (pt != NO_PIECE_TYPE) + p.put_piece(make_piece(col, pt), make_square(File(file), Rank(rank))); + ++file; + } + } + + auto skip_space = [&] { while (i < fen.size() && fen[i] == ' ') ++i; }; + + // 2) side to move + skip_space(); + if (i < fen.size()) { p.sideToMove = (fen[i] == 'b') ? BLACK : WHITE; ++i; } + + // 3) castling rights + skip_space(); + for (; i < fen.size() && fen[i] != ' '; ++i) { + switch (fen[i]) { + case 'K': p.castlingRights |= WHITE_OO; break; + case 'Q': p.castlingRights |= WHITE_OOO; break; + case 'k': p.castlingRights |= BLACK_OO; break; + case 'q': p.castlingRights |= BLACK_OOO; break; + default: break; // '-' or Chess960 letters + } + } + + // 4) en passant + skip_space(); + if (i < fen.size() && fen[i] != '-' && fen[i] != ' ') { + File f = File(fen[i] - 'a'); + Rank r = Rank(fen[i + 1] - '1'); + p.epSquare = make_square(f, r); + i += 2; + } else if (i < fen.size() && fen[i] == '-') { + ++i; + } + + // 5) halfmove clock + skip_space(); + int halfmove = 0; + for (; i < fen.size() && std::isdigit((unsigned char)fen[i]); ++i) + halfmove = halfmove * 10 + (fen[i] - '0'); + p.rule50 = halfmove; + + // 6) fullmove number + skip_space(); + int fullmove = 1; + if (i < fen.size() && std::isdigit((unsigned char)fen[i])) { + fullmove = 0; + for (; i < fen.size() && std::isdigit((unsigned char)fen[i]); ++i) + fullmove = fullmove * 10 + (fen[i] - '0'); + } + p.gamePly = (fullmove - 1) * 2 + (p.sideToMove == BLACK ? 1 : 0); + + // finalize the hash + if (p.sideToMove == BLACK) p.zkey ^= Zobrist::side; + p.zkey ^= Zobrist::castling[p.castlingRights]; + if (p.epSquare != SQ_NONE) p.zkey ^= Zobrist::enpassant[file_of(p.epSquare)]; + + p.repKeys[0] = p.zkey; + p.repCount = 1; + return p; +} + +std::string Position::to_fen() const { + std::string s; + for (int r = 7; r >= 0; --r) { + int empty = 0; + for (int f = 0; f < 8; ++f) { + Piece pc = board[make_square(File(f), Rank(r))]; + if (pc == NO_PIECE) { ++empty; continue; } + if (empty) { s += char('0' + empty); empty = 0; } + s += piece_to_char(pc); + } + if (empty) s += char('0' + empty); + if (r) s += '/'; + } + s += sideToMove == WHITE ? " w " : " b "; + + std::string cr; + if (castlingRights & WHITE_OO) cr += 'K'; + if (castlingRights & WHITE_OOO) cr += 'Q'; + if (castlingRights & BLACK_OO) cr += 'k'; + if (castlingRights & BLACK_OOO) cr += 'q'; + s += cr.empty() ? "-" : cr; + + s += ' '; + if (epSquare == SQ_NONE) s += '-'; + else { s += char('a' + file_of(epSquare)); s += char('1' + rank_of(epSquare)); } + + s += ' '; + s += std::to_string(rule50); + s += ' '; + s += std::to_string(fullmove_number()); + return s; +} + +Bitboard Position::attackers_to(Square s, Bitboard occ) const { + return (PawnAttacks[BLACK][s] & pieces(WHITE, PAWN)) + | (PawnAttacks[WHITE][s] & pieces(BLACK, PAWN)) + | (KnightAttacks[s] & byTypeBB[KNIGHT]) + | (KingAttacks[s] & byTypeBB[KING]) + | (bishop_attacks(s, occ) & (byTypeBB[BISHOP] | byTypeBB[QUEEN])) + | (rook_attacks(s, occ) & (byTypeBB[ROOK] | byTypeBB[QUEEN])); +} + +bool Position::in_check() const { + return (attackers_to(king_square(sideToMove)) & pieces(~sideToMove)) != 0; +} + +bool Position::gives_check(Move m) { + do_move(m); + bool checked = in_check(); + undo_move(m); + return checked; +} + +void Position::do_move(Move m) { + Color us = sideToMove, them = ~us; + Square from = m.from(), to = m.to(); + MoveFlag flag = m.type(); + Piece pc = board[from]; + Piece captured = (flag == EN_PASSANT) ? make_piece(them, PAWN) : board[to]; + + Undo& u = undoStack[undoCount++]; + u.castlingRights = castlingRights; + u.epSquare = epSquare; + u.rule50 = rule50; + u.key = zkey; + u.captured = captured; + + if (epSquare != SQ_NONE) { + zkey ^= Zobrist::enpassant[file_of(epSquare)]; + epSquare = SQ_NONE; + } + + ++rule50; + + if (captured != NO_PIECE) { + Square capsq = to; + if (flag == EN_PASSANT) capsq = (us == WHITE) ? Square(to - 8) : Square(to + 8); + remove_piece(capsq); + rule50 = 0; + } + + move_piece(from, to); + + if (type_of(pc) == PAWN) { + rule50 = 0; + if ((int(to) ^ int(from)) == 16) { + epSquare = Square((from + to) / 2); + zkey ^= Zobrist::enpassant[file_of(epSquare)]; + } else if (flag == PROMOTION) { + remove_piece(to); + put_piece(make_piece(us, m.promotion()), to); + } + } + + if (flag == CASTLING) { + Square rookFrom, rookTo; + if (to > from) { rookFrom = Square(from + 3); rookTo = Square(from + 1); } + else { rookFrom = Square(from - 4); rookTo = Square(from - 1); } + move_piece(rookFrom, rookTo); + } + + int cr = castlingRights & ~(castling_mask(from) | castling_mask(to)); + if (cr != castlingRights) { + zkey ^= Zobrist::castling[castlingRights]; + zkey ^= Zobrist::castling[cr]; + castlingRights = cr; + } + + sideToMove = them; + zkey ^= Zobrist::side; + ++gamePly; + + repKeys[repCount++] = zkey; +} + +void Position::undo_move(Move m) { + Color us = ~sideToMove; + Square from = m.from(), to = m.to(); + MoveFlag flag = m.type(); + Undo u = undoStack[--undoCount]; + + if (flag == PROMOTION) { + remove_piece(to); + put_piece(make_piece(us, PAWN), to); + } + + move_piece(to, from); + + if (u.captured != NO_PIECE) { + Square capsq = to; + if (flag == EN_PASSANT) capsq = (us == WHITE) ? Square(to - 8) : Square(to + 8); + put_piece(u.captured, capsq); + } + + if (flag == CASTLING) { + Square rookFrom, rookTo; + if (to > from) { rookFrom = Square(from + 3); rookTo = Square(from + 1); } + else { rookFrom = Square(from - 4); rookTo = Square(from - 1); } + move_piece(rookTo, rookFrom); + } + + sideToMove = us; + castlingRights = u.castlingRights; + epSquare = u.epSquare; + rule50 = u.rule50; + zkey = u.key; + --gamePly; + --repCount; +} + +bool Position::insufficient_material() const { + if (byTypeBB[PAWN] | byTypeBB[ROOK] | byTypeBB[QUEEN]) + return false; + int minors = popcount(byTypeBB[KNIGHT] | byTypeBB[BISHOP]); + return minors <= 1; // KvK, KvKN, KvKB +} + +bool Position::is_draw() const { + if (rule50 >= 100) return true; + if (insufficient_material()) return true; + + uint64_t k = repKeys[repCount - 1]; + int seen = 0; + for (int i = repCount - 3; i >= 0 && i >= repCount - 1 - rule50; i -= 2) + if (repKeys[i] == k && ++seen >= 2) + return true; // threefold + return false; +} + +void Position::print() const { + std::printf("\n +---+---+---+---+---+---+---+---+\n"); + for (int r = 7; r >= 0; --r) { + std::printf("%d ", r + 1); + for (int f = 0; f < 8; ++f) { + Piece pc = board[make_square(File(f), Rank(r))]; + std::printf("| %c ", pc == NO_PIECE ? ' ' : piece_to_char(pc)); + } + std::printf("|\n +---+---+---+---+---+---+---+---+\n"); + } + std::printf(" a b c d e f g h\n"); + std::printf(" %s to move key=%016llx\n", + sideToMove == WHITE ? "White" : "Black", + (unsigned long long)zkey); +} + +} // namespace chess diff --git a/native/chess_engine/src/position.h b/native/chess_engine/src/position.h new file mode 100644 index 0000000..cc9e99e --- /dev/null +++ b/native/chess_engine/src/position.h @@ -0,0 +1,87 @@ +// The board. Hybrid representation: bitboards (per piece type and per color) +// for fast generation/attacks, plus a piece-on-square mailbox for O(1) +// "what's here?" queries. do_move/undo_move keep both in sync, along with the +// Zobrist key. One Position is one game line; it is freely copyable. +#ifndef CHESS_POSITION_H +#define CHESS_POSITION_H + +#include "types.h" +#include "bitboard.h" + +#include +#include + +namespace chess { + +class Position { +public: + /// Parse a FEN string into a position. + static Position from_fen(std::string_view fen); + /// Serialize back to FEN. + std::string to_fen() const; + + // --- mailbox queries --- + Piece piece_on(Square s) const { return board[s]; } + bool empty(Square s) const { return board[s] == NO_PIECE; } + Color side_to_move() const { return sideToMove; } + Square ep_square() const { return epSquare; } + int halfmove_clock() const { return rule50; } + int fullmove_number() const { return 1 + gamePly / 2; } + bool can_castle(Color c, CastlingSide side) const; + Square king_square(Color c) const { return lsb(pieces(c, KING)); } + + // --- bitboard accessors --- + Bitboard pieces() const { return byColorBB[WHITE] | byColorBB[BLACK]; } + Bitboard pieces(Color c) const { return byColorBB[c]; } + Bitboard pieces(PieceType pt) const { return byTypeBB[pt]; } + Bitboard pieces(Color c, PieceType pt) const { return byTypeBB[pt] & byColorBB[c]; } + + // --- attacks / checks --- + Bitboard attackers_to(Square s) const { return attackers_to(s, pieces()); } + Bitboard attackers_to(Square s, Bitboard occ) const; + bool in_check() const; // is side_to_move in check? + bool gives_check(Move m); // does m check the opponent? + + // --- the three you asked for --- + void generate_legal(MoveList& list); // defined in movegen.cpp + void do_move(Move m); + void undo_move(Move m); + + // --- freebies --- + uint64_t key() const { return zkey; } + bool is_draw() const; // 50-move + threefold + insufficient material + void print() const; + +private: + void put_piece(Piece pc, Square s); + void remove_piece(Square s); + void move_piece(Square from, Square to); + bool insufficient_material() const; + + Bitboard byTypeBB[PIECE_TYPE_NB]; + Bitboard byColorBB[COLOR_NB]; + Piece board[SQUARE_NB]; + Color sideToMove; + int castlingRights; + Square epSquare; + int rule50; + int gamePly; + uint64_t zkey; + + struct Undo { + int castlingRights; + Square epSquare; + int rule50; + uint64_t key; + Piece captured; + }; + Undo undoStack[1024]; + int undoCount; + + uint64_t repKeys[1024]; + int repCount; +}; + +} // namespace chess + +#endif // CHESS_POSITION_H diff --git a/native/chess_engine/src/types.h b/native/chess_engine/src/types.h new file mode 100644 index 0000000..f16e41c --- /dev/null +++ b/native/chess_engine/src/types.h @@ -0,0 +1,107 @@ +// Core vocabulary for the chess engine: squares, pieces, moves. +// Everything else is built on these. Convention: A1 = 0 ... H8 = 63, +// file = square & 7 (A..H), rank = square >> 3 (1..8). North = +8. +#ifndef CHESS_TYPES_H +#define CHESS_TYPES_H + +#include + +namespace chess { + +using Bitboard = uint64_t; + +enum Color : int { WHITE, BLACK, COLOR_NB = 2 }; + +enum PieceType : int { + NO_PIECE_TYPE, PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING, PIECE_TYPE_NB = 8 +}; + +enum Piece : int { + NO_PIECE, + W_PAWN = PAWN, W_KNIGHT, W_BISHOP, W_ROOK, W_QUEEN, W_KING, + B_PAWN = PAWN + 8, B_KNIGHT, B_BISHOP, B_ROOK, B_QUEEN, B_KING, + PIECE_NB = 16 +}; + +enum Square : int { + A1, B1, C1, D1, E1, F1, G1, H1, + A2, B2, C2, D2, E2, F2, G2, H2, + A3, B3, C3, D3, E3, F3, G3, H3, + A4, B4, C4, D4, E4, F4, G4, H4, + A5, B5, C5, D5, E5, F5, G5, H5, + A6, B6, C6, D6, E6, F6, G6, H6, + A7, B7, C7, D7, E7, F7, G7, H7, + A8, B8, C8, D8, E8, F8, G8, H8, + SQ_NONE, + SQUARE_NB = 64 +}; + +enum File : int { FILE_A, FILE_B, FILE_C, FILE_D, FILE_E, FILE_F, FILE_G, FILE_H, FILE_NB = 8 }; +enum Rank : int { RANK_1, RANK_2, RANK_3, RANK_4, RANK_5, RANK_6, RANK_7, RANK_8, RANK_NB = 8 }; + +enum CastlingSide : int { KINGSIDE, QUEENSIDE }; + +// Castling rights as a bitmask. +enum CastlingRights : int { + NO_CASTLING = 0, + WHITE_OO = 1, WHITE_OOO = 2, + BLACK_OO = 4, BLACK_OOO = 8, + ANY_CASTLING = 15 +}; + +constexpr Color operator~(Color c) { return Color(c ^ BLACK); } + +constexpr Square make_square(File f, Rank r) { return Square((r << 3) + f); } +constexpr File file_of(Square s) { return File(s & 7); } +constexpr Rank rank_of(Square s) { return Rank(s >> 3); } + +constexpr Piece make_piece(Color c, PieceType pt) { return Piece((c << 3) + pt); } +constexpr PieceType type_of(Piece p) { return PieceType(p & 7); } +constexpr Color color_of(Piece p) { return Color(p >> 3); } // assumes p != NO_PIECE + +// A move packed into 16 bits: from:6 | to:6 | promotion:2 | flag:2. +// The promotion bits encode KNIGHT..QUEEN as 0..3 and are only meaningful +// when the flag is PROMOTION. +enum MoveFlag : int { NORMAL, PROMOTION, EN_PASSANT, CASTLING }; + +struct Move { + uint16_t data; + + constexpr Move() : data(0) {} + constexpr explicit Move(uint16_t d) : data(d) {} + + /// Build a move. `promo` only matters when `flag == PROMOTION`. + static constexpr Move make(Square from, Square to, MoveFlag flag = NORMAL, + PieceType promo = KNIGHT) { + return Move(uint16_t((flag << 14) | ((promo - KNIGHT) << 12) | (to << 6) | from)); + } + + constexpr Square from() const { return Square(data & 0x3F); } + constexpr Square to() const { return Square((data >> 6) & 0x3F); } + constexpr MoveFlag type() const { return MoveFlag((data >> 14) & 0x3); } + constexpr PieceType promotion() const { return PieceType(((data >> 12) & 0x3) + KNIGHT); } + + constexpr bool operator==(Move m) const { return data == m.data; } + constexpr bool operator!=(Move m) const { return data != m.data; } +}; + +// A1->A1 is never a real move, so an all-zero move is our "none" sentinel. +constexpr Move MOVE_NONE = Move(0); + +// Fixed-capacity, allocation-free, range-for friendly. 256 covers any legal position. +struct MoveList { + Move moves[256]; + int count = 0; + + void add(Move m) { moves[count++] = m; } + int size() const { return count; } + + Move* begin() { return moves; } + Move* end() { return moves + count; } + const Move* begin() const { return moves; } + const Move* end() const { return moves + count; } +}; + +} // namespace chess + +#endif // CHESS_TYPES_H diff --git a/native/chess_engine/src/uci.cpp b/native/chess_engine/src/uci.cpp new file mode 100644 index 0000000..3871e57 --- /dev/null +++ b/native/chess_engine/src/uci.cpp @@ -0,0 +1,50 @@ +#include "uci.h" + +#include + +namespace chess { + +std::string move_to_uci(Move m) { + if (m == MOVE_NONE) return "0000"; + + Square f = m.from(), t = m.to(); + std::string s; + s += char('a' + file_of(f)); + s += char('1' + rank_of(f)); + s += char('a' + file_of(t)); + s += char('1' + rank_of(t)); + + if (m.type() == PROMOTION) { + static const char promo[PIECE_TYPE_NB] = { 0, 0, 'n', 'b', 'r', 'q', 0 }; + s += promo[m.promotion()]; + } + return s; +} + +Move move_from_uci(const Position& pos, std::string_view uci) { + if (uci.size() < 4) return MOVE_NONE; + + Square from = make_square(File(uci[0] - 'a'), Rank(uci[1] - '1')); + Square to = make_square(File(uci[2] - 'a'), Rank(uci[3] - '1')); + + if (uci.size() >= 5) { + PieceType promo = QUEEN; + switch (uci[4]) { + case 'q': promo = QUEEN; break; + case 'r': promo = ROOK; break; + case 'b': promo = BISHOP; break; + case 'n': promo = KNIGHT; break; + } + return Move::make(from, to, PROMOTION, promo); + } + + Piece pc = pos.piece_on(from); + if (type_of(pc) == KING && std::abs(int(to) - int(from)) == 2) + return Move::make(from, to, CASTLING); + if (type_of(pc) == PAWN && to == pos.ep_square() && file_of(from) != file_of(to)) + return Move::make(from, to, EN_PASSANT); + + return Move::make(from, to); +} + +} // namespace chess diff --git a/native/chess_engine/src/uci.h b/native/chess_engine/src/uci.h new file mode 100644 index 0000000..3baa976 --- /dev/null +++ b/native/chess_engine/src/uci.h @@ -0,0 +1,19 @@ +// Conversions between moves and UCI long-algebraic strings ("e2e4", "e7e8q"). +// move_from_uci resolves the move's flag (castling / en passant / promotion) +// against the given position. +#ifndef CHESS_UCI_H +#define CHESS_UCI_H + +#include "position.h" + +#include +#include + +namespace chess { + +std::string move_to_uci(Move m); +Move move_from_uci(const Position& pos, std::string_view uci); + +} // namespace chess + +#endif // CHESS_UCI_H diff --git a/native/chess_engine/src/zobrist.cpp b/native/chess_engine/src/zobrist.cpp new file mode 100644 index 0000000..da832bf --- /dev/null +++ b/native/chess_engine/src/zobrist.cpp @@ -0,0 +1,39 @@ +#include "zobrist.h" + +namespace chess { +namespace Zobrist { + +uint64_t psq[PIECE_NB][SQUARE_NB]; +uint64_t enpassant[FILE_NB]; +uint64_t castling[16]; +uint64_t side; + +namespace { +struct PRNG { + uint64_t s; + explicit PRNG(uint64_t seed) : s(seed) {} + uint64_t next() { + s ^= s >> 12; s ^= s << 25; s ^= s >> 27; + return s * 2685821657736338717ULL; + } +}; +} + +void init() { + PRNG rng(0xC0FFEE123456789Aull); + + for (int p = 0; p < PIECE_NB; ++p) + for (int s = 0; s < SQUARE_NB; ++s) + psq[p][s] = rng.next(); + + for (int f = 0; f < FILE_NB; ++f) + enpassant[f] = rng.next(); + + for (int c = 0; c < 16; ++c) + castling[c] = rng.next(); + + side = rng.next(); +} + +} // namespace Zobrist +} // namespace chess diff --git a/native/chess_engine/src/zobrist.h b/native/chess_engine/src/zobrist.h new file mode 100644 index 0000000..79751c5 --- /dev/null +++ b/native/chess_engine/src/zobrist.h @@ -0,0 +1,21 @@ +// Zobrist hashing keys. Filled once by Zobrist::init() (from engine_create). +// Position maintains the running key incrementally in do_move/undo_move. +#ifndef CHESS_ZOBRIST_H +#define CHESS_ZOBRIST_H + +#include "types.h" + +namespace chess { +namespace Zobrist { + +extern uint64_t psq[PIECE_NB][SQUARE_NB]; +extern uint64_t enpassant[FILE_NB]; +extern uint64_t castling[16]; +extern uint64_t side; + +void init(); + +} // namespace Zobrist +} // namespace chess + +#endif // CHESS_ZOBRIST_H diff --git a/native/chess_engine/test/perft_main.cpp b/native/chess_engine/test/perft_main.cpp new file mode 100644 index 0000000..f968095 --- /dev/null +++ b/native/chess_engine/test/perft_main.cpp @@ -0,0 +1,76 @@ +// Standalone perft harness: validates move generation + make/unmake against +// published node counts. Build separately from the DLL (see build instructions +// in the repo); not part of the shipped library. +#include "../src/bitboard.h" +#include "../src/zobrist.h" +#include "../src/position.h" +#include "../src/perft.h" + +#include +#include + +using namespace chess; + +struct Case { + const char* name; + const char* fen; + int depth; + uint64_t expected; +}; + +// Verifies the incrementally-maintained key matches a from-scratch hash of the +// same position (also exercises to_fen -> from_fen round-tripping). +static uint64_t verify_keys(Position& pos, int depth) { + uint64_t mismatches = 0; + if (pos.key() != Position::from_fen(pos.to_fen()).key()) + ++mismatches; + if (depth == 0) return mismatches; + + MoveList list; + pos.generate_legal(list); + for (Move m : list) { + pos.do_move(m); + mismatches += verify_keys(pos, depth - 1); + pos.undo_move(m); + } + return mismatches; +} + +int main() { + init_bitboards(); + Zobrist::init(); + + const Case cases[] = { + {"startpos d5", "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", 5, 4865609ULL}, + {"kiwipete d4", "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1", 4, 4085603ULL}, + {"position3 d5", "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1", 5, 674624ULL}, + {"position4 d4", "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1", 4, 422333ULL}, + {"position5 d4", "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8", 4, 2103487ULL}, + {"position6 d4", "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10", 4, 3894594ULL}, + }; + + int fails = 0; + for (const Case& c : cases) { + Position pos = Position::from_fen(c.fen); + uint64_t got = perft(pos, c.depth); + bool ok = (got == c.expected); + std::printf("%-14s %14llu expected %14llu %s\n", + c.name, (unsigned long long)got, (unsigned long long)c.expected, + ok ? "OK" : "FAIL"); + if (!ok) ++fails; + } + + std::printf("\n%s\n", fails ? "*** PERFT FAILED ***" : "ALL PERFT PASSED"); + + // Zobrist key + FEN round-trip consistency. + const char* startfen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"; + const char* kiwifen = "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1"; + Position a = Position::from_fen(startfen); + Position b = Position::from_fen(kiwifen); + uint64_t km = verify_keys(a, 4) + verify_keys(b, 3); + std::printf("key/fen mismatches: %llu %s\n", (unsigned long long)km, + km == 0 ? "OK" : "FAIL"); + if (km) ++fails; + + return fails ? 1 : 0; +}