Test support & emulator¶
The BoardKitTestSupport library and the boardkit-emulator executable make
it possible to develop and test board integrations without physical hardware.
ReplayTransport¶
A deterministic, synchronous harness that replays a scripted sequence of byte
chunks and lifecycle events through a real BoardAdapter.
Use ReplayTransport for adapter framing regressions against captured byte
streams. For session-level tests that bypass byte decoding, use
SimulatedBoard.
import BoardKitTestSupport
import ChessnutAdapter
// Build a script from raw BLE frames.
var adapter = ChessnutAdapter()
let replay = ReplayTransport(adapter: adapter, script: [
.lifecycle(.connected),
.bytes(Data([0x01, 0x22, /* ... Chessnut initial-position frame */])),
])
let events = replay.runSync()
// events: [.connected, .identitySnapshot([Piece?]), .ready]
Script steps¶
public enum ReplayTransport<A: BoardAdapter>.Step: Sendable {
case bytes(Data) // push through adapter.feed(bytes:)
case lifecycle(BoardEvent) // inject directly into the output stream
case delay(TimeInterval) // recorded but not enforced in runSync()
}
Execution modes¶
runSync() — executes all steps and returns the accumulated event list.
Delay steps are skipped. Use for golden-fixture assertions.
runByStep() — returns [[BoardEvent]], one inner array per step. Use
when a test needs to assert the event set between step N and step N+1.
To assert delay values without blocking, inspect replay.recordedSteps — it
exposes the full script (including .delay steps) exactly as provided at init:
// Assert that a delay step was recorded between frames:
let steps = replay.recordedSteps
if case .delay(let t) = steps[1] {
XCTAssertGreaterThan(t, 0.1)
}
Conformance
ReplayTransport does not conform to BoardTransport. It intentionally
omits the BLE scan/connect surface that requires a platform import. It
replaces only the byte-injection path.
ReplayScript¶
A line-oriented text parser that converts .replay capture-log files into
[ReplayScript.Step] values for use with ReplayTransport.
let scriptText = try String(contentsOf: fixtureURL, encoding: .utf8)
let steps = try ReplayScript.parse(text: scriptText)
let replay = ReplayTransport(adapter: ChessnutAdapter(), parsedScript: steps)
let events = replay.runSync()
.replay file format¶
One directive per line. #-comment lines and blank lines are ignored.
| Line form | Meaning |
|---|---|
rx 01 22 00 FF |
Raw bytes (space-separated hex octets) through adapter.feed |
delay 250 |
Record a 250 ms pause (not enforced in runSync()) |
event connected |
Inject .connected |
event disconnected |
Inject .disconnected(error: nil) |
Generating a .replay file from a BLE capture¶
# Requires tshark (Wireshark CLI) and an Apple HCI log (.pcapng).
tshark -r capture.pcapng \
-Y 'btatt.opcode == 0x1b' \
-T fields -e btatt.value \
| sed 's/../& /g;s/ $//' \
| sed 's/^/rx /'
# Paste output (rx lines) into a new .replay file. Tests/Fixtures/ does not
# exist yet — create it with your first fixture and load it via a
# #filePath-relative path (the test target declares no SwiftPM resources),
# or follow the existing Captures/ convention.
Before committing, reduce the capture to the protocol bytes required by the test and remove device addresses and UUIDs, serial numbers, names, pairing material, local paths, account data, and unrelated traffic. Do not commit the raw PacketLogger, btsnoop, pcap, phone, or application log.
Parse errors¶
ReplayScript.parse(text:) throws ReplayScript.ParseError on the first
malformed or unrecognised line:
public enum ReplayScript.ParseError: Error {
case malformedLine(lineNumber: Int, text: String, reason: String)
case unknownDirective(lineNumber: Int, text: String)
}
SimulatedBoard¶
A virtual chess board (actor) that applies ChessCore moves to a position and
emits the corresponding physical sensor events. Bypasses byte decoding — tests
session logic directly.
import BoardKitTestSupport
let sim = SimulatedBoard(
position: .initial(),
capabilities: [.occupancySensing, .pieceIdentity]
)
// Apply a move and get the physical lift/place events.
let events = try await sim.executeMove(uci: "e2e4")
// [squareSensed("e2", isLift: true, piece: Piece(type: .pawn, color: .white)),
// squareSensed("e4", isLift: false, piece: Piece(type: .pawn, color: .white))]
// Emit a full snapshot.
let snapshot = await sim.boardSnapshot()
// .identitySnapshot([Piece?]) — because .pieceIdentity is set
// Reset to a custom position.
await sim.reset(to: endgamePosition)
Event sequences by move type¶
| Move type | Events emitted (in order) |
|---|---|
| Simple (e2e4) | lift(e2, piece), place(e4, piece) |
| Normal capture | lift(from, mover), lift(to, captured), place(to, mover) |
| En passant (e5d6) | lift(e5, pawn), lift(d5, captured), place(d6, pawn) |
| Castling (e1g1) | lift(e1, K), lift(h1, R), place(f1, R), place(g1, K) |
| Promotion (e7e8q) | lift(e7, pawn), place(e8, queen) |
When capabilities do not include .pieceIdentity, all piece fields are nil.
boardkit-emulator¶
A macOS CLI executable that advertises over real Bluetooth as a physical chess board. An app on a real iPhone or iPad connects to it exactly as it would to physical hardware.
All CoreBluetooth code is inside the executable target, guarded by
#if os(macOS) && canImport(CoreBluetooth) — the library targets remain
platform-free.
Supported personalities (board emulation profiles):
SquareOffPersonality— Square Off Pro protocolChessnutPersonality— Chessnut Air protocolPegasusPersonality— DGT Pegasus protocolMillenniumPersonality— Millennium protocolCertaboPersonality— Certabo protocolChessUpPersonality— ChessUp gen-1 protocol
Build and run:
swift build -c release --product boardkit-emulator
# Select the board kind as the first positional argument:
.build/release/boardkit-emulator chessnut
# Play a specific PGN file (main line only):
.build/release/boardkit-emulator chessnut --pgn game.pgn
# Dry-run (no BLE, print frames and exit after N plies):
.build/release/boardkit-emulator chessnut --dry-run 8
# Other personality tokens: squareoff | pegasus | millennium | certabo | chessup
.build/release/boardkit-emulator millennium --chaos clumsy --seed 42
The board kind is selected via a positional token (squareoff, chessnut,
pegasus, millennium, certabo, or chessup) — there is no --personality
flag. Game input comes from --pgn <file> or a seeded random game; there is
no --moves flag. Run boardkit-emulator --help to see the full option list.
The emulator personalities reuse the same host-side codec structs from the adapter targets in the opposite direction — encoding the board's outbound frames using the same tables the adapter uses to decode them.
Chaos / fallible-human simulation¶
ChaosEngine (in BoardKitTestSupport) transforms the clean sensor event
sequence for one chess move into a perturbed stream that models how a real
human actually handles pieces on a sensor board: j'adoube adjusts, captures
executed in either order, lift-and-put-back second thoughts, slow two-phase
castles, pieces slid across intermediate squares, sensor chatter, knocked
neighbours, promotion piece-swaps, and mid-move stalls.
The same engine is shared between the boardkit-emulator tool and consumer
unit tests, so CI and the live radio path exercise identical perturbation logic.
Determinism contract¶
ChaosEngine.perturb is a pure function of (context, profile, rng state).
No Date, no uncontrolled randomness. Pass the same SeededRNG seed and you
get byte-identical perturbation streams on every run.
ChaosProfile presets¶
| Profile | Characteristics |
|---|---|
.clean |
No perturbation; steady human pacing |
.casual |
Occasional j'adoube, captures sometimes captured-piece-first, human-speed castles |
.clumsy |
Same tolerated patterns as .casual at much higher rates |
.hostile |
Everything in .clumsy plus dragged-piece blips and knocked neighbours (adversarial) |
Profiles .clean, .casual, and .clumsy draw only tolerated patterns:
the OccupancyMoveInference and BoardExecutionGate kernels must resolve and
execute 100% of streams from these profiles. .hostile adds adversarial
patterns that may legitimately deviate the gate — the survival corpus asserts
the outcome distribution rather than perfection.
Usage¶
import BoardKitTestSupport
// Build a context from the clean SimulatedBoard events for one move.
let sim = SimulatedBoard(
position: .initial(),
capabilities: [.occupancySensing, .pieceIdentity]
)
let cleanEvents = try await sim.executeMove(uci: "e2e4")
let move = /* ChessCore.Move for e2e4 */
let context = ChaosMoveContext(
move: move,
positionBefore: .initial(),
cleanEvents: cleanEvents
)
// Perturb with a seeded RNG for reproducibility.
var rng = SeededRNG(seed: 0xDEAD_BEEF)
let engine = ChaosEngine(profile: .casual)
let result = engine.perturb(context, rng: &rng)
// result.events: [ChaosMoveEvent] — feed into your inference under test.
// result.appliedPatterns: [ChaosPatternID] — log which transforms fired.
for event in result.events {
let feedback = inference.handle(square: event.square, isLift: event.isLift)
// … assert feedback
}
ChaosPatternID¶
Every transform the engine can apply is identified by a ChaosPatternID case
(e.g. .adjustInPlace, .captureOrderSwap, .knockedNeighbor). The
appliedPatterns array in ChaosPerturbation lists exactly which patterns
fired for a given call, letting tests classify and assert outcome distributions
per pattern type.
SeededRNG¶
SeededRNG is a simple xorshift64 generator exposed publicly so consumer tests
can produce reproducible perturbation streams independently of the engine. Pass
an explicit seed to SeededRNG(seed:) and the perturbation output is
byte-identical across platforms and Swift versions.