Imported from zhaob1n/mirai (
AGENTS.md). Install upstream withnpx skills add zhaob1n/mirai. Copyright stays with the author.
AGENTS.md
Orientation for anyone — human or agent — picking up mirai cold. Read this file, then the
one document your task points at. Do not re-explore the tree; it is mapped for you.
mirai is a GTK4/libadwaita desktop application that drives KataGo for Go analysis, review and play, either as a local subprocess or over a network via a purpose-built protocol (MRP). Six crates, GPL-3.0-or-later.
The code is complete and reviewed, and it is still in development: there are no external users yet, so compatibility with older builds is not a constraint. Treat it as a working system to extend carefully, not a draft to rewrite.
Core principles
Do not modify this section without explicit approval.
Documentation
docs/dev/and this file are for developers and agents.docs/user/andREADME.mdare for users.docs/archive/is legacy. The code is authoritative.- Keep documentation short. When the project changes, update it: a new reader should recover the decisions and the scars from these files, the comments, and the Git history.
- Prefer mermaid over ASCII diagrams.
- Prefer lsp over grep when feasible
- Don't spawn a subagent when you already have all the context to finish the task.
Development
- Git history follows upstream Linux kernel conventions:
focused, bisectable commits and a topic branch per independent change.
Subject line names the change; the body says why. That includes merge
commits:
--no-ffonly when the branch carries a series worth summarising or when it genuinely diverged, and then the merge body is that summary. A topic branch holding one commit off the current tip fast-forwards — an emptyMerge branch 'x'carries no information and should not exist. - Follow Linus Torvalds' code taste.
- Launch a reviewer subagent before a branch is merged into main.
- Helper scripts and tools that paid for themselves stay in the tree so the next task can reuse them.
1. Where to look
| You need | Read |
|---|---|
| Where does X live? What may I depend on? | docs/dev/ARCHITECTURE.md — module map, data model, extension recipes |
| Implement or change the network protocol | docs/dev/PROTOCOL.md — normative MRP spec, implementable without reading Rust |
| Prove a change works, especially in the GUI | docs/dev/TESTING.md — crate coverage, engine verification, GUI harness, debugging playbook |
| Draw in a widget, or chase a dropped frame | docs/dev/RENDERING.md — why the custom widgets draw with quads, and the measurements behind it |
| Candidate colour, or why the list is not monotonic | docs/dev/CANDIDATE_COLOUR.md — KataGo's order is play-selection value, not the win-rate column; what that does to the ramp |
| Fox HTTP, or the Fox SGF dialect | docs/dev/FOX_KIFU_API_SPEC.md |
| Arch packaging, desktop entry, metainfo | docs/dev/PACKAGING.md — what to regenerate, and why there is no Flatpak |
| Add a user-visible string, or translate | docs/dev/TRANSLATING.md — gettext conventions, the po/ workflow, why the GTK-free crates stay English |
| Touch anything the HarmonyOS client depends on | ../mirai-hmos/docs/dev/UPSTREAM.md — optional adjacent checkout, not a path in this repository. Present only if mirai-hmos is checked out beside this one; that client's ledger of what it reuses from here |
| Why is it built this way? What already went wrong? | docs/archive/RETROSPECTIVE.md — decisions, obstacles, defects found |
| What was originally specified, before any code | docs/archive/PLAN.md — historical; the code, not the plan, is authoritative |
| What does the application do, from a user's seat | docs/user/GUIDE.md; GUIDE.zh-CN.md beside it is the Simplified Chinese translation — change both |
| Project front page and install | README.md — product, and Requirements (build dependencies and commands); README.zh-CN.md is its translation — change both |
| Client settings, including a hand-edited config | docs/user/GUIDE.md |
Search user-facing questions in README.md docs/user/ and implementation questions in
AGENTS.md docs/dev/; search docs/archive/ only when tracing history.
crates/mirai-core geometry, rules, scoring, game tree, SGF no I/O, no GUI
crates/mirai-proto MRP types, frame codec, QUIC transport knows nothing about KataGo
crates/mirai-engine Engine trait, LocalEngine, RemoteEngine knows nothing about GTK
crates/mirai-client shared analysis, session, play, Fox no GTK, no files
crates/mirai-server headless host sharing KataGo across clients
crates/mirai the GTK application the only crate that links GTK
That layering is a rule, not an observation. A use gtk:: in mirai-engine, or a KataGo JSON
key in mirai-proto, is a design break — fix the design, not the import.
2. Non-negotiable invariants
These are load-bearing. Each is cheap to violate by accident and expensive to debug.
docs/dev/ARCHITECTURE.md says where each is enforced.
INV-1 — point encoding. Point(u16), index = y * width + x, y = 0 is the TOP row,
Point::PASS == u16::MAX. This is KataGo's own ordering, so ownership and policy arrays
index identically to the board array. Never introduce a remap. Boards are 2..=19 per side.
INV-2 — perspective. KataGo runs with reportAnalysisWinratesAs=BLACK. Everything stored
and transmitted is Black-perspective. Convert to side-to-move only where you display it,
with winrate_for / score_lead_for / utility_for — utility and score lead negate, win rate
is 1 - p. A double conversion looks plausible on screen and is nearly invisible — check every
new call site.
INV-3 — cancellation. For a consumer, dropping a Subscription is the only way to
cancel a query; there is no public cancel API, and do not add one. Local sends KataGo a
terminate; remote sends Cancel and stop_sending on that subscription's stream. The engine
runs that same internal cancel itself when it fails or rejects a subscription whose search may
still be running (a report it cannot decode, an unfit remote report): a subscription that has
ended for its consumer must not leave KataGo searching.
INV-4 — stateless queries. Every request carries its whole position. There is no
engine-side session state, and adding some would collapse the remote design. The corollary is
that nothing is replayed: when a remote link returns (RemoteStatus::Connected) or the engine
is replaced, the owner of the view re-requests what it shows now — live analysis, a stalled
AI turn. An engine never queues stale work, and a consumer must not drop the status channel
that tells it when to re-request.
INV-5 — komi travels as komi_x2: i16; KataGo accepts only integer/half-integer komi.
INV-6 — quantisation. Wire floats are fixed-point; the scales live in
crates/mirai-proto/src/types.rs. Round-trip error is budgeted and tested: winrate ≤ 1e-4,
score lead ≤ 0.02 points, ownership ≤ 0.005. Changing a scale means updating
tests/wire_size.rs (during development the protocol version does not move).
INV-7 — one source of truth, per window. AppState (crates/mirai/src/app.rs) owns
application state. One Change dispatcher in window.rs pushes projections to widgets; widgets
never talk to siblings or install competing AppState dispatchers. Emit the narrowest Change
that is true — a comment commit is not a structural Tree change — because each variant's
dispatcher work is what the UI pays for it. There is one AppState per window and several
windows are normal. MiraiApplication owns only the process-wide Tokio runtime and shared
EnginePool (whose engine entries are weak). Config writes use Config::save_merged;
autosaves remain per-window.
INV-8 — window ownership. MiraiWindow owns exactly one plain Ui value in its GObject
state. Long-lived handlers capture glib::WeakRef<MiraiWindow> and enter through
MiraiWindow::with_ui; stateful controllers do the same, while the custom widgets are handed
their window's AppState and hold it directly. close-request, dispose and
ApplicationImpl::shutdown all reduce to MiraiWindow::shutdown, whose take_ui drops the
Ui — and releasing is Ui's Drop, so a new exit path cannot forget it. Finite async
captures must be explicitly transient and own one teardown path. The rule is about cycles, not
only the window: no signal or action closure holds a strong reference to an object that owns,
directly or through the widget tree, the emitter it is connected to (a dialog's button closure
holding the dialog; an action closure holding a widget whose handler holds the action). Use
#[weak] or go through with_ui. A handler that something shorter-lived (a dialog) connects on
something longer-lived (AppState) is disconnected when the shorter-lived owner closes.
INV-9 — rendering. Board, win-rate graph and move tree are custom gtk::Widget subclasses
drawn with gsk in snapshot(). No GtkDrawingArea, no cairo. Tree-derived projections,
heat-map textures and reusable render nodes are built outside snapshot(). Draw with quads —
colour nodes, border nodes, rounded clips, the helpers in widgets/paint.rs — and never hand
GSK a fill or stroke node for a shape they can draw: GSK keys its rasterisation cache on
the path pointer, so a path rebuilt every frame always misses, and rebuilding board-sized paths
cost 30–120 ms a frame. A path that must stay a path and spans the plot (the win-rate curve)
is kept as a cached node and rebuilt only when its data changes — an identical curve rebuilt
is still a miss. snapshot() does not heap-allocate per drawn item; format labels on the
stack. Text is cached per PangoFont instead, never per position, so board text may be
deferred while Layout::cell moves — never merely because the widget was reallocated
(docs/dev/RENDERING.md).
INV-10 — borrow and identity discipline. The session lives in a RefCell. Shared borrows
(tree(), cursor(), tree_epoch()) may overlap one another; what panics is a mutable
borrow while any other borrow is live. Mutable borrows are not only edits: position(),
to_play() and with_tree_cached borrow mutably because the tree caches positions, and
changed, set_cursor, set_report and toast enter dispatcher code that may edit (a
comment flush). Never hold a tree() borrow across any of those; copy what you need into
locals first. NodeId is arena-local, so anything retained across a tree replacement carries
NodeRef { epoch, id } and is validated with resolve_node.
INV-11 — the GTK thread does not wait on the system. No synced write (write_atomic*),
directory scan, PATH search, network round-trip or process wait runs on the GTK main thread.
It goes to the runtime's blocking pool and the result comes back through the weak window.
Reading one small file mirai owns is fine. Two writes stay synchronous because something must
not run until they land: flush_config when a window closes or before another loads the file,
and an explicit Save, which the user waits on and a quit must not overtake. Anything else that
syncs is a 50–100 ms dropped frame no test sees.
3. Working here
Commands
cargo build --workspace --all-targets
cargo test --workspace # expect 0 failures
cargo clippy --workspace --all-targets -- -D warnings # expect exit 0
cargo run -p mirai # the application
Full detail, including how to drive the GUI headlessly and verify against a real engine, is in
docs/dev/TESTING.md.
Rules of engagement
- Never leave the tree broken.
clippy -D warningsand the full test suite pass on every commit. If your change needs a lint suppressed, justify it in a comment. - Commit messages and pull requests say what changed and why. They do not list the routine checks — build, tests, clippy, fmt passing, or test counts. Passing them is what every commit owes (above), not news. Quote evidence only when it is the point: a measured number, or how a defect reproduces.
- Do not add dependencies. Versions are pinned in the root
[workspace.dependencies]and member crates usedep.workspace = true. If you truly need a crate, say so and why rather than adding it quietly. Two hard-won constraints:rustlsis pinned to theringprovider (default-features = false) because a second crypto provider makesClientConfig::builder()panic at runtime; SHA-256 is implemented in-tree (mirai-proto/src/sha256.rs) rather than pulled in for under 100 lines. - Keep the tree rustfmt- and clippy-clean. Both are clean across the workspace today
(
cargo fmt --all --checkandcargo clippy --workspace --all-targets -- -D warningsexit 0), so day to day you only need them on what you touched:cargo fmt,cargo clippy -p <crate>. Never hand-format around rustfmt.clippy --fixacross the tree stays banned: it rewrites files you did not read. A new toolchain can add lints to untouched code; fix those in their own commit rather than inside a feature change. - Every new
.rsfile starts with the SPDX header:// SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2026 Huang Zhaobin - Do not commit
target/. It is ignored; keep it that way. - GTK crates are renamed in
crates/mirai:use gtk::…is packagegtk4,use adw::…islibadwaita.gtkre-exportsgdk,gio,glib,graphene,gsk,pango. - mirai runs on Wayland by default. Never set
GDK_BACKENDto make something work; a fix that only holds under XWayland is not a fix. - Follow the GNOME Human Interface Guidelines for every user-facing UI change; prefer standard GTK/libadwaita patterns and components.
- Use Blueprint for static UI hierarchy and layout; keep state, business logic and genuinely dynamic UI in Rust.
Toolchain
Rust stable, selected by rust-toolchain.toml. There is no minimum supported version and no
rust-version: mirai tracks the latest stable compiler and the latest release of every
dependency, so update both freely and never hold one back for an older toolchain. No nightly
feature is used, and none should be added. Let-chains (if let Some(x) = a && cond) are used
throughout and are expected. GTK 4.22+, libadwaita 1.9+ and Blueprint Compiler 0.22+ are
required to build mirai.
Testing expectations
Tests defend observable contracts and must fail on a plausible bug. Do not test plumbing, defaults, or source text.
- Bug fix → reproduce it first, then fix, then confirm the reproduction is dead.
- New observable contract → a test that pins it.
- Refactor with no behaviour change → no new test; the existing suite is the check.
- GUI change → drive it and look at it. Visual confirmation is the proof; see the harness
recipes in
docs/dev/TESTING.md. - Per-frame cost → a screenshot cannot prove it and the suite cannot see it. Measure with
MIRAI_FRAMES=1andtools/perf/, and quote the numbers (docs/dev/RENDERING.md).
4. Traps this codebase has already fallen into
The playbook — symptom, cause, and where the guard lives — is
docs/dev/TESTING.md. Do not keep a second copy here.
5. Deliberate simplifications — do not "fix" these by accident
The full list, with the source of each, is
docs/dev/ARCHITECTURE.md.
They are commented at the source. Do not "fix" one by accident.
6. Scope
Deliberately out of scope: screen-board OCR, joseki dictionaries, KataGo auto-download, theme skinning, dual-engine comparison. Proposals to add them should be weighed against the maintenance surface, not accepted by default.
In scope, decided: mirai owns the KataGo analysis config. It writes the file itself from
one set of static defaults (mirai-engine/src/tuning.rs), editable in Preferences; a
user-supplied analysis.cfg stays available and then owns every setting but the two thread
counts. A measured calibration — timing a few thread combinations against the real model and
keeping the winner — is an explicit opt-in per profile, never something that runs on its own.
Why a reported device memory or a model-name table is not a substitute is
docs/dev/ARCHITECTURE.md.
