Desync Detection and Recovery¶
Everything up to this point assumes the gantry does what it is told. It does not, always. A drive skips steps, a piece is dropped in transit, a magnet releases early, or a human leans on the board and knocks a knight over. After any of these the physical board and the engine’s position have diverged, and the system has to decide what that means.
What the sensors can and cannot tell you¶
The scan returns occupancy only — a 64-bit bitboard of which squares have a piece on them. It carries no colour and no identity. Two facts follow immediately:
A swap of two same-colour pieces is undetectable by scanning. Identity is tracked in software (Ch. Piece Identity and Physical Setup), never sensed.
Divergence is discovered as a set difference: squares the engine expects to be occupied that are empty (missing), and squares it expects empty that are occupied (extra).
That difference, plus knowledge of which move was just attempted, is enough to distinguish the common failure modes from each other — which is the entire basis of the recovery logic.
The decision function¶
kirin/src/app/board_recovery.h is a pure function. It takes the engine’s expected occupancy, the debounced scan, the move just reproduced, a retry count and the authoritative ply count; it returns one action.
enum class RecoveryAction {
Proceed, // board matches expected -- continue play
RetryDrive, // last move looks incomplete -- re-run the drive
PromptOperator, // a re-drive can't fix this -- halt and ask a human
Resync, // this board is behind authority -- re-derive from a move index
};
The classification logic is the interesting part:
Action |
When |
|---|---|
|
expected and actual occupancy agree |
|
divergence is confined to {from, to} of the move just driven, and retries remain — the piece did not land, so driving it again is likely to work |
|
divergence touches squares unrelated to the last move (a bump), or a stray extra piece appeared, or the retry budget is spent — no re-drive can fix any of these |
|
the authority’s ply count exceeds this board’s, meaning moves were missed; the protocol gap dominates whatever the physical diff says |
The Resync precedence is worth dwelling on. If this board is behind the authoritative move list, the local scan may look perfectly clean — it is a consistent picture of a stale position. Trusting the physical diff there would conclude everything is fine and continue diverging. The ply comparison is checked first for exactly that reason.
D-35 — Desync recovery is a pure decision function, separated from the physical integration that acts on it
Context Recovery is the part of networked play most likely to decide whether the product feels magical or flaky, and it is also the part hardest to test: reproducing a dropped piece on demand requires a gantry, a wired sensor board and a lot of patience.
Decision Split it. planRecovery(RecoveryInputs) $\rightarrow$ RecoveryPlan is pure, hardware-free and network-free, deciding what should happen from scripted bitboards. A separate integration layer in the game loop and hardware backend actually re-drives, raises the operator overlay, or requests a resync.
Rejected Implementing recovery inline in the game loop, where the state it needs is already to hand. That would have made the decision logic reachable only with real hardware attached, and the decision logic is where the subtle mistakes live — the Resync-beats-clean-scan precedence above is a bug you would never find by driving a gantry.
Consequences The classification is covered by 29 assertions off scripted inputs — clean match, dropped-in-transit, slid-back-to-source, retry exhaustion, off-move bump, stray extra, resync on ply gap even when the scan looks locally clean, unknown last move, and clean capture — with no gantry, sensors or network. What remains genuinely hardware-gated is physical acceptance: that a retry actually re-seats a real piece, and that a resync re-drives cleanly.
Acting on the plan¶
GameSession::reconcileBoard() feeds expected-versus-scanned occupancy into planRecovery and acts on the result. RetryDrive re-runs the gantry drive and re-scans; PromptOperator and Resync halt into a BoardMismatch state which the touch UI surfaces as an overlay naming the exact squares to fix.
The backend hooks that make this possible — readBoardOccupancy() and redriveLastMove() — have deliberate no-op defaults, so a simulation backend returns false, the move is trusted, and behaviour without sensors is unchanged. A board with no sensor hardware does not spuriously enter recovery.
Note
The Resync branch currently halts as an operator prompt. The full networked round-trip — requesting the authoritative move list from the session Durable Object and re-deriving the position from a given ply — is the one piece of recovery not yet built, and is tracked in docs/BACKLOG.md rather than here.