The Application Layer¶
Everything described so far — the engine, the interpreter, the gantry, the sensors — is a library. This part covers the program that puts them in front of a person: kirin-gui, the fullscreen Qt 6/QML touch application that is the appliance’s primary interface, and the frontend-agnostic layer beneath it that actually runs a game.
That layer is a third static library, kirin_app (kirin/src/app/), sitting between kirin_engine and kirin_hardware on one side and the Qt code on the other. It holds the game loop, the clock, the personalities, the review and puzzle sessions, and the storage — everything that is neither chess nor wiring. It contains no Qt, no HTTP and no GPIO, so it runs in the hardware-free CTest suite exactly as the engine does.
One process¶
D-50 — The GUI links the engine and hardware libraries in-process — no IPC, no daemon
Context The touch UI has to start games, drive a gantry, read sensors, and render the engine’s view of the board. The obvious alternative shape for that is a background service owning the hardware with the UI talking to it over a socket.
Decision kirin_engine and kirin_hardware are static libraries, so kirin-gui links them and calls them directly. main.cpp calls the engine’s initAll() in its own process before the QGuiApplication exists.
Rejected A separate engine or hardware daemon with an IPC channel. It buys process isolation the appliance does not need — one board, one game, one operator — and pays for it in latency, in a second copy of the engine’s memory, and in a protocol that would have to be kept in step with the C++ API on both sides.
Consequences A crash anywhere takes the whole appliance down, which is why the OTA updater is deliberately not in this process (Chapter Over-the-Air Updates). It also means the engine’s global state is this process’s global state — see D-14. Comfortable on the Pi 5’s 4 GB: the single C++/QML process is the lightest option available.
GameSession: the loop, without a frontend¶
D-52 — Game orchestration is extracted out of main.cpp into a frontend-agnostic GameSession
Context The physical-game loop lived inline in the CLI’s runPhysicalMode, coupled to printf and stdin. No second frontend could use it without duplicating it.
Decision Move the loop into GameSession (kirin/src/app/game_session.h): a worker thread, a thread-safe command queue, and a listener interface it fans events out to. The CLI’s --simulate path and the GUI both drive the same object.
Rejected Leaving the loop in main.cpp and giving the GUI its own copy. Two loops diverge, and the physical loop is the most battle-tested code in the tree — the one place where a second implementation is least affordable.
Consequences This was the prerequisite for the entire GUI, and it is why the CLI still works: it remained a regression oracle throughout the refactor. Every later mode — review, puzzles, broadcast, networked play — is either a sibling session or a substitution inside this one, never a fork of it.
Commands, events, state¶
The session is driven by five commands, all safe to call from any thread: newGame(config), submitHumanMove(uci), takeback(), stop(), restart(mode), plus resetBoard() and shutdown(). Each is queued; the worker picks it up. Commands that must interrupt a game already in progress go through enqueueInterrupting, which raises the stop and wait-cancel flags under the same mutex that guards the queue, so the worker cannot observe a half-set interrupt.
Progress comes back through GameSessionListener, whose callbacks all fire on the worker thread:
onStateChanged(SessionState) onGameStarted(GameStartInfo)
onMovePlayed(MoveEvent) onHumanTurn(char sideToMove)
onGameOver(GameOverInfo) onBoardMismatch(BoardMismatchInfo)
onClockUpdate(ClockInfo) onTakeback(TakebackInfo)
onThinking(ThinkingInfo) onInfo(string) / onError(string)
The lifecycle is a small enum — Idle, Preparing, EngineThinking, HumanToMove, GameOver, BoardMismatch, Error — and every frontend renders off it rather than tracking its own idea of what is happening. It is also read outside the process: main.cpp mirrors the current state into /run/kirin-gui/lifecycle so the unprivileged OTA updater can tell whether a game is in progress without an IPC channel into the UI (Chapter Over-the-Air Updates), and the same signal inhibits display sleep (Chapter The Touch Interface).
Note
onThinking fires from inside the engine’s search, once per completed iterative-deepening iteration. A listener that does real work there slows the engine down. Copy and forward; do not compute.
Interrupting a blocking wait¶
On a physical board the worker spends most of a game blocked in waitForHumanMove — polling hall sensors with no timeout. Stop, restart, takeback and a flag fall all have to break that wait, so the backends take a cancel token (const std::atomic<bool>*) threaded down into GameController::waitForHumanMove.
A cancelled wait is ambiguous by construction, and the session resolves it with a second flag: the Interrupt enum distinguishes ending the game (stop/restart/shutdown, which set stopRequested_) from things that happen within a game — a takeback, or the flag watchdog firing. Whatever wakes from the wait consults interrupt_ to learn which it was.
The flag watchdog exists for the same reason: while a timed side is on move, nothing else is watching the clock, because the worker is either blocked in the sensor poll or inside the search. A small thread sleeps until the running side would run out and trips the same interrupt a takeback uses.
The two interfaces underneath¶
GameSession touches the rest of the system through exactly two interfaces. Both exist so the loop can be exercised with no hardware and no engine surprises.
IEngine¶
kirin/src/app/engine_interface.h is a ten-method view of an engine: apply a personality’s options, start a new game, play or parse a move, go() with a SearchLimits budget, analyze() a position, stop(), and report FEN, side to move and result. SearchLimits honours exactly one budget, checked in order: nodes, movetime, clock, depth. When the clock fields are set the engine budgets the move itself through its managed-time path (Chapter Time Management) — the path the time-management tuning was actually done against.
D-14 — There is one logical engine instance; LinkedEngine wraps its globals and access is serialized by the session worker
Context The Kirin engine keeps its search and evaluation state in globals. LinkedEngine (kirin/src/app/linked_engine.h) does not own an instance of that state — it wraps it.
Decision One LinkedEngine, reached only from the session’s worker thread, fully reset between games and on option changes (transposition table cleared, history and killers dropped).
Rejected Multiple concurrent engine instances — there is no such thing to instantiate; a second “instance” is the same globals under a different name.
Consequences This single fact propagates surprisingly far. It is why post-game analysis cannot run on a background thread (D-15); why review, puzzles and broadcast are modes sharing one engine rather than independent screens (Chapter Match Review); and why a pinned-binary opponent would have to be a subprocess rather than a second linked engine.
The UciSubprocessEngine path — an IEngine that speaks UCI over pipes to a pinned historical build, so an old release can be an opponent — is designed and reserved (docs/gui-docs/PLAN.md §3.2) but not implemented. The personality schema already carries the field that would select it; today only "builtin" opponents run.
D-15 — Engine-consuming work outside the game loop runs on the session worker thread, not a background thread
Context Post-game analysis (Chapter Match Review) and the LED hint both need the engine while the UI stays responsive. The obvious answer is a background thread.
Decision They run on the session worker thread, at a moment the engine is provably idle — for analysis, the instant the game ends, driven from the recorder’s sink. GameSession already serializes everything on that thread, so a new game requested mid-analysis simply queues behind it. Analysis is cancellable between plies so a player who starts a new game never waits.
Rejected A separate worker thread. Given D-14 there is no private engine to give it: a background analysis would trample the search state of whatever is being played. The loop also already blocks in waitForLegalMove, so the worker is not a scarce resource.
Consequences Worth recording because both plans got this wrong first and corrected it — the review plan and the LED plan each specified a background thread, and the code refused. The safety comes from the threading model that already exists rather than a lock bolted onto it.
IBoardBackend¶
kirin/src/app/board_backend.h is the physical board as the loop sees it: prepare a new game, execute an engine move, wait for a human move, verify board state, sync the tracker. Two implementations exist.
HardwareBackend— a thin adapter overGameController, so the gantry, the scanner andPieceTrackerare all reached through the interfaces described in Parts IV and V.SimBackend— no gantry and no sensors; “human” moves are injected from the UI. It runs a realGameControllerin dry-run, so path planning and blocker parking genuinely happen off-hardware. That is what makes the routing and G-code telemetry streams (Chapter The Live Board Feed and Telemetry) debuggable at a desk.
Optional capabilities are default-implemented rather than pure, so a backend opts in to what it can do: prepareNewGameFromSnapshot (the identity-targeted setup behind “play from here” and odds games), undoMove, executeSetup, resetToStart, readBoardOccupancy and redriveLastMove (desync recovery, Chapter Desync Detection and Recovery). A backend that cannot do one returns false, and the loop degrades to its pre-existing behaviour — in simulation, “no occupancy available” means the move is simply trusted.
D-53 — The backend is chosen at startup by capability detection, in a Qt-free factory, with environment overrides
Context The same binary has to run on a desk with no hardware and on a board with a gantry, and the choice cannot be a compile-time flag if one image is to ship to every unit.
Decision selectBoardBackend (kirin/src/app/backend_factory.h) picks HardwareBackend when a gantry serial port is present and SimBackend otherwise, probing the same candidate list the CLI’s --boot path uses (/dev/ttyACM*, /dev/ttyUSB*). KIRIN_BACKEND=sim|hardware|auto and KIRIN_GANTRY_PORT override it. The bridge exposes the outcome to QML as hardwareBackend and backendDescription.
Rejected Hardcoding SimBackend (which is what the GUI did through Phase 4) and a compile-time switch, which would fork the shipped artifact.
Consequences The decision lives outside the Qt layer, so it is unit-tested headlessly rather than being a property of the running UI. It was the keystone Phase 5 waited on: with it, the same SessionBridge drives simulation and hardware unchanged.
The Qt layer: bridges¶
D-54 — QML stays logic-free; all logic lives in C++ bridges
Context QML is expressive enough to hold real behaviour, and there is constant pressure to put “just this one rule” in a binding.
Decision Every Qt-aware class is a bridge: a QObject that owns a piece of the Qt-free core, forwards QML calls into it, and marshals its events back as Q_PROPERTY changes and signals. QML renders off properties and calls invokables; it decides nothing.
Rejected Business logic in QML. The headless test suite can drive a bridge but not a binding, so logic that migrates into QML leaves the tested region — and this project’s rule is that everything is testable without hardware.
Consequences The screens are thin enough that the QML files read as layout, and the interesting behaviour is reviewable as C++. It also keeps the door open for a second frontend: the bridges are the only Qt in the system.
The bridge inventory¶
kirin-gui/src/main.cpp constructs each bridge once and installs it as a QML context property. There is no bridge registry and no dependency injection framework — the wiring is 60 lines of explicit construction, and the sharing it expresses is the architecture.
Context property |
Class |
Owns / does |
|---|---|---|
|
|
The engine, the board backend, the LED chain, |
|
|
The archive and |
|
|
|
|
|
Tournament mirroring (Ch. Broadcast Mirroring). |
|
|
Account-scoped Lichess HTTP + token (Ch. The Lichess Integration). |
|
|
Device provisioning and the account link handshake (Ch. Accounts, Devices and Ratings). |
|
|
Wi-Fi via |
|
|
One persisted bit: has first-run setup completed (Ch. The First-Run Wizard). |
|
|
Observes and pokes the OTA updater (Ch. Over-the-Air Updates). |
|
|
Backlight blanking and wake (Ch. The Touch Interface). |
Three of these — review, puzzles, broadcast — are handed the session’s engine, backend and LED controller by reference rather than building their own. That is D-14 made visible: play, review, puzzles and broadcast are four modes of one appliance, and two of them running at once would be two things driving one gantry.
Warning
The display-sleep bridge is exposed as displaySleep, not display. display is an enum property on every AbstractButton, and a context property of that name is shadowed inside any button’s handlers — a binding that silently resolves to the wrong object.
Threading across the seam¶
The rule is one sentence: listener callbacks fire on the worker thread; every one hops to the UI thread via a queued invokeMethod before touching bridge state or emitting a signal. QML therefore only ever observes consistent UI-thread data, and no QML binding can be re-entered from a worker.
ReviewBridge is the deliberate exception: review has no worker thread at all. Stepping, jumping and opening a game all run synchronously on the UI thread, which in simulation is instant and on hardware drives the gantry inline. That is acceptable for stepping through a finished game and is the same trade the CLI makes; it is not acceptable for live play, which is why live play has a worker.
GameSession::shutdown() blocks until the worker has exited, and that is a contract rather than an implementation detail: the listeners — including the bridge that registered itself — are destroyed immediately after it returns, so the worker must be provably unable to call into them.