CARA integrates UCI (Universal Chess Interface) chess engines for position analysis, game evaluation, and manual analysis. The implementation follows a layered architecture with clear separation between UCI protocol communication, engine-specific logic, and configuration management.
The engine implementation is organized into three main layers:
-
UCI Communication Layer (UCICommunicationService)
- Low-level UCI protocol communication
- Process spawning and management
- Command sending and response reading
- Debug logging of all UCI interactions
- Automatic filtering of zero-value parameters (depth=0, movetime=0)
-
Specialized Engine Services
- EvaluationEngineService: Continuous position evaluation for evaluation bar
- GameAnalysisEngineService: Batch analysis of all moves in a game
- ManualAnalysisEngineService: Continuous analysis with MultiPV support
-
Configuration Management
- EngineParametersService: Persistence of engine-specific parameters
- EngineConfigurationService: Validation and recommended defaults
- EngineValidationService: Engine discovery and option parsing
All engine operations run in separate QThread instances to keep the UI responsive:
- EvaluationEngineThread: Runs continuous evaluation for the evaluation bar
- GameAnalysisEngineThread: Persistent thread for analyzing multiple positions
- ManualAnalysisEngineThread: Runs continuous analysis with MultiPV support
Each thread manages its own UCICommunicationService instance and handles engine lifecycle (spawn, initialize, search, stop, quit).
The UCICommunicationService provides a unified interface for UCI protocol communication:
-
Process Management
spawn_process(): Spawns engine process as subprocess- Uses binary mode (
text=False) to avoid Windows text mode blocking issues - Uses unbuffered mode (
bufsize=0) for immediate data availability - Initializes binary read buffer for manual line splitting
- Uses binary mode (
is_process_alive(): Checks if process is runningget_process_pid(): Returns process PID for debuggingcleanup(): Terminates process and cleans up resources
-
UCI Protocol
initialize_uci(): Sends "uci" command and waits for "uciok"set_option(): Sets engine options (Threads, Hash, etc.)set_position(): Sets position using FEN notationstart_search(): Starts search with depth/movetime parametersstop_search(): Sends "stop" commandquit_engine(): Sends "quit" commandread_line(timeout): Reads a line from engine stdout- Uses binary mode with manual line buffering
- Reads chunks from stdout and buffers them
- Splits on newline characters (
\n) to extract complete lines - Decodes bytes to UTF-8 strings
- Implements fast path for already-buffered lines (zero latency)
- Non-blocking read with timeout support
- Required timeout parameter ensures responsive behavior
-
Search Command Logic
start_search(depth=0, movetime=0, **kwargs)- Automatically omits parameters with value 0
- If both depth and movetime are 0, sends "go infinite"
- Otherwise builds command: "go depth X movetime Y" (only non-zero params)
-
Debug Support
- Module-level debug flags for outbound/inbound/lifecycle events
- Timestamped console output with thread IDs
- Identifier strings for tracking different engine instances
The UCI layer automatically filters out zero-value parameters:
depth=0: Not sent to engine (unlimited depth)movetime=0: Not sent to engine (unlimited time)- Both 0: Sends "go infinite" instead
- This ensures engines only receive meaningful constraints
Engine parameters are stored in engine_parameters.json. The file location is determined by resolve_data_file_path() (same logic as user_settings.json):
- Portable mode: If app root has write access, file is stored in app root directory
- User data directory: If app root is not writable (or macOS app bundle), file is stored in platform-specific user data directory:
- Windows:
%APPDATA%\CARA\ - macOS:
~/Library/Application Support/CARA/ - Linux:
~/.local/share/CARA/
- Windows:
File structure:
{
"engine_path": {
"options": [...], // Parsed engine options from UCI
"tasks": {
"evaluation": { "threads": 6, "depth": 40, "movetime": 0, ... },
"game_analysis": { "threads": 8, "depth": 0, "movetime": 1000, ... },
"manual_analysis": { "threads": 6, "depth": 0, "movetime": 0, ... }
}
}
}-
Common Parameters (per task):
threads: Number of CPU threads (1-512)depth: Maximum search depth (0 = unlimited)movetime: Maximum time per move in milliseconds (0 = unlimited)
-
Engine-Specific Options:
- All other options parsed from engine (Hash, Ponder, MultiPV, etc.)
- Stored per task for task-specific configuration
Singleton service for managing engine parameter persistence:
-
Singleton Pattern
- Single instance across application
- Thread-safe file operations using locks
- Cached parameters to avoid repeated file I/O
-
Methods:
load(): Loads parameters from engine_parameters.jsonsave(): Saves parameters to filereload(): Forces reload from disk (for external file changes)get_task_parameters(engine_path, task): Gets parameters for specific taskset_task_parameters(engine_path, task, parameters): Sets parametersset_all_task_parameters(engine_path, tasks_parameters): Sets all tasks at onceremove_engine_options(engine_path): Removes engine configuration
-
Static Helper:
get_task_parameters_for_engine(engine_path, task, config)- Loads from engine_parameters.json with fallback to config.json defaults
- Returns recommended defaults if engine not configured
Service for managing recommended defaults and validation:
-
Recommended Defaults (from config.json):
- Evaluation: threads=6, depth=0 (infinite), movetime=0
- Game Analysis: threads=8, depth=0, movetime=1000
- Manual Analysis: threads=6, depth=0, movetime=0
-
Validation Rules:
- Evaluation: depth and movetime are ignored (WARNING if set) - runs on infinite analysis
- Game Analysis: movetime required (ERROR if 0), WARNING if both depth and movetime set
- Manual Analysis: depth and movetime should be 0 (ERROR if >0)
-
ValidationResult:
- Contains list of ValidationIssue objects
- Each issue has severity (ERROR, WARNING, INFO), parameter, message
- UI dialog shows issues and allows "Save Anyway" or "Cancel"
EngineValidationService handles engine discovery and option parsing:
-
validate_engine(engine_path, debug_callback=None, save_to_file=True):
- Spawns engine and sends "uci" command
- Parses "id name", "id author", and "option" lines
- Returns
EngineValidationResultcontaining:- Validation status (is_valid, error_message)
- Engine information (name, author, version)
- Parsed options list (stored in
optionsfield)
- Stores parsed options to engine_parameters.json if save_to_file=True
- Optional debug_callback for custom debug message handling
- Note: During engine addition, called with
save_to_file=Falseto avoid premature persistence
-
refresh_engine_options(engine_path, debug_callback=None, save_to_file=True):
- Re-connects to engine and re-parses options
- Useful for refreshing defaults or when options may have changed
- Can update UI without saving to file (save_to_file=False)
- Returns tuple of (success: bool, options: List[Dict[str, Any]])
- Optional debug_callback for custom debug message handling
-
Option Parsing:
- Parses UCI option strings: "option name Threads type spin default 1 min 1 max 1024"
- Extracts: name, type (spin/check/combo/string/button), default, min, max, var
- Stores as structured JSON for UI generation
EngineDialogController implements option caching to optimize the engine addition flow:
_cached_options: Dictionary mapping engine paths to parsed options lists- Caching Flow:
EngineValidationThreadstoresEngineValidationResultin_validation_result_on_validation_complete()extractsresult.optionsand caches in_cached_options[engine_path]prepare_engine_for_addition()retrieves from cache (or falls back torefresh_engine_options()if not cached)
- Benefits: Avoids redundant engine connections and option parsing when adding validated engines
Provides continuous position evaluation for the evaluation bar:
-
Purpose: Real-time evaluation display as user navigates through game
-
Thread: EvaluationEngineThread (one per engine instance)
-
Configuration: Reads depth and movetime from engine_parameters.json (but both are ignored)
-
Behavior:
- Always uses infinite search (depth=0, movetime=0) regardless of configured values
- Continuously updates evaluation as engine analyzes
- Stops when position changes or evaluation is stopped
- Position updates send: stop → position fen → go infinite
- Never restarts engine on position changes (only updates position)
-
UCI Protocol:
- Initial setup: uci → setoption (all parameters) → isready → position fen → go infinite
- Position update: stop → position fen → go infinite
- Uses depth=0 to send "go infinite" command (engine analyzes until stopped)
- Does not handle "bestmove" to restart search (infinite search never completes)
-
Lifecycle:
start_evaluation(engine_path, fen): Creates thread and starts evaluationupdate_position(fen): Updates position without restarting threadstop_evaluation(): Stops and cleans up thread (non-blocking, cleanup happens asynchronously)suspend_evaluation(): Suspends search but keeps engine process alive for reuseresume(fen): Resumes evaluation with new position (requires engine process still alive)
Analyzes all moves in a game sequentially:
-
Purpose: Batch analysis of entire game for move quality assessment
-
Thread: GameAnalysisEngineThread (persistent, reused for all positions)
-
Configuration: Reads depth and movetime from engine_parameters.json
-
Behavior:
- Uses persistent thread to avoid engine restart overhead
- Analyzes each position with configured depth/movetime
- Respects configured depth and movetime (even if not recommended)
-
Lifecycle:
start_engine(): Creates and starts persistent threadanalyze_position(fen, move_number): Queues position for analysis- Thread processes queue sequentially
stop_analysis(): Stops current analysis and clears queueshutdown(): Shuts down engine process and thread (non-blocking, cleanup happens asynchronously)cleanup(): Calls shutdown to clean up resources
Provides continuous analysis with MultiPV support:
-
Purpose: Manual position analysis with multiple candidate moves
-
Thread: ManualAnalysisEngineThread (one per engine instance)
-
Configuration: Reads depth and movetime from engine_parameters.json
-
Behavior:
- Supports MultiPV for showing multiple analysis lines
- Continuously analyzes current position
- Respects configured depth and movetime (even if not recommended)
- Throttles UI updates (default: 100ms interval)
- Implements race condition prevention for position and MultiPV updates
-
Bestmove Handling:
- Uses infinite search (
go infinite) when depth=0 and movetime=0 - Ignores
bestmovemessages (similar to evaluation service) bestmovemessages only occur afterstopcommand or when movetime expires- Position updates handle restarting the search when needed
- No automatic restart on
bestmove(infinite search never completes naturally)
- Uses infinite search (
-
Race Condition Prevention:
- Uses
_search_just_startedflag and_search_start_timetimestamp for tracking search state - Flags are cleared after search is established (depth >= 1 or 2+ info lines after 100ms)
- Prevents handling stale messages from previous searches
- Uses
-
Update Throttling:
- Engine thread throttles
line_updatesignal emissions usingupdate_interval_ms(default: 100ms) - Updates are only emitted if at least
update_interval_msmilliseconds have passed since last update for that MultiPV line - Pending updates are stored in
_pending_updatesand the latest update is emitted when throttling period expires - Prevents excessive signal emissions when engines send many info lines rapidly (some engines can send 50-100+ info lines per second)
- Without throttling, each info line would trigger: signal emission → model update → controller processing (PV parsing, BoardModel updates, trajectory parsing) → board redraws
- The view also has its own debounce timer, but controller work (including board updates) happens on every signal
- Configurable via
config.jsonunderui.panels.detail.manual_analysis.update_interval_ms
- Engine thread throttles
-
Lifecycle:
start_analysis(engine_path, fen, multipv): Creates thread and starts analysisupdate_position(fen): Updates position without restarting threadset_multipv(multipv): Changes number of analysis linesstop_analysis(keep_engine_alive=False): Stops and cleans up thread- If
keep_engine_alive=True: Stops analysis but keeps engine process alive for reuse by other services - If
keep_engine_alive=False: Normal shutdown, engine process is terminated - Shutdown is non-blocking (cleanup happens asynchronously in thread's
finallyblock)
- If
When a user adds an engine:
- User selects engine executable in Add Engine dialog
EngineValidationService.validate_engine()is called withsave_to_file=False- Validates engine is UCI-compliant
- Parses engine options
- Returns
EngineValidationResultcontaining options (stored in memory) - Does NOT save to engine_parameters.json yet
EngineValidationThreadstores the fullEngineValidationResult(including options) in_validation_resultEngineDialogController._on_validation_complete()caches options in_cached_optionsdict (keyed by engine path)- User clicks "Add Engine" button
EngineDialogController.prepare_engine_for_addition()is called:- Retrieves cached options from
_cached_options(or falls back torefresh_engine_options()withsave_to_file=Falseif not cached) - Loads
EngineParametersServiceonce - Updates
EngineParametersService.get_parameters()in memory for both options and task parameters (recommended defaults fromEngineConfigurationService) - Calls
EngineParametersService.save()once at the end to persist all changes
- Retrieves cached options from
- Engine is added to EngineModel
Key Points:
- Options are cached after validation to avoid re-parsing when adding the engine
- All persistence operations (options + task parameters) are combined into a single
save()call - This reduces redundant file I/O and ensures atomic updates
When a user configures engine parameters:
- User opens "Engine Configuration" dialog from engine menu
- Dialog loads current parameters from engine_parameters.json
- User modifies parameters in UI (common + engine-specific)
- User clicks "Save Changes"
EngineConfigurationService.validate_parameters()validates all tasks- If validation issues found, shows dialog with errors/warnings/info
- User can "Save Anyway" or "Cancel"
- If saved,
EngineParametersService.set_all_task_parameters()persists changes
When an engine is used for a task:
- Service/Controller calls
EngineParametersService.get_task_parameters_for_engine() - Service loads parameters from engine_parameters.json
- If not found, falls back to config.json recommended defaults
- Service passes parameters to engine thread constructor
- Thread passes depth and movetime to
UCICommunicationService.start_search() - UCI layer filters out zero values and sends appropriate "go" command
All engine services use non-blocking shutdown to keep the UI responsive:
-
Service shutdown methods (
stop_analysis(),stop_evaluation(),shutdown()):- Set flags to stop the thread (
running = False,_stop_requested = True) - Send
stop_search()command to engine - Disconnect signals to prevent pending updates
- Return immediately without waiting for thread completion
- Thread reference is set to
Noneimmediately
- Set flags to stop the thread (
-
Thread cleanup:
- Thread's
run()method checks stop flags and exits loop naturally - Thread's
finallyblock handles cleanup automatically:- Calls
uci.cleanup()which sendsquitand terminates process - Cleanup happens asynchronously in background thread
- No blocking waits on UI thread
- Calls
- Thread's
-
Engine reuse (
keep_engine_alive=True):- Manual analysis service supports
stop_analysis(keep_engine_alive=True) - Sets
_keep_engine_aliveflag in thread - Thread's
finallyblock skips cleanup when flag is set - Allows evaluation service to reuse the same engine process
- Used when switching between manual analysis and evaluation with same engine
- Manual analysis service supports
Common parameters (threads, depth, movetime) are applied as follows:
-
Threads:
- Set via
set_option("Threads", value)before search - Applied once during engine initialization
- Confirmed with isready/readyok after all options are set
- Set via
-
Depth:
- Passed to
start_search(depth=value) - UCI layer sends "go depth X" if value > 0
- Omitted if value is 0
- Passed to
-
Movetime:
- Passed to
start_search(movetime=value) - UCI layer sends "go movetime X" if value > 0
- Omitted if value is 0
- Passed to
Engine-specific options are applied during engine initialization:
- Set via
set_option(name, value)for each option - Applied after common parameters (Threads, MultiPV)
- All options set with
wait_for_ready=False - Single
confirm_ready()call after all options are set - This is more efficient than waiting for readyok after each option
EngineParametersService uses singleton pattern with thread safety:
- Class-level
_instancevariable Threading.Lockfor file operations- All
load()andsave()operations are locked - Ensures consistent state across multiple threads
Each engine thread has its own UCICommunicationService instance:
- No shared state between threads
- Each thread manages its own process
- Thread-safe signal/slot communication with UI
- Proper cleanup on thread termination
Module-level debug flags in UCICommunicationService:
_debug_outbound_enabled: Log all commands sent to engine_debug_inbound_enabled: Log all responses from engine_debug_lifecycle_enabled: Log engine lifecycle events (STARTED, STOPPED, QUIT, CRASHED)
Debug output includes:
- Timestamp with milliseconds
- Identifier string (e.g., "Evaluation", "GameAnalysis-EngineName")
- Thread ID (OS thread ID if available)
- Message content
Debug menu items (if show_debug_menu is enabled in config.json):
- "Debug UCI Outbound": Toggle outbound command logging
- "Debug UCI Inbound": Toggle inbound response logging
- "Debug UCI Lifecycle": Toggle lifecycle event logging
- Process spawn failures: Emitted via
error_occurredsignal - UCI initialization timeout: Emitted via
error_occurredsignal - Engine crashes: Logged via lifecycle debug, emitted via
error_occurredsignal - Process termination: Detected and handled gracefully
- Missing engine_parameters.json: Uses recommended defaults from config.json
- Corrupted JSON: Falls back to defaults, logs warning
- Invalid parameter values: Validated by EngineConfigurationService
- Validation issues: Shown to user in dialog before saving
- Always use recommended defaults when adding engines
- Validate parameters before saving
- Respect user overrides even if not recommended
- Never hardcode limits in specialized engine threads
- Reuse persistent threads when possible (GameAnalysisEngineThread)
- Clean up threads properly on shutdown
- Handle engine crashes gracefully
- Provide clear error messages to users
- Use EngineParametersService singleton for all parameter access
- Always provide fallback to config.json defaults
- Validate parameters before applying
- Persist changes only when user explicitly saves
-
engine_parameters.json: Location determined by
resolve_data_file_path()(same logic asuser_settings.json)- Portable mode: App root directory (if writable)
- User data directory: Platform-specific location if app root is not writable or macOS app bundle:
- Windows:
%APPDATA%\CARA\ - macOS:
~/Library/Application Support/CARA/ - Linux:
~/.local/share/CARA/
- Windows:
- Stores engine-specific parameters per task
- Created automatically when first engine is added
- Updated when user configures engine parameters
-
config.json:
app/config/config.json- Contains recommended defaults for each task
- Contains validation rules
- Contains UI styling for engine configuration dialog