Engine Protocol & UCI Output¶
ChessCore defines an engine abstraction that is independent of any concrete
engine, and it parses generic UCI info and bestmove output.
Parsing UCI output¶
UCIOutputParser converts engine output lines into a structured UCIInfo.
The parser conforms to the UCI protocol and does not depend on any
Stockfish-specific behavior.
public struct UCIInfo: Sendable, Equatable {
public var depth: Int?
public var multipv: Int?
public var scoreCp: Int?
public var mateIn: Int?
public var nps: Int?
public var pv: [String] // UCI moves
// Computed API
public var score: Score? // nil when no score was reported
public var multiPV: Int // multipv ?? 1
public var centipawns: Int? // mate maps to ±100_000 (engine-POV)
public var bestMoveUCI: String? // pv.first
public var displayText: String? // "+1.3", "M5", "-M3"
// White-POV conversion
public func whitePovCentipawns(sideToMoveIsWhite: Bool) -> Int?
public static func whitePovCp(_ cp: Int, sideToMoveIsWhite: Bool) -> Int
public static func whitePovMate(_ mate: Int, sideToMoveIsWhite: Bool) -> Int
public enum Score: Sendable, Equatable {
case cp(Int) // centipawns
case mate(Int) // mate in N
public var centipawns: Int // mate maps to ±100_000
public var displayText: String // "+1.5", "M3", "-M2"
public var negated: Score
}
}
if let info = UCIOutputParser.parseInfo(
"info depth 20 score cp 31 multipv 1 pv e2e4 e7e5 g1f3"
) {
if let score = info.score {
print(info.depth, score.displayText) // Optional(20) "+0.3"
}
// Render the PV in SAN:
let san = UCIParser.convertPVToSAN(info.pv, from: position)
}
let best = UCIOutputParser.parseBestMove("bestmove e2e4 ponder e7e5") // "e2e4"
// Distil a MultiPV batch. The result maps each MultiPV index (1 = best)
// to the deepest UCIInfo seen for that rank. Equal-depth updates prefer the
// latest line; lines without a multipv field count as rank 1.
let byRank: [Int: UCIInfo] = UCIOutputParser.bestInfoByRank(collectedInfos)
// parse(_:) is a concise alias for parseInfo(_:):
let info2 = UCIOutputParser.parse(line)
Bounded scores
parseInfo returns nil for lines carrying lowerbound / upperbound —
those are partial results from an unresolved aspiration window and should not
drive the eval bar or move arrows.
White-POV scores¶
UCI engines report scores from the perspective of the side to move. To drive an eval bar anchored to White you need to flip the sign for Black's lines:
// Using the instance helper:
if let wpCp = info.whitePovCentipawns(
sideToMoveIsWhite: position.activeColor == .white
) {
updateEvaluationBar(centipawns: wpCp)
}
// Or the static helpers for a raw value you already have:
let flipped = UCIInfo.whitePovCp(rawCp, sideToMoveIsWhite: false) // Black to move
let mateDist = UCIInfo.whitePovMate(rawMate, sideToMoveIsWhite: false)
// displayText is nil on a scoreless info line:
if let text = info.displayText {
print(text)
}
The ChessEngine protocol¶
ChessEngine is the abstraction that decouples ChessCore from any specific
engine implementation. Conform a type to it to drive SwiftStockfish, a neural
engine, or a mock:
public protocol ChessEngine: AnyObject {
var name: String { get }
var isReady: Bool { get }
func analyze(position: Position, topK: Int) async throws -> EngineAnalysis
}
EngineAnalysis¶
public struct EngineAnalysis: Sendable {
public let topMoves: [ScoredMove]
public let evaluation: Evaluation?
public let depth: Int?
public struct ScoredMove: Identifiable, Sendable {
public var id: String { notation } // SAN is stable across depth updates
public let move: Move
public let notation: String
public let probability: Double? // policy networks only; nil for search engines
public let score: UCIInfo.Score?
public let pvLine: [String]
}
public enum Evaluation: Equatable, Sendable {
case winDrawLoss(win: Double, draw: Double, loss: Double)
case centipawns(Int)
case mate(Int)
}
}
probability is for engines that produce one — a policy network ranking moves
by how likely they are to be played. A search engine leaves it nil and ranks
by score; there is no invented value to tell apart from a genuine zero.
Evaluation carries the engine's assessment in whichever form the engine
natively produces. Formatting it — decimal places, mate spelling, whether a
win/draw/loss split becomes a percentage — is the caller's.
ScoredMove.id is the SAN notation — it is unique within one position's move
list and stable across depth updates, so list rows keep a stable identity as the
engine publishes deeper results.
Engine errors¶
public enum EngineError: LocalizedError {
case engineUnavailable // no engine loaded, or not ready
case invalidPosition // position could not be encoded for the engine
case analysisFailed(String)
case noLegalMoves
}
Driving a live engine (UCIEngine)¶
UCIEngine is the low-level transport seam. It carries no chess logic — just a
command channel and an ordered output stream. Both SwiftStockfish.StockfishEngine
and SwiftReckless.RecklessEngine conform to it; the consuming app declares the
conformances (each links its own engine package).
public protocol UCIEngine: AnyObject, Sendable {
/// Ordered output lines from the engine, without trailing newlines.
var output: AsyncStream<String> { get }
/// Send a raw UCI command (no trailing newline needed).
func send(_ command: String)
// Convenience shorthands:
func uci() // send("uci")
func isReady() // send("isready")
func quit() // send("quit")
}
A typical adapter loop reads the output stream, pipes each line through
UCIOutputParser.parseInfo and UCIOutputParser.parseBestMove, and accumulates
UCIInfo values to later convert into an EngineAnalysis via the
ChessEngine-level analyze(position:topK:) call:
for await line in engine.output {
if let info = UCIOutputParser.parseInfo(line) {
collectedInfos.append(info)
} else if let bestMove = UCIOutputParser.parseBestMove(line) {
let byRank = UCIOutputParser.bestInfoByRank(collectedInfos)
// build EngineAnalysis from byRank …
break
}
}
One engine at a time
Some in-process engine implementations (e.g. StockfishEngine) capture
process-global stdio while live. Only one UCIEngine instance should be
live at a time.
Wiring a real engine¶
A typical ChessEngine adapter drives SwiftStockfish,
sends position.consistentFEN, collects info lines via
UCIOutputParser.parseInfo, and constructs ScoredMove values with
UCIParser.uciToMove and MoveGenerator.algebraicNotation. See the
Usage Examples for a complete implementation.