← All Pull Requests

1.0.99: fourteen more standard verbs; update_pcb and teardrops in the standard's shape; edit drill #62

Merged opened by Colby Knox 2026-11-02

1.0.99: fourteen more of the standard's verbs under their own names, and update_pcb and teardrops in the standard's shape.

New verbs: read_board, read_schematic, schematic_data, pcb_report, instances, check_dialogs, save, close_document, close_all_documents, ensure_licensed, signin_status, setup_trial, set_models, add_models.

Changed:

  • kicad_update_pcb takes execute, allowRemovals, keepBoardOnly, and projectPath alone. It adds the standard's reply fields, and execute:true refuses removals unless told what to do with them. apply:true is unchanged.
  • kicad_teardrops takes the standard's arguments and defaults: Track to track on and save on by default, force:true refused.
  • kicad_save is now its own verb, and also saves schematics. kicad_save_board stays.

Tests: 767 cargo tests pass. The battery on KiCad 10.0.8 passed 370, failed 0 and skipped 1, with no foreground grabs.

New edit_drill.py: runs the edit verbs for real on copies and confirms each change with a separate read. It found no bugs. Its one failure on the last live run was the drill's own check on a named refusal, since corrected to accept it; that one change hasn't been rerun.

🤖 Generated with Claude Code

Diff Skip to comments

--- a/bridge.json+++ b/bridge.json@@ -1,477 +1,491 @@⋯ 359 unchanged lines ⋯     "kicad_footprint_primitives",     "kicad_rules_by_kind",     "kicad_document_info",+    "kicad_read_board",+    "kicad_read_schematic",+    "kicad_schematic_data",+    "kicad_pcb_report",+    "kicad_instances",+    "kicad_ensure_licensed",+    "kicad_signin_status",+    "kicad_setup_trial",+    "kicad_set_models",+    "kicad_add_models",+    "kicad_check_dialogs",+    "kicad_close_document",+    "kicad_close_all_documents",     "kicad_review_board"   ],   "statusVerb": "kicad_status",⋯ 109 unchanged lines ⋯   "releasedAt": "2026-06-26T16:00:00Z",   "uninstall": "kicad_uninstall" }+--- a/rust/Cargo.lock+++ b/rust/Cargo.lock@@ -1,1307 +1,1308 @@⋯ 318 unchanged lines ⋯  [[package]] name = "kicad-bridge"-version = "1.0.98"+version = "1.0.99" dependencies = [  "adom-eda-checks",  "kicad-core",⋯ 5 unchanged lines ⋯  [[package]] name = "kicad-core"-version = "1.0.98"+version = "1.0.99" dependencies = [  "kicad-ipc-rs",  "kicad-platform",⋯ 20 unchanged lines ⋯  [[package]] name = "kicad-platform"-version = "1.0.98"+version = "1.0.99" dependencies = [  "serde",  "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+--- a/rust/Cargo.toml+++ b/rust/Cargo.toml@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"]  [workspace.package]-version = "1.0.98"+version = "1.0.99" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+--- a/rust/crates/kicad-bridge/src/catalog.rs+++ b/rust/crates/kicad-bridge/src/catalog.rs@@ -1,373 +1,373 @@⋯ 189 unchanged lines ⋯ pub static ALIASES: &[(&str, &str)] = &[     ("kicad_net_classes", "kicad_netclasses"),     ("kicad_repour", "kicad_refill_zones"),-    ("kicad_save", "kicad_save_board"),     ("kicad_update_pcb", "kicad_update_pcb_from_schematic"),     ("kicad_annotate", "kicad_sch_annotate"),     ("kicad_create_symbol", "kicad_sym_create"),⋯ 174 unchanged lines ⋯         assert_eq!(doc["prewarm"]["verb"], "kicad_prewarm");     } }+--- a/rust/crates/kicad-bridge/src/kicad_only.txt+++ b/rust/crates/kicad-bridge/src/kicad_only.txt@@ -1,103 +1,105 @@⋯ 73 unchanged lines ⋯ kicad_rescan_libraries kicad_route kicad_routing_validate+kicad_save_board kicad_sch_check kicad_sch_components kicad_sch_connect_to_net⋯ 21 unchanged lines ⋯ kicad_upgrade kicad_validate_for_fab kicad_zone_state+--- a/rust/crates/kicad-bridge/src/standard_names.txt+++ b/rust/crates/kicad-bridge/src/standard_names.txt@@ -1,271 +1,272 @@-# The EDA Bridge Standard's verb names (adom/eda-bridge-standard verbs/standard.json, reference altium 0.2.87). Generated by tools/sync_standard_names.py; do not edit.+# The EDA Bridge Standard's verb names (adom/eda-bridge-standard verbs/standard.json, reference altium 0.2.88). Generated by tools/sync_standard_names.py; do not edit. a365_configure a365_files_upload a365_library_overview⋯ 264 unchanged lines ⋯ workspace_search xcopy_footprint zoom_to+--- a/rust/crates/kicad-bridge/src/std_shapes.rs+++ b/rust/crates/kicad-bridge/src/std_shapes.rs@@ -1,984 +1,985 @@⋯ 300 unchanged lines ⋯  /// Where y = 0 is: the grid point at the sheet's lower-left corner, from its `(paper ...)` (A4 when the file cannot be /// read). On the grid, so a part placed at whole mils lands with its pins on KiCad's grid and reads back the same mils.-fn page_height(path: Option<&str>) -> f64 {+pub(crate) fn page_height(path: Option<&str>) -> f64 {     let h = paper_height(path);     ((h / GRID).floor() * GRID * 10000.0).round() / 10000.0 }⋯ 19 unchanged lines ⋯     (on_grid(x * MIL), on_grid(height - y * MIL)) } -fn sheet_to_mil(at: &Value, height: f64) -> Value {+pub(crate) fn sheet_to_mil(at: &Value, height: f64) -> Value {     match (at.get(0).and_then(Value::as_f64), at.get(1).and_then(Value::as_f64)) {         (Some(x), Some(y)) => json!([(x / MIL).round(), ((height - y) / MIL).round()]),         _ => Value::Null,⋯ 648 unchanged lines ⋯         assert!(to_native("kicad_route_net", "kicad_route_net", &json!({})).is_none(), "verbs outside the 47 are left alone");     } }+--- a/rust/crates/kicad-bridge/src/verbs_composite.rs+++ b/rust/crates/kicad-bridge/src/verbs_composite.rs@@ -1,440 +1,441 @@⋯ 115 unchanged lines ⋯                 row["pins"] = json!(pins["symbols"].as_array().into_iter().flatten().filter(|s| s["reference"] == rf)                     .flat_map(|s| s["pins"].as_array().cloned().unwrap_or_default())                     .filter(|p| p["unit"].as_i64() == Some(unit) || p["unit"].as_i64() == Some(0))-                    .map(|p| json!({"number": p["number"], "name": p["name"], "type": p["type"], "at": p["at"], "hidden": p["hidden"]})).collect::<Vec<_>>());+                    .map(|p| json!({"number": p["number"], "name": p["name"], "type": p["type"], "at": p["at"], "direction": p["direction"], "length": p["length"], "hidden": p["hidden"]})).collect::<Vec<_>>());             }             out.push(row);         }⋯ 315 unchanged lines ⋯         }     } }+--- a/rust/crates/kicad-bridge/src/verbs_pcb_sync.rs+++ b/rust/crates/kicad-bridge/src/verbs_pcb_sync.rs@@ -1,171 +1,257 @@⋯ 21 unchanged lines ⋯     name: "kicad_update_pcb_from_schematic",     summary: "Compare the schematic's netlist with the board (missing, extra and changed footprints, changed values, pad net changes) and, on a closed board file, apply it: add missing footprints from the libraries in a row below the board, update values and links, assign pad nets, mark extras.",     mechanism: Mechanism::File, risk: "write", timeout_sec: 180,-    input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"netlistPath\"?: \"C:/.../x.net\" | \"schematicPath\"?: \"C:/.../x.kicad_sch\" | \"projectPath\"?, \"apply\"?: false, \"updateFootprints\"?: true, \"deleteExtra\"?: false}",+    input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"netlistPath\"?: \"C:/.../x.net\" | \"schematicPath\"?: \"C:/.../x.kicad_sch\" | \"projectPath\"?, \"apply\" | \"execute\"?: false, \"updateFootprints\"?: true, \"deleteExtra\" | \"allowRemovals\"?: false, \"keepBoardOnly\"?: false}; projectPath alone names the board beside it",     example: "kicad_update_pcb_from_schematic {\"filePath\":\"C:/p/b.kicad_pcb\"}",-    hint: "Plan by default (dryRun): missingFootprints, extraFootprints (boardOnlyFootprints are left alone), changedValues, changedFootprints, padNetChanges, relinks, inSync. The netlist comes from netlistPath (a KiCad XML netlist: File > Export > Netlist) or kicad-cli on the schematic beside the board. apply:true needs the board CLOSED: new parts land in a row 5 mm below the outline (place them next), replaced footprints keep their pose, uuid and link, every pad net is set, extras get an Adom_NotInSchematic property (deleteExtra:true removes them). The file is replaced atomically with a .adom-bak; done[] says what landed and notApplied[] what could not (a footprint not in any fp-lib-table).",+    hint: "The standard's reply is here too: preview {addParts, removeParts, sheetParts, boardParts}, changeOrder, executed, afterExecute, board, dialogs, steps; execute:true refuses while removeParts is not empty unless allowRemovals:true. Plan by default (dryRun): missingFootprints, extraFootprints (boardOnlyFootprints are left alone), changedValues, changedFootprints, padNetChanges, relinks, inSync. The netlist comes from netlistPath (a KiCad XML netlist: File > Export > Netlist) or kicad-cli on the schematic beside the board. apply:true needs the board CLOSED: new parts land in a row 5 mm below the outline (place them next), replaced footprints keep their pose, uuid and link, every pad net is set, extras get an Adom_NotInSchematic property (deleteExtra:true removes them). The file is replaced atomically with a .adom-bak; done[] says what landed and notApplied[] what could not (a footprint not in any fp-lib-table).",     related: &["kicad_netlist", "kicad_extract_netlist", "kicad_placement_state", "kicad_move_footprint", "kicad_run_drc"],     pitfalls: &["Footprints are matched by reference, then linked by symbol uuid: annotate the schematic before syncing.", "Live apply is refused (board_open) while KiCad has the board open: use KiCad's Tools > Update PCB from Schematic (F8) there, or save and close the board and apply here.", "A replaced footprint's old tracks stay where they were; DRC shows the ones that no longer reach a pad.", "Back-side footprints are not replaced (they would need a flip); they appear in notApplied."], }];⋯ 48 unchanged lines ⋯     Ok((nl, norm(&sch))) } +/// The standard's update_pcb preview and change order, from the plan.+fn standard_reply(plan: &Value) -> (Value, Vec<String>) {+    let refs = |k: &str| plan[k].as_array().into_iter().flatten().map(|r| r["ref"].clone()).collect::<Vec<_>>();+    let preview = json!({"addParts": refs("missingFootprints"), "removeParts": refs("extraFootprints"), "sheetParts": plan["counts"]["schematicComponents"], "boardParts": plan["counts"]["boardFootprints"]});+    let mut order = Vec::new();+    let mut group = |name: &str, key: &str, line: &dyn Fn(&Value) -> String| {+        let rows = plan[key].as_array().cloned().unwrap_or_default();+        if !rows.is_empty() {+            order.push(format!("{name} ({})", rows.len()));+            order.extend(rows.iter().map(|r| format!("  {}", line(r))));+        }+    };+    let s = |v: &Value| v.as_str().unwrap_or("").to_string();+    group("Add footprints", "missingFootprints", &|r| format!("{} {} {}", s(&r["ref"]), s(&r["value"]), s(&r["footprint"])));+    group("Remove footprints", "extraFootprints", &|r| format!("{} {} {}", s(&r["ref"]), s(&r["value"]), s(&r["footprint"])));+    group("Change values", "changedValues", &|r| format!("{}: {} to {}", s(&r["ref"]), s(&r["from"]), s(&r["to"])));+    group("Change footprints", "changedFootprints", &|r| format!("{}: {} to {}", s(&r["ref"]), s(&r["from"]), s(&r["to"])));+    group("Change pad nets", "padNetChanges", &|r| match r.as_str() { Some(t) => t.to_string(), None => r.to_string() });+    group("Link to symbols", "relinks", &|r| s(&r["ref"]));+    (preview, order)+}+ fn update(state: &mut State, args: &Value) -> Value {+    // The standard's shape: execute and allowRemovals, and the project names the board.+    let mut a = args.clone();+    let standard_execute = args.get("execute") == Some(&json!(true));+    if standard_execute {+        a["apply"] = json!(true);+    }+    if args.get("allowRemovals") == Some(&json!(true)) {+        a["deleteExtra"] = json!(true);+    }+    if arg_str(args, "filePath").or(arg_str(args, "boardPath")).is_none() {+        if let Some(p) = arg_str(args, "projectPath") {+            a["filePath"] = json!(norm(&PathBuf::from(p.trim()).with_extension("kicad_pcb")));+        }+    }+    let mut out = update_inner(state, &a, standard_execute);+    if let Some(plan) = out.get("plan").filter(|p| p.is_object()).cloned() {+        let (preview, order) = standard_reply(&plan);+        out["preview"] = preview;+        out["changeOrder"] = json!(order);+        out["executed"] = json!(out["mutated"] == json!(true));+        out["afterExecute"] = json!(out.get("after").filter(|p| p.is_object()).map(|p| standard_reply(p).1).unwrap_or_default());+        out["board"] = out.get("boardPath").cloned().or_else(|| a.get("filePath").cloned()).unwrap_or(Value::Null);+        out["dialogs"] = json!([]);+        out["steps"] = json!(if out["executed"] == json!(true) { vec!["netlist", "compare", "apply to the closed board file"] } else { vec!["netlist", "compare"] });+    }+    out+}++fn update_inner(state: &mut State, args: &Value, standard_execute: bool) -> Value {     let loaded = match pcb::load(args, true) {         Ok(l) => l,         Err(e) => return e,⋯ 13 unchanged lines ⋯         out["_hint"] = json!("The board matches the schematic; nothing was written.");         return out;     }+    if standard_execute && args.get("deleteExtra") != Some(&json!(true)) && args.get("keepBoardOnly") != Some(&json!(true)) && plan["extraFootprints"].as_array().is_some_and(|x| !x.is_empty()) {+        let mut v = fail("removals_not_allowed", "the change would delete board parts that are on no sheet", "preview.removeParts names them. allowRemovals:true deletes them; keepBoardOnly:true updates the rest and leaves them on the board. A part ticked Not in schematic in KiCad's footprint properties (a fiducial, a mounting hole) is never counted here.");+        v["plan"] = plan;+        v["mutated"] = json!(false);+        return v;+    }     if let Some(lck) = pcb::lock_file(&loaded.path) {         let mut v = fail("board_open", "KiCad has this board open: the file cannot be updated under it, and a live update is not offered (the IPC API cannot load library footprints into the editor)", "In that KiCad run Tools > Update PCB from Schematic (F8); or save and close the board (kicad_close) and call again with apply:true.");         v["lock"] = json!(norm(&lck));⋯ 28 unchanged lines ⋯      fn scratch() -> PathBuf {         let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/konnect-route");-        let d = std::env::temp_dir().join(format!("kb-sync-{}", std::process::id()));+        let d = std::env::temp_dir().join(format!("kb-sync-{}-{:?}", std::process::id(), std::thread::current().id()).replace(['(', ')'], ""));         let _ = std::fs::remove_dir_all(&d);         std::fs::create_dir_all(d.join("Fixture.pretty")).unwrap();         for f in ["route-fixture.kicad_pcb", "route-fixture.kicad_pro", "route-fixture.net", "fp-lib-table", "Fixture.pretty/R_0603_1608Metric.kicad_mod", "Fixture.pretty/PinHeader_1x04_P2.00mm_Vertical.kicad_mod"] {⋯ 28 unchanged lines ⋯         assert_eq!(none["errorCode"], "schematic_not_found");         let _ = std::fs::remove_dir_all(&d);     }++    #[test]+    fn the_standards_update_pcb_previews_and_refuses_removals() {+        let d = scratch().join("std");+        std::fs::create_dir_all(&d).unwrap();+        for f in ["route-fixture.kicad_pcb", "route-fixture.kicad_pro", "route-fixture.net", "fp-lib-table"] {+            std::fs::copy(d.parent().unwrap().join(f), d.join(f)).unwrap();+        }+        std::fs::create_dir_all(d.join("Fixture.pretty")).unwrap();+        for e in std::fs::read_dir(d.parent().unwrap().join("Fixture.pretty")).unwrap().flatten() {+            std::fs::copy(e.path(), d.join("Fixture.pretty").join(e.file_name())).unwrap();+        }+        let (project, net) = (norm(&d.join("route-fixture.kicad_pro")), norm(&d.join("route-fixture.net")));+        let p = call(json!({"projectPath": project, "netlistPath": net}));+        assert_eq!((p["executed"].as_bool(), p["preview"]["addParts"][0].as_str()), (Some(false), Some("R2")), "{p}");+        assert!(p["preview"]["removeParts"].as_array().is_some_and(|r| !r.is_empty()), "{p}");+        assert!(p["preview"]["sheetParts"].is_u64() && p["preview"]["boardParts"].is_u64(), "{p}");+        assert!(p["changeOrder"][0].as_str().unwrap().starts_with("Add footprints (1)"), "{p}");+        assert!(p["board"].as_str().unwrap().ends_with("route-fixture.kicad_pcb"), "{p}");+        let no = call(json!({"projectPath": project, "netlistPath": net, "execute": true}));+        assert_eq!((no["errorCode"].as_str(), no["mutated"].as_bool()), (Some("removals_not_allowed"), Some(false)), "{no}");+        let kept = call(json!({"projectPath": project, "netlistPath": net, "execute": true, "keepBoardOnly": true}));+        assert_eq!(kept["executed"], true, "{kept}");+        assert!(kept["afterExecute"].as_array().unwrap().iter().any(|l| l.as_str().is_some_and(|t| t.starts_with("Remove footprints"))), "{kept}");+        let gone = call(json!({"projectPath": project, "netlistPath": net, "execute": true, "allowRemovals": true}));+        assert_eq!((gone["executed"].as_bool(), gone["after"]["inSync"].as_bool()), (Some(true), Some(true)), "{gone}");+        let _ = std::fs::remove_dir_all(d.parent().unwrap());+    } }+--- a/rust/crates/kicad-bridge/src/verbs_std_more.rs+++ b/rust/crates/kicad-bridge/src/verbs_std_more.rs@@ -1,623 +1,1169 @@ //! Standard verbs KiCad answered only partly under other names (tools/equivalents/kicad.json of the EDA Bridge //! Standard), now under their own names with the standard's arguments and replies: list_library, open_document,-//! open_project, activate_document, render_board_layers, export_bom, routing_drc. Each is a thin shape over KiCad verbs the bridge+//! open_project, activate_document, render_board_layers, export_bom, routing_drc, read_board, read_schematic,+//! schematic_data, pcb_report, instances. Each is a thin shape over KiCad verbs the bridge //! already has; the ones that open windows call them through verbs::call, so these are never Window verbs themselves //! (a Window verb calling another through dispatch would wait on the GUI lock it holds). ⋯ 116 unchanged lines ⋯         related: &["kicad_state", "kicad_activate_document", "kicad_open_document"],         pitfalls: &["With no KiCad window open it answers no_document.", "A new, never-saved sheet is untitled with saved false and path null."],     },+    Verb {+        name: "kicad_read_board",+        summary: "The whole board in one read (the standard's read_board): components, nets, layers, copper, rules, occupancy and outline, from the open PCB editor when it serves the board, else the file.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"sections\"?: \"components,nets,layers,copper,rules,occupancy,outline\", \"limit\"?: n (per list), \"source\"?: \"auto|live|file\"}",+        example: "kicad_read_board {\"filePath\":\"C:/p/b.kicad_pcb\",\"sections\":\"components,rules\"}",+        hint: "data holds the sections you asked for (all by default), in mm in the standard's y-up frame. rules is kicad_rules_by_kind's answer, occupancy is copper items per layer. limit caps each list and truncated names the capped ones. kicad_routing_state {model:\"standard\"} is the same copper with the revision for edits.",+        related: &["kicad_routing_state", "kicad_board_summary", "kicad_rules_by_kind", "kicad_layers_in_use"],+        pitfalls: &["A file read does not see unsaved edits; source says which was read."],+    },+    Verb {+        name: "kicad_read_schematic",+        summary: "The parts on one schematic sheet (the standard's read_schematic): designator, library reference, comment, position and parameters, for filePath or the sheet open in the Schematic Editor.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\" | \"sheet\"?: \"C:/.../x.kicad_sch\" (default the open sheet), \"limit\"?: 500, \"params\"?: true}",+        example: "kicad_read_schematic {\"filePath\":\"C:/p/b.kicad_sch\"}",+        hint: "One sheet, read from the saved file. Without filePath it reads the sheet the topmost Schematic Editor holds (KiCad's Schematic Editor has no API, so unsaved edits are not in the answer). Positions are mils from the grid point at the sheet's lower-left corner, y up. A hierarchical root holds sheet symbols: kicad_sch_hierarchy names the others.",+        related: &["kicad_schematic_data", "kicad_cross_check", "kicad_sch_hierarchy"],+        pitfalls: &["One sheet only.", "Reads the saved file."],+    },+    Verb {+        name: "kicad_schematic_data",+        summary: "The parts on one schematic sheet with their pins (the standard's schematic_data): number, name, electrical type, position, orientation and length of each pin; designator filters to one part.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"filePath\" | \"sheet\"?: \"C:/.../x.kicad_sch\" (default the open sheet), \"designator\"?: \"U1\", \"limit\"?: 500, \"params\"?: false, \"paramNamesOnly\"?: false, \"pins\"?: true}",+        example: "kicad_schematic_data {\"filePath\":\"C:/p/b.kicad_sch\",\"designator\":\"U1\"}",+        hint: "data.components[] has one row per placed unit (partId). electrical is the standard's ordinal (0 input, 1 IO, 2 output, 3 open collector, 4 passive, 5 tri-state, 6 open emitter, 7 power) and electricalType KiCad's own name, which also tells free, unspecified and no-connect pins (ordinal 4). kind is 0, or 5 for a part kept out of the BOM. Positions are mils from the grid point at the sheet's lower-left corner, y up.",+        related: &["kicad_read_schematic", "kicad_sch_pins", "kicad_netlist"],+        pitfalls: &["One sheet per call.", "Reads the saved file."],+    },+    Verb {+        name: "kicad_pcb_report",+        summary: "Numbers instead of pixels (the standard's pcb_report): for a footprint library, every footprint's pads, pad box, primitive census and 3D bodies; for a board, outline, parts with nets and bodies, census and tracks.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 60,+        input: "{\"path\" | \"filePath\": \"C:/.../x.kicad_pcb\" | \"C:/.../Lib.pretty\" | \"C:/.../X.kicad_mod\", \"name\"?: \"R_0603\" (a footprint name filter, library form), \"source\"?: \"auto|live|file\"}",+        example: "kicad_pcb_report {\"path\":\"C:/p/Lib.pretty\",\"name\":\"SOT\"}",+        hint: "data.kind is library or board. Boxes are mils: a library footprint's in its own frame, a board's in the standard's y-up frame. bodies counts a footprint's 3D models; on a board it is 1 when the part has a model and 0 when not. kicad_model_check says whether a model file really exists.",+        related: &["kicad_read_board", "kicad_fp_info", "kicad_model_check"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_instances",+        summary: "Every running KiCad process (the standard's instances): the project manager and any standalone editors, with pid and version.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 15,+        input: "{}",+        example: "kicad_instances {}",+        hint: "KiCad needs no licence, so licensed is true and agentAlive is null: it runs no script agent. Windows and documents: kicad_state.",+        related: &["kicad_state", "kicad_document_info", "kicad_window_info"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_ensure_licensed",+        summary: "One call to a usable KiCad (the standard's ensure_licensed): KiCad needs no seat or sign-in, so this says it is free and starts it if it is not running.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 120,+        input: "{}",+        example: "kicad_ensure_licensed {}",+        hint: "alreadyLicensed is true whenever KiCad is installed: it is GPL-3.0-or-later. steps says whether it was launched. Not installed answers not_installed: kicad_upgrade installs it.",+        related: &["kicad_license_status", "kicad_readiness", "kicad_launch", "kicad_upgrade"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_signin_status",+        summary: "Account and workspace state (the standard's signin_status): KiCad has neither, so licensed is true, workspaceConnected false, and running says whether it is up.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 15,+        input: "{}",+        example: "kicad_signin_status {}",+        hint: "Nothing here needs a person. kicad_license_status has the licence in plain words.",+        related: &["kicad_license_status", "kicad_readiness"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_setup_trial",+        summary: "From nothing to a working KiCad, one step per call (the standard's setup_trial): reports stage install, launch or done and the one next action. KiCad has no trial or sign-in, so no person is needed.",+        mechanism: Mechanism::File, risk: "read", timeout_sec: 15,+        input: "{}",+        example: "kicad_setup_trial {}",+        hint: "nextAction is the verb to call: kicad_upgrade at install, kicad_launch at launch. Nothing is started by this verb.",+        related: &["kicad_upgrade", "kicad_launch", "kicad_readiness"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_set_models",+        summary: "Set a library symbol's one footprint link (the standard's set_models): replaces the Footprint field of a symbol in a .kicad_sym and says whether another was there.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"symbolName\": \"LM1117\", \"footprint\": \"Package_TO_SOT_SMD:SOT-223-3_TabPin2\"}",+        example: "kicad_set_models {\"libraryPath\":\"C:/p/Mine.kicad_sym\",\"symbolName\":\"LM1117\",\"footprint\":\"Package_TO_SOT_SMD:SOT-223-3_TabPin2\"}",+        hint: "removed is 1 when a different footprint was linked before. The footprint is written as given; Lib:Name is what KiCad resolves. kicad_footprint_models is another job: the 3D models on a footprint.",+        related: &["kicad_add_models", "kicad_set_params", "kicad_sym_info", "kicad_footprint_models"],+        pitfalls: &["Sheets that already placed the symbol keep their own copy until kicad_sch_update_from_library."],+    },+    Verb {+        name: "kicad_add_models",+        summary: "Attach footprints, parameters, comment and description to a library symbol (the standard's add_models). KiCad has one Footprint field: the first model goes there when it is empty, and every model joins the symbol's footprint filters.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 30,+        input: "{\"libraryPath\": \"C:/.../Mine.kicad_sym\", \"symbolName\": \"LM1117\", \"models\"?: [{\"name\": \"SOT-223-3_TabPin2\", \"pcblib\"?: \"Package_TO_SOT_SMD\" (a nickname, or a .pretty path)}], \"params\"?: {\"MPN\": \"...\"}, \"comment\"?: \"value\", \"description\"?: \"text\"}",+        example: "kicad_add_models {\"libraryPath\":\"C:/p/Mine.kicad_sym\",\"symbolName\":\"LM1117\",\"models\":[{\"name\":\"SOT-223-3_TabPin2\",\"pcblib\":\"Package_TO_SOT_SMD\"}]}",+        hint: "data {libref, models, params}. Calling again with the same model changes nothing: filters are kept unique. KiCad's footprint chooser offers what the filters match when a part has no footprint yet.",+        related: &["kicad_set_models", "kicad_set_params", "kicad_sym_info"],+        pitfalls: &["Footprint filters are name patterns KiCad offers, not implementations a part switches between as in Altium."],+    }, ];  pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {⋯ 8 unchanged lines ⋯         "kicad_set_track_width" => set_track_width(state, args),         "kicad_footprint_primitives" => footprint_primitives(state, args),         "kicad_rules_by_kind" => rules_by_kind(args),-        "kicad_document_info" => document_info(),+        "kicad_document_info" => document_info(None),+        "kicad_read_board" => read_board(state, args),+        "kicad_read_schematic" => sheet_read(state, args, false),+        "kicad_schematic_data" => sheet_read(state, args, true),+        "kicad_pcb_report" => pcb_report(state, args),+        "kicad_instances" => instances(state),+        "kicad_ensure_licensed" => ensure_licensed(state),+        "kicad_signin_status" => signin_status(state),+        "kicad_setup_trial" => setup_trial(state),+        "kicad_set_models" => set_models(state, args),+        "kicad_add_models" => add_models(state, args),         _ => return None,     }) }⋯ 217 unchanged lines ⋯ /// A KiCad window title: (name, kind, unsaved, sub-sheet), or None for a window that holds no document. /// "*rp2040 — PCB Editor", "untitled [Unsaved] — Schematic Editor", "board [/power/] — Schematic Editor", /// "rp2040 — KiCad 10.0" (the project manager).-fn parse_title(title: &str) -> Option<(String, &'static str, bool, Option<String>)> {+pub(crate) fn parse_title(title: &str) -> Option<(String, &'static str, bool, Option<String>)> {     let (left, right) = title.rsplit_once(" — ").or_else(|| title.rsplit_once(" - "))?;     let kind = match right.trim() {         "PCB Editor" => "board",⋯ 24 unchanged lines ⋯     pool.into_iter().rev().find(|t| exts.iter().any(|e| t.to_ascii_lowercase().ends_with(e))).map(|t| t.replace('\\', "/")) } -fn document_info() -> Value {+fn document_info(want_kind: Option<&str>) -> Value {     use kicad_platform::native;     const KICAD: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "kicad", "pcbnew", "eeschema"];     let wins: Vec<_> = native().list_windows().unwrap_or_default().into_iter().filter(|w| KICAD.contains(&w.exe.as_str()) && w.owner == 0).collect();     let docs: Vec<(kicad_platform::WindowInfo, (String, &'static str, bool, Option<String>))> = wins.iter().filter_map(|w| parse_title(&w.title).map(|t| (w.clone(), t))).collect();     // the topmost editor; the project manager only when no editor is open-    let Some((win, (name, kind, modified, sub))) = docs.iter().find(|(_, t)| t.1 != "project").or_else(|| docs.first()).cloned() else {-        return fail("no_document", "no KiCad document is open", "kicad_open_document opens one; kicad_state lists KiCad's windows.");+    // (or the topmost of the kind a verb that reads "the current sheet" needs)+    let pick = match want_kind {+        Some(k) => docs.iter().find(|(_, t)| t.1 == k),+        None => docs.iter().find(|(_, t)| t.1 != "project").or_else(|| docs.first()),     };+    let Some((win, (name, kind, modified, sub))) = pick.cloned() else {+        return fail("no_document", format!("no KiCad {} is open", want_kind.unwrap_or("document")), "kicad_open_document opens one, or pass filePath; kicad_state lists KiCad's windows.");+    };     let ext = match kind { "board" => "kicad_pcb", "schematic" => "kicad_sch", "project" => "kicad_pro", _ => "" };     let untitled = name == "untitled";     // the editor's command line (Adom Bridge's process_list), or the project manager's for an editor it hosts⋯ 46 unchanged lines ⋯     ext(p) } +fn read_board(state: &mut State, args: &Value) -> Value {+    use kicad_core::board_model;+    let r = match crate::verbs_standard::read(state, args) {+        Ok(r) => r,+        Err(e) => return e,+    };+    let opts = board_model::Opts { y_up: true };+    let model = match board_model::build(&r.text, Some(&r.file), &opts) {+        Ok(m) => m,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    let wanted: Vec<String> = arg_str(args, "sections").map(|s| s.split(',').map(|x| x.trim().to_ascii_lowercase()).filter(|x| !x.is_empty()).collect()).unwrap_or_default();+    const SECTIONS: &[&str] = &["components", "nets", "layers", "copper", "rules", "occupancy", "outline"];+    if let Some(bad) = wanted.iter().find(|w| !SECTIONS.contains(&w.as_str())) {+        return fail("bad_args", format!("no section {bad}"), format!("sections is a comma list of {}.", SECTIONS.join(", ")));+    }+    let on = |s: &str| wanted.is_empty() || wanted.iter().any(|w| w == s);+    let limit = args.get("limit").and_then(Value::as_u64).map(|n| n as usize);+    let mut truncated = Vec::new();+    let mut list = |k: &str| -> Value {+        let a = model[k].as_array().cloned().unwrap_or_default();+        match limit {+            Some(n) if a.len() > n => {+                truncated.push(k.to_string());+                json!(a[..n])+            }+            _ => json!(a),+        }+    };+    let mut data = json!({"file": r.file});+    if on("components") {+        data["components"] = list("components");+    }+    if on("nets") {+        data["nets"] = list("nets");+    }+    if on("layers") {+        data["layers"] = json!({"copper": model["copperLayers"]});+    }+    if on("copper") {+        data["copper"] = json!({"tracks": list("tracks"), "arcs": list("arcs"), "vias": list("vias"), "pads": list("pads"), "pours": list("polygons")});+    }+    if on("rules") {+        let pro = PathBuf::from(&r.file).with_extension("kicad_pro");+        let pv: Value = std::fs::read_to_string(&pro).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);+        data["rules"] = kicad_core::project_rules::rules_by_kind(&pv, std::fs::read_to_string(pro.with_extension("kicad_dru")).ok().as_deref());+    }+    if on("occupancy") {+        data["occupancy"] = board_model::derive(&model, "layers_in_use", args).unwrap_or(Value::Null);+    }+    if on("outline") {+        data["outline"] = json!({"outline": model["outline"], "edges": model["outlineEdges"]});+    }+    let mut out = json!({"success": true, "data": data, "counts": model["counts"], "revision": model["revision"], "frame": model["frame"], "truncated": truncated});+    crate::verbs_standard::stamp(&mut out, &r);+    out+}++/// The standard's electrical ordinal for a KiCad pin type (schematic_data: 0 Input, 1 IO, 2 Output, 3 OpenCollector,+/// 4 Passive, 5 HiZ, 6 OpenEmitter, 7 Power). KiCad's free, unspecified and no-connect pins have no ordinal of their own.+fn electrical_ordinal(kicad: &str) -> u8 {+    match kicad {+        "input" => 0,+        "bidirectional" => 1,+        "output" => 2,+        "open_collector" => 3,+        "tri_state" => 5,+        "open_emitter" => 6,+        "power_in" | "power_out" => 7,+        _ => 4,+    }+}++/// `kicad_read_schematic` (with_pins false) and `kicad_schematic_data` (true): one sheet, in the standard's mils.+fn sheet_read(state: &mut State, args: &Value, data_form: bool) -> Value {+    let (path, sub) = match arg_any(args, &["filePath", "sheet", "path"]) {+        Some(p) => (p.to_string(), Value::Null),+        None => {+            let d = document_info(Some("schematic"));+            if d["success"] != json!(true) {+                return d;+            }+            match d["path"].as_str() {+                Some(p) => (p.to_string(), d["subSheet"].clone()),+                None => return fail("no_path", "the open Schematic Editor's sheet has no file yet", "Save it in KiCad, or pass filePath."),+            }+        }+    };+    let flag = |k: &str, d: bool| args.get(k).and_then(Value::as_bool).unwrap_or(d);+    let want_pins = data_form && flag("pins", true);+    let names_only = flag("paramNamesOnly", false);+    let want_params = flag("params", !data_form) || names_only;+    let raw = crate::verbs::call(state, "kicad_read_sheet_file", &json!({"path": path, "pins": want_pins, "params": want_params}));+    if raw["success"] != json!(true) {+        return raw;+    }+    let height = crate::std_shapes::page_height(Some(path.as_str()));+    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(500) as usize;+    let want = arg_str(args, "designator").map(str::to_ascii_lowercase);+    let params_of = |c: &Value| -> Value {+        match (c["params"].as_object(), names_only) {+            (Some(m), true) => json!(m.keys().collect::<Vec<_>>()),+            (Some(m), false) => json!(m.iter().map(|(k, v)| json!({"name": k, "value": v})).collect::<Vec<_>>()),+            _ => json!([]),+        }+    };+    let rows: Vec<Value> = raw["components"].as_array().into_iter().flatten()+        .filter(|c| want.as_ref().map_or(true, |w| c["designator"].as_str().is_some_and(|d| d.eq_ignore_ascii_case(w))))+        .map(|c| {+            let at = crate::std_shapes::sheet_to_mil(&json!([c["xMm"], c["yMm"]]), height);+            let mut row = json!({"designator": c["designator"], "partId": c["partId"], "libReference": c["libReference"], "sourceLibrary": c["sourceLibrary"], "comment": c["comment"],+                "description": c["description"], "footprint": c["footprint"], "kind": if c["inBom"] == json!(false) { 5 } else { 0 }, "dnp": c["dnp"],+                "xMil": at[0], "yMil": at[1], "orientation": c["orientation"], "mirror": c["mirror"], "uniqueId": c["uniqueId"]});+            if want_params {+                row["params"] = params_of(c);+            }+            if want_pins {+                row["pins"] = json!(c["pins"].as_array().into_iter().flatten().map(|p| {+                    let pat = crate::std_shapes::sheet_to_mil(&p["at"], height);+                    let ty = p["type"].as_str().unwrap_or("");+                    let orientation = match p["direction"].as_str() { Some("right") => 0, Some("up") => 90, Some("left") => 180, Some("down") => 270, _ => 0 };+                    json!({"number": p["number"], "name": p["name"], "electrical": electrical_ordinal(ty), "electricalType": ty, "xMil": pat[0], "yMil": pat[1], "orientation": orientation,+                        "lengthMil": p["length"].as_f64().map(|l| (l / 0.0254 * 100.0).round() / 100.0), "hidden": p["hidden"]})+                }).collect::<Vec<_>>());+            }+            row+        }).collect();+    if let (Some(d), true) = (arg_str(args, "designator"), rows.is_empty()) {+        return fail("part_not_found", format!("{d} is not on this sheet"), "Other sheets: kicad_sch_hierarchy names them.");+    }+    let count = rows.len();+    let rows: Vec<Value> = rows.into_iter().take(limit).collect();+    let mut out = if data_form {+        json!({"success": true, "data": {"document": path, "count": count, "components": rows}})+    } else {+        let names: Vec<Value> = rows.iter().map(|r| r["designator"].clone()).collect();+        json!({"success": true, "document": path, "count": count, "components": rows, "designators": names})+    };+    out["truncated"] = json!(count > limit);+    if !sub.is_null() {+        out["subSheet"] = sub;+        out["_note"] = json!("The Schematic Editor is showing this sub-sheet, but the path is its project's root sheet: pass filePath for the sub-sheet's own parts (kicad_sch_hierarchy names the files).");+    }+    out+}++fn mil2(mm: f64) -> f64 {+    (mm / 0.0254 * 100.0).round() / 100.0+}++/// One library footprint's numbers for pcb_report.+fn footprint_numbers(text: &str, name: &str) -> Result<Value, String> {+    let root = kicad_core::sxedit::parse(text)?;+    let mut census: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();+    let mut boxx: Option<(f64, f64, f64, f64)> = None;+    let mut pads = 0;+    let mut bodies = 0;+    for k in root.kids.iter().filter(|k| k.is_list()) {+        match k.tag() {+            Some("pad") => {+                pads += 1;+                let at = k.kid("at");+                let size = k.kid("size");+                let (x, y) = (at.and_then(|a| a.f64_at(1)).unwrap_or(0.0), at.and_then(|a| a.f64_at(2)).unwrap_or(0.0));+                let (w, h) = (size.and_then(|s| s.f64_at(1)).unwrap_or(0.0) / 2.0, size.and_then(|s| s.f64_at(2)).unwrap_or(0.0) / 2.0);+                let b = boxx.unwrap_or((x - w, y - h, x + w, y + h));+                boxx = Some((b.0.min(x - w), b.1.min(y - h), b.2.max(x + w), b.3.max(y + h)));+                *census.entry("pad".into()).or_default() += 1;+            }+            Some("model") => {+                bodies += 1;+                *census.entry("model".into()).or_default() += 1;+            }+            Some(t) if t.starts_with("fp_") || t == "zone" => *census.entry(t.trim_start_matches("fp_").to_string()).or_default() += 1,+            _ => {}+        }+    }+    Ok(json!({"name": name, "pads": pads, "padsBboxMil": boxx.map(|b| json!([mil2(b.0), mil2(b.1), mil2(b.2), mil2(b.3)])), "census": census, "bodies": bodies}))+}++fn pcb_report(state: &mut State, args: &Value) -> Value {+    let given = arg_any(args, &["path", "filePath", "libraryPath"]).map(|p| PathBuf::from(p.trim_end_matches(['/', '\\'])));+    let lib_form = given.as_ref().is_some_and(|p| matches!(ext(p).as_str(), "pretty" | "kicad_mod"));+    if lib_form {+        let p = given.unwrap();+        let files: Vec<PathBuf> = if ext(&p) == "kicad_mod" { vec![p.clone()] } else {+            std::fs::read_dir(&p).map(|rd| rd.flatten().map(|e| e.path()).filter(|f| ext(f) == "kicad_mod").collect()).unwrap_or_default()+        };+        if files.is_empty() {+            return fail("file_not_found", format!("no footprints at {}", norm(&p)), "Pass a .pretty folder or a .kicad_mod file.");+        }+        let needle = arg_str(args, "name").map(str::to_ascii_lowercase);+        let mut rows = Vec::new();+        for f in files {+            let name = f.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();+            if needle.as_ref().is_some_and(|n| !name.to_ascii_lowercase().contains(n)) {+                continue;+            }+            match std::fs::read_to_string(&f).map_err(|e| e.to_string()).and_then(|t| footprint_numbers(&t, &name)) {+                Ok(r) => rows.push(r),+                Err(e) => rows.push(json!({"name": name, "error": e})),+            }+        }+        rows.sort_by_key(|r| r["name"].as_str().unwrap_or("").to_string());+        return json!({"success": true, "data": {"kind": "library", "path": norm(&p), "count": rows.len(), "components": rows}});+    }+    let mut a = args.clone();+    if let Some(p) = &given {+        a["filePath"] = json!(norm(p));+    }+    let r = match crate::verbs_standard::read(state, &a) {+        Ok(r) => r,+        Err(e) => return e,+    };+    let m = match kicad_core::board_model::build(&r.text, Some(&r.file), &kicad_core::board_model::Opts { y_up: true }) {+        Ok(m) => m,+        Err(e) => return fail("board_unparsable", e, "A damaged file opens and saves in KiCad to repair it."),+    };+    let pads = m["pads"].as_array().cloned().unwrap_or_default();+    let f = |v: &Value| v.as_f64().unwrap_or(0.0);+    let comps: Vec<Value> = m["components"].as_array().into_iter().flatten().map(|c| {+        let mine: Vec<&Value> = pads.iter().filter(|p| p["ref"] == c["ref"]).collect();+        let mut nets: Vec<&str> = mine.iter().filter_map(|p| p["net"].as_str()).filter(|n| !n.is_empty()).collect();+        nets.sort();+        nets.dedup();+        let bbox = mine.iter().fold(None::<(f64, f64, f64, f64)>, |b, p| {+            let (x, y, w, h) = (f(&p["x"]), f(&p["y"]), f(&p["sizeX"]) / 2.0, f(&p["sizeY"]) / 2.0);+            let b = b.unwrap_or((x - w, y - h, x + w, y + h));+            Some((b.0.min(x - w), b.1.min(y - h), b.2.max(x + w), b.3.max(y + h)))+        });+        json!({"name": c["ref"], "designator": c["ref"], "pattern": c["pattern"], "side": c["side"], "pads": mine.len(), "padsBboxMil": bbox.map(|b| json!([mil2(b.0), mil2(b.1), mil2(b.2), mil2(b.3)])), "nets": nets, "bodies": if c["has3d"] == json!(true) { 1 } else { 0 }})+    }).collect();+    let mut out = json!({"success": true, "data": {"kind": "board", "file": r.file, "outline": m["outline"], "components": comps, "census": m["counts"], "tracks": m["counts"]["tracks"], "frame": m["frame"]}});+    crate::verbs_standard::stamp(&mut out, &r);+    out+}++fn kicad_running() -> bool {+    kicad_platform::native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).map(|p| !p.is_empty()).unwrap_or(false)+}++const FREE: &str = "free: KiCad is GPL-3.0-or-later and needs no licence, seat or sign-in";++fn ensure_licensed(state: &mut State) -> Value {+    if state.kicad_info().primary().is_none() {+        return fail("not_installed", "KiCad is not installed", "kicad_upgrade installs it with the official installer.");+    }+    let mut steps = vec![json!("installed")];+    if kicad_running() {+        steps.push(json!("already running"));+    } else {+        let r = crate::verbs::call(state, "kicad_launch", &json!({}));+        if r["success"] != json!(true) {+            return r;+        }+        steps.push(json!("launched"));+    }+    json!({"success": true, "alreadyLicensed": true, "licenceState": FREE, "seatStuck": false, "needsHuman": Value::Null, "page": Value::Null, "steps": steps, "summary": "KiCad is ready; it needs no licence."})+}++fn signin_status(state: &mut State) -> Value {+    let installed = state.kicad_info().primary().is_some();+    json!({"success": true, "licenceState": if installed { FREE } else { "not installed" }, "licensed": installed, "running": kicad_running(), "workspace": Value::Null, "workspaceConnected": false, "workspaceMenu": "none: KiCad has no account or cloud workspace"})+}++fn setup_trial(state: &mut State) -> Value {+    let (stage, next, summary) = if state.kicad_info().primary().is_none() {+        ("install", "kicad_upgrade", "KiCad is not installed; kicad_upgrade installs it with the official installer.")+    } else if !kicad_running() {+        ("launch", "kicad_launch", "KiCad is installed and not running; kicad_launch starts it.")+    } else {+        ("done", "none", "KiCad is installed and running. It has no trial or sign-in.")+    };+    json!({"success": true, "stage": stage, "nextAction": next, "summary": summary, "licenceState": FREE, "needsHuman": Value::Null})+}++/// A symbol's fields as kicad_sym_info reads them; None when it cannot be read.+fn symbol_fields(state: &mut State, args: &Value) -> Result<Value, Value> {+    let lib = arg_str(args, "libraryPath").unwrap_or("");+    let name = arg_any(args, &["symbolName", "name"]).unwrap_or("");+    if lib.is_empty() || name.is_empty() {+        return Err(fail("missing_arg", "libraryPath and symbolName are required", "The .kicad_sym and the symbol in it."));+    }+    let info = crate::verbs::call(state, "kicad_sym_info", &json!({"libraryPath": lib, "name": name}));+    if info["success"] != json!(true) {+        return Err(info);+    }+    Ok(info["fields"].clone())+}++fn set_models(state: &mut State, args: &Value) -> Value {+    let Some(fp) = arg_str(args, "footprint") else {+        return fail("missing_arg", "footprint is required", "Lib:Name, as KiCad's Footprint field holds it.");+    };+    let before = match symbol_fields(state, args) {+        Ok(f) => f["Footprint"].as_str().unwrap_or("").to_string(),+        Err(e) => return e,+    };+    let mut a = json!({"libraryPath": args["libraryPath"], "symbolName": arg_any(args, &["symbolName", "name"]), "footprint": fp});+    if let Some(b) = args.get("backup") {+        a["backup"] = b.clone();+    }+    let r = crate::verbs::call(state, "kicad_set_params", &a);+    if r["success"] != json!(true) {+        return r;+    }+    json!({"success": true, "footprint": fp, "removed": if !before.is_empty() && before != fp { 1 } else { 0 }, "previous": before, "path": r["path"], "backup": r["backup"]})+}++fn add_models(state: &mut State, args: &Value) -> Value {+    let fields = match symbol_fields(state, args) {+        Ok(f) => f,+        Err(e) => return e,+    };+    let models = args.get("models").and_then(Value::as_array).cloned().unwrap_or_default();+    let mut names: Vec<String> = fields["ki_fp_filters"].as_str().unwrap_or("").split_whitespace().map(str::to_string).collect();+    let mut link = None;+    for m in &models {+        let name = m["name"].as_str().or(m.as_str()).unwrap_or("").trim();+        if name.is_empty() {+            return fail("bad_args", "a model has no name", "models is [{name, pcblib?}].");+        }+        if link.is_none() {+            let lib = m["pcblib"].as_str().map(|p| Path::new(p.trim_end_matches(['/', '\\'])).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()).unwrap_or_default();+            link = Some(if lib.is_empty() || name.contains(':') { name.to_string() } else { format!("{lib}:{name}") });+        }+        let filter = name.rsplit(':').next().unwrap_or(name).to_string();+        if !names.contains(&filter) {+            names.push(filter);+        }+    }+    let mut params: serde_json::Map<String, Value> = args.get("params").and_then(Value::as_object).cloned().unwrap_or_default();+    let param_count = params.len();+    if !names.is_empty() {+        params.insert("ki_fp_filters".into(), json!(names.join(" ")));+    }+    if let Some(d) = arg_str(args, "description") {+        params.insert("Description".into(), json!(d));+    }+    let mut a = json!({"libraryPath": args["libraryPath"], "symbolName": arg_any(args, &["symbolName", "name"]), "params": params});+    if let Some(c) = arg_str(args, "comment") {+        a["comment"] = json!(c);+    }+    if let (Some(l), true) = (&link, fields["Footprint"].as_str().unwrap_or("").is_empty()) {+        a["footprint"] = json!(l);+    }+    let r = crate::verbs::call(state, "kicad_set_params", &a);+    if r["success"] != json!(true) {+        return r;+    }+    json!({"success": true, "data": {"libref": arg_any(args, &["symbolName", "name"]), "models": models.len(), "params": param_count}, "filters": names, "fields": r["fields"], "path": r["path"], "backup": r["backup"]})+}++fn instances(state: &mut State) -> Value {+    let version = state.kicad_info().primary().map(|i| if i.cli_version.is_empty() { i.version.clone() } else { i.cli_version.clone() }).unwrap_or_default();+    let ps = kicad_platform::native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();+    let rows: Vec<Value> = ps.iter().map(|p| json!({"pid": p.pid, "key": p.exe, "edition": format!("KiCad {version}"), "licensed": true, "agentAlive": Value::Null, "path": p.path})).collect();+    json!({"success": true, "count": rows.len(), "instances": rows})+}+ fn rules_by_kind(args: &Value) -> Value {     let Some(pro) = arg_any(args, &["projectPath", "filePath", "boardPath"]).map(|p| PathBuf::from(p).with_extension("kicad_pro")) else {         return fail("missing_arg", "projectPath or filePath is required", "The project, or its board: the .kicad_pro and .kicad_dru sit beside it.");⋯ 156 unchanged lines ⋯     }      #[test]+    fn a_sheet_reads_in_the_standards_mils_and_ordinals() {+        let fx = Path::new(env!("CARGO_MANIFEST_DIR")).join("../kicad-core/tests/fixtures/sch-edit/edit10.kicad_sch");+        let (mut st, path) = (State::new(), norm(&fx));+        let r = sheet_read(&mut st, &json!({"filePath": path}), false);+        assert_eq!(r["success"], true, "{r}");+        assert!(r["designators"].as_array().unwrap().iter().any(|d| d == "R1"), "{r}");+        let one = r["components"].as_array().unwrap().iter().find(|c| c["designator"] == "R1").unwrap().clone();+        assert_eq!(one["libReference"], "R");+        assert!(one["params"].as_array().unwrap().iter().any(|p| p["name"] == "Value"), "{one}");+        assert!(one["xMil"].as_f64().unwrap() > 0.0 && one["yMil"].as_f64().unwrap() > 0.0, "y up from the lower left: {one}");+        assert!(one.get("pins").is_none(), "read_schematic has no pins: {one}");+        let d = sheet_read(&mut st, &json!({"filePath": path, "designator": "r1"}), true);+        let c = &d["data"]["components"][0];+        assert_eq!((d["data"]["count"].as_u64(), c["designator"].as_str(), c["pins"].as_array().map(|p| p.len())), (Some(1), Some("R1"), Some(2)), "{d}");+        assert_eq!((c["pins"][0]["electrical"].as_u64(), c["pins"][0]["electricalType"].as_str()), (Some(4), Some("passive")), "{c}");+        assert!(c["pins"][0]["lengthMil"].as_f64().unwrap() > 0.0, "{c}");+        assert!(c.get("params").is_none(), "params are off by default in schematic_data: {c}");+        assert_eq!(sheet_read(&mut st, &json!({"filePath": path, "designator": "NOPE"}), true)["errorCode"], "part_not_found");+        assert_eq!((electrical_ordinal("power_out"), electrical_ordinal("no_connect"), electrical_ordinal("tri_state")), (7, 4, 5));+    }++    #[test]+    fn read_board_gives_the_sections_asked_for() {+        let fx = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../demo/placement/esc-g431-unrouted.kicad_pcb");+        let mut st = State::new();+        let all = read_board(&mut st, &json!({"filePath": norm(&fx), "source": "file"}));+        assert_eq!(all["success"], true, "{}", all["error"]);+        for k in ["components", "nets", "layers", "copper", "rules", "occupancy", "outline"] {+            assert!(all["data"].get(k).is_some(), "section {k}");+        }+        let some = read_board(&mut st, &json!({"filePath": norm(&fx), "source": "file", "sections": "components", "limit": 2}));+        assert_eq!((some["data"]["components"].as_array().map(|a| a.len()), some["data"].get("copper").is_none(), some["truncated"].clone()), (Some(2), true, json!(["components"])));+        assert_eq!(read_board(&mut st, &json!({"filePath": norm(&fx), "source": "file", "sections": "nope"}))["errorCode"], "bad_args");+        let rep = pcb_report(&mut st, &json!({"path": norm(&fx), "source": "file"}));+        assert_eq!(rep["data"]["kind"], "board", "{}", rep["error"]);+        assert!(rep["data"]["components"].as_array().unwrap().iter().any(|c| c["pads"].as_u64().unwrap_or(0) > 0 && c["padsBboxMil"].is_array()));+    }++    #[test]+    fn set_and_add_models_write_the_symbols_footprint_and_filters() {+        let d = std::env::temp_dir().join(format!("kb-models-{}", std::process::id()));+        let _ = std::fs::remove_dir_all(&d);+        std::fs::create_dir_all(&d).unwrap();+        let lib = norm(&d.join("M.kicad_sym"));+        let mut st = State::new();+        let c = crate::verbs::call(&mut st, "kicad_sym_create", &json!({"libraryPath": lib, "name": "P", "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B"}]}));+        assert_eq!(c["success"], true, "{c}");+        let a = add_models(&mut st, &json!({"libraryPath": lib, "symbolName": "P", "models": [{"name": "R_0603", "pcblib": "C:/libs/Res.pretty"}, {"name": "R_0805"}], "params": {"MPN": "X1"}, "comment": "10k", "description": "a part"}));+        assert_eq!((a["success"].as_bool(), a["data"]["models"].as_u64(), a["data"]["params"].as_u64()), (Some(true), Some(2), Some(1)), "{a}");+        let i = crate::verbs::call(&mut st, "kicad_sym_info", &json!({"libraryPath": lib, "name": "P"}));+        assert_eq!((i["fields"]["Footprint"].as_str(), i["fields"]["MPN"].as_str(), i["fields"]["Value"].as_str(), i["fields"]["Description"].as_str()), (Some("Res:R_0603"), Some("X1"), Some("10k"), Some("a part")), "{i}");+        assert_eq!(i["fields"]["ki_fp_filters"], "R_0603 R_0805", "{i}");+        let again = add_models(&mut st, &json!({"libraryPath": lib, "symbolName": "P", "models": [{"name": "R_0805"}]}));+        assert_eq!(again["filters"], json!(["R_0603", "R_0805"]), "no duplicates: {again}");+        let s = set_models(&mut st, &json!({"libraryPath": lib, "symbolName": "P", "footprint": "Other:SOT"}));+        assert_eq!((s["footprint"].as_str(), s["removed"].as_u64()), (Some("Other:SOT"), Some(1)), "{s}");+        assert_eq!(set_models(&mut st, &json!({"libraryPath": lib, "symbolName": "P", "footprint": "Other:SOT"}))["removed"], 0);+        assert_eq!(set_models(&mut st, &json!({"libraryPath": lib, "symbolName": "NOPE", "footprint": "x"}))["errorCode"], "symbol_not_found");+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn a_footprint_library_reports_its_numbers() {+        let v = footprint_numbers("(footprint \"X\" (fp_line (start 0 0) (end 1 0)) (fp_line (start 0 0) (end 0 1)) (pad \"1\" smd rect (at -1 0) (size 1 2)) (pad \"2\" smd rect (at 1 0) (size 1 2)) (model \"x.step\"))", "X").unwrap();+        assert_eq!((v["pads"].as_u64(), v["bodies"].as_u64(), v["census"]["line"].as_u64()), (Some(2), Some(1), Some(2)), "{v}");+        assert_eq!(v["padsBboxMil"][2].as_f64(), Some(59.06));+    }++    #[test]     fn every_preset_names_layers_and_bottom_ones_are_mirrored() {         assert!(PRESETS.iter().all(|p| !p.1.is_empty() && !p.4.is_empty()));         assert!(PRESETS.iter().filter(|p| p.0.starts_with("bottom") || p.0 == "assembly_bottom").all(|p| p.2));         assert_eq!(PRESETS.len(), 14, "the standard's fourteen presets");     } }+--- a/rust/crates/kicad-bridge/src/verbs_windows.rs+++ b/rust/crates/kicad-bridge/src/verbs_windows.rs@@ -1,3755 +1,4004 @@⋯ 257 unchanged lines ⋯         name: "kicad_teardrops",         summary: "Add (or remove) teardrops with KiCad's own Edit > Edit Teardrops on the board open in the PCB editor: on vias, PTH pads and SMD pads, optionally track to track, for the whole board or the selection; teardrops counted before and after.",         mechanism: Mechanism::Window, risk: "write", timeout_sec: 180,-        input: "{\"filePath\": \"C:/.../x.kicad_pcb\", \"vias\"?: true, \"pthPads\"?: true, \"smdPads\"?: true, \"tJunctions\"?: false (track to track), \"selectedOnly\"?: false, \"remove\"?: false (removes ALL teardrops)}",-        example: "kicad_teardrops {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"smdPads\":false}",-        hint: "Uses the board's default teardrop shapes (Board Setup > Teardrops). before and after count the teardrop areas; KiCad adds them as one Undo step. Save with kicad_save_board, then check with kicad_run_drc.",-        related: &["kicad_save_board", "kicad_run_drc", "kicad_refill_zones", "kicad_open_board"],-        pitfalls: &["The board must be open in the PCB editor (kicad_open_board); KiCad's API has no teardrop call, so the bridge drives KiCad's own dialog, which shows for a moment.", "Counts need the IPC API on (kicad_ipc_api); without it before and after are null and only the dialog's result is known.", "remove:true removes every teardrop on the board, whatever the scope says."],-    },-    Verb {-        name: "kicad_click",-        summary: "Click at coordinates inside a KiCad window.",-        mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,-        input: "{\"hwnd\": 123, \"x\": 0.5, \"y\": 0.5, \"relative\"?: true}",-        example: "kicad_click {\"hwnd\":123456,\"x\":0.5,\"y\":0.5}",-        hint: "Posts the click to the window's own queue (background: the user's mouse never moves); read window_info + screenshot_all first to compute coordinates. relative:true (default) takes fractions 0.0-1.0 of the window.",-        related: &["kicad_window_info", "kicad_screenshot_all", "kicad_send_key"],-        pitfalls: &["coordinates are window-relative and break if the window resized; re-read window_info", "allowForeground (a real cursor click) is not in the native build"],-    },-    Verb {-        name: "kicad_fix_keyboard",-        summary: "Reset stuck modifier keys on a KiCad window.",-        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,-        input: "{}", example: "kicad_fix_keyboard {}",-        hint: "Clears stuck Ctrl/Shift/Alt state after send_key sequences misbehave. The native build never sends global keystrokes, so there is never anything to fix.",-        related: &["kicad_send_key"],-        pitfalls: &[],-    },-    Verb {-        name: "kicad_place_footprint",-        summary: "Deterministically place a footprint into a board preview.",-        mechanism: Mechanism::Window, risk: "write", timeout_sec: 130,-        input: "{\"fileName\": \"X.kicad_mod\", \"fileContent\": \"<base64>\", \"footprintName\"?: \"X\", \"x\"?: 148, \"y\"?: 105, \"open\"?: true}",-        example: "kicad_place_footprint {\"fileName\":\"BMI423.kicad_mod\",\"fileContent\":\"KGZvb3RwcmludCAu...\"}",-        hint: "Splices a footprint into a fresh preview board via s-expr (no GUI edit) and opens it in pcbnew in the background; good for previews.",-        related: &["kicad_open_board", "kicad_install_footprint"],-        pitfalls: &["operates on the file; close the board in KiCad first or the edit races the GUI"],-    },-    Verb {-        name: "kicad_enable_software_opengl",-        summary: "LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively.",-        mechanism: Mechanism::File, risk: "write", timeout_sec: 240,-        input: "{}", example: "kicad_enable_software_opengl {}",-        hint: "Only when KiCad cannot render at all AND there is no GPU. Drops Mesa opengl32/libgallium DLLs + .local redirection into KiCad bin (needs elevation to write Program Files, or use a per-user install). Close+reopen editors after.",-        related: &["kicad_dismiss_dialogs", "kicad_upgrade"],-        pitfalls: &["a real GPU / Hyper-V GPU-P passthrough is far better; this is CPU rendering, slow", "writing KiCad's bin needs an elevated Bridge"],-    },-    Verb {-        name: "kicad_rescan_libraries",-        summary: "Make newly installed parts visible ONCE: reload every open editor's library tree in place (seconds, nothing closed), then verify each listed footprint by loading it in the Footprint Editor. force:true is the process restart, the last resort.",-        mechanism: Mechanism::Window, risk: "write", timeout_sec: 200,-        input: "{\"footprints\"?: [\"Lib:Name\", ...], \"force\"?: bool, \"confirmRestart\"?: bool}",-        example: "kicad_rescan_libraries {\"footprints\":[\"Adom:LQFP-48_STM32G431\"]}",-        hint: "Wiki #51: install N parts, call this once, then show them all. A restart closes KiCad gracefully first (so it saves its own geometry) and puts every window back on the monitor the user had it on (wiki #48; closeHow and placementRestored report both). Pass footprints so the reply can say what KiCad can load now; without the list nothing is verified. A restart (force) refuses while any KiCad runs unless confirmRestart:true, because the native build cannot yet tell a user-launched KiCad from its own (the ownership ledger arrives with phase 4).",-        related: &["kicad_install_footprint", "kicad_show_footprint", "kicad_list_footprints"],-        pitfalls: &["KiCad 10 caches footprint libraries per process; the tree refresh reveals parts installed after the editor started (measured 2026-09-07), a full restart is only for the cases it does not."],-    },-];--/// Verbs that may spawn or raise a KiCad window: they get the 5-round settle sweep and-/// the foreground etiquette (server.py `_SPAWNS_WINDOW`, this group's members).-const SPAWNS_WINDOW: &[&str] = &["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_place_footprint",-    // loads a footprint into the editor, which KiCad may raise once the load finishes (a late raise on a slow box: winvm)-    "kicad_rescan_libraries"];--/// Verbs that touch no window at all (no sweep, no focus check).-const NO_GUI: &[&str] = &["kicad_fix_keyboard", "kicad_enable_software_opengl"];--pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {-    if !VERBS.iter().any(|v| v.name == command) {-        return None;-    }-    // Caller identity, so every ab callback made while serving this verb is attributed-    // "<thread> (via kicad)". ab relays it as args.caller; headers are the fallback.-    ab::set_caller_from_args(args);-    let gui = !NO_GUI.contains(&command);-    let fg_before = if gui { native().foreground().ok().filter(|h| *h != 0) } else { None };-    if gui {-        // Phase 4: arm the guard and snapshot the park baseline for the etiquette loop.-        native().etiquette_begin_verb(command, SPAWNS_WINDOW.contains(&command));-    }-    let mut out = match command {-        "kicad_launch" => launch(state, args),-        "kicad_rescan_libraries" => rescan_libraries(state, args),-        "kicad_open_board" => open_file(state, args, WindowKind::PcbEditor),-        "kicad_open_schematic" => open_file(state, args, WindowKind::SchematicEditor),-        "kicad_open_symbol_editor" => open_symbol_editor(state, args),-        "kicad_open_footprint_editor" => open_footprint_editor(state, args),-        "kicad_open_3d_viewer" => open_3d_viewer(args),-        "kicad_3d_view" => view_3d(args),-        "kicad_refresh_board_views" => refresh_board_views(args),-        "kicad_menu_items" => menu_items(args),-        "kicad_open_editors" => open_editors(),-        "kicad_close" => close_kicad(state, args),-        "kicad_close_symbol_editor" => close_kind(WindowKind::SymbolEditor, "Symbol Editor"),-        "kicad_close_footprint_editor" => close_kind(WindowKind::FootprintEditor, "Footprint Editor"),-        "kicad_close_3d_viewer" => close_kind(WindowKind::Viewer3d, "3D Viewer"),-        "kicad_dismiss_dialogs" => dismiss_dialogs(state, args),-        "kicad_foreground" => foreground(args),-        "kicad_dialog_log" => dialog_log(args),-        "kicad_window_info" => window_info(state, args),-        "kicad_state" => kicad_state(state, args),-        "kicad_screenshot_all" => screenshot_all(state),-        "kicad_send_key" => send_key(args),-        "kicad_teardrops" => teardrops(state, args),-        "kicad_click" => click(args),-        "kicad_fix_keyboard" => fix_keyboard(),-        "kicad_place_footprint" => place_footprint(state, args),-        "kicad_enable_software_opengl" => enable_software_opengl(state),-        _ => return None,-    };-    if gui && out.get("errorCode") != Some(&json!("not_supported_on_platform")) {-        post_verb(state, command, args, &mut out, fg_before);-    }-    if gui {-        native().etiquette_end_verb(command);-    }-    ab::clear_caller();-    Some(out)-}--/// The server.py dispatch wrapper for a window verb: dialog sweep, foreground etiquette,-/// the one-shot focus check, window labelling.-/// Set when KiCad's own "Could not use OpenGL" box was seen; cleared by kicad_enable_software_opengl.-pub(crate) static NO_OPENGL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);--fn is_3d_verb(command: &str) -> bool {-    matches!(command, "kicad_open_3d_viewer" | "kicad_show_3d_board" | "kicad_show_3d_chip" | "kicad_3d_view")-}--pub(crate) fn post_verb(state: &mut State, command: &str, args: &Value, out: &mut Value, fg_before: Option<u64>) {-    let spawns = SPAWNS_WINDOW.contains(&command);-    let info = state.kicad_info();-    // CONSTANT DIALOG COVERAGE: KiCad throws modal error boxes constantly; sweep after-    // every GUI verb so the AI never runs blind past one and the next verb is unblocked.-    let rounds = if spawns { 5 } else { 1 };-    // Wiki #120: a read-only verb never presses anything. It still reports what it saw.-    let read_only = crate::catalog::find(command).map(|v| v.risk == "read").unwrap_or(false);-    if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600), !read_only) {-        let had_error = swept["hadError"].as_bool().unwrap_or(false);-        if had_error {-            let titles: Vec<String> = swept["dialogs"].as_array().map(|a| a.iter().filter(|d| !d["benign"].as_bool().unwrap_or(false)).map(|d| format!("\"{}\"", d["title"].as_str().unwrap_or(""))).collect()).unwrap_or_default();-            out["_dialogHint"] = json!(if read_only {-                format!("KiCad has {} dialog(s) open (non-benign: {}). This verb is read-only, so it pressed nothing: read each body in _autoDialogs and decide (kicad_dismiss_dialogs, or answer it yourself).", swept["count"], titles.join(", "))-            } else {-                format!("KiCad popped {} dialog(s) during this verb (non-benign: {}). Each was screenshotted (see _autoDialogs[].screenshot + .body) and auto-expired so you are unblocked. If your verb 'failed'/'did not open', this dialog is almost certainly why: read the body and act on it.", swept["count"], titles.join(", "))-            });-        }-        // KiCad says this once per process start on a machine without usable OpenGL (a VM with no-        // GPU, RDP): 2D falls back to software, the 3D viewer stays BLACK. Remember it, so the 3D-        // verbs stop answering ok over a black canvas (wiki #100, seen on winvm).-        if swept["dialogs"].as_array().is_some_and(|a| a.iter().any(|d| d["body"].as_str().unwrap_or("").contains("Could not use OpenGL"))) {-            NO_OPENGL.store(true, std::sync::atomic::Ordering::Relaxed);-        }-        out["_autoDialogs"] = swept;-    }-    if is_3d_verb(command) && NO_OPENGL.load(std::sync::atomic::Ordering::Relaxed) && out.get("success").and_then(Value::as_bool).unwrap_or(true) {-        out["canvasRendered"] = json!(false);-        out["renderVerified"] = json!(false);-        out["_renderHint"] = json!("KiCad reported \"Could not use OpenGL\" on this machine (no GPU: a VM or a remote session). The PCB and schematic canvases fall back to software, but the 3D viewer needs OpenGL 1.5 and shows a BLACK canvas: do not present its frame as proof. Fix: kicad_close, then kicad_enable_software_opengl (Mesa on the CPU, slow but correct, for GPU-less hosts only), then open again.");-    }-    // Foreground etiquette (John, 2026-08-14 and 2026-08-16): the ONE sanctioned way a-    // KiCad window comes to the front is an explicit foreground flag WITH a reason.-    let wants_fg = user_wants_foreground(args);-    let success = out.get("success").and_then(Value::as_bool).unwrap_or(true);-    if wants_fg && spawns {-        std::thread::sleep(Duration::from_secs(1)); // let the window exist-        let reason = arg_str(args, "foregroundReason").or_else(|| arg_str(args, "reason")).unwrap_or("").trim().to_string();-        let mut shown: Option<u64> = None;-        if !reason.is_empty() {-            if let Some(h) = verb_window(command, out) {-                if bring_to_user(h, &reason) {-                    shown = Some(h);-                }-            }-        }-        out["_broughtToUser"] = json!(shown.is_some());-        out["windowEtiquette"] = json!(if shown.is_some() { "foreground" } else { "background" });-        if shown.is_none() {-            out["errorCode"] = json!("foreground_reason_required");-            out["_foregroundRefused"] = json!("foreground requested without foregroundReason - pass foregroundReason:'<why the user needs to see this>' and the bridge will caption it for 3s, then foreground.");-            out["_hint"] = json!("errorCode foreground_reason_required: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'.");-        }-    } else if spawns && success {-        let o = out.as_object_mut().unwrap();-        o.entry("windowEtiquette").or_insert(json!("background"));-        o.entry("openedInBackground").or_insert(json!(true));-        o.entry("_foregroundHint").or_insert(json!("Window opened in the BACKGROUND - the user's foreground was not touched. To show it to the user: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'. The bridge captions the reason via an ab toast for 3 seconds, then brings the window forward. Foreground without a reason is refused."));-    }-    // One-shot focus check (the phase 4 guardian's stand-in): did a KiCad window take the-    // foreground from a user window during this verb? Push it back once, and say so.-    let mut events = Vec::new();-    if !(wants_fg && out["_broughtToUser"] == json!(true)) {-        // With the etiquette loop running (Windows) the one-shot push would be a second action-        // on a window the loop already handled once; keep it only where no loop exists.-        // Exception (gate on 2026-09-12, ConfRoomROG and arav-rog): a freshly spawned editor-        // re-raises itself when its board finishes loading WITHOUT a foreground transition-        // (it already was the foreground while loading), so the loop sees nothing to bounce-        // and the user's window stays covered. For spawn verbs the one-shot check runs-        // regardless, twice: now and after a short settle for the late re-raise.-        let loop_running = native().etiquette_debug() != Value::Null;-        if let Some(ev) = if loop_running && !spawns { None } else { one_shot_focus_check(fg_before) } {-            events.push(ev);-        }-        if spawns {-            // Wait for the editor to finish loading (its "Load PCB" / "Loading" progress-            // dialog gone), because that is the moment it raises itself; then check twice.-            for _ in 0..16 {-                let loading = scan_dialogs().iter().any(|d| wm::is_progress_dialog(&d.title, &d.body));-                if !loading {-                    break;-                }-                sleep_ms(500);-            }-            sleep_ms(700);-            if let Some(ev) = one_shot_focus_check(fg_before) {-                events.push(ev);-            }-            sleep_ms(700);-            if let Some(ev) = one_shot_focus_check(fg_before) {-                events.push(ev);-            }-        }-    }-    // kicad_state already carries the loop's ring buffer; never clobber it with the-    // per-verb list (which is empty while the loop runs).-    if out.get("focusEvents").map(|v| v.as_array().map(|a| a.is_empty()).unwrap_or(true)).unwrap_or(true) {-        out["focusEvents"] = json!(events);-    }-    if spawns {-        out["_userFocusRestored"] = json!(false);-    }-    // #12.3: label the KiCad window this verb raised with the owning AI thread, so the-    // user's taskbar/Alt-Tab shows which of their ~20 tabs owns it. Best-effort.-    if spawns && success {-        let thread = ab::thread_name();-        let label = match command {-            "kicad_open_symbol_editor" => Some("Symbol Editor"),-            "kicad_open_footprint_editor" => Some("Footprint Editor"),-            "kicad_open_board" => Some("PCB Editor"),-            "kicad_open_schematic" => Some("Schematic Editor"),-            "kicad_open_3d_viewer" => Some("3D Viewer"),-            _ => None,-        };-        if let (false, Some(word)) = (thread.is_empty(), label) {-            if let Ok((wins, _)) = find_windows(false, true) {-                if let Some(w) = wins.iter().find(|w| w.title.to_lowercase().contains(&word.to_lowercase())) {-                    if ab::desktop_set_window_identity(w.hwnd, &format!("KiCad ({thread})"), &format!("KiCad {word} - driven by {thread}")).is_ok() {-                        out["_windowLabeledFor"] = json!(thread);-                    }-                }-            }-        }-    }-    invalidate();-}--// ── Platform plumbing ─────────────────────────────────────────────────────────--fn is_not_impl(e: &str) -> bool {-    e.contains(": not implemented on ")-}--/// Map a platform Result: "not implemented on <os>" becomes the honest-/// not_supported_on_platform reply; anything else is a platform_error.-fn plat<T>(feature: &str, r: Result<T, String>) -> Result<T, Value> {-    r.map_err(|e| if is_not_impl(&e) { unsupported(feature) } else { fail("platform_error", format!("{feature}: {e}"), "The OS layer refused. kicad_diagnostics shows the platform capabilities; kicad_screenshot_all shows what is on screen.") })-}--fn exe_suffix() -> &'static str {-    if native().cli_file_name().ends_with(".exe") { ".exe" } else { "" }-}--/// `<bin_dir>/<name>[.exe]` for the primary install.-fn kicad_exe(info: &KicadInfo, name: &str) -> Option<PathBuf> {-    let inst = info.primary()?;-    let p = PathBuf::from(&inst.bin_dir).join(format!("{name}{}", exe_suffix()));-    if p.is_file() { Some(p) } else { None }-}--fn utc_stamp() -> String {-    // 2026-09-11T12:34:56Z -> 20260911-123456-    let s = kicad_common::now_utc_iso();-    let date: String = s.chars().take(10).filter(|c| c.is_ascii_digit()).collect();-    let time: String = s.chars().skip(11).take(8).filter(|c| c.is_ascii_digit()).collect();-    format!("{date}-{time}")-}--/// Spawn a KiCad GUI without activating its window, stderr captured to a launch log.-fn spawn_background(exe: &Path, args: &[String], tag: &str, trace_masks: Option<&str>) -> Result<(u32, Option<PathBuf>), Value> {-    skip_setup_wizard();-    let log = bridge_log::log_dir().and_then(|d| bridge_log::new_log_path(&d, &utc_stamp(), tag).ok());-    let env = bridge_log::trace_env(trace_masks);-    let pid = plat("spawn_background", native().spawn_background(exe, args, &env, log.as_deref()))?;-    native().etiquette_register_pid(pid, None);-    invalidate();-    Ok((pid, log))-}--/// Is `name` a row of the user's own fp-lib-table (not only reachable through a nested table)?-fn user_table_row(ctx: &LibCtx, name: &str) -> bool {-    ctx.fp_lib_table()-        .and_then(|p| kicad_core::libraries::LibTable::parse_file(&p).ok())-        .is_some_and(|t| t.entries.iter().any(|e| e.name == name && !e.lib_type.eq_ignore_ascii_case("Table")))-}--/// Before a KiCad process starts, and only while none runs: mark KiCad's setup wizard as done so-/// it never pops up in front of the user (see kicad_common::mark_first_run_done).-fn skip_setup_wizard() {-    if !native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default().is_empty() {-        return;-    }-    let versions: Vec<String> = kicad_core::detect::detect().installs.iter().map(|i| i.version.clone()).collect();-    for v in versions {-        if let Some(cfg) = native().config_dir(&v) {-            match kicad_common::mark_first_run_done(&cfg) {-                Ok(true) => eprintln!("[kicad-bridge] KiCad {v}: setup wizard marked done (kicad.json first_run_shown), it will not pop up again"),-                Ok(false) => {}-                Err(e) => eprintln!("[kicad-bridge] KiCad {v}: could not mark the setup wizard done: {e}"),-            }-        }-    }-}--fn sleep_ms(ms: u64) {-    std::thread::sleep(Duration::from_millis(ms));-}--fn user_wants_foreground(args: &Value) -> bool {-    ["foreground", "showToUser", "bringToFront"].iter().any(|k| args.get(*k).and_then(Value::as_bool).unwrap_or(false))-}--fn user_idle_seconds() -> Option<f64> {-    native().seconds_since_input().ok().filter(|s| *s < 1e8).map(|s| (s * 10.0).round() / 10.0)-}--// ── Window finder (kicad_windows.py): ab first for z-order, local fallback ────--#[derive(Clone, Debug, Default)]-pub(crate) struct Win {-    pub(crate) hwnd: u64,-    pub(crate) title: String,-    class_name: String,-    /// x, y, width, height.-    rect: (i32, i32, i32, i32),-    z: Option<i64>,-    owner: u64,-    pid: u32,-    exe: String,-    minimized: bool,-    maximized: bool,-    hung: bool,-}--impl Win {-    fn kind(&self) -> WindowKind {-        wm::classify(&self.title, &self.class_name, self.owner)-    }-    fn is_kicad(&self) -> bool {-        if !self.exe.is_empty() {-            return wm::is_kicad_exe(&self.exe);-        }-        // No exe known (an ab older than 2.1.12 on a platform with no window_info): fail-        // open on the title, like the Python fallback.-        wm::title_looks_kicad(&self.title)-    }-    fn rect_json(&self) -> Value {-        json!({"x": self.rect.0, "y": self.rect.1, "width": self.rect.2, "height": self.rect.3})-    }-    /// `z` is the window's position in the OS enumeration, which EnumWindows returns front-    /// first, so a caller can tell which of two windows of a kind the user was looking at-    /// (wiki #48). ab's inventory carries its own z; this is the native fallback's.-    fn from_info(w: &WindowInfo, z: usize) -> Win {-        Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: Some(z as i64), owner: w.owner, pid: w.pid, exe: w.exe.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung }-    }-}--struct Cache {-    at: Option<Instant>,-    rows: Vec<Win>,-    source: &'static str,-}--static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), source: "(not yet queried)" });-const CACHE_TTL: Duration = Duration::from_millis(250);--/// Call after anything that opens, closes, or retitles a KiCad window.-pub(crate) fn invalidate() {-    if let Ok(mut c) = CACHE.lock() {-        c.at = None;-        c.rows.clear();-    }-}--fn list_all_windows() -> Result<(Vec<Win>, &'static str), Value> {-    match ab::desktop_list_windows() {-        Ok(l) if !(l.windows.is_empty() && l.degraded) => {-            let mut rows: Vec<Win> = l.windows.iter().map(|w| Win {-                hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: w.z, owner: w.owner, pid: w.pid,-                exe: w.image.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung,-            }).collect();-            // ab before 2.1.12 carries no image/owner (they arrived together): fill both-            // from the OS layer when it can, only for rows that lack the exe.-            for r in rows.iter_mut() {-                if r.exe.is_empty() {-                    if let Ok(Some(i)) = native().window_info(r.hwnd) {-                        r.exe = i.exe;-                        if r.owner == 0 { r.owner = i.owner; }-                        if r.class_name.is_empty() { r.class_name = i.class_name; }-                    }-                }-            }-            Ok((rows, "ad:desktop_list_windows"))-        }-        _ => {-            let rows = plat("window enumeration (list_windows)", native().list_windows())?;-            Ok((rows.iter().filter(|w| w.visible).enumerate().map(|(z, w)| Win::from_info(w, z)).collect(), "local:EnumWindows"))-        }-    }-}--/// KiCad windows now (all_windows: every top-level window). Cached 250 ms.-fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static str), Value> {-    let mut c = CACHE.lock().map_err(|_| fail("internal", "window cache poisoned", "Retry."))?;-    let stale = fresh || c.at.map(|t| t.elapsed() > CACHE_TTL).unwrap_or(true);-    if stale {-        let (rows, src) = list_all_windows()?;-        c.rows = rows;-        c.source = src;-        c.at = Some(Instant::now());-    }-    let src = c.source;-    if all_windows {-        return Ok((c.rows.clone(), src));-    }-    // A #32770 owned by a KiCad window is KiCad's too (kicad_ui._find_all_kicad_windows).-    let kicad_hwnds: Vec<u64> = c.rows.iter().filter(|r| r.is_kicad()).map(|r| r.hwnd).collect();-    let rows = c.rows.iter().filter(|r| r.is_kicad() || (r.class_name == wm::DIALOG_CLASS && r.owner != 0 && kicad_hwnds.contains(&r.owner))).cloned().collect();-    Ok((rows, src))-}--pub(crate) fn kicad_windows() -> Vec<Win> {-    find_windows(false, false).map(|(w, _)| w).unwrap_or_default()-}--pub(crate) fn windows_of_kind(kind: WindowKind) -> Vec<Win> {-    kicad_windows().into_iter().filter(|w| w.kind() == kind).collect()-}--fn first_of_kind(kind: WindowKind) -> Option<u64> {-    windows_of_kind(kind).first().map(|w| w.hwnd)-}--/// Wiki #48: learn where the user keeps each KiCad window, per window KIND, so a restart can-/// put it back. Called before anything the bridge does that destroys geometry (a restart, a-/// close) and whenever the inventory is in hand anyway. Minimized and parked windows teach-/// nothing, so they are skipped.-pub(crate) fn remember_placement() -> usize {-    let now = utc_stamp();-    let mut mem = window_memory::load();-    let mut learned = 0;-    // Front to back, so that when two windows share a kind (two PCB Editors, say) the one the-    // user was actually looking at is the one whose place is remembered for that kind.-    let mut windows = kicad_windows();-    windows.sort_by_key(|w| w.z.unwrap_or(i64::MAX));-    let mut seen: Vec<WindowKind> = Vec::new();-    for w in windows {-        let kind = w.kind();-        if matches!(kind, WindowKind::Dialog | WindowKind::Other) || seen.contains(&kind) {-            continue;-        }-        seen.push(kind);-        // placement() reports the NORMAL rect plus the maximized flag, which is what has to-        // come back; the enumerated rect is the fallback where that call is unavailable.-        let place = native().placement(w.hwnd).unwrap_or(Placement {-            x: w.rect.0, y: w.rect.1, width: w.rect.2, height: w.rect.3, maximized: w.maximized,-        });-        if window_memory::observe(&mut mem, kind.as_str(), &place, w.minimized, &now) {-            learned += 1;-        }-    }-    if learned > 0 {-        if let Err(e) = window_memory::save(&mem) {-            eprintln!("[placement] could not save the placement memory: {e}");-        }-    }-    learned-}--/// Wiki #48: put each window back where the user had it, without activating it. A fresh-/// KiCad process places every window on the PRIMARY display, which is how a multi-monitor-/// user's PCB Editor kept coming back to the laptop panel. Returns one row per window so the-/// caller's reply can say what moved and what was left alone.-pub(crate) fn restore_placement() -> Vec<Value> {-    let mem = window_memory::load();-    if mem.is_empty() {-        return Vec::new();-    }-    kicad_windows().iter().filter_map(|w| restore_one(&mem, w.kind(), w.hwnd)).collect()-}--/// Wiki #48: one freshly opened window put back on the monitor the user keeps that kind of-/// window on. Called when a verb OPENS an editor, not only when it restarts KiCad, because-/// "the bridge opens or restarts KiCad and it comes back on the laptop screen" is one-/// complaint with two halves. Returns None when nothing is remembered for that kind.-pub(crate) fn restore_placement_one(kind: WindowKind, hwnd: u64) -> Option<Value> {-    restore_one(&window_memory::load(), kind, hwnd)-}--fn restore_one(mem: &window_memory::Memory, kind: WindowKind, hwnd: u64) -> Option<Value> {-    let remembered = mem.get(kind.as_str())?;-    let r = &remembered.place;-    let current = match native().placement(hwnd) {-        Ok(p) => p,-        Err(e) => return Some(json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e})),-    };-    let on_monitor = native().on_a_monitor((r.x, r.y, r.width, r.height)).unwrap_or(false);-    Some(match window_memory::decide(r, &current, on_monitor) {-        Restore::Skip(why) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "why": why}),-        Restore::Move(p) => match native().set_placement(hwnd, &p) {-            Ok(()) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": true,-                "to": {"x": p.x, "y": p.y, "width": p.width, "height": p.height, "maximized": p.maximized},-                "rememberedAt": remembered.at}),-            Err(e) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e}),-        },-    })-}--fn find_project_manager() -> Option<u64> {-    kicad_windows().iter().find(|w| wm::is_project_manager(&w.title, w.owner)).map(|w| w.hwnd)-}--/// A window's current title without hanging on a modal-blocked thread.-pub(crate) fn title_of(hwnd: u64) -> String {-    if let Ok(Some(i)) = native().window_info(hwnd) {-        return i.title;-    }-    find_windows(true, true).ok().and_then(|(w, _)| w.into_iter().find(|w| w.hwnd == hwnd)).map(|w| w.title).unwrap_or_default()-}--pub(crate) fn window_alive(hwnd: u64) -> bool {-    match native().window_info(hwnd) {-        Ok(Some(i)) => i.visible,-        Ok(None) => false,-        Err(_) => find_windows(true, true).map(|(w, _)| w.iter().any(|w| w.hwnd == hwnd)).unwrap_or(false),-    }-}--/// The window a verb surfaced, for foreground and labelling: the kind the verb opens,-/// else the topmost KiCad window.-fn verb_window(command: &str, out: &Value) -> Option<u64> {-    if let Some(h) = out.get("hwnd").and_then(Value::as_u64).filter(|h| *h != 0) {-        return Some(h);-    }-    let kind = match command {-        "kicad_open_symbol_editor" => Some(WindowKind::SymbolEditor),-        "kicad_open_footprint_editor" => Some(WindowKind::FootprintEditor),-        "kicad_open_board" | "kicad_place_footprint" => Some(WindowKind::PcbEditor),-        "kicad_open_schematic" => Some(WindowKind::SchematicEditor),-        "kicad_open_3d_viewer" => Some(WindowKind::Viewer3d),-        _ => None,-    };-    let wins = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();-    kind.and_then(|k| wins.iter().find(|w| w.kind() == k).map(|w| w.hwnd)).or_else(|| wins.first().map(|w| w.hwnd))-}--// ── Focus etiquette (win_focus.py, the one-shot part only) ────────────────────--static FOCUS_EVENTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());--fn record_focus_event(ev: Value) {-    if let Ok(mut g) = FOCUS_EVENTS.lock() {-        g.push(ev);-        if g.len() > 200 {-            let n = g.len() - 200;-            g.drain(..n);-        }-    }-}--fn is_kicad_hwnd(hwnd: u64) -> bool {-    if let Ok(Some(i)) = native().window_info(hwnd) {-        return wm::is_kicad_exe(&i.exe);-    }-    kicad_windows().iter().any(|w| w.hwnd == hwnd)-}--/// capture_foreground before, compare after: if a KiCad window now holds the foreground-/// and the user had a non-KiCad window before, push KiCad back once (never activate the-/// user's window: Windows re-activates it naturally) and report the event.-fn one_shot_focus_check(fg_before: Option<u64>) -> Option<Value> {-    // Part of the background guard: with the guard OFF the bridge moves no KiCad window at all.-    // Before 1.0.48 this per-verb check ignored the switch, so "guard off" still pushed KiCad back-    // after every verb and the A/B comparison showed nothing (AdomLapper, 2026-09-21).-    if !native().etiquette_enabled() {-        return None;-    }-    let before = fg_before?;-    if is_kicad_hwnd(before) || native().is_shell_window(before) {-        return None;-    }-    // What covers the user's window is the top of the Z order, not only the activated-    // window: a KiCad editor raises itself when its board finishes loading without a-    // foreground transition (gate on 2026-09-12).-    let fg = native().foreground().ok().filter(|h| *h != 0);-    let top = native().z_top().ok().filter(|h| *h != 0);-    let after = match (fg, top) {-        (Some(f), _) if is_kicad_hwnd(f) => f,-        (_, Some(t)) if is_kicad_hwnd(t) => t,-        _ => return None,-    };-    if after == before {-        return None;-    }-    let title = title_of(after);-    let pushed = native().push_to_background(after).is_ok();-    // A Z-order push alone does not hold: the editor still owns the activation and raises-    // itself again on its next paint. Give the user's window its activation back (this-    // restores what the user had before the verb; it is not a foreground of ours).-    // ASK for the user's window back, never force it (no thread-input attach): the forcing-    // bring_to_front here was the same call that crashed Explorer when aimed at the taskbar.-    let restored = native().request_foreground(before);-    let action = match (pushed, restored) {-        (true, true) => "pushed-to-background, user window restored",-        (true, false) => "pushed-to-background",-        (false, true) => "user window restored",-        (false, false) => "push-failed",-    };-    let ev = json!({"event": "self-raise", "hwnd": after, "title": title, "userWindow": before, "action": action, "at": kicad_common::now_utc_iso()});-    record_focus_event(ev.clone());-    Some(ev)-}--/// Explicit, user-requested foreground (win_focus.bring_to_user): caption the reason-/// first, then restore and activate. Refused without a reason.-/// Every dialog a sweep dismissed, newest last (kicad_dialog_log): in memory, the last 200.-static DIALOG_LOG: std::sync::Mutex<std::collections::VecDeque<Value>> = std::sync::Mutex::new(std::collections::VecDeque::new());--fn log_dialog(entry: &Value, benign: bool) {-    let at = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);-    let mut e = json!({"at": at, "title": entry["title"], "body": entry["body"], "benign": benign, "action": entry["action"]});-    if let Some(s) = entry["screenshot"].as_str().filter(|s| !s.is_empty()) {-        e["screenshot"] = json!(s);-    }-    if let Ok(mut l) = DIALOG_LOG.lock() {-        l.push_back(e);-        while l.len() > 200 {-            l.pop_front();-        }-    }-}--/// kicad_dialog_log: the last `limit` dismissals.-fn dialog_log(args: &Value) -> Value {-    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(25).clamp(1, 200) as usize;-    let all: Vec<Value> = DIALOG_LOG.lock().map(|l| l.iter().cloned().collect()).unwrap_or_default();-    let entries: Vec<Value> = all.iter().skip(all.len().saturating_sub(limit)).cloned().collect();-    json!({"success": true, "entries": entries, "total": all.len(),-        "output": format!("{} dialog(s) dismissed since the bridge started; {} shown", all.len(), entries.len()),-        "_hint": "Newest last. at is Unix seconds. Dialogs left open are not here: kicad_window_info lists them."})-}--/// kicad_foreground: one KiCad window to the front, captioned with the reason first (bring_to_user).-fn foreground(args: &Value) -> Value {-    let reason = arg_any(args, &["reason", "foregroundReason"]).unwrap_or("").trim().to_string();-    if reason.chars().count() < 10 {-        return fail("reason_required", "kicad_foreground needs a reason of 10 characters or more, written for the person", "Say why they need to see it, e.g. \"the routed board is ready for your review\".");-    }-    invalidate();-    let kind = |k: &str| match k {-        "pcb" | "board" => Some(WindowKind::PcbEditor),-        "sch" | "schematic" => Some(WindowKind::SchematicEditor),-        "fp" | "footprint" => Some(WindowKind::FootprintEditor),-        "sym" | "symbol" => Some(WindowKind::SymbolEditor),-        "3d" => Some(WindowKind::Viewer3d),-        "manager" | "project" => Some(WindowKind::ProjectManager),-        _ => None,-    };-    let hwnd = match (arg_any_u64(args, &["hwnd"]), arg_str(args, "editor")) {-        (Some(h), _) => Some(h),-        (None, Some(e)) => match kind(&e.to_lowercase()) {-            Some(k) => first_of_kind(k),-            None => return fail("bad_args", format!("unknown editor {e}"), "editor is pcb, sch, fp, sym, 3d or manager."),-        },-        (None, None) => first_of_kind(WindowKind::PcbEditor).or_else(|| first_of_kind(WindowKind::SchematicEditor)).or_else(|| kicad_windows().into_iter().map(|w| w.hwnd).next()),-    };-    let Some(h) = hwnd else {-        return fail("no_window", "no KiCad window is open", "Open one first (kicad_open_board, kicad_open_schematic, kicad_launch).");-    };-    let title = kicad_windows().into_iter().find(|w| w.hwnd == h).map(|w| w.title).unwrap_or_default();-    let shown = bring_to_user(h, &reason);-    json!({"success": shown, "hwnd": h, "title": title, "broughtToUser": shown, "windowEtiquette": if shown { "foreground" } else { "background" },-        "errorCode": if shown { Value::Null } else { json!("foreground_failed") },-        "output": if shown { format!("{title} is in front of the user: {reason}") } else { format!("could not bring {title} forward") },-        "_hint": "Hand the screen back when they are done: every other verb works in the background."})-}--fn bring_to_user(hwnd: u64, reason: &str) -> bool {-    if reason.trim().is_empty() {-        return false;-    }-    // The toast is debounced (settings.foregroundToastMinutes, 0 = never): ab mirrors it to-    // the user's other boxes, and one per verb from a busy thread was worse than the-    // foreground itself. The caption is local to the driven box and always shown.-    let toast = foreground_toast_due();-    if toast {-        let msg = format!("KiCad bridge needs your screen for ~3s: {reason}. Background methods were exhausted; this was explicitly allowed by the calling AI (foreground:true).");-        let _ = ab::notify_user("Taking the foreground briefly", &msg, "warning");-    }-    let _ = ab::desktop_caption("kicad-foreground", reason, 3000, "Announce the foreground the calling AI asked for before the KiCad window moves");-    sleep_ms(1200); // let the caption render BEFORE the window moves-    native().etiquette_sanction(hwnd, 20.0); // the loop must not bounce a foreground the AI asked for-    let ok = match native().bring_to_front(hwnd) {-        Ok(b) => b,-        Err(e) if is_not_impl(&e) => ab::desktop_bring_to_front(hwnd, reason).is_ok(),-        Err(_) => false,-    };-    record_focus_event(json!({"event": "user-foregrounded", "hwnd": hwnd, "reason": reason, "ok": ok, "toast": toast, "at": kicad_common::now_utc_iso()}));-    ok-}--static LAST_FOREGROUND_TOAST: Mutex<Option<Instant>> = Mutex::new(None);--/// One foreground toast per `foregroundToastMinutes`; 0 means never. Records the time-/// when it says yes.-fn foreground_toast_due() -> bool {-    let minutes = kicad_core::settings::get_settings().get("foregroundToastMinutes").and_then(Value::as_u64).unwrap_or(10);-    if minutes == 0 {-        return false;-    }-    let mut last = match LAST_FOREGROUND_TOAST.lock() {-        Ok(g) => g,-        Err(p) => p.into_inner(),-    };-    let due = last.map(|t| t.elapsed() >= Duration::from_secs(minutes * 60)).unwrap_or(true);-    if due {-        *last = Some(Instant::now());-    }-    due-}--// ── Dialogs (close_windows.py) ────────────────────────────────────────────────--#[derive(Clone, Debug, Default)]-pub(crate) struct Dialog {-    pub(crate) hwnd: u64,-    pub(crate) title: String,-    class_name: String,-    owner: u64,-    pub(crate) body: String,-    via_global_sweep: bool,-}--/// A dialog's message text from its Static children (never blocks on a hung window).-fn dialog_body_text(hwnd: u64) -> String {-    let Ok(children) = native().child_windows(hwnd) else { return String::new() };-    let texts: Vec<String> = children.iter().filter(|c| c.class_name == "Static").map(|c| c.title.clone()).collect();-    wm::dialog_body(&texts)-}--/// RELIABLE scan for EVERY dialog around a running KiCad: the KiCad-process windows that-/// are dialogs (Win32 dialog class or owned), plus a GLOBAL #32770/error-title sweep so-/// an error box owned by an untracked pcbnew/eeschema/orphan is caught too.-pub(crate) fn scan_dialogs() -> Vec<Dialog> {-    let Ok((all, _)) = find_windows(true, false) else { return Vec::new() };-    let mut out = Vec::new();-    let mut seen = Vec::new();-    for w in all.iter().filter(|w| w.is_kicad()) {-        if !wm::is_dialog(&w.class_name, w.owner) {-            continue;-        }-        out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: false });-        seen.push(w.hwnd);-    }-    for w in all.iter() {-        if seen.contains(&w.hwnd) {-            continue;-        }-        // Wiki #120: only a dialog OWNED BY A KICAD PROCESS (an orphaned pcbnew/eeschema-        // included) is ever ours. Before 1.0.56 any titled #32770 matched, so a read-only-        // kicad_state pressed a button on an unrelated Remote Desktop Connection dialog.-        if !w.is_kicad() {-            continue;-        }-        if wm::looks_like_global_dialog(&w.title, &w.class_name) {-            out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: true });-            seen.push(w.hwnd);-        }-    }-    out-}--fn find_save_prompt() -> Option<Dialog> {-    scan_dialogs().into_iter().find(|d| wm::is_save_prompt(&d.title, &d.body))-}--/// Dismiss a dialog: a known dismiss button (OK for info boxes, Cancel/Finish for-/// wizards) through a time-boxed click, then Escape + WM_CLOSE. All posted to the exact-/// hwnd, no focus theft.-fn dismiss_dialog(hwnd: u64) -> String {-    for tier in wm::DISMISS_BUTTON_TIERS {-        if native().click_button(hwnd, tier).is_ok() {-            invalidate();-            return "clicked-button".into();-        }-    }-    let _ = native().post_key(hwnd, wm::VK_ESCAPE, None);-    let _ = native().post_close(hwnd);-    invalidate();-    "escape+WM_CLOSE".into()-}--/// Capture a dialog BEFORE we expire it, so the AI can be shown what was auto-closed.-fn screenshot_dialog(hwnd: u64, title: &str) -> String {-    ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None).map(|s| s.full_path).unwrap_or_default()-}--/// Persist the Cairo (software) canvas in every kicad_common.json so KiCad stops-/// attempting OpenGL on this machine. Called only right after dismissing the "could not-/// use OpenGL" notice, which is PROOF OpenGL is unavailable here.-fn force_software_canvas(version: &str) -> bool {-    let Some(root) = native().config_root() else { return false };-    let mut targets: Vec<PathBuf> = Vec::new();-    if !version.is_empty() {-        let d = root.join(kicad_platform::major_minor(version));-        if d.is_dir() {-            targets.push(d.join("kicad_common.json"));-        }-    }-    if targets.is_empty() {-        targets = kicad_common::common_files(&root);-        if targets.is_empty() && !version.is_empty() {-            targets.push(root.join(kicad_platform::major_minor(version)).join("kicad_common.json"));-        }-    }-    let mut changed = false;-    for cfg in targets {-        let mut doc = std::fs::read_to_string(&cfg).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));-        if !wm::force_software_canvas_doc(&mut doc) {-            continue;-        }-        if let Some(p) = cfg.parent() {-            let _ = std::fs::create_dir_all(p);-        }-        if let Ok(text) = serde_json::to_string_pretty(&doc) {-            if std::fs::write(&cfg, text).is_ok() {-                changed = true;-            }-        }-    }-    changed-}--/// close_windows._win_sweep_dialogs: expire the benign dialogs (or ALL dialogs with-/// dismiss_all), looping because dismissing one often spawns the next.-fn sweep_dialogs(info: &KicadInfo, dismiss_all: bool, screenshot: bool) -> Value {-    let mut dismissed = Vec::new();-    let mut remaining = Vec::new();-    let mut forced = false;-    let mut shot: Vec<u64> = Vec::new();-    let deadline = Instant::now() + Duration::from_secs(25);-    for _pass in 0..5 {-        if Instant::now() > deadline {-            break;-        }-        remaining.clear();-        let mut acted = false;-        for d in scan_dialogs() {-            match wm::sweep_decision(&d.title, &d.body, dismiss_all) {-                DialogAction::LeaveProgress => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body, "progress": true})),-                DialogAction::Dismiss { benign } => {-                    let mut entry = json!({"title": d.title, "body": d.body});-                    if screenshot && !shot.contains(&d.hwnd) {-                        entry["screenshot"] = json!(screenshot_dialog(d.hwnd, &d.title));-                        shot.push(d.hwnd);-                    }-                    entry["action"] = json!(dismiss_dialog(d.hwnd));-                    log_dialog(&entry, benign);-                    dismissed.push(entry);-                    acted = true;-                    if benign && !forced {-                        forced = force_software_canvas(&info.version);-                    }-                }-                DialogAction::Leave => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})),-            }-        }-        if !acted {-            break;-        }-        sleep_ms(400);-    }-    json!({"dismissed": dismissed, "remaining": remaining, "forcedSoftwareCanvas": forced})-}--/// close_windows.auto_sweep_dialogs: the post-verb sweep. Settle-polls for a late-/// dialog, screenshots and reads each one found, expires it (never a progress dialog),-/// and returns None when nothing was ever seen.-fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize, settle_gap: Duration, dismiss: bool) -> Option<Value> {-    let rounds = settle_rounds.max(1);-    let mut found = Vec::new();-    for r in 0..rounds {-        invalidate();-        found = scan_dialogs();-        if !found.is_empty() {-            break;-        }-        if r + 1 < rounds {-            std::thread::sleep(settle_gap);-        }-    }-    if found.is_empty() {-        return None;-    }-    let mut dialogs = Vec::new();-    let mut had_error = false;-    let mut seen: Vec<u64> = Vec::new();-    let deadline = Instant::now() + Duration::from_secs(25);-    for _pass in 0..4 {-        if Instant::now() > deadline {-            break;-        }-        let mut acted = false;-        invalidate();-        for d in scan_dialogs() {-            if seen.contains(&d.hwnd) {-                continue;-            }-            seen.push(d.hwnd);-            match wm::auto_sweep_decision(&d.title, &d.body) {-                DialogAction::LeaveProgress => dialogs.push(json!({"title": d.title, "body": d.body, "benign": true, "progress": true, "action": "left-running"})),-                DialogAction::Dismiss { benign } if !dismiss => {-                    if !benign {-                        had_error = true;-                    }-                    dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": "left-open (read-only verb)"}));-                }-                DialogAction::Dismiss { benign } => {-                    if !benign {-                        had_error = true;-                    }-                    let shot = if screenshot { screenshot_dialog(d.hwnd, &d.title) } else { String::new() };-                    let action = dismiss_dialog(d.hwnd);-                    let entry = json!({"title": d.title, "body": d.body, "benign": benign, "action": action, "screenshot": shot});-                    log_dialog(&entry, benign);-                    dialogs.push(entry);-                    acted = true;-                }-                DialogAction::Leave => {}-            }-        }-        if !acted {-            break;-        }-        sleep_ms(350);-    }-    if dialogs.is_empty() {-        return None;-    }-    Some(json!({"count": dialogs.len(), "dialogs": dialogs, "hadError": had_error}))-}--/// The rows `kicad_errors` reads (bridge_log::DialogRow), for the misc group to consume-/// once kicad_errors moves to a group that runs after this one.-pub fn dialog_rows() -> Vec<bridge_log::DialogRow> {-    scan_dialogs().into_iter().map(|d| {-        let texts: Vec<String> = native().child_windows(d.hwnd).map(|c| c.iter().filter(|c| c.class_name != "Button").map(|c| c.title.clone()).filter(|t| !t.trim().is_empty()).collect()).unwrap_or_default();-        bridge_log::DialogRow { hwnd: d.hwnd, title: d.title, texts, uia_text: None }-    }).collect()-}--// ── Menu walk, UIA, posted keys: the rungs ────────────────────────────────────--/// Resolve a menu item whose label contains `needle` from the window's live menu bar and-/// fire it with WM_COMMAND. Ok(Some((id, label))) when fired, Ok(None) when no such item.-fn menu_invoke_by_text(hwnd: u64, needle: &str) -> Result<Option<(u32, String)>, String> {-    let items = native().menu_tree(hwnd)?;-    let want = needle.to_lowercase();-    // an exact label wins over a longer one that contains it: "Zoom to Selection" must never-    // fire "Zoom to Selection Area" (an interactive tool that parks the editor waiting for a drag)-    let clean = |l: &str| l.replace('&', "").split('\t').next().unwrap_or("").trim().to_lowercase();-    if let Some(it) = items.iter().find(|it| it.id != 0 && it.path.last().map(|l| clean(l) == want).unwrap_or(false)) {-        native().menu_invoke(hwnd, it.id)?;-        invalidate();-        return Ok(Some((it.id, it.path.last().unwrap().replace('&', ""))));-    }-    // Wiki #121: a substring that matches more than one command is refused with the choices,-    // never resolved to whichever item comes first.-    let mut hits: Vec<(u32, String)> = Vec::new();-    for it in &items {-        let Some(label) = it.path.last() else { continue };-        if it.id != 0 && clean(label).contains(&want) && !hits.iter().any(|(id, _)| *id == it.id) {-            hits.push((it.id, label.replace('&', "").split('\t').next().unwrap_or("").trim().to_string()));-        }-    }-    match hits.len() {-        0 => Ok(None),-        1 => {-            let (id, label) = hits.remove(0);-            native().menu_invoke(hwnd, id)?;-            invalidate();-            Ok(Some((id, label)))-        }-        _ => Err(format!(-            "ambiguous menu label '{needle}': it matches {}. Pass the exact label of the one you mean.",-            hits.iter().map(|(_, l)| format!("\"{l}\"")).collect::<Vec<_>>().join(", ")-        )),-    }-}--/// win_menu.find_by_accelerator: a chord to the menu command that carries it.-fn menu_invoke_by_accel(hwnd: u64, chord: &str) -> Result<Option<(u32, String)>, String> {-    let items = native().menu_tree(hwnd)?;-    let want = wm::norm_accel(chord);-    for it in items {-        if let Some(a) = &it.accel {-            if wm::norm_accel(a) == want && it.id != 0 {-                native().menu_invoke(hwnd, it.id)?;-                invalidate();-                let label = it.path.last().map(|l| l.replace('&', "")).unwrap_or_default();-                return Ok(Some((it.id, label)));-            }-        }-    }-    Ok(None)-}--/// Poll for a window of `kind` until `deadline`; `repost` fires once at `repost_after`.-fn wait_for_kind(kind: WindowKind, deadline: Instant, repost: Option<(u64, u32)>, repost_after: Duration) -> Option<u64> {-    let t0 = Instant::now();-    let mut reposted = false;-    loop {-        invalidate();-        if let Some(h) = first_of_kind(kind) {-            return Some(h);-        }-        if Instant::now() >= deadline {-            return None;-        }-        if !reposted && t0.elapsed() > repost_after {-            if let Some((host, id)) = repost {-                let _ = native().menu_invoke(host, id);-            }-            reposted = true;-        }-        sleep_ms(500);-    }-}--/// Every descendant (class, text, rect, visible) of a window with its parent handle.-/// The platform's `child_windows` returns the whole subtree with `owner` carrying the-/// PARENT handle (the Windows implementation is built for exactly this join).-#[derive(Clone, Debug)]-struct Child {-    hwnd: u64,-    class_name: String,-    text: String,-    rect: (i32, i32, i32, i32),-    visible: bool,-    parent: u64,-}--fn descendants(root: u64) -> Vec<Child> {-    let Ok(kids) = native().child_windows(root) else { return Vec::new() };-    kids.iter().map(|k| Child { hwnd: k.hwnd, class_name: k.class_name.clone(), text: k.title.clone(), rect: k.rect, visible: k.visible, parent: if k.owner == 0 { root } else { k.owner } }).collect()-}--/// The library tree's filter Edit: class "Edit" under a parent whose text is "searchCtrl".-fn find_search_edit(frame: u64) -> Option<Child> {-    let all = descendants(frame);-    all.iter().find(|c| c.class_name == "Edit" && all.iter().any(|p| p.hwnd == c.parent && p.text == "searchCtrl")).cloned()-}--static TREE_TOGGLED: Mutex<Vec<(u64, Instant)>> = Mutex::new(Vec::new());--/// The tree search control, showing the Library Tree pane through the frame's own View-/// menu when the user has it hidden (toggle at most once per editor per minute).-fn ensure_tree_search(frame: u64, trace: &mut Vec<String>) -> Option<Child> {-    if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {-        return Some(e);-    }-    let may_toggle = TREE_TOGGLED.lock().map(|g| !g.iter().any(|(h, t)| *h == frame && t.elapsed() < Duration::from_secs(60))).unwrap_or(true);-    if may_toggle {-        match menu_invoke_by_text(frame, "library tree") {-            Ok(Some((id, _))) => {-                trace.push(format!("library tree pane was hidden: View menu toggled (id {id})"));-                if let Ok(mut g) = TREE_TOGGLED.lock() {-                    g.retain(|(h, _)| *h != frame);-                    g.push((frame, Instant::now()));-                }-            }-            Ok(None) => trace.push("search Edit not visible and no 'Library Tree' menu item found".into()),-            Err(e) => trace.push(format!("search Edit not visible; menu walk failed: {e}")),-        }-    }-    for _ in 0..16 {-        if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {-            return Some(e);-        }-        sleep_ms(250);-    }-    None-}--/// The rung used to put text in the filter box: "uia" (ValuePattern) else "post" (WM_SETTEXT).-fn set_filter_text(edit: u64, text: &str) -> Result<&'static str, String> {-    if native().uia_set_value(edit, "", text).is_ok() {-        return Ok("uia");-    }-    native().set_text(edit, text).map(|_| "post")-}--fn post_vk(hwnd: u64, vk: u16) {-    let _ = native().post_key(hwnd, vk, None);-}--/// Which editor title check applies during navigation.-#[derive(Clone, Copy)]-enum Verify<'a> {-    Symbol { name: &'a str, library: &'a str },-    Footprint { name: &'a str, library: &'a str },-    Never,-}--fn verify_title(title: &str, v: Verify<'_>) -> bool {-    match v {-        Verify::Symbol { name, library } => wm::title_confirms_symbol(title, name, library),-        Verify::Footprint { name, library } => wm::title_confirms_footprint(title, name, library),-        Verify::Never => false,-    }-}--/// The plugin-free BACKGROUND library-tree load (0.9.83/0.9.89, wiki #71): text into the-/// LIB_TREE filter, Down through the filter box, Enter posted to the tree candidates,-/// Down x1..3 then Enter, a posted double-click on the first row. Zero focus.-/// Returns (confirmed, trace, mechanism).-fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, &'static str) {-    let mut steps: Vec<String> = Vec::new();-    let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)});-    let mut mechanism = "post";-    let Some(edit) = ensure_tree_search(frame, &mut steps) else {-        steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into());-        trace["steps"] = json!(steps);-        return (false, trace, mechanism);-    };-    trace["editHwnd"] = json!(edit.hwnd);-    let mut set = set_filter_text(edit.hwnd, filter);-    if let Err(e) = &set {-        // A slow box (2 cores, software rendering) spends many seconds loading every library the-        // first time an editor opens, and its UI thread answers nothing meanwhile: the text timed out-        // at 1.5 s and the verb gave up on a symbol that exists (winvm). Wait for the editor to answer-        // again, then set the text once more.-        steps.push(format!("filter text not accepted yet ({e}); waiting for the editor to become responsive"));-        let mut waited = 0;-        while waited < 40 && !native().responsive(frame, 500).unwrap_or(true) {-            sleep_ms(500);-            waited += 1;-        }-        sleep_ms(800);-        set = set_filter_text(edit.hwnd, filter);-    }-    match set {-        Ok(m) => {-            mechanism = m;-            steps.push(format!("filter text set via {m}"));-        }-        Err(e) => {-            steps.push(format!("could not set the filter text: {e}"));-            trace["steps"] = json!(steps);-            return (false, trace, mechanism);-        }-    }-    sleep_ms(1400); // LIB_TREE search debounce on the UI thread + tree rebuild-    // Down THROUGH the filter box: KiCad forwards nav keys to the tree.-    post_vk(edit.hwnd, wm::VK_DOWN);-    sleep_ms(500);-    steps.push(format!("VK_DOWN posted to edit; title now {:?}", title_of(frame)));-    // Tree candidates: tall siblings under the Libraries pane (wx generic controls share-    // bland class names, so geometry is the discriminator) plus any wxDataView.-    let all = descendants(frame);-    let parent = all.iter().find(|c| c.hwnd == edit.hwnd).map(|c| c.parent).unwrap_or(frame);-    let pane = all.iter().find(|c| c.hwnd == parent).map(|c| c.parent).filter(|p| *p != 0).unwrap_or(parent);-    let cands: Vec<Child> = all.iter().filter(|c| c.hwnd != edit.hwnd && (c.parent == pane || all.iter().any(|p| p.hwnd == c.parent && p.parent == pane)) && (c.rect.3 > 200 || c.class_name.starts_with("wxDataView"))).cloned().collect();-    trace["treeCandidates"] = json!(cands.iter().map(|c| json!({"hwnd": c.hwnd, "class": c.class_name, "rect": [c.rect.0, c.rect.1, c.rect.0 + c.rect.2, c.rect.1 + c.rect.3]})).collect::<Vec<_>>());-    let title_ok = || verify_title(&title_of(frame), verify);-    // Round 1: Enter posted straight to each tree candidate.-    for c in &cands {-        post_vk(c.hwnd, wm::VK_RETURN);-    }-    for _ in 0..6 {-        sleep_ms(700);-        if title_ok() {-            steps.push("VK_RETURN to tree candidates -> title confirms".into());-            trace["steps"] = json!(steps);-            return (true, trace, mechanism);-        }-    }-    steps.push(format!("VK_RETURN to {} tree candidate(s); title still {:?}", cands.len(), title_of(frame)));-    // Round 1b/1c (wiki #71): on a FRESH editor nothing is selected, so select from inside-    // the tree: Down (first lands on the library node, second on the symbol row), then Enter.-    for downs in 1..=3 {-        for c in &cands {-            post_vk(c.hwnd, wm::VK_DOWN);-        }-        sleep_ms(400);-        for c in &cands {-            post_vk(c.hwnd, wm::VK_RETURN);-        }-        for _ in 0..5 {-            sleep_ms(600);-            if title_ok() {-                steps.push(format!("Down x{downs} then VK_RETURN to tree candidates -> title confirms"));-                trace["steps"] = json!(steps);-                return (true, trace, mechanism);-            }-        }-        steps.push(format!("Down x{downs} then VK_RETURN to tree; title still {:?}", title_of(frame)));-    }-    // Round 2: a posted double-click on the first visible row (just under the header).-    for c in &cands {-        let x = (c.rect.2 / 3).max(10);-        let y = 58;-        let _ = native().post_click(c.hwnd, x, y, false);-        let _ = native().post_click(c.hwnd, x, y, true);-    }-    for _ in 0..8 {-        sleep_ms(700);-        if title_ok() {-            steps.push("posted double-click on first row -> title confirms".into());-            trace["steps"] = json!(steps);-            return (true, trace, mechanism);-        }-    }-    steps.push(format!("posted double-click on first row; title still {:?}", title_of(frame)));-    trace["steps"] = json!(steps);-    (false, trace, mechanism)-}--// ── Opening an editor frame: menu walk, then UIA ──────────────────────────────--struct Budget {-    t0: Instant,-    limit: Duration,-}--impl Budget {-    fn new(args: &Value) -> Budget {-        let inline = args.get("waitInline").and_then(Value::as_bool).unwrap_or(false);-        Budget { t0: Instant::now(), limit: if inline { Duration::from_secs(1_000_000) } else { Duration::from_secs(110) } }-    }-    fn out(&self) -> bool {-        self.t0.elapsed() > self.limit-    }-    fn elapsed_sec(&self) -> u64 {-        self.t0.elapsed().as_secs()-    }-}--struct OpenSpec {-    kind: WindowKind,-    /// Menu label fragment ("symbol editor") and the UIA accessible name ("Symbol Editor").-    menu_needle: &'static str,-    uia_name: &'static str,-    /// Frames whose menu bar / UIA tree carries the launcher, in preference order-    /// (a running editor of the same family first, the project manager last).-    hosts: &'static [WindowKind],-    error_code: &'static str,-    starting_code: &'static str,-    label: &'static str,-}--const SYMBOL_SPEC: OpenSpec = OpenSpec { kind: WindowKind::SymbolEditor, menu_needle: "symbol editor", uia_name: "Symbol Editor", hosts: &[WindowKind::SchematicEditor, WindowKind::ProjectManager], error_code: "symbol_editor_did_not_open", starting_code: "symbol_editor_starting", label: "Symbol Editor" };-const FOOTPRINT_SPEC: OpenSpec = OpenSpec { kind: WindowKind::FootprintEditor, menu_needle: "footprint editor", uia_name: "Footprint Editor", hosts: &[WindowKind::PcbEditor, WindowKind::ProjectManager], error_code: "footprint_editor_did_not_open", starting_code: "footprint_editor_starting", label: "Footprint Editor" };--/// The early-return contract when a slow cold start would outlive the transport budget-/// (#54 ask 2, wiki #71): honest about whether anything is actually starting.-fn starting_contract(spec: &OpenSpec, budget: &Budget, phase: &str, pathways: &[String]) -> Value {-    invalidate();-    if first_of_kind(spec.kind).is_none() {-        return json!({-            "success": false, "errorCode": spec.error_code, "retryable": true, "retryAfterMs": 5000,-            "phase": phase, "elapsedSec": budget.elapsed_sec(), "pathwaysTried": pathways,-            "error": format!("No {} window exists. The bridge asked KiCad to open one and none appeared, so this is not a slow cold start - nothing is coming up.", spec.label),-            "_hint": format!("Call kicad_open_{} first and check it returns a window, then re-call this verb. If the editor never opens, KiCad is usually busy with a human (it is single-instance and will not service our menu command while someone is driving it) - kicad_window_info shows what is actually open.", spec.kind.as_str()),-        });-    }-    json!({-        "success": false, "errorCode": spec.starting_code, "starting": true, "retryable": true, "statusVerb": "kicad_state",-        "retryAfterMs": 20000, "phase": phase, "elapsedSec": budget.elapsed_sec(),-        "error": format!("The {} is still coming up (cold start indexes every library, which can outlive the transport budget). This call returned early instead of timing out; KiCad keeps working.", spec.label),-        "_hint": "Not a failure - poll kicad_state (or just wait retryAfterMs) and re-call with the SAME args; a warm editor resolves in seconds. Pass waitInline:true to force the old single-call blocking behaviour.",-    })-}--/// Make sure the project manager exists (spawn kicad.exe in the background if not).-fn ensure_project_manager(info: &KicadInfo, pathways: &mut Vec<String>) -> Result<u64, Value> {-    invalidate();-    if let Some(h) = find_project_manager() {-        return Ok(h);-    }-    let Some(exe) = kicad_exe(info, "kicad") else {-        return Err(fail("kicad_exe_not_found", "KiCad executable not found", "Report to the user and ask them to reinstall KiCad; the installation appears corrupted."));-    };-    spawn_background(&exe, &[], "launch", None).map_err(|mut e| { if e["errorCode"] != json!("not_supported_on_platform") { e["_hint"] = json!("Call kicad_close with {\"force\": true} to clear stuck processes, then retry."); } e })?;-    pathways.push("spawned-project-manager".into());-    let deadline = Instant::now() + Duration::from_secs(10);-    loop {-        sleep_ms(500);-        invalidate();-        if let Some(h) = find_project_manager() {-            sleep_ms(2000);-            return Ok(h);-        }-        if Instant::now() > deadline {-            return Err(fail("project_manager_did_not_open", "KiCad launched but project manager window not found after 10s", "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry."));-        }-    }-}--/// Open an editor frame: an existing one wins; else the native menu walk on each host-/// frame (WM_COMMAND, repost once at 10 s); else UIA Invoke. Returns (hwnd, mechanism).-fn open_editor_frame(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {-    let out = open_editor_frame_inner(info, spec, budget, pathways);-    // Wiki #48: a frame the bridge just opened is placed by Windows defaults, which means the-    // primary display. An existing window is the user's own and is never moved.-    if let Ok((hwnd, mechanism)) = &out {-        if *mechanism != "existing" {-            if let Some(row) = restore_placement_one(spec.kind, *hwnd) {-                if row["moved"] == json!(true) {-                    pathways.push(format!("placement-restored:{}", spec.kind.as_str()));-                }-            }-        }-    }-    out-}--fn open_editor_frame_inner(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {-    invalidate();-    if let Some(h) = first_of_kind(spec.kind) {-        return Ok((h, "existing"));-    }-    // Hosts: a live editor frame of the same family first, then the project manager-    // (spawned in the background when nothing at all is running).-    let mut hosts: Vec<u64> = Vec::new();-    for k in spec.hosts {-        if *k == WindowKind::ProjectManager {-            continue;-        }-        hosts.extend(windows_of_kind(*k).iter().map(|w| w.hwnd));-    }-    if spec.hosts.contains(&WindowKind::ProjectManager) {-        let pm = match find_project_manager() {-            Some(h) => h,-            None if hosts.is_empty() => ensure_project_manager(info, pathways)?,-            None => 0,-        };-        if pm != 0 {-            hosts.push(pm);-        }-    }-    if hosts.is_empty() {-        return Err(fail(spec.error_code, format!("No window can host the {} launcher: neither a project manager nor a related editor is open.", spec.label), "Call kicad_launch first, then retry."));-    }-    // Rung: native menu walk + WM_COMMAND (background, version-proof ids).-    let mut menu_unsupported: Option<Value> = None;-    for &host in &hosts {-        if budget.out() {-            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));-        }-        match menu_invoke_by_text(host, spec.menu_needle) {-            Ok(Some((id, _label))) => {-                pathways.push(format!("menu-walk-id-{id}"));-                pathways.push("wm_command".into());-                let deadline = Instant::now() + Duration::from_secs(30);-                if let Some(h) = wait_for_kind(spec.kind, deadline, Some((host, id)), Duration::from_secs(10)) {-                    return Ok((h, "menu"));-                }-                pathways.push("wm_command-no-window".into());-            }-            Ok(None) => pathways.push(format!("menu-walk-miss:{host}")),-            Err(e) if is_not_impl(&e) => { menu_unsupported = Some(unsupported("native menu walk (menu_tree)")); }-            Err(e) => pathways.push(format!("menu-walk-error:{}", e.chars().take(60).collect::<String>())),-        }-    }-    // Rung: UI Automation Invoke by accessible name (no focus, no cursor).-    let mut uia_ran = false;-    for &host in &hosts {-        if budget.out() {-            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));-        }-        match native().uia_invoke(host, spec.uia_name) {-            Ok(_) => {-                uia_ran = true;-                pathways.push("uia".into());-                let deadline = Instant::now() + Duration::from_secs(30);-                if let Some(h) = wait_for_kind(spec.kind, deadline, None, Duration::from_secs(10)) {-                    return Ok((h, "uia"));-                }-                pathways.push("uia-no-window".into());-            }-            Err(e) if is_not_impl(&e) => {-                if let Some(u) = menu_unsupported.take() {-                    return Err(u);-                }-                pathways.push("uia-unsupported".into());-            }-            Err(e) => {-                uia_ran = true;-                pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>()));-            }-        }-    }-    if let (Some(u), false) = (menu_unsupported, uia_ran) {-        return Err(u);-    }-    let idle = user_idle_seconds();-    let busy = idle.map(|i| i < 120.0).unwrap_or(false);-    Err(json!({-        "success": false, "errorCode": spec.error_code, "pathwaysTried": pathways, "userIdleSeconds": idle,-        "error": format!("KiCad accepted the {} command but no editor window appeared within 30s.{}", spec.label,-            if busy { format!(" A human was active on this machine {}s ago, which is the most likely reason: KiCad is single-instance, and while someone is driving it, it does not service our menu command promptly.", idle.unwrap_or(0.0) as i64) } else { String::new() }),-        "_hint": "Retry - this is usually a busy KiCad rather than a broken one, and it succeeds on a machine nobody is touching (measured: 63s cold, 57s with the project manager already running). If it repeats on an IDLE box, that is a real bug worth reporting. Otherwise check for a modal dialog with kicad_screenshot_all and dismiss it with send_key escape.",-    }))-}--/// Ask an open editor to close so the next open re-indexes the libraries: ab's-/// desktop_close_window first (its bookkeeping), local WM_CLOSE otherwise; the WAIT is-/// measured here. Returns (gone, how).-fn close_and_wait(hwnd: u64, reason: &str, timeout: Duration) -> (bool, &'static str) {-    let how = match ab::desktop_close_window(hwnd, reason) {-        Ok(_) => "ad:desktop_close_window",-        Err(_) => {-            if native().post_close(hwnd).is_err() {-                return (false, "none");-            }-            "local:WM_CLOSE"-        }-    };-    invalidate();-    let deadline = Instant::now() + timeout;-    while Instant::now() < deadline {-        sleep_ms(300);-        invalidate();-        if !window_alive(hwnd) {-            return (true, how);-        }-    }-    (false, how)-}--// ── kicad_launch ──────────────────────────────────────────────────────────────--pub(crate) fn launch(state: &mut State, args: &Value) -> Value {-    let existing = match find_windows(false, true) {-        Ok((w, _)) => w,-        Err(e) => return e,-    };-    if !existing.is_empty() {-        return json!({-            "success": true, "alreadyRunning": true,-            "windows": existing.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),-            "output": format!("KiCad is already running ({} window(s)); nothing launched.", existing.len()),-            "_hint": "Idempotent no-op: KiCad was already up. Use kicad_open_editors to inventory editors, or the open_* verbs to open a specific one.",-        });-    }-    let project = arg_str(args, "project").unwrap_or("").trim().to_string();-    if !project.is_empty() && !Path::new(&project).exists() {-        return fail("project_not_found", format!("Project not found: {project}"), "Absolute Windows path required; %VAR% expansion is not performed.");-    }-    let info = state.kicad_info();-    let Some(exe) = kicad_exe(&info, "kicad") else {-        return fail("kicad_exe_not_found", "kicad.exe not found", "No KiCad installation detected. kicad_readiness reports install state; kicad_upgrade installs KiCad silently (per-user, no UAC).");-    };-    let argv: Vec<String> = if project.is_empty() { vec![] } else { vec![project.clone()] };-    let (pid, log) = match spawn_background(&exe, &argv, "launch", arg_str(args, "traceMasks")) {-        Ok(x) => x,-        Err(mut e) => {-            if e["errorCode"] != json!("not_supported_on_platform") {-                e["error"] = json!(format!("Failed to launch KiCad: {}", e["error"].as_str().unwrap_or("")));-            }-            return e;-        }-    };-    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(15.0).max(3.0);-    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);-    let mut windows = Vec::new();-    while Instant::now() < deadline {-        windows = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();-        if !windows.is_empty() {-            break;-        }-        sleep_ms(500);-    }-    let log_path = log.as_ref().map(|p| norm(p));-    if !windows.is_empty() {-        // Wiki #48 ask 2: a cold spawn on a box that has run KiCad before lands on the monitor-        // the user last used, not on whatever Windows defaults to.-        sleep_ms(1200);-        let restored = restore_placement();-        return json!({-            "success": true, "launched": true, "pid": pid, "mechanism": "spawn",-            "windows": windows.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),-            "output": format!("KiCad launched; {} window(s) up.", windows.len()),-            "logPath": log_path, "placementRestored": restored,-            "_hint": "Launched in the BACKGROUND (no focus steal). First run on a fresh install may show a setup wizard; kicad_window_info self-heals benign dialogs; kicad_screenshot_all shows every window if something looks stuck.",-            "_next": ["kicad_window_info", "kicad_open_editors", "kicad_open_board"],-        });-    }-    json!({-        "success": false, "pid": pid, "mechanism": "spawn", "logPath": log_path,-        "error": format!("kicad.exe started but no KiCad window appeared within {}s", wait_s as i64),-        "_hint": "Cold start on a slow box (or software-OpenGL) can exceed the wait; retry kicad_window_info in a few seconds before treating this as a failure. A modal (crash-recovery / first-run) can also hold the window back: kicad_screenshot_all will show it.",-        "statusVerb": "kicad_window_info",-    })-}--// ── kicad_open_board / kicad_open_schematic ───────────────────────────────────--pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");-    }-    let Some(file_path) = arg_str(args, "filePath") else {-        return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the file on the desktop.");-    };-    let path = PathBuf::from(file_path);-    if !path.exists() {-        return fail("file_not_found", format!("File not found: {file_path}"), "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.");-    }-    let (exe_name, label, lint_verb) = match kind {-        WindowKind::PcbEditor => ("pcbnew", "PCB board", "kicad_lint_board"),-        _ => ("eeschema", "schematic", "kicad_lint_schematic"),-    };-    let Some(exe) = kicad_exe(&info, exe_name) else {-        return fail("kicad_exe_not_found", format!("KiCad executable not found: {exe_name}_exe"), "Report to the user and ask them to reinstall KiCad; the installation appears corrupted.");-    };-    let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_default();-    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();-    // Already open: the existing window, not a fresh reload.-    invalidate();-    if let Some(w) = windows_of_kind(kind).into_iter().find(|w| !stem.is_empty() && w.title.to_lowercase().contains(&stem)) {-        // Wiki #122: a title match is only a basename match. For a board, ask KiCad which-        // file it really has open before reusing the window.-        let mut path_verified = false;-        if kind == WindowKind::PcbEditor {-            if let Ok(open) = open_board_paths(Duration::from_secs(3)) {-                let wanted = norm(&path);-                if open.iter().any(|p| same_board_path(p, &wanted)) {-                    path_verified = true;-                } else if !open.is_empty() {-                    // the API serves one editor: a board of the same file name elsewhere, or another editor's-                    // board entirely (a second pcbnew holds the API, and this window cannot be verified)-                    let same_name = open.iter().any(|p| p.replace('\\', "/").rsplit('/').next().map(|f| f.eq_ignore_ascii_case(&name)).unwrap_or(false));-                    let (code, error) = if same_name {-                        ("different_board_same_name", format!("The PCB editor has a different board open with the same file name ({name}): {}", open.join(", ")))-                    } else {-                        ("api_serves_another_board", format!("A PCB editor titled {name} is open, but KiCad's API answers for another editor's board ({}), so this window's board cannot be verified", open.join(", ")))-                    };-                    return json!({-                        "success": false, "errorCode": code, "hwnd": w.hwnd, "title": w.title,-                        "error": error,-                        "openBoards": open, "requested": wanted,-                        "_hint": "KiCad titles show only the file name and its API serves one editor, so the bridge will not guess. Close the other board (save it first if it has changes), or open a copy of yours under a unique file name.",-                    });-                }-            }-        }-        return json!({-            "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",-            "pathVerified": path_verified,-            "output": format!("{label} editor already has {name} open"),-            "_hint": if path_verified { "The existing window was reused, not reloaded (KiCad confirmed the exact path). kicad_close first if you need a fresh load from disk." } else { "The existing window was reused on a file-name match that could not be confirmed against the full path (no KiCad API answer). If a same-named file from another folder could be open, kicad_close it first." },-        });-    }-    let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {-        Ok(x) => x,-        Err(mut e) => {-            if e["errorCode"] != json!("not_supported_on_platform") {-                e["error"] = json!(format!("Failed to launch: {}", e["error"].as_str().unwrap_or("")));-                e["_hint"] = json!("Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.");-            }-            return e;-        }-    };-    // Verify by window title with one deadline (no nested sleeps).-    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);-    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);-    if !native().capabilities().window_control {-        return open_file_without_windows(kind, &path, pid, log, deadline, wait_s, exe_name, label, lint_verb);-    }-    let mut appeared: Option<Win> = None;-    while Instant::now() < deadline {-        invalidate();-        let wins = windows_of_kind(kind);-        appeared = wins.iter().find(|w| w.title.to_lowercase().contains(&stem)).or_else(|| wins.iter().find(|w| w.pid == pid)).cloned();-        if appeared.is_some() {-            break;-        }-        if native().process_alive(pid) == Some(false) {-            break;-        }-        sleep_ms(500);-    }-    let hint = format!(-        "GUI launched. For unfamiliar files (ones you did not generate / have not validated), prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info -> kicad_screenshot_all -> kicad_send_key to handle it; or close + retry after lint."-    );-    let mut out = json!({-        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),-        "output": format!("Launched {label} editor with {name}"),-        "_hint": hint,-    });-    match appeared {-        Some(w) => {-            out["hwnd"] = json!(w.hwnd);-            out["title"] = json!(w.title);-            out["windowAppeared"] = json!(true);-            // Wiki #48: this window was spawned by us, so Windows placed it on the primary-            // display; put it back on the monitor the user keeps this editor on.-            if let Some(row) = restore_placement_one(kind, w.hwnd) {-                out["placementRestored"] = row;-            }-        }-        None => {-            let alive = native().process_alive(pid);-            out["windowAppeared"] = json!(false);-            if alive == Some(false) {-                out["success"] = json!(false);-                out["errorCode"] = json!("editor_exited");-                out["error"] = json!(format!("{exe_name} exited before a {label} window appeared"));-                out["_hint"] = json!(format!("The file probably failed to load: the launch log (logPath) and kicad_log_tail carry KiCad's own message; {lint_verb} says what is wrong with the file."));-            } else {-                out["statusVerb"] = json!("kicad_window_info");-                out["_hint"] = json!(format!("{exe_name} is running but no {label} window titled after the file appeared within {}s (a cold start or a large file can take longer). Poll kicad_window_info; a modal dialog (kicad_screenshot_all) can also be holding it. {hint}", wait_s as i64));-            }-        }-    }-    out-}--/// Wiki #127: no window list on this OS (Linux for now). A board is confirmed through KiCad's-/// own API (the editor reports the exact path it has open); a schematic by the process staying up.-#[allow(clippy::too_many_arguments)]-fn open_file_without_windows(kind: WindowKind, path: &Path, pid: u32, log: Option<PathBuf>, deadline: Instant, wait_s: f64, exe_name: &str, label: &str, lint_verb: &str) -> Value {-    let wanted = norm(path);-    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();-    let mut out = json!({-        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),-        "output": format!("Launched {label} editor with {name}"),-        "windowControl": false,-    });-    let mut confirmed = false;-    let mut last_open: Vec<String> = Vec::new();-    while Instant::now() < deadline {-        if native().process_alive(pid) == Some(false) {-            out["success"] = json!(false);-            out["errorCode"] = json!("editor_exited");-            out["error"] = json!(format!("{exe_name} exited before the {label} loaded"));-            out["_hint"] = json!(format!("The launch log (logPath) carries KiCad's own message; {lint_verb} says what is wrong with the file."));-            return out;-        }-        if kind == WindowKind::PcbEditor {-            if let Ok(open) = open_board_paths(Duration::from_secs(2)) {-                if open.iter().any(|p| same_board_path(p, &wanted)) {-                    confirmed = true;-                    break;-                }-                last_open = open;-            }-        } else if Instant::now() + Duration::from_secs_f64(wait_s) > deadline + Duration::from_secs(5) {-            // A schematic: no API answer to wait for on KiCad 10; five seconds alive is the signal.-            confirmed = true;-            break;-        }-        sleep_ms(500);-    }-    out["loaded"] = json!(confirmed);-    out["_hint"] = json!(if confirmed {-        if kind == WindowKind::PcbEditor { "KiCad's API confirms the board is open, so the live verbs (routing, placement, zones) can use it now. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_render_board or kicad_export_svg for a picture." } else { "The schematic editor is running. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_export_svg or kicad_export_pdf for a picture." }-    } else if last_open.is_empty() {-        "The editor is running but KiCad's API did not report the board in time. A cold start can take longer: poll kicad_routing_state with filePath. If it answers api_server_off, run kicad_ipc_api {\"enable\":true} with KiCad closed."-    } else {-        "The editor is running, but KiCad's API reports a different board (openBoards). KiCad serves one API per machine: the first KiCad that started owns it."-    });-    if !last_open.is_empty() && !confirmed {-        out["openBoards"] = json!(last_open);-    }-    out-}--// ── kicad_open_symbol_editor ──────────────────────────────────────────────────--fn lib_ctx(info: &KicadInfo) -> LibCtx {-    let inst = info.primary();-    let version = inst.map(|i| i.version.clone()).unwrap_or_default();-    let mut ctx = LibCtx {-        version: version.clone(),-        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),-        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },-        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },-        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),-        env_overrides: Default::default(),-    };-    ctx.load_env_overrides();-    ctx-}--/// open_symbol_editor._resolve_symbol_alias: exact, case-insensitive, then a UNIQUE-/// prefix match in either direction against the Adom library's real names.-fn resolve_symbol_alias(info: &KicadInfo, requested: &str) -> (Option<String>, String) {-    let ctx = lib_ctx(info);-    let Some((_table, lib_path)) = libraries::adom_paths(&ctx) else { return (None, "library file not found".into()) };-    if !lib_path.is_file() {-        return (None, "library file not found".into());-    }-    let names = libraries::list_symbols(&lib_path);-    if names.iter().any(|n| n == requested) {-        return (Some(requested.to_string()), "exact".into());-    }-    if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {-        return (Some(n.clone()), "case-insensitive".into());-    }-    let r = requested.to_lowercase();-    let pref: Vec<&String> = names.iter().filter(|n| { let l = n.to_lowercase(); l.starts_with(&r) || r.starts_with(&l) }).collect();-    match pref.len() {-        1 => (Some(pref[0].clone()), "unique prefix match".into()),-        0 => (None, "no match".into()),-        _ => (None, format!("ambiguous: {:?}", pref.iter().take(5).collect::<Vec<_>>())),-    }-}--fn symbol_on_disk(info: &KicadInfo, name: &str) -> bool {-    let ctx = lib_ctx(info);-    libraries::adom_paths(&ctx).map(|(_, p)| p.is_file() && libraries::list_symbols(&p).iter().any(|n| n.eq_ignore_ascii_case(name))).unwrap_or(false)-}--pub(crate) fn open_symbol_editor(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");-    }-    let mut symbol_name = arg_str(args, "symbolName").unwrap_or("").to_string();-    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();-    // Wiki #62: the file is named for the MPN but the symbol inside may not be. Resolve-    // the caller's name against the library's real names BEFORE navigating and say so.-    let mut note = Map::new();-    if !symbol_name.is_empty() && (library_name.is_empty() || library_name.eq_ignore_ascii_case("adom")) {-        let (resolved, how) = resolve_symbol_alias(&info, &symbol_name);-        match resolved {-            Some(r) if r != symbol_name => {-                note.insert("aliasedFrom".into(), json!(symbol_name));-                note.insert("aliasHow".into(), json!(how));-                symbol_name = r;-            }-            None if how.starts_with("ambiguous") => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not in the library and the prefix match is {how}"))); }-            None if how == "no match" => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not a symbol name in the Adom library (no exact, case-insensitive or prefix match)"))); }-            _ => {}-        }-    }-    let mut out = open_symbol_editor_inner(&info, args, &symbol_name, &library_name);-    if !note.is_empty() {-        if let Some(o) = out.as_object_mut() {-            for (k, v) in &note {-                o.insert(k.clone(), v.clone());-            }-            if let Some(from) = note.get("aliasedFrom").and_then(Value::as_str) {-                let prev = o.get("_hint").and_then(Value::as_str).unwrap_or("").to_string();-                o.insert("_hint".into(), json!(format!("Resolved '{from}' to symbol '{symbol_name}' ({}). {prev}", note["aliasHow"].as_str().unwrap_or(""))));-            }-        }-    }-    out-}--fn open_symbol_editor_inner(info: &KicadInfo, args: &Value, symbol_name: &str, library_name: &str) -> Value {-    let spec = &SYMBOL_SPEC;-    let budget = Budget::new(args);-    let rescan = args.get("rescan").and_then(Value::as_bool).unwrap_or(false);-    let mut pathways: Vec<String> = Vec::new();-    invalidate();-    // Already loaded? (#49: the library is a CONSTRAINT, exact library:symbol match only.)-    if !symbol_name.is_empty() && !rescan {-        for w in windows_of_kind(WindowKind::SymbolEditor) {-            if wm::title_confirms_symbol(&w.title, symbol_name, library_name) {-                let _ = native().show_no_activate(w.hwnd);-                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Symbol '{symbol_name}' is already loaded in the Symbol Editor.")});-                merge(&mut v, wm::resolved_fields(&w.title, library_name, "Symbol Editor"));-                return v;-            }-        }-    }-    // rescan:true (0.9.192): the remedy for library_cache_stale. Close the editor so the-    // open below indexes a fresh one. Opt-in: it closes a window the user may be using.-    let mut rescanned = false;-    let mut close_via = "";-    if rescan {-        if let Some(h) = first_of_kind(WindowKind::SymbolEditor) {-            let (gone, how) = close_and_wait(h, "Close the Symbol Editor so its next open re-indexes the symbol libraries", Duration::from_secs(12));-            close_via = how;-            if gone {-                rescanned = true;-                pathways.push("rescan-closed-stale-editor".into());-            } else {-                pathways.push("rescan-close-failed".into());-            }-        }-    }-    let (frame, mechanism) = match open_editor_frame(info, spec, &budget, &mut pathways) {-        Ok(x) => x,-        Err(e) => return e,-    };-    if mechanism != "existing" {-        sleep_ms(1500); // let the frame settle-    }-    let _ = native().show_no_activate(frame);-    if symbol_name.is_empty() && library_name.is_empty() {-        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Symbol Editor is already open.".to_string() } else { format!("Symbol Editor opened successfully ({mechanism}).") }});-    }-    // Library-only browse mode (0.9.92): filter the tree to the library, no part load.-    if symbol_name.is_empty() {-        let (_, trace, nav_mech) = navigate_tree(frame, library_name, Verify::Never);-        return json!({-            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,-            "output": format!("Symbol Editor open with the '{library_name}' library filtered and selected. No symbol was requested, so none was loaded into the canvas."),-            "via": "win32-library-browse",-            "_hint": "Library browse mode: the tree is filtered to the library. To load a part into the canvas, call again with symbolName too.",-        });-    }-    let filter = if library_name.is_empty() { symbol_name.to_string() } else { format!("{library_name} {symbol_name}") };-    let verify = Verify::Symbol { name: symbol_name, library: library_name };-    let mut attempts = 0;-    let mut last_trace = Value::Null;-    let mut last_mech = "post";-    for attempt in 1..=2 {-        attempts = attempt;-        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);-        last_trace = trace;-        last_mech = nav_mech;-        if ok {-            let title = title_of(frame);-            let mut v = json!({-                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,-                "output": format!("Symbol '{symbol_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),-                "navigation": last_trace, "pathwaysTried": pathways, "rescanned": rescanned,-                "editorCloseVia": if rescanned { json!(close_via) } else { Value::Null },-            });-            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));-            return v;-        }-        if budget.out() {-            // Wiki #71: a navigation that ran against an editor that is up is not "starting".-            let title = title_of(frame);-            let mut v = json!({-                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,-                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "rescanned": rescanned, "mechanism": nav_mech,-                "navigation": last_trace,-                "error": format!("The Symbol Editor is open and one background navigation ran, but the title did not confirm '{symbol_name}' before this call's time budget ended (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds). Read navigation.steps and navigation.treeCandidates: zero candidates means Enter reached no window (report it with the trace).",-            });-            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));-            return v;-        }-    }-    // Two background attempts did not confirm. Is the symbol even in the editor's world?-    invalidate();-    if first_of_kind(WindowKind::SymbolEditor).is_none() {-        return json!({-            "success": false, "editorOpened": false, "symbolLoaded": false, "errorCode": "symbol_editor_not_open",-            "error": format!("No Symbol Editor is open, so '{symbol_name}' could not be loaded (it was open earlier in this call if rescan was requested)."),-            "_hint": "The editor is gone rather than confused: nothing to navigate. Call again without rescan to open a fresh one. If this followed rescan:true, the close succeeded and the reopen did not - that is a bridge bug worth reporting, not a library problem.",-        });-    }-    let title = title_of(frame);-    // Wiki #54, #71 (the 0.9.287 staleVeto, ported): an editor THIS call opened indexed the-    // libraries after the library file was last written, so its cache cannot be stale. Saying-    // library_cache_stale there sends the caller to rescan:true, which only closes and reopens-    // an already fresh editor and burns the budget again. The verdict is navigation_failed.-    let on_disk = symbol_on_disk(info, symbol_name);-    let editor_is_ours = mechanism != "existing";-    let stale = on_disk && !rescan && !editor_is_ours;-    if stale {-        let ctx = lib_ctx(info);-        let paths = libraries::adom_paths(&ctx);-        return json!({-            "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,-            "errorCode": "library_cache_stale", "mechanism": last_mech, "navigation": last_trace,-            "symbolInLibraryFile": true,-            "symLibTable": paths.as_ref().map(|(t, _)| norm(t)),-            "libraryFile": paths.as_ref().map(|(_, l)| norm(l)),-            "error": format!("'{symbol_name}' is in the Adom library file on disk, but two background navigations did not load it (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-            "_hint": "The verdict is only 'navigation failed while the symbol is in the Adom file'. Check the sym-lib-table row points at libraryFile (a OneDrive-redirected Documents folder is the classic split), then re-call with rescan:true to close and reopen the editor so it re-indexes; kicad_rescan_libraries force:true restarts KiCad when a reopen is not enough.",-        });-    }-    let mut v = json!({-        "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,-        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,-        "error": format!("Symbol Editor open but '{symbol_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-        "_hint": "Two background attempts did not confirm, so a third is unlikely to help - check that the symbol really exists in this library. The keystroke fallback (allowFocusSteal) is not in the native build; pass foreground:true with a foregroundReason to bring the editor to the user and let them load it.",-    });-    if on_disk && editor_is_ours {-        v["symbolInLibraryFile"] = json!(true);-        v["staleVeto"] = json!(format!("this call opened the Symbol Editor itself ({mechanism}), so it indexed the libraries after the library file was last written: the index cannot be stale, and rescan:true would close and reopen an already fresh editor"));-        v["_hint"] = json!("The symbol IS in the Adom library file and this call opened the editor itself, so the index is fresh: this is a navigation failure, not a stale cache, and rescan:true cannot help. Read navigation.steps and navigation.treeCandidates (zero candidates means Enter reached no window, worth reporting with the trace), then pass foreground:true with a foregroundReason to hand the editor to the user.");-    }-    merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));-    v-}--fn merge(into: &mut Value, extra: Value) {-    if let (Some(o), Some(e)) = (into.as_object_mut(), extra.as_object()) {-        for (k, v) in e {-            o.insert(k.clone(), v.clone());-        }-    }-}--// ── kicad_open_footprint_editor ───────────────────────────────────────────────--pub(crate) fn open_footprint_editor(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");-    }-    let spec = &FOOTPRINT_SPEC;-    let budget = Budget::new(args);-    let footprint_name = arg_any(args, &["footprintName", "footprint"]).unwrap_or("").to_string();-    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();-    let mut pathways: Vec<String> = Vec::new();-    invalidate();-    if !footprint_name.is_empty() {-        for w in windows_of_kind(WindowKind::FootprintEditor) {-            if wm::title_confirms_footprint(&w.title, &footprint_name, &library_name) {-                let _ = native().show_no_activate(w.hwnd);-                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Footprint '{footprint_name}' is already loaded in the Footprint Editor.")});-                merge(&mut v, wm::resolved_fields(&w.title, &library_name, "Footprint Editor"));-                return v;-            }-        }-    }-    if args.get("allowCursorClick").and_then(Value::as_bool).unwrap_or(false) {-        pathways.push("icon_click_not_in_native_build".into());-    } else {-        pathways.push("icon_click_skipped_background_mode".into());-    }-    let (frame, mechanism) = match open_editor_frame(&info, spec, &budget, &mut pathways) {-        Ok(x) => x,-        Err(mut e) => {-            if e["errorCode"] == json!(spec.error_code) {-                e["error"] = json!("Could not open the Footprint Editor via the background path.");-                e["_hint"] = json!("The background rungs (the host frame's native menu, then UI Automation) fired without a Footprint Editor appearing. Open a board first (kicad_open_board) so a PCB Editor hosts the launcher, check kicad_screenshot_all for a modal dialog, then retry. The mouse-moving launcher click (allowCursorClick) is not in the native build.");-            }-            return e;-        }-    };-    if mechanism != "existing" {-        sleep_ms(1500);-    }-    let _ = native().show_no_activate(frame);-    if footprint_name.is_empty() && library_name.is_empty() {-        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Footprint Editor is already open.".to_string() } else { format!("Footprint Editor opened successfully ({mechanism}).") }});-    }-    if footprint_name.is_empty() {-        let (_, trace, nav_mech) = navigate_tree(frame, &library_name, Verify::Never);-        return json!({-            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,-            "output": format!("Footprint Editor open with the '{library_name}' library filtered and selected. No footprint was requested, so none was loaded into the canvas."),-            "via": "win32-library-browse",-            "_hint": "Library browse mode: the tree is filtered to the library. To load a part, call again with footprintName too.",-        });-    }-    let filter = if library_name.is_empty() { footprint_name.clone() } else { format!("{library_name} {footprint_name}") };-    let verify = Verify::Footprint { name: &footprint_name, library: &library_name };-    let mut last_trace = Value::Null;-    let mut last_mech = "post";-    for attempt in 1..=2 {-        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);-        last_trace = trace;-        last_mech = nav_mech;-        if ok {-            let title = title_of(frame);-            let mut v = json!({-                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,-                "output": format!("Footprint '{footprint_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),-                "navigation": last_trace, "pathwaysTried": pathways,-            });-            merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));-            return v;-        }-        if budget.out() {-            return json!({-                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,-                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "mechanism": nav_mech, "navigation": last_trace,-                "error": format!("The Footprint Editor is open and one background navigation ran, but the title did not confirm '{footprint_name}' before this call's time budget ended (title: {})", title_of(frame)),-                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds).",-            });-        }-    }-    let title = title_of(frame);-    let mut v = json!({-        "success": false, "editorOpened": true, "footprintLoaded": false, "attempts": 2, "hwnd": frame,-        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,-        "error": format!("Footprint Editor open but '{footprint_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),-        "_hint": "Two background attempts did not confirm - check that the footprint really exists in this library (kicad_list_footprints). If it was installed after the editor opened, KiCad's library index predates it: kicad_close_footprint_editor and re-call, or kicad_rescan_libraries force:true. The keystroke fallback (allowFocusSteal) is not in the native build.",-    });-    // Wiki #111: tell the three causes apart from the DISK, which the bridge can read and the running-    // KiCad may not have: no such library row, no such footprint in the library, or both are there and-    // this KiCad simply started before the library was registered.-    if !library_name.is_empty() {-        let ctx = lib_ctx(&state.kicad_info());-        let (libs, _) = libraries::list_footprints(&ctx, Some(&library_name));-        match libs.first() {-            None => {-                v["errorCode"] = json!("library_not_registered");-                v["error"] = json!(format!("No footprint library named '{library_name}' in the fp-lib-table of KiCad {} (or its folder is missing), so the Footprint Editor cannot load '{footprint_name}'", ctx.version));-                v["_hint"] = json!("kicad_list_footprints lists the registered libraries (names are case-sensitive in KiCad). Register one with kicad_install_library {libraryPath, libraryType:\"footprint\"}.");-            }-            Some((_, info)) if !info.footprints.iter().any(|f| f.eq_ignore_ascii_case(&footprint_name)) => {-                v["errorCode"] = json!("footprint_not_in_library");-                v["error"] = json!(format!("Library '{library_name}' is registered ({}), but it holds no footprint named '{footprint_name}'", info.path));-                v["availableFootprints"] = json!(info.footprints.iter().take(40).collect::<Vec<_>>());-                v["_hint"] = json!("Pick a name from availableFootprints (kicad_list_footprints {library} has the full list).");-            }-            // Only a row in the user's OWN table can have been added after KiCad started. A stock-            // library (reached through KiCad's nested template table) is loaded by every KiCad at-            // start, so for those the failure is the navigation itself: keep navigation_failed-            // (1.0.37 blamed Resistor_SMD on "registered after launch", AdomLapper 2026-09-21).-            Some(_) if !user_table_row(&ctx, &library_name) => {}-            Some((_, info)) => {-                v["errorCode"] = json!("library_not_loaded_by_running_kicad");-                v["libraryOnDisk"] = json!(info.path);-                v["error"] = json!(format!("'{library_name}:{footprint_name}' exists on disk and the library is in fp-lib-table, but the running KiCad did not load it (title: {}). KiCad reads its library tables when it starts, so a library registered after launch is unknown to it", if title.is_empty() { "unknown" } else { title.as_str() }));-                v["_hint"] = json!(format!("kicad_rescan_libraries {{\"footprints\":[\"{library_name}:{footprint_name}\"],\"force\":true,\"confirmRestart\":true}} restarts KiCad so it re-reads the tables (windows go back where they were; ask the user first if they have unsaved work), then call this verb again. Retrying without that will fail the same way."));-            }-        }-    }-    merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));-    v-}--// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────--static VIEWER_SOURCE_BINDINGS: Mutex<BTreeMap<u64,(u32,u64)>> = Mutex::new(BTreeMap::new());-fn viewer_for_frame(frame:u64, source_pid:u32, windows:Vec<Win>, before:&[u64], editor_count:usize, known:Option<u64>)->Option<Win>{-    let eligible:Vec<_>=windows.into_iter().filter(|w|source_pid!=0 && w.pid==source_pid && (w.owner==0 || w.owner==frame)).collect();-    let exact:Vec<_>=eligible.iter().filter(|w|w.owner==frame).cloned().collect();-    if exact.len()==1{return exact.into_iter().next()}-    if exact.len()>1{return None}-    if let Some(id)=known {if let Some(w)=eligible.iter().find(|w|w.hwnd==id){return Some(w.clone())}}-    let created:Vec<_>=eligible.iter().filter(|w|!before.contains(&w.hwnd)).cloned().collect();-    if created.len()==1{return created.into_iter().next()}-    if editor_count==1 && eligible.len()==1{return eligible.into_iter().next()}-    None-}-fn poll_3d_window(frame:u64,source_pid:u32,before:&[u64],rounds:usize)->Option<Win>{-    for _ in 0..rounds.max(1){-        sleep_ms(750);invalidate();-        let editor_count=kicad_windows().iter().filter(|w|w.pid==source_pid && matches!(w.kind(),WindowKind::PcbEditor|WindowKind::FootprintEditor)).count();-        let known=VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m|m.get(&frame).filter(|(pid,_)|*pid==source_pid).map(|(_,h)|*h));-        if let Some(w)=viewer_for_frame(frame,source_pid,windows_of_kind(WindowKind::Viewer3d),before,editor_count,known){-            if let Ok(mut map)=VIEWER_SOURCE_BINDINGS.lock(){map.insert(frame,(source_pid,w.hwnd));}-            return Some(w)-        }-    }-    None-}--/// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a-/// 3D Viewer window appeared.-fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {-    let source_pid = match kicad_windows().into_iter().find(|w| w.hwnd == frame) {-        Some(w) => w.pid,-        None => return Err(fail("source_editor_missing", "The requested source editor is no longer open.", "Call kicad_open_editors and pass the current pcbHwnd or fpHwnd.")),-    };-    let before:Vec<u64>=windows_of_kind(WindowKind::Viewer3d).iter().map(|w|w.hwnd).collect();-    let mut unsupported_menu: Option<Value> = None;-    match menu_invoke_by_text(frame, "3d viewer") {-        Ok(Some((id, _))) => {-            pathways.push(format!("win32menu(id={id})"));-            if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {-                return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));-            }-        }-        Ok(None) => pathways.push("win32menu-not-found".into()),-        Err(e) if is_not_impl(&e) => unsupported_menu = Some(unsupported("native menu walk (menu_tree)")),-        Err(e) => pathways.push(format!("win32menu-{}", e.chars().take(40).collect::<String>())),-    }-    match native().uia_invoke(frame, "3D Viewer") {-        Ok(_) => {-            pathways.push("uia".into());-            if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {-                return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));-            }-        }-        Err(e) if is_not_impl(&e) => {-            // Neither rung exists on this OS: say so instead of "did not open".-            if let Some(u) = unsupported_menu {-                return Err(u);-            }-            pathways.push("uia-unsupported".into());-        }-        Err(e) => pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>())),-    }-    Ok(None)-}---/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.--/// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.-fn is_view_refresh(path: &[String]) -> bool {-    let clean = |s: &str| {-        s.replace('&', "")-            .split('\t')-            .next()-            .unwrap_or("")-            .trim()-            .to_lowercase()-    };-    path.len() == 2 && clean(&path[0]) == "view" && clean(&path[1]) == "refresh"-}--pub(crate) fn refresh_board_views(args: &Value) -> Value {-    let Some(frame) = arg_any_u64(args, &["pcbHwnd"]) else {-        return fail(-            "source_editor_required",-            "Pass the exact pcbHwnd.",-            "Read kicad_open_editors first.",-        );-    };-    invalidate();-    let windows = kicad_windows();-    let Some(source) = windows-        .iter()-        .find(|w| w.hwnd == frame && w.kind() == WindowKind::PcbEditor)-    else {-        return fail(-            "source_editor_missing",-            "Requested PCB editor is not open.",-            "No substitute was used.",-        );-    };-    let viewers = windows_of_kind(WindowKind::Viewer3d);-    let before: Vec<_> = viewers.iter().map(|w| w.hwnd).collect();-    let count = windows-        .iter()-        .filter(|w| {-            w.pid == source.pid-                && matches!(-                    w.kind(),-                    WindowKind::PcbEditor | WindowKind::FootprintEditor-                )-        })-        .count();-    let known = VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m| {-        m.get(&frame)-            .filter(|(pid, _)| *pid == source.pid)-            .map(|(_, h)| *h)-    });-    let Some(viewer) = viewer_for_frame(frame, source.pid, viewers, &before, count, known) else {-        return fail(-            "viewer_binding_ambiguous",-            "No uniquely linked open 3D viewer.",-            "Open the viewer from this exact PCB editor first; nothing was refreshed.",-        );-    };-    let mut commands = Vec::new();-    for hwnd in [frame, viewer.hwnd] {-        let items = match native().menu_tree(hwnd) {-            Ok(items) => items,-            Err(e) => return fail("menu_walk_failed", &e, "No refresh was invoked."),-        };-        let matches: Vec<_> = items-            .iter()-            .filter(|m| m.id != 0 && is_view_refresh(&m.path))-            .collect();-        if matches.len() != 1 {-            return fail(-                "refresh_action_missing",-                "Exact View > Refresh menu not uniquely available.",-                "No fallback menu or keyboard action was invoked.",-            );-        }-        commands.push((hwnd, matches[0].id));-    }-    let mut invoked = Vec::new();-    for (hwnd, id) in commands {-        if let Err(e) = native().menu_invoke(hwnd, id) {-            return json!({"success":false,"errorCode":"refresh_invoke_failed","error":e,"refreshRequestedFor":invoked,"sourceHwnd":frame,"viewerHwnd":viewer.hwnd});-        }-        invoked.push(hwnd);-    }-    invalidate();-    json!({"success":true,"refreshRequested":true,"sourceHwnd":frame,"sourcePid":source.pid,"viewerHwnd":viewer.hwnd,"viewerPid":viewer.pid,"renderVerified":false,"_hint":"Both refresh commands were requested without foreground or cursor actions. Inspect kicad_state screenshots after rebuilding; command acknowledgement is not render completion."})-}--pub(crate) fn menu_items(args: &Value) -> Value {-    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {-        Some(h) => h,-        None => return fail("no_window", "No KiCad window found.", "kicad_window_info lists the windows; pass hwnd."),-    };-    let needle = arg_str(args, "contains").map(|s| s.to_lowercase()).unwrap_or_default();-    match native().menu_tree(hwnd) {-        Ok(items) => {-            let list: Vec<Value> = items.iter().filter(|it| needle.is_empty() || it.path.iter().any(|p| p.replace('&', "").to_lowercase().contains(&needle))).map(|it| json!({"path": it.path.iter().map(|p| p.replace('&', "")).collect::<Vec<_>>(), "id": it.id, "accel": it.accel})).collect();-            json!({"success": true, "hwnd": hwnd, "title": title_of(hwnd), "count": list.len(), "items": list})-        }-        Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),-        Err(e) => fail("menu_walk_failed", &e, "Retry once the window is idle."),-    }-}--pub(crate) fn view_3d(args: &Value) -> Value {-    const ACTIONS: &[(&str, &str)] = &[-        ("pan_left", "Move Board Left"), ("pan_right", "Move Board Right"),-        ("pan_up", "Move Board Up"), ("pan_down", "Move Board Down"),-        ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"),-        ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"),-        ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),-        ("rotate_z_cw", "Rotate Z Clockwise"), ("rotate_z_ccw", "Rotate Z Counterclockwise"),-        ("view_top", "View Top"), ("view_bottom", "View Bottom"), ("view_front", "View Front"), ("view_back", "View Back"),-        ("view_left", "View Left"), ("view_right", "View Right"), ("flip", "Flip Board"),-    ];-    let action = arg_str(args, "action").unwrap_or("").trim().to_lowercase();-    if action == "actions" || action.is_empty() {-        return json!({"success": true, "actions": ACTIONS.iter().map(|(a, l)| json!({"action": a, "menuItem": l})).collect::<Vec<_>>(), "tour": "fit, view_top, rotate_x_ccw x2, rotate_z_cw x8, zoom_in x3, rotate_z_ccw x4, zoom_out x3, view_front, rotate_z_cw x4, view_bottom, flip, view_top, fit", "_hint": "Pass action (and count / pauseMs), or action:\"tour\" with an optional steps list of [action, count] pairs."});-    }-    invalidate();-    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| first_of_kind(WindowKind::Viewer3d)) {-        Some(h) => h,-        None => return fail("viewer_not_open", "No 3D Viewer window is open.", "kicad_open_3d_viewer {editor:\"pcb\"} opens it from the PCB Editor's View menu; it takes a few seconds to render."),-    };-    let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(700).clamp(0, 10_000);-    let count = args.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize;-    let steps: Vec<(String, usize)> = if action == "tour" {-        match args.get("steps").and_then(Value::as_array) {-            Some(a) => a.iter().filter_map(|e| { let a = e.as_array()?; Some((a.first()?.as_str()?.to_lowercase(), a.get(1).and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize)) }).collect(),-            None => vec![("fit", 1), ("view_top", 1), ("rotate_x_ccw", 2), ("rotate_z_cw", 8), ("zoom_in", 3), ("rotate_z_ccw", 4), ("zoom_out", 3), ("view_front", 1), ("rotate_z_cw", 4), ("view_bottom", 1), ("flip", 1), ("view_top", 1), ("fit", 1)].into_iter().map(|(a, n)| (a.to_string(), n)).collect(),-        }-    } else {-        vec![(action.clone(), count)]-    };-    let mut done: Vec<Value> = Vec::new();-    let started = Instant::now();-    for (act, n) in &steps {-        let Some((_, label)) = ACTIONS.iter().find(|(a, _)| *a == act.as_str()) else {-            return fail("bad_action", format!("Unknown action '{act}'."), "kicad_3d_view {action:\"actions\"} lists them.");-        };-        for _ in 0..*n {-            match menu_invoke_by_text(hwnd, label) {-                Ok(Some((id, _))) => { done.push(json!({"action": act, "cmdId": id})); }-                Ok(None) => return fail("menu_item_not_found", format!("The 3D Viewer has no menu item '{label}' (KiCad version?)."), "kicad_send_key {hwnd, menu:...} with a substring finds what this build calls it."),-                Err(e) => return fail("menu_invoke_failed", &e, "Retry once the viewer has finished rendering."),-            }-            std::thread::sleep(Duration::from_millis(pause));-        }-    }-    json!({"success": true, "hwnd": hwnd, "commands": done.len(), "steps": steps.iter().map(|(a, n)| json!([a, n])).collect::<Vec<_>>(), "seconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0, "foreground": false,-        "_hint": "Every command was posted to the viewer's own menu in the background. kicad_screenshot_all shows the view; a window recording of the viewer has the motion."})-}--pub(crate) fn open_3d_viewer(args: &Value) -> Value {-    let choice = arg_str(args, "editor").unwrap_or("auto").to_lowercase();-    if !["auto", "fp", "footprint", "pcb", "board"].contains(&choice.as_str()) {-        return fail("bad_arg", format!("Unknown editor '{choice}'. Use 'auto', 'fp', or 'pcb'."), "editor selects which open editor's View menu opens the viewer.");-    }-    invalidate();-    let mut fp = first_of_kind(WindowKind::FootprintEditor);-    let mut pcb = first_of_kind(WindowKind::PcbEditor);-    // Explicit source editors (tour, 2026-09-03): a caller that knows its editor's hwnd-    // passes pcbHwnd / fpHwnd and we never guess among several PCB Editors.-    if let Some(h) = arg_any_u64(args, &["pcbHwnd", "pcb_hwnd"]) {-        pcb = Some(h);-    }-    if let Some(h) = arg_any_u64(args, &["fpHwnd", "fp_hwnd"]) {-        fp = Some(h);-    }-    match choice.as_str() {-        "fp" | "footprint" => {-            if fp.is_none() {-                return fail("footprint_editor_not_open", "Footprint Editor is not open.", "Call open_footprint_editor first or pass editor='pcb'.");-            }-            pcb = None;-        }-        "pcb" | "board" => {-            if pcb.is_none() {-                return fail("pcb_editor_not_open", "PCB Editor is not open.", "Call open_board first or pass editor='fp'.");-            }-            fp = None;-        }-        _ => {-            if pcb.is_some() && fp.is_some() {-                fp = None; // auto: prefer PCB when both are open (tour-like flow)-            }-        }-    }-    let mut pathways: Vec<String> = Vec::new();-    if let Some(f) = fp {-        match open_3d_from(f, "Footprint Editor", &mut pathways) {-            Ok(Some(v)) => return v,-            Err(e) => return e,-            Ok(None) => {}-        }-        if choice == "auto" && pcb.is_none() {-            pcb = first_of_kind(WindowKind::PcbEditor);-        }-        if pcb.is_none() {-            return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open from Footprint Editor.", "_hint": "Call open_footprint_editor with a footprintName first, OR pass editor='pcb' to target the board's 3D viewer instead."});-        }-    }-    if let Some(p) = pcb {-        match open_3d_from(p, "PCB Editor", &mut pathways) {-            Ok(Some(v)) => return v,-            Err(e) => return e,-            Ok(None) => {}-        }-        return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open via the background path.", "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry; kicad_screenshot_all shows whether a dialog is holding the editor."});-    }-    fail("no_source_editor", "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_board.", "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.")-}--fn arg_any_u64(args: &Value, keys: &[&str]) -> Option<u64> {-    keys.iter().find_map(|k| args.get(*k)).and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).filter(|h| *h != 0)-}--// ── kicad_open_editors ────────────────────────────────────────────────────────--fn open_editors() -> Value {-    let (wins, source) = match find_windows(false, true) {-        Ok(x) => x,-        Err(e) => return e,-    };-    let mut editors = Vec::new();-    let mut by_kind: BTreeMap<String, Vec<Value>> = BTreeMap::new();-    for w in &wins {-        let kind = w.kind();-        if !kind.is_editor() && kind != WindowKind::ProjectManager {-            continue;-        }-        let row = json!({"kind": kind.as_str(), "exeName": w.exe, "pid": w.pid, "frameClass": w.class_name, "frameTitle": w.title, "hwnd": w.hwnd, "isShown": !w.minimized});-        by_kind.entry(kind.as_str().to_string()).or_default().push(row.clone());-        editors.push(row);-    }-    let running = !wins.is_empty();-    let hint = if editors.is_empty() && !running {-        "0 editors and no KiCad window: KiCad is almost certainly NOT RUNNING. This verb only INVENTORIES editors inside a running KiCad (despite its name); it never launches anything. To cold-start KiCad use kicad_launch; kicad_open_board / kicad_open_symbol_editor also cold-launch when opening a specific target."-    } else {-        "Inventory from window enumeration (the native build has no in-process plugin): each KiCad top-level frame is classified by its title. kind is one of project_manager, schematic_editor, pcb_editor, symbol_editor, footprint_editor, 3d_viewer."-    };-    json!({-        "success": true, "editors": editors, "byKind": by_kind, "kicadRunning": running, "windowSource": source,-        "summary": {"totalEditors": editors.len(), "probedPlugins": 0, "failedPlugins": 0, "byKindCounts": by_kind.iter().map(|(k, v)| (k.clone(), json!(v.len()))).collect::<Map<String, Value>>()},-        "_hint": hint,-    })-}--// ── kicad_close* ──────────────────────────────────────────────────────────────--fn close_kind(kind: WindowKind, label: &str) -> Value {-    invalidate();-    let wins = match find_windows(false, true) {-        Ok((w, _)) => w.into_iter().filter(|w| w.kind() == kind).collect::<Vec<_>>(),-        Err(e) => return e,-    };-    if wins.is_empty() {-        return json!({"success": true, "output": format!("{label} is not open.")});-    }-    let mut closed = Vec::new();-    let mut failed = Vec::new();-    let mut hows = Vec::new();-    for w in &wins {-        let (gone, how) = close_and_wait(w.hwnd, &format!("Close the KiCad {label} the calling AI asked to close"), Duration::from_secs(5));-        hows.push(how);-        if gone { closed.push(w.title.clone()) } else { failed.push(w.title.clone()) }-    }-    if !failed.is_empty() {-        let prompt = find_save_prompt();-        let mut v = fail("close_failed", format!("Failed to close: {}", failed.join(", ")), "Call close_kicad with {\"force\": true} to force-kill the window, then retry.");-        if let Some(p) = prompt {-            v["errorCode"] = json!("unsaved_changes");-            v["unsavedChanges"] = json!([{"window": failed[0], "dialog": p.title, "body": p.body}]);-            v["_hint"] = json!("KiCad is asking whether to save changes. Ask the user, then save in KiCad or call kicad_close {\"discardChanges\": true} to press Discard on their behalf.");-        }-        return v;-    }-    json!({-        "success": true, "closeVia": hows,-        "output": if closed.len() == 1 { format!("{label} closed: {}", closed[0]) } else { format!("Closed {} {label} window(s).", closed.len()) },-    })-}--/// kicad_close {force:true}: the ONLY path that kills a process.-fn force_kill_kicad() -> Result<Vec<String>, Value> {-    let procs = plat("process enumeration (processes)", native().processes(wm::KILL_PROCESS_NAMES))?;-    let mut killed = Vec::new();-    for name in wm::KILL_PROCESS_NAMES {-        let mut any = false;-        for p in procs.iter().filter(|p| p.exe.eq_ignore_ascii_case(name) || p.exe.eq_ignore_ascii_case(name.trim_end_matches(".exe"))) {-            if native().kill_process(p.pid).is_ok() {-                any = true;-            }-        }-        if any {-            killed.push(name.to_string());-        }-    }-    invalidate();-    Ok(killed)-}--#[derive(Default)]-struct CloseState {-    discard: bool,-    closed: Vec<String>,-    failed: Vec<String>,-    unsaved: Vec<Value>,-    discard_log: Vec<Value>,-}--/// One window: WM_CLOSE and wait; on a save prompt press Discard (discardChanges) or-/// report unsavedChanges. Some(true) closed, Some(false) failed, None already recorded.-fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> {-    let (gone, _) = close_and_wait(w.hwnd, "Close KiCad as the calling AI asked", Duration::from_secs(8));-    if gone {-        return Some(true);-    }-    let Some(prompt) = find_save_prompt() else { return Some(false) };-    if st.discard {-        let mut attempts = Vec::new();-        let mut pressed = match native().click_button(prompt.hwnd, wm::DISCARD_LABELS) {-            Ok(m) => { attempts.push(json!({"win32Button": true, "method": m})); true }-            Err(e) => { attempts.push(json!({"win32Button": false, "error": e.chars().take(120).collect::<String>()})); false }-        };-        if !pressed {-            for label in wm::DISCARD_LABELS_UIA {-                match native().uia_invoke(prompt.hwnd, label) {-                    Ok(m) => { attempts.push(json!({"label": label, "uia": m})); pressed = true; break; }-                    Err(e) => attempts.push(json!({"label": label, "uia": e.chars().take(120).collect::<String>()})),-                }-            }-        }-        st.discard_log.push(json!({"window": w.title, "dialog": prompt.title, "attempts": attempts}));-        if pressed {-            let deadline = Instant::now() + Duration::from_secs(8);-            while Instant::now() < deadline {-                sleep_ms(300);-                invalidate();-                if !window_alive(w.hwnd) {-                    st.closed.push(format!("{} (changes discarded)", w.title));-                    return None;-                }-            }-        }-    }-    st.unsaved.push(json!({"window": w.title, "dialog": prompt.title, "body": prompt.body}));-    Some(false)-}--fn close_kicad(_state: &mut State, args: &Value) -> Value {-    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);-    let discard = args.get("discardChanges").and_then(Value::as_bool).unwrap_or(false);-    // Wiki #48: a close is the last moment these windows exist, so learn where they are-    // before the geometry is gone. A kill in particular denies KiCad its own save.-    remember_placement();-    if force {-        return match force_kill_kicad() {-            Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}}),-            Ok(_) => json!({"success": true, "output": "KiCad is not running (nothing to kill)."}),-            Err(e) => e,-        };-    }-    invalidate();-    let wins = match find_windows(false, true) {-        Ok((w, _)) => w,-        Err(e) => return e,-    };-    let mut editors = Vec::new();-    let mut manager: Option<Win> = None;-    for w in wins.into_iter().filter(|w| w.owner == 0) {-        if wm::is_manager_title(&w.title) {-            manager = Some(w);-        } else {-            editors.push(w);-        }-    }-    if editors.is_empty() && manager.is_none() {-        return json!({"success": true, "output": "KiCad is not running."});-    }-    // Issue #84: a dirty board answers WM_CLOSE with "Save changes?". Read it; with-    // discardChanges press Discard and stay graceful; otherwise leave the window open and-    // report unsavedChanges. Nothing is ever killed here.-    let mut st = CloseState::default();-    st.discard = discard;-    for w in &editors {-        match close_one(&mut st, w) {-            Some(true) => st.closed.push(w.title.clone()),-            Some(false) => st.failed.push(w.title.clone()),-            None => {}-        }-    }-    if let (Some(m), true) = (&manager, st.unsaved.is_empty()) {-        match close_one(&mut st, m) {-            Some(true) => st.closed.push(m.title.clone()),-            Some(false) => st.failed.push(m.title.clone()),-            None => {}-        }-    }-    let CloseState { closed, failed, unsaved, discard_log, .. } = st;-    if !unsaved.is_empty() {-        return json!({-            "success": false, "errorCode": "unsaved_changes", "unsavedChanges": unsaved,-            "error": format!("KiCad is asking whether to save changes; nothing was killed: {}", unsaved.iter().map(|u| format!("{}: {}", u["window"].as_str().unwrap_or(""), u["body"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect::<Vec<_>>().join("; ")),-            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },-            "_hint": "The user's unsaved work is still open. Ask them, then either save in KiCad (Ctrl+S via desktop_press_key) and call kicad_close again, or call kicad_close {\"discardChanges\": true} to press Discard on their behalf. force:true kills the process, which discards silently.",-            "discard": discard_log,-        });-    }-    if !failed.is_empty() {-        // No save prompt and no close: the native build never kills here (force:true is-        // the only kill path); say what did not close and how to force it.-        // Wiki #109 ask 3: a dirty Symbol Editor refused to go and the reply named no cause.-        // Report every dialog that is up (the unrecognised save prompt is among them) and-        // the editor's own close verb.-        let seen: Vec<Value> = scan_dialogs().iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body.chars().take(240).collect::<String>()})).collect();-        let editor_verbs: Vec<&str> = [("symbol editor", "kicad_close_symbol_editor"), ("footprint editor", "kicad_close_footprint_editor"), ("3d viewer", "kicad_close_3d_viewer")]-            .iter().filter(|(word, _)| failed.iter().any(|t| t.to_lowercase().contains(word))).map(|(_, verb)| *verb).collect();-        return json!({-            "success": false, "errorCode": "close_failed", "dialogsSeen": seen, "editorCloseVerbs": editor_verbs,-            "error": format!("Failed to close: {}", failed.join(", ")),-            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },-            "_hint": "A title starting with * has unsaved work: its editor is waiting on a prompt. dialogsSeen lists every dialog that is up right now; answer it (kicad_dismiss_dialogs, or discardChanges:true to press Discard) or close that editor with the verb in editorCloseVerbs, then call kicad_close again. force:true kills the process and discards unsaved work silently. kicad_screenshot_all shows the windows.",-        });-    }-    let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});-    if !discard_log.is_empty() {-        out["discard"] = json!(discard_log);-    }-    out-}--// ── kicad_dismiss_dialogs / kicad_window_info ─────────────────────────────────--fn dismiss_dialogs(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if args.get("forceSoftwareCanvas").and_then(Value::as_bool).unwrap_or(false) {-        let changed = force_software_canvas(&info.version);-        return json!({-            "success": true, "forcedSoftwareCanvas": changed,-            "output": if changed { "forced Cairo software canvas (canvas_type=2)" } else { "software canvas already set / no config" },-            "_hint": "Close and reopen KiCad editors; they will render via Cairo (no OpenGL) so no GPU is needed.",-        });-    }-    if let Err(e) = find_windows(true, true) {-        return e;-    }-    if args.get("debug").and_then(Value::as_bool).unwrap_or(false) {-        let wins = scan_dialogs();-        let pids: Vec<u32> = native().processes(wm::KICAD_EXES).map(|p| p.iter().map(|p| p.pid).collect()).unwrap_or_default();-        return json!({-            "success": true, "debug": true, "kicadPids": pids,-            "windows": wins.iter().map(|w| json!({"title": w.title, "className": w.class_name, "owner": w.owner, "isDialog": true, "body": w.body.chars().take(80).collect::<String>(), "viaGlobalSweep": w.via_global_sweep})).collect::<Vec<_>>(),-            "kicadInfoHasBinDir": info.primary().map(|i| !i.bin_dir.is_empty()).unwrap_or(false),-        });-    }-    let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);-    let screenshot = args.get("screenshot").and_then(Value::as_bool).unwrap_or(true);-    let res = sweep_dialogs(&info, all, screenshot);-    let n = res["dismissed"].as_array().map(|a| a.len()).unwrap_or(0);-    let remaining = res["remaining"].as_array().map(|a| a.len()).unwrap_or(0);-    let forced = res["forcedSoftwareCanvas"].as_bool().unwrap_or(false);-    json!({-        "success": true,-        "output": format!("dismissed {n} dialog(s), {remaining} remaining{}", if forced { "; forced Cairo software canvas (no GPU); close+reopen editors" } else { "" }),-        "dismissed": res["dismissed"], "remaining": res["remaining"], "forcedSoftwareCanvas": forced,-        "_hint": format!("Expired {n} dialog(s). Now retry your open/screenshot verb. Benign-only by default; pass {{\"all\":true}} to clear every dialog. The OpenGL notice on a GPU-less host is auto-expired by window_info too."),-    })-}--fn window_info(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if let Err(e) = find_windows(true, true) {-        return e;-    }-    let mut auto_dismissed = json!([]);-    let mut forced_sw = false;-    if args.get("autoDismiss").and_then(Value::as_bool).unwrap_or(true) {-        let s = sweep_dialogs(&info, false, false);-        auto_dismissed = s["dismissed"].clone();-        forced_sw = s["forcedSoftwareCanvas"].as_bool().unwrap_or(false);-    }-    invalidate();-    let (wins, source) = match find_windows(false, true) {-        Ok(x) => x,-        Err(e) => return e,-    };-    let mut editors = Vec::new();-    let mut dialogs = Vec::new();-    let mut manager = Value::Null;-    for w in &wins {-        let base = json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "minimized": w.minimized, "maximized": w.maximized, "hung": w.hung, "kind": w.kind().as_str()});-        if w.owner != 0 || w.class_name == wm::DIALOG_CLASS {-            let mut d = base;-            d["ownerHwnd"] = json!(w.owner);-            d["body"] = json!(dialog_body_text(w.hwnd));-            dialogs.push(d);-        } else if wm::is_manager_title(&w.title) {-            manager = base;-        } else {-            editors.push(base);-        }-    }-    let mut data = json!({-        "projectManager": manager, "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": !dialogs.is_empty(),-        "autoDismissed": auto_dismissed, "windowSource": source,-    });-    if forced_sw {-        data["forcedSoftwareCanvas"] = json!(true);-    }-    let n_auto = data["autoDismissed"].as_array().map(|a| a.len()).unwrap_or(0);-    if n_auto > 0 {-        data["_autoDismissHint"] = json!(format!("Auto-expired {n_auto} benign blocking dialog(s) (e.g. the OpenGL/software-rendering notice) so editors are not stalled; no action needed.{}", if forced_sw { " This host lacks OpenGL, so KiCad was switched to the Cairo software canvas (kicad_common.json canvas_type=2): CLOSE and REOPEN the editor for it to render without a GPU." } else { "" }));-    }-    if !dialogs.is_empty() {-        data["_dialogHint"] = json!("hasModalDialogs is true: KiCad has a BLOCKING modal dialog. Read each modalDialogs[].body for the message text; if body is empty, screenshot modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel).");-    }-    json!({"success": true, "output": format!("{} editor(s), {} dialog(s)", editors.len(), dialogs.len()), "data": data})-}--// ── Screenshots (ab) and the canvas probe (local) ─────────────────────────────--/// Uniformity verdict for the editor canvas: one local capture, sampled, discarded.-fn canvas_probe(hwnd: u64) -> Value {-    let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };-    let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));-    wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)-}--/// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull);-/// the canvas verdict is always ours.-pub(crate) fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value {-    let mut v = match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad window {label} as evidence for the calling AI"), resize_max) {-        Ok(shot) => shot.to_json(),-        Err(e) => json!({"success": false, "error": e.message(), "abUnavailable": e.is_unavailable(), "_hint": "The native build takes every screenshot through ab's desktop_screenshot_window; with ab unreachable no image is written. Only the canvas probe (canvas.rendered) answers locally."}),-    };-    v["canvas"] = canvas_probe(hwnd);-    v-}--fn screenshot_all(state: &mut State) -> Value {-    let info = window_info(state, &json!({}));-    if info["success"] != json!(true) {-        return info;-    }-    let data = &info["data"];-    let mut windows: Vec<(u64, String, &str)> = Vec::new();-    for ed in data["editors"].as_array().into_iter().flatten() {-        windows.push((ed["hwnd"].as_u64().unwrap_or(0), ed["title"].as_str().unwrap_or("").into(), "editor"));-    }-    if let Some(h) = data["projectManager"]["hwnd"].as_u64() {-        windows.push((h, data["projectManager"]["title"].as_str().unwrap_or("").into(), "main"));-    }-    for d in data["modalDialogs"].as_array().into_iter().flatten() {-        windows.push((d["hwnd"].as_u64().unwrap_or(0), d["title"].as_str().unwrap_or("").into(), "dialog"));-    }-    if windows.is_empty() {-        return fail("no_windows", "No KiCad windows found.", "kicad_launch or an open_* verb first; kicad_window_info lists what is up.");-    }-    let mut shots = Vec::new();-    let mut ab_unavailable = false;-    for (hwnd, title, kind) in &windows {-        let label = wm::safe_label(title, kind);-        let r = screenshot_hwnd(*hwnd, &label, None);-        let mut entry = json!({"type": kind, "title": title, "hwnd": hwnd, "canvas": r["canvas"]});-        if r["success"] == json!(true) {-            for k in ["savedTo", "fullPath", "safePath", "localFullPath", "sizeKB", "capturedBy", "shotId", "coordMap", "ownedPopupCount", "popups", "screenshots"] {-                if let Some(v) = r.get(k) {-                    entry[k] = v.clone();-                }-            }-        } else {-            entry["error"] = json!(r["error"].as_str().unwrap_or("screenshot failed"));-            if r["abUnavailable"] == json!(true) {-                ab_unavailable = true;-            }-        }-        shots.push(entry);-    }-    let captured = shots.iter().filter(|s| s.get("fullPath").is_some()).count();-    let mut out = json!({-        "success": captured > 0 || !ab_unavailable,-        "output": format!("Captured {captured} of {} KiCad window(s)", shots.len()),-        "data": {"screenshots": shots},-        "_hint": "Each entry is a background PrintWindow capture by ab with its owned popups (popups[]) and a coordMap: click what you see with desktop_click {space:\"image\", shotId, x, y}. canvas.rendered false means the GL canvas has not painted yet (or there is no usable GPU).",-    });-    if ab_unavailable {-        out["abUnavailable"] = json!(true);-        out["errorCode"] = json!("ab_unavailable");-        out["_hint"] = json!("ab's direct API is unreachable, so no image was written (the native build has no local PNG path). ADOM_DIRECT_API_URL missing from the spawn env is the usual cause; ab's own desktop_screenshot_window from the CLI still works.");-    }-    out-}--// ── kicad_state ───────────────────────────────────────────────────────────────--pub(crate) fn b64_encode(bytes: &[u8]) -> String {-    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";-    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);-    for chunk in bytes.chunks(3) {-        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];-        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;-        out.push(T[(n >> 18) as usize & 63] as char);-        out.push(T[(n >> 12) as usize & 63] as char);-        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });-        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });-    }-    out-}--fn kicad_state(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    let (wins, source) = match find_windows(false, true) {-        Ok(x) => x,-        Err(e) => return e,-    };-    let include_images = args.get("includeImages").and_then(Value::as_bool).unwrap_or(true);-    let max_width = args.get("maxWidth").and_then(Value::as_u64).unwrap_or(1100) as u32;-    let mut shots = Vec::new();-    let mut ab_unavailable = false;-    if include_images {-        for w in &wins {-            let mut entry = json!({"title": w.title, "hwnd": w.hwnd, "className": w.class_name});-            let r = screenshot_hwnd(w.hwnd, "state", Some(max_width));-            entry["canvas"] = r["canvas"].clone();-            if r["success"] == json!(true) {-                let path = r["safePath"].as_str().filter(|p| !p.is_empty()).or_else(|| r["fullPath"].as_str()).unwrap_or("");-                match std::fs::read(path) {-                    Ok(bytes) if path.to_lowercase().ends_with(".png") => {-                        entry["image"] = json!(b64_encode(&bytes));-                        entry["path"] = json!(path);-                        if let Some(c) = r.get("coordMap") {-                            if let (Some(w), Some(h)) = (c["image"]["w"].as_u64(), c["image"]["h"].as_u64()) {-                                entry["width"] = json!(w);-                                entry["height"] = json!(h);-                            }-                        }-                        if let Some(s) = r.get("shotId") { entry["shotId"] = s.clone(); }-                        if let Some(p) = r.get("popups") { entry["popups"] = p.clone(); }-                    }-                    Ok(_) => { entry["error"] = json!("capture is not a png"); entry["path"] = json!(path); }-                    Err(e) => { entry["error"] = json!(format!("could not read {path}: {e}")); }-                }-            } else {-                entry["error"] = json!(r["error"].as_str().unwrap_or("capture failed"));-                if r["abUnavailable"] == json!(true) {-                    ab_unavailable = true;-                }-            }-            shots.push(entry);-        }-    }-    let editors = open_editors();-    let modal: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})).collect();-    let loop_events = native().etiquette_events();-    let mut events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();-    events.extend(loop_events);-    let events: Vec<Value> = events.into_iter().rev().take(40).rev().collect();-    let focus_debug = match native().etiquette_debug() {-        Value::Null => json!({"guardian": "no etiquette loop on this OS; one-shot check per verb only", "oneShotCheck": true}),-        v => v,-    };-    let mut out = json!({-        "success": true, "running": !wins.is_empty(), "focusEvents": events,-        // PRESENCE GATE (John, 2026-08-17): automation that spawns windows checks this-        // first and defers while the user is active (< 600 = touched in the last 10 min).-        "userIdleSeconds": user_idle_seconds(),-        "focusDebug": focus_debug,-        "activeVersion": info.version,-        "windows": wins.iter().map(|w| json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "kind": w.kind().as_str()})).collect::<Vec<_>>(),-        "windowSource": source,-        "shots": shots,-        "editors": {"kicadRunning": editors["kicadRunning"], "list": editors["editors"], "summary": editors["summary"]},-        "modalDialogs": modal,-        "_hint": "focusEvents is the focus log: a self-raise (pushed-to-background) entry is a KiCad window that took the foreground and was pushed back once; user-foregrounded means the caller asked with a reason. The full state of the union: every KiCad-owned window is in shots[] as inline base64 PNG (render them all; one window is NOT the app). modalDialogs non-empty means something is blocking; editors.list is the window inventory by kind. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll.",-    });-    if ab_unavailable {-        out["abUnavailable"] = json!(true);-    }-    out-}--// ── kicad_send_key / kicad_click / kicad_fix_keyboard ─────────────────────────--fn foreground_kicad_hwnd() -> Option<u64> {-    if let Ok(fg) = native().foreground() {-        if fg != 0 && is_kicad_hwnd(fg) {-            return Some(fg);-        }-    }-    kicad_windows().first().map(|w| w.hwnd)-}--fn send_key(args: &Value) -> Value {-    // `menu`: a menu-item label (substring, case-insensitive) fired as WM_COMMAND on the-    // target window's menu bar, fully background. The way the PCM dialog is opened.-    if let Some(needle) = arg_str(args, "menu").map(str::trim).filter(|s| !s.is_empty()) {-        let target = match arg_any_u64(args, &["hwnd"]) {-            Some(h) => h,-            None => match foreground_kicad_hwnd() {-                Some(h) => h,-                None => return fail("no_window", "No KiCad window found to send the menu command to.", "kicad_window_info lists the windows; pass hwnd."),-            },-        };-        let title = title_of(target);-        return match menu_invoke_by_text(target, needle) {-            Ok(Some((id, label))) => json!({-                "success": true, "mechanism": "menu",-                "output": format!("Menu item '{label}' fired on '{title}' (background)"),-                "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},-                "_hint": "Resolved from the window's menu bar by label and fired with WM_COMMAND; no focus was touched. A dialog it opens is a child of this window: kicad_screenshot_all captures it.",-            }),-            Ok(None) => fail("menu_item_not_found", &format!("No menu item on '{title}' contains '{needle}'."), "kicad_window_info lists windows; the label match is a case-insensitive substring of the item text without '&'."),-            Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),-            Err(e) => fail("menu_invoke_failed", &e, "Retry once the window is idle; pass hwnd explicitly."),-        };-    }-    let Some(key) = arg_str(args, "key").map(str::trim) else {-        return fail("missing_arg", "Missing required arg: key", "Pass key: a named key (enter/escape/tab/space/f1-f12), a single character, or a chord like 'ctrl+s'.");-    };-    let target = match arg_any_u64(args, &["hwnd"]) {-        Some(h) => h,-        None => match foreground_kicad_hwnd() {-            Some(h) => h,-            None => return fail("no_window", "No KiCad window found to send key to.", "kicad_window_info lists the windows; pass hwnd."),-        },-    };-    let title = title_of(target);-    // NEVER steal the user's screen: show without activating and post to THIS hwnd's queue.-    let _ = native().show_no_activate(target);-    sleep_ms(50);-    let allow_fg = args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false);-    if let Some((_mods, _base)) = wm::parse_modifier_combo(key) {-        // A CHORD usually IS a menu accelerator (v0.9.106): resolve it from the window's-        // menu bar and fire WM_COMMAND, fully background.-        match menu_invoke_by_accel(target, key) {-            Ok(Some((id, label))) => {-                return json!({-                    "success": true, "mechanism": "menu",-                    "output": format!("Chord '{key}' delivered as menu command '{label}' (background)"),-                    "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},-                    "_hint": "Resolved via the window's menu-bar accelerator table and fired with WM_COMMAND; no focus was touched. This is the preferred path for every chord that maps to a menu item.",-                });-            }-            Err(e) if is_not_impl(&e) => return unsupported("native menu walk (menu_tree)"),-            _ => {}-        }-        if allow_fg {-            return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");-        }-        return json!({-            "success": false, "errorCode": "foreground_required",-            "error": format!("send the key chord '{key}' to KiCad needs the foreground, which would take over the user's screen: no menu item carries this accelerator, so only real (focused) keystrokes can deliver it"),-            "_hint": "Use ab's desktop_press_key (it owns focus-first chord delivery and asks the user), or find the menu item and call it by name. The native bridge never sends global keystrokes.",-        });-    }-    if allow_fg {-        return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");-    }-    let (vk, ch) = match wm::vk_for_key(key) {-        Some(vk) => (vk, None),-        None => {-            let mut chars = key.chars();-            match (chars.next(), chars.next()) {-                (Some(c), None) => (wm::vk_for_char(c).unwrap_or(0), Some(c)),-                _ => return fail("unknown_key", format!("Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."), "Chords resolve through the menu bar; anything else needs desktop_press_key."),-            }-        }-    };-    match native().post_key(target, vk, ch) {-        Ok(()) => json!({-            "success": true, "mechanism": "post",-            "output": format!("Posted '{key}' to '{title}' (background)"),-            "data": {"hwnd": target, "title": title, "method": "postmessage", "foreground": false, "vk": vk},-            "_hint": "Delivered without touching the user's focus. If KiCad ignored it, the control may need real input: prefer a menu command by name; only as a last resort ask the user and use desktop_press_key.",-        }),-        Err(e) if is_not_impl(&e) => unsupported("posted keys (post_key)"),-        Err(e) => fail("post_failed", format!("Could not post '{key}' to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),-    }-}--fn click(args: &Value) -> Value {-    let Some(hwnd) = arg_any_u64(args, &["hwnd"]) else {-        return fail("missing_arg", "Missing required arg: hwnd", "kicad_window_info lists the windows; pass hwnd.");-    };-    let (Some(x), Some(y)) = (args.get("x").and_then(Value::as_f64), args.get("y").and_then(Value::as_f64)) else {-        return fail("missing_arg", "Missing required args: x, y", "Pass x and y as fractions of the window (relative:true, default) or pixels (relative:false).");-    };-    let relative = args.get("relative").and_then(Value::as_bool).unwrap_or(true);-    if relative && !(0.0..=1.0).contains(&x) || relative && !(0.0..=1.0).contains(&y) {-        return json!({-            "success": false, "errorCode": "bad_arg",-            "error": format!("relative=True expects x/y as fractions 0.0-1.0 of the window, but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels."),-            "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ~ {\"x\":0.35,\"y\":0.09}. For exact pixel coords add \"relative\": false.",-        });-    }-    if args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false) {-        return unsupported("kicad_click allowForeground (a real cursor click via SendInput)");-    }-    let info = match native().window_info(hwnd) {-        Ok(Some(i)) => i,-        Ok(None) => return fail("bad_hwnd", format!("HWND {hwnd} is not a valid window."), "kicad_window_info lists the current handles."),-        Err(e) => return plat::<()>("window lookup (window_info)", Err(e)).err().unwrap(),-    };-    let (w, h) = (info.rect.2, info.rect.3);-    let _ = native().show_no_activate(hwnd);-    sleep_ms(50);-    let (cx, cy) = if relative { ((x * w as f64) as i32, (y * h as f64) as i32) } else { (x as i32, y as i32) };-    match native().post_click(hwnd, cx, cy, false) {-        Ok(()) => json!({-            "success": true, "mechanism": "post",-            "output": format!("Posted click at ({cx},{cy}) in '{}' (background)", info.title),-            "data": {"hwnd": hwnd, "title": info.title, "client": [cx, cy], "screen": [info.rect.0 + cx, info.rect.1 + cy], "method": "postmessage", "foreground": false, "relative": relative},-            "_hint": "Clicked without moving the user's mouse or taking focus. If the canvas ignored it, that control may need real input: ab's desktop_click {space:\"image\", shotId, x, y} on a kicad_screenshot_all coordMap asks the user first.",-        }),-        Err(e) if is_not_impl(&e) => unsupported("posted clicks (post_click)"),-        Err(e) => fail("post_failed", format!("Could not post the click to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),-    }-}--fn fix_keyboard() -> Value {-    json!({-        "success": true, "stuckKeys": [], "released": false,-        "output": "Nothing to fix: the native bridge never sends global keystrokes (no SendInput/keybd_event path), so it can never leave a modifier key held. Keys and clicks are posted to one window's queue, which touches no global keyboard state.",-        "_hint": "If keys ARE stuck on this desktop, another tool held them: ab's desktop_press_key path or a release of each modifier through it clears the OS state.",-    })-}--// ── kicad_place_footprint ─────────────────────────────────────────────────────--const BLANK_BOARD: &str = include_str!("../../../../templates/blank-board.kicad_pcb");--fn pseudo_uuid() -> String {-    // Not cryptographic: a KiCad item id only has to be unique within the board.-    let mut x: u64 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0x9E3779B97F4A7C15) ^ ((std::process::id() as u64) << 32);-    let mut bytes = [0u8; 16];-    for b in bytes.iter_mut() {-        x ^= x << 13;-        x ^= x >> 7;-        x ^= x << 17;-        *b = (x & 0xff) as u8;-    }-    bytes[6] = (bytes[6] & 0x0f) | 0x40;-    bytes[8] = (bytes[8] & 0x3f) | 0x80;-    let h: String = bytes.iter().map(|b| format!("{b:02x}")).collect();-    format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])-}--/// Quote an unquoted top-level `(layer X)` token: `(layer F.Cu)` -> `(layer "F.Cu")`.-fn quote_first_layer(text: &str) -> String {-    let Some(i) = text.find("(layer ") else { return text.to_string() };-    let rest = &text[i + 7..];-    let trimmed = rest.trim_start();-    if trimmed.starts_with('"') {-        return text.to_string();-    }-    let end = trimmed.find(')').unwrap_or(0);-    let name = trimmed[..end].trim();-    if name.is_empty() || !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {-        return text.to_string();-    }-    format!("{}(layer \"{}\"){}", &text[..i], name, &trimmed[end + 1..])-}--/// Inject `(uuid ...)` and `(at x y)` right after the footprint's first quoted `(layer "...")`.-fn place_in_board(fp_text: &str, uid: &str, x: f64, y: f64) -> Option<String> {-    let i = fp_text.find("(layer \"")?;-    let close = fp_text[i..].find(')')? + i + 1;-    Some(format!("{}\n\t\t(uuid \"{uid}\")\n\t\t(at {x} {y}){}", &fp_text[..close], &fp_text[close..]))-}--fn place_footprint(state: &mut State, args: &Value) -> Value {-    let info = state.kicad_info();-    if !info.installed {-        return fail("not_installed", "KiCad not installed", "Install KiCad from https://www.kicad.org/download/ then retry.");-    }-    let file_name = arg_str(args, "fileName").unwrap_or("");-    let Some(content) = arg_str(args, "fileContent") else {-        return fail("missing_arg", "No fileContent (base64 .kicad_mod) specified", "Pass fileContent: the base64 of the .kicad_mod text.");-    };-    let mut fp_text = match kicad_core::install::b64_decode(content) {-        Ok(b) => String::from_utf8_lossy(&b).trim().to_string(),-        Err(e) => return fail("bad_content", format!("could not decode fileContent: {e}"), "fileContent must be standard base64."),-    };-    // Legacy `(module ...)` footprints (KiCad <= 5 / EAGLE imports): rename the wrapper-    // and quote the top-level layer token the splice relies on.-    if fp_text.starts_with("(module") {-        fp_text = format!("(footprint{}", &fp_text["(module".len()..]);-        fp_text = quote_first_layer(&fp_text);-    }-    if !fp_text.starts_with("(footprint") {-        return fail("bad_content", "fileContent is not a .kicad_mod (must start with '(footprint' or '(module')", "Send the raw .kicad_mod text, base64-encoded.");-    }-    let fp_name = arg_str(args, "footprintName").map(str::to_string)-        .or_else(|| kicad_core::install::footprint_name_in(&fp_text))-        .or_else(|| Path::new(file_name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()))-        .unwrap_or_else(|| "footprint".into());-    let x = args.get("x").and_then(Value::as_f64).unwrap_or(148.0);-    let y = args.get("y").and_then(Value::as_f64).unwrap_or(105.0);-    let uid = pseudo_uuid();-    let Some(fp_board) = place_in_board(&fp_text, &uid, x, y) else {-        return fail("bad_content", "footprint has no (layer \"...\") token to anchor the placement", "A .kicad_mod carries (layer \"F.Cu\") near the top; this one does not parse.");-    };-    let skel = BLANK_BOARD.trim_end();-    let idx = skel.rfind(')').unwrap_or(skel.len());-    let combined = format!("{}\n{}\n)\n", skel[..idx].trim_end(), fp_board);-    let user_dir = info.primary().and_then(|i| native().user_dir(&i.version)).or_else(|| std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)).unwrap_or_else(|| PathBuf::from("."));-    let _ = std::fs::create_dir_all(&user_dir);-    let out_path = user_dir.join(format!("adom-place-{fp_name}.kicad_pcb"));-    if let Err(e) = std::fs::write(&out_path, combined) {-        return fail("write_failed", format!("could not write board: {e}"), "The preview board goes to Documents/KiCad/<version>; check it is writable.");-    }-    let board_path = norm(&out_path);-    let open_it = args.get("open").and_then(Value::as_bool).unwrap_or(true);-    let mut opened = false;-    let mut extra = Map::new();-    if open_it {-        match kicad_exe(&info, "pcbnew") {-            Some(exe) => match spawn_background(&exe, &[board_path.clone()], "pcbnew", None) {-                Ok((pid, _)) => {-                    opened = true;-                    extra.insert("pid".into(), json!(pid));-                    // Verify the frame by title with one deadline; the sweep runs after.-                    let stem = format!("adom-place-{fp_name}").to_lowercase();-                    let deadline = Instant::now() + Duration::from_secs(20);-                    while Instant::now() < deadline {-                        invalidate();-                        if let Some(w) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.to_lowercase().contains(&stem) || w.pid == pid) {-                            extra.insert("hwnd".into(), json!(w.hwnd));-                            extra.insert("title".into(), json!(w.title));-                            break;-                        }-                        sleep_ms(500);-                    }-                }-                Err(e) => {-                    if e["errorCode"] == json!("not_supported_on_platform") {-                        return e;-                    }-                    return json!({"success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": false, "output": format!("Board written but pcbnew launch failed: {}", e["error"].as_str().unwrap_or("")), "_hint": format!("Open it manually: {board_path}")});-                }-            },-            None => {}-        }-    }-    let mut v = json!({-        "success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": opened, "mechanism": if opened { "spawn" } else { "file" },-        "output": format!("Placed footprint '{fp_name}' at ({x}, {y}) mm on a preview board{}", if opened { " and opened it in pcbnew" } else { "" }),-    });-    merge(&mut v, Value::Object(extra));-    v-}--// ── kicad_enable_software_opengl ──────────────────────────────────────────────--const MESA_URL: &str = "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.60/mesa-llvmpipe-x64.zip";-const MESA_DLLS: &[&str] = &["opengl32.dll", "libgallium_wgl.dll", "dxil.dll"];-const MESA_EXES: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "gerbview.exe", "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe"];--fn enable_software_opengl(state: &mut State) -> Value {-    if native().capabilities().os != "windows" {-        return json!({"success": false, "error": "Windows-only", "errorCode": "unsupported_platform", "_hint": "Mesa llvmpipe DotLocal redirection only applies to KiCad's Windows build."});-    }-    let info = state.kicad_info();-    let mut bins: Vec<PathBuf> = Vec::new();-    for i in &info.installs {-        let mut b = PathBuf::from(if i.bin_dir.is_empty() { &i.base_dir } else { &i.bin_dir });-        if b.file_name().map(|n| n.to_string_lossy().to_lowercase() != "bin").unwrap_or(true) {-            b = b.join("bin");-        }-        if b.is_dir() && !bins.contains(&b) {-            bins.push(b);-        }-    }-    if bins.is_empty() {-        return json!({"success": false, "error": "No KiCad bin dir found; is KiCad installed?", "errorCode": "host_app_not_installed", "errorCodeLegacy": "kicad_not_installed", "hostApp": "KiCad", "_hint": "Run kicad_list_versions. Install KiCad first via kicad_upgrade."});-    }-    eprintln!("[kicad-bridge] enable_software_opengl: downloading Mesa llvmpipe...");-    let data = match pcm::http_fetch(MESA_URL, Duration::from_secs(180)) {-        Ok(d) => d,-        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),-    };-    let zip = match pcm::ZipFile::parse(&data) {-        Ok(z) => z,-        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),-    };-    let mut enabled = Vec::new();-    let mut errors = Vec::new();-    for bd in &bins {-        let mut ok = true;-        for dll in MESA_DLLS {-            let Some(entry) = zip.entries.iter().find(|e| e.name.rsplit('/').next() == Some(dll)) else {-                errors.push(format!("{}: {dll} missing from the Mesa zip", norm(bd)));-                ok = false;-                break;-            };-            let bytes = match zip.read(entry) {-                Ok(b) => b,-                Err(e) => { errors.push(format!("{}: {dll}: {e}", norm(bd))); ok = false; break; }-            };-            if let Err(e) = std::fs::write(bd.join(dll), bytes) {-                errors.push(if e.kind() == std::io::ErrorKind::PermissionDenied { format!("{}: permission denied (writing KiCad's bin needs elevation)", norm(bd)) } else { format!("{}: {e}", norm(bd)) });-                ok = false;-                break;-            }-        }-        if !ok {-            continue;-        }-        for exe in MESA_EXES {-            if bd.join(exe).exists() {-                let _ = std::fs::write(bd.join(format!("{exe}.local")), "");-            }-        }-        enabled.push(norm(bd));-        eprintln!("[kicad-bridge] enable_software_opengl: deployed to {}", norm(bd));-    }-    let ok = !enabled.is_empty();-    if !enabled.is_empty() {-        NO_OPENGL.store(false, std::sync::atomic::Ordering::Relaxed); // the next start renders through Mesa-    }-    json!({-        "success": ok, "enabledIn": enabled, "errors": errors, "renderer": "Mesa llvmpipe (CPU software OpenGL)",-        "_hint": if ok {-            "LAST-RESORT software-OpenGL fallback deployed: KiCad will render via Mesa llvmpipe on the CPU (SLOW). This is ONLY for a GPU-less host (Hyper-V/RDP/headless) where KiCad cannot render at all; do NOT enable it on a normal machine. CLOSE and REOPEN KiCad editors to load it. To undo, delete opengl32.dll/libgallium_wgl.dll/dxil.dll and the *.local files from KiCad's bin."-        } else {-            "Could not deploy; see errors[]. Writing KiCad's bin (Program Files) needs an elevated Bridge; a per-user KiCad install (kicad_upgrade scope=user) avoids that."-        },-    })-}--#[cfg(test)]-mod tests {-    use super::*;-    #[test]-    fn viewer_selection_is_bound_to_source_and_refuses_ambiguity(){-        let win=|hwnd,owner,pid|Win{hwnd,owner,pid,title:"3D Viewer".into(),class_name:"wxWindowNR".into(),rect:(0,0,100,100),z:None,exe:"pcbnew.exe".into(),minimized:false,maximized:false,hung:false};-        assert_eq!(viewer_for_frame(2,200,vec![win(10,1,200),win(20,2,200)],&[10,20],2,None).unwrap().hwnd,20);-        assert!(viewer_for_frame(2,200,vec![win(10,1,200)],&[10],2,None).is_none());-        assert!(viewer_for_frame(2,200,vec![win(10,0,200)],&[10],2,None).is_none());-        assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10],2,None).unwrap().hwnd,20);-        assert!(viewer_for_frame(2,200,vec![win(20,0,200),win(21,0,200)],&[],2,None).is_none());-        assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10,20],2,Some(20)).unwrap().hwnd,20);-    }---    #[test]-    fn footprint_splice_matches_python() {-        let fp = "(footprint \"X\"\n\t(version 20240108)\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect)\n)";-        let placed = place_in_board(fp, "u-u-i-d", 148.0, 105.0).unwrap();-        assert!(placed.contains("(layer \"F.Cu\")\n\t\t(uuid \"u-u-i-d\")\n\t\t(at 148 105)"));-        let legacy = "(module X (layer F.Cu) (pad 1 smd rect))";-        let modern = format!("(footprint{}", &legacy["(module".len()..]);-        let q = quote_first_layer(&modern);-        assert!(q.starts_with("(footprint X (layer \"F.Cu\")"));-        assert_eq!(quote_first_layer("(footprint X (layer \"B.Cu\"))"), "(footprint X (layer \"B.Cu\"))");-        assert!(place_in_board("(footprint X)", "u", 1.0, 2.0).is_none());-        let u = pseudo_uuid();-        assert_eq!(u.len(), 36);-        assert_eq!(&u[14..15], "4");-    }--    #[test]-    fn base64_round_trip() {-        assert_eq!(b64_encode(b""), "");-        assert_eq!(b64_encode(b"f"), "Zg==");-        assert_eq!(b64_encode(b"fo"), "Zm8=");-        assert_eq!(b64_encode(b"foo"), "Zm9v");-        assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");-        let back = kicad_core::install::b64_decode(&b64_encode(b"\x89PNG\r\n\x1a\n")).unwrap();-        assert_eq!(back, b"\x89PNG\r\n\x1a\n");-    }--    #[test]-    fn stamp_and_foreground_args() {-        let s = utc_stamp();-        assert_eq!(s.len(), 15);-        assert_eq!(&s[8..9], "-");-        assert!(user_wants_foreground(&json!({"foreground": true})));-        assert!(user_wants_foreground(&json!({"showToUser": true})));-        assert!(user_wants_foreground(&json!({"bringToFront": true})));-        assert!(!user_wants_foreground(&json!({"foreground": false, "reason": "x"})));-        assert_eq!(arg_any_u64(&json!({"pcbHwnd": "123"}), &["pcbHwnd", "pcb_hwnd"]), Some(123));-        assert_eq!(arg_any_u64(&json!({"pcb_hwnd": 0}), &["pcbHwnd", "pcb_hwnd"]), None);-    }--    #[test]-    fn dispatch_claims_only_its_verbs_and_is_honest_off_windows() {-        let mut state = State::new();-        assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());-        assert!(dispatch(&mut state, "kicad_run_drc", &json!({})).is_none());-        let fk = dispatch(&mut state, "kicad_fix_keyboard", &json!({})).unwrap();-        assert_eq!(fk["success"], json!(true));-        assert_eq!(fk["stuckKeys"], json!([]));-        // Without window control (this host) the window verbs answer not_supported_on_platform-        // or an ab-unavailable error, never a hang and never a fake success.-        if !native().capabilities().window_control {-            std::env::remove_var("ADOM_DIRECT_API_URL");-            let r = dispatch(&mut state, "kicad_window_info", &json!({})).unwrap();-            assert_eq!(r["success"], json!(false));-            let code = r["errorCode"].as_str().unwrap_or("");-            assert!(code == "not_supported_on_platform" || code == "ab_unavailable", "{r}");-            let r = dispatch(&mut state, "kicad_open_editors", &json!({})).unwrap();-            assert_eq!(r["success"], json!(false));-            let r = dispatch(&mut state, "kicad_send_key", &json!({"key": "escape", "hwnd": 5})).unwrap();-            assert_eq!(r["success"], json!(false));-        }-        let r = dispatch(&mut state, "kicad_click", &json!({"hwnd": 5, "x": 300, "y": 2})).unwrap();-        assert_eq!(r["errorCode"], json!("bad_arg"));-        let r = dispatch(&mut state, "kicad_send_key", &json!({})).unwrap();-        assert_eq!(r["errorCode"], json!("missing_arg"));-        let r = dispatch(&mut state, "kicad_open_3d_viewer", &json!({"editor": "sideways"})).unwrap();-        assert_eq!(r["errorCode"], json!("bad_arg"));-    }--    #[test]-    fn catalog_covers_the_python_window_group() {-        let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();-        for n in ["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_open_editors", "kicad_close", "kicad_close_symbol_editor", "kicad_close_footprint_editor", "kicad_close_3d_viewer", "kicad_dismiss_dialogs", "kicad_window_info", "kicad_state", "kicad_screenshot_all", "kicad_send_key", "kicad_click", "kicad_fix_keyboard", "kicad_place_footprint", "kicad_enable_software_opengl"] {-            assert!(names.contains(&n), "{n} missing from the catalog");-        }-        assert!(!names.contains(&"kicad_errors"));-        for v in VERBS {-            assert!(!v.summary.contains('\u{2014}') && !v.hint.contains('\u{2014}'), "{} carries an em dash", v.name);-        }-    }-}---/// kicad_rescan_libraries: tree refresh first (#51), restart only on force plus confirmation.-fn rescan_libraries(state: &mut State, args: &Value) -> Value {-    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);-    if !force {-        let mut refreshed: Vec<u64> = Vec::new();-        for kind in [WindowKind::FootprintEditor, WindowKind::SymbolEditor] {-            if let Some(h) = first_of_kind(kind) {-                if let Ok(Some(_)) = menu_invoke_by_text(h, "refresh") {-                    refreshed.push(h);-                }-            }-        }-        if !refreshed.is_empty() {-            // The tree reloads asynchronously; the Python waited 3 s before probing.-            std::thread::sleep(std::time::Duration::from_secs(3));-            invalidate();-            let mut wanted: Vec<(String, String)> = Vec::new();-            for spec in args.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default() {-                match spec {-                    Value::String(s) if s.contains(':') => {-                        let (lib, name) = s.split_once(':').unwrap();-                        wanted.push((lib.to_string(), name.to_string()));-                    }-                    Value::Object(o) => {-                        if let Some(name) = o.get("name").and_then(Value::as_str) {-                            wanted.push((o.get("library").and_then(Value::as_str).unwrap_or("Adom").to_string(), name.to_string()));-                        }-                    }-                    _ => {}-                }-            }-            let mut probed: Vec<Value> = Vec::new();-            if !wanted.is_empty() {-                let fp = first_of_kind(WindowKind::FootprintEditor);-                for (lib, name) in &wanted {-                    let (ok, trace, _mech) = match fp {-                        Some(h) => navigate_tree(h, &format!("{lib} {name}"), Verify::Footprint { name, library: lib }),-                        None => (false, json!({}), "none"),-                    };-                    probed.push(json!({-                        "library": lib, "name": name, "visible": ok,-                        "error": if ok { Value::Null } else if fp.is_none() { json!("no Footprint Editor open") } else { json!("editor tree did not load it") },-                        "navigation": trace,-                    }));-                }-            }-            let verified = !wanted.is_empty() && probed.iter().all(|p| p["visible"].as_bool().unwrap_or(false));-            let mut out = json!({"success": true, "method": "tree-refresh", "refreshedEditors": refreshed, "verified": verified, "probed": probed});-            out["_hint"] = json!(if !wanted.is_empty() && !verified {-                format!("Tree refreshed but KiCad still cannot load: {}. Check the name with kicad_list_footprints; if it is on disk, call again with force:true and confirmRestart:true for the process restart (about a minute).",-                    probed.iter().filter(|p| !p["visible"].as_bool().unwrap_or(false)).map(|p| format!("{}:{}", p["library"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))-            } else if !wanted.is_empty() {-                "Verified in the editor: every listed footprint loaded after the refresh, no restart. The last one is now shown in the Footprint Editor.".to_string()-            } else {-                "Refresh invoked on the open editor tree(s). Pass footprints:[\"Lib:Name\"] to have each loaded in the editor as proof; without that list nothing is verified. force:true is the process restart, the last resort.".to_string()-            });-            post_verb(state, "kicad_rescan_libraries", args, &mut out, None);-            return out;-        }-    }-    // Restart path: the native build has no ownership ledger yet (phase 4), so a running-    // KiCad is treated as the user's unless the caller confirms.-    let running = native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();-    let confirm = args.get("confirmRestart").and_then(Value::as_bool).unwrap_or(false);-    if !running.is_empty() && !confirm {-        return json!({"success": false, "errorCode": "rescan_refused",-            "reason": "KiCad is running and the native build cannot yet tell a user-launched KiCad from its own",-            "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),-            "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});-    }-    // Wiki #48: a fresh process places every window on the primary display, so the user's-    // monitor choice is captured here, while the windows still exist, and restored below.-    let remembered = remember_placement();-    let mut close_how = "nothing was running";-    let mut graceful_failure = Value::Null;-    if !running.is_empty() {-        // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;-        // the kill is the fallback for a window that will not go.-        let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));-        close_how = "graceful";-        if !closed["success"].as_bool().unwrap_or(false) {-            if closed["errorCode"] == json!("unsaved_changes") {-                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,-                    "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});-            }-            // Keep WHY the graceful close failed: otherwise the kill overwrites the only-            // evidence and the next person sees "force-kill" with no cause.-            graceful_failure = json!({"errorCode": closed.get("errorCode").cloned().unwrap_or(Value::Null), "error": closed.get("error").cloned().unwrap_or(Value::Null), "output": closed.get("output").cloned().unwrap_or(Value::Null)});-            closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));-            close_how = "force-kill after the graceful close failed";-            if !closed["success"].as_bool().unwrap_or(false) {-                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how, "gracefulClose": graceful_failure});-            }-        }-        std::thread::sleep(std::time::Duration::from_secs(4));-    }-    let launched = launch(state, &json!({}));-    if !launched["success"].as_bool().unwrap_or(false) {-        return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});-    }-    for _ in 0..80 {-        std::thread::sleep(std::time::Duration::from_millis(750));-        invalidate();-        if !kicad_windows().is_empty() {-            sleep_ms(1200); // let the fresh windows settle before moving them-            let restored = restore_placement();-            let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();-            return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",-                "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how, "gracefulClose": graceful_failure,-                "_hint": format!("Library caches rebuilt. Every part installed before this call is now visible; show verbs will not trigger another restart until the next install-while-open. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});-        }-    }-    json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",-        "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})-}---#[cfg(test)]-mod linked_refresh_tests {-    #[test]-    fn exact_view_refresh_excludes_plugins_and_partial_labels() {-        let path = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();-        assert!(super::is_view_refresh(&path(&["&View", "&Refresh\tF5"])));-        for a in [-            vec!["Tools", "Refresh Plugins"],-            vec!["View", "Refresh All"],-            vec!["Refresh"],-        ] {-            assert!(!super::is_view_refresh(&path(&a)));-        }-    }-}--/// The boards KiCad's API says are open; without the ipc feature (the macOS type-check build) there is-/// no API to ask, and the callers fall back to the window title.-#[cfg(feature = "ipc")]-fn open_board_paths(timeout: Duration) -> Result<Vec<String>, String> {-    kicad_core::ipc::open_board_paths(timeout)-}--#[cfg(not(feature = "ipc"))]-fn open_board_paths(_timeout: Duration) -> Result<Vec<String>, String> {-    Err("this build has the ipc feature off".into())-}--#[cfg(feature = "ipc")]-fn same_board_path(a: &str, b: &str) -> bool {-    kicad_core::ipc::same_board_path(a, b)-}--#[cfg(not(feature = "ipc"))]-fn same_board_path(a: &str, b: &str) -> bool {-    a.replace('\\', "/").eq_ignore_ascii_case(&b.replace('\\', "/"))-}--// ----------------------------------------------------------------------------// kicad_teardrops: KiCad's own Edit > Edit Teardrops-// -----------------------------------------------------------------------------/// Teardrop areas on the board the editor serves (zones carrying `(teardrop (type ..))`), or-/// None without the IPC API. Read straight from KiCad, not through another verb: this runs-/// under GUI_LOCK, which verbs::call would try to take again.-/// (teardrop areas, the board's revision) for the board the editor serves; None without IPC.-#[cfg(feature = "ipc")]-fn teardrop_count(state: &mut State, args: &Value) -> Option<(usize, String)> {-    use kicad_core::{detect, ipc};-    let info = state.kicad_info();-    let ctx = ipc::Ctx {-        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),-        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),-    };-    let (text, _, _) = ipc::board_text(&ctx, args).ok()?;-    Some((text.match_indices("(teardrop").filter(|(i, _)| !text[i + 9..].starts_with('s')).count(), ipc::revision_of(&text)))-}--#[cfg(not(feature = "ipc"))]-fn teardrop_count(_state: &mut State, _args: &Value) -> Option<(usize, String)> {-    None-}--fn teardrops(state: &mut State, args: &Value) -> Value {-    let Some(path) = arg_str(args, "filePath") else {-        return fail("missing_arg", "filePath is required", "The .kicad_pcb open in the PCB editor.");-    };-    let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();-    let Some(editor) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.trim_start_matches('*').starts_with(&stem)) else {-        return fail("board_not_open", format!("{stem} is not open in a PCB editor"), "Open it with kicad_open_board: teardrops run in KiCad's PCB editor.");-    };-    let flag = |k: &str, d: bool| args.get(k).and_then(Value::as_bool).unwrap_or(d);-    let remove = flag("remove", false);-    let before = teardrop_count(state, args).map(|(n, _)| n);-    match menu_invoke_by_text(editor.hwnd, "Edit Teardrops...") {-        Ok(Some(_)) => {}-        Ok(None) => return fail("menu_not_found", "the PCB editor has no Edit Teardrops... command", "KiCad 8 or newer has it under Edit."),-        Err(e) => return fail("menu_failed", e, "Check the editor with kicad_window_info."),-    }-    let mut dialog = None;-    for _ in 0..60 {-        if let Some(d) = scan_dialogs().into_iter().find(|d| d.title.trim() == "Set Teardrops") {-            dialog = Some(d.hwnd);-            break;-        }-        sleep_ms(250);-    }-    let Some(dlg) = dialog else {-        return fail("dialog_not_shown", "KiCad did not show the Set Teardrops dialog within 15 s", "kicad_window_info lists what KiCad shows; another dialog may be in the way.");-    };-    let mut steps = Vec::new();-    let mut set = |label: &str, on: bool| -> Result<(), String> { native().uia_set_checked(dlg, label, on).map(|r| steps.push(r)) };-    let options = [("PTH pads", flag("pthPads", true)), ("SMD pads", flag("smdPads", true)), ("Vias", flag("vias", true)), ("Track to track", flag("tJunctions", false)), ("Selected items only", flag("selectedOnly", false))];-    let mut failed = None;-    for (label, on) in options {-        if let Err(e) = set(label, on) {-            failed = Some(e);-            break;-        }-    }-    if failed.is_none() {-        let action = if remove { "Remove all teardrops" } else { "Add teardrops with default values for shape" };-        match native().uia_invoke(dlg, action) {-            Ok(r) => steps.push(r),-            Err(e) => failed = Some(e),-        }-    }-    if let Some(e) = failed {-        let _ = native().click_button(dlg, &["close"]);-        return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed. kicad_screenshot_all shows KiCad's windows.");-    }-    if let Err(e) = native().click_button(dlg, &["apply and close"]) {-        let _ = native().click_button(dlg, &["close"]);-        return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed.");-    }-    steps.push("apply and close".into());-    let started = Instant::now();-    while window_alive(dlg) && started.elapsed() < Duration::from_secs(150) {-        sleep_ms(250);-    }-    if window_alive(dlg) {-        return fail("dialog_still_open", "Set Teardrops is still open after 150 s", "KiCad may still be working; check with kicad_window_info before calling again.");-    }-    let after_read = teardrop_count(state, args);-    let after = after_read.as_ref().map(|(n, _)| *n);-    let revision = after_read.map(|(_, r)| r);-    let delta = match (before, after) {-        (Some(b), Some(a)) => Some(a as i64 - b as i64),-        _ => None,-    };-    json!({-        "success": true,-        "output": match delta {-            Some(d) => format!("Teardrops {}: {} before, {} after", if remove { "removed" } else { "applied" }, before.unwrap_or(0), after.unwrap_or(0)) + if d == 0 && !remove { " (nothing new: every item in scope already had one, or none qualified)" } else { "" },-            None => format!("Teardrops {} through KiCad's dialog (counts need the IPC API)", if remove { "removed" } else { "applied" }),-        },-        "before": before, "after": after, "added": delta.filter(|d| *d > 0), "removed": delta.filter(|d| *d < 0).map(|d| -d),-        "mutated": delta.map(|d| d != 0),-        "dialogSteps": steps,-        "revision": revision,-        "saved": false,-        "_hint": "One KiCad Undo step; revision is the board's new revision (KiCad touches the board even when it adds none), so pass it to the next edit. Save with kicad_save_board, then kicad_run_drc: teardrops are copper areas and take part in DRC.",-    })-}+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\" (default the open board), \"vias\"?: true (and through-hole pads), \"pthPads\"?: as vias, \"smdPads\"?: true, \"tracks\" | \"tJunctions\"?: true (KiCad's one Track to track box), \"selectedOnly\"?: false, \"remove\"?: false (removes ALL teardrops), \"save\"?: true}",+        example: "kicad_teardrops {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\",\"smdPads\":false}",+        hint: "Uses the board's default teardrop shapes (Board Setup > Teardrops). teardropsBefore and teardropsAfter count the teardrop areas; KiCad adds them as one Undo step and the board is saved (save:false leaves that to you). force:true is refused: KiCad has no such mode. Check with kicad_run_drc.",+        related: &["kicad_save_board", "kicad_run_drc", "kicad_refill_zones", "kicad_open_board"],+        pitfalls: &["The board must be open in the PCB editor (kicad_open_board); KiCad's API has no teardrop call, so the bridge drives KiCad's own dialog, which shows for a moment.", "Counts need the IPC API on (kicad_ipc_api); without it before and after are null and only the dialog's result is known.", "remove:true removes every teardrop on the board, whatever the scope says."],+    },+    Verb {+        name: "kicad_check_dialogs",+        summary: "List every KiCad dialog open right now, classified, each with its buttons, what it means and what is safe to do, and a screenshot (the standard's check_dialogs). Answers nothing, closes nothing.",+        mechanism: Mechanism::Window, risk: "read", timeout_sec: 60,+        input: "{\"screenshots\"?: true}",+        example: "kicad_check_dialogs {}",+        hint: "category is save_prompt, progress, tool, benign or other, and safeAction a suggestion. A save_prompt is never answered for you: it may name someone's work. kicad_dismiss_dialogs clears the benign ones, or all.",+        related: &["kicad_dismiss_dialogs", "kicad_window_info", "kicad_dialog_log"],+        pitfalls: &["shot is the path the bridge saved the picture to, not base64."],+    },+    Verb {+        name: "kicad_save",+        summary: "Save the document at filePath, or the topmost open board or sheet (the standard's save): a board through KiCad's API, a schematic through the Schematic Editor's own File > Save.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 120,+        input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\" | \"C:/.../x.kicad_sch\"}",+        example: "kicad_save {\"filePath\":\"C:/p/b.kicad_sch\"}",+        hint: "saved says the file reached the disk, read from the title's unsaved mark and the file's time. A document that is not open has nothing to save: saved:false with a note, and success. data.process names how KiCad was asked.",+        related: &["kicad_save_board", "kicad_close_document", "kicad_document_info"],+        pitfalls: &["Without filePath it saves whatever is on top, which may be someone else's document.", "A never-saved document answers with KiCad's Save As dialog: the reply lists it in dialogs; name it there or pass kicad_send_key."],+    },+    Verb {+        name: "kicad_close_document",+        summary: "Close ONE open board or schematic by its path, never saving (the standard's close_document): the editor window goes, KiCad and the project stay.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 60,+        input: "{\"path\": \"C:/.../x.kicad_pcb\" | \"C:/.../x.kicad_sch\"}",+        example: "kicad_close_document {\"path\":\"C:/p/b.kicad_pcb\"}",+        hint: "data {closed:true, path, discarded} or {closed:false, note:\"not open: <path>\"}, which is still success. A schematic's editor holds the whole hierarchy, so any of its sheets closes it. Keep unsaved work with kicad_save first.",+        related: &["kicad_close_all_documents", "kicad_save", "kicad_close", "kicad_open_document"],+        pitfalls: &["Unsaved edits are DISCARDED, as the standard says: KiCad's own Save prompt is answered Discard."],+    },+    Verb {+        name: "kicad_close_all_documents",+        summary: "Close every open board, schematic and library editor without a save dialog (the standard's close_all_documents); unsaved work stays open and is listed unless save or discardUnsaved says what to do.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 180,+        input: "{\"save\"?: false, \"discardUnsaved\"?: false, \"includeScratch\"?: false}",+        example: "kicad_close_all_documents {}",+        hint: "closed, saved and keptOpen [{name, why}]. KiCad's project manager stays up; kicad_close ends KiCad itself. A scratch document is one that has never been saved.",+        related: &["kicad_close_document", "kicad_close", "kicad_save"],+        pitfalls: &["discardUnsaved loses changes that exist nowhere else: ask the person first."],+    },+    Verb {+        name: "kicad_click",+        summary: "Click at coordinates inside a KiCad window.",+        mechanism: Mechanism::Window, risk: "input", timeout_sec: 30,+        input: "{\"hwnd\": 123, \"x\": 0.5, \"y\": 0.5, \"relative\"?: true}",+        example: "kicad_click {\"hwnd\":123456,\"x\":0.5,\"y\":0.5}",+        hint: "Posts the click to the window's own queue (background: the user's mouse never moves); read window_info + screenshot_all first to compute coordinates. relative:true (default) takes fractions 0.0-1.0 of the window.",+        related: &["kicad_window_info", "kicad_screenshot_all", "kicad_send_key"],+        pitfalls: &["coordinates are window-relative and break if the window resized; re-read window_info", "allowForeground (a real cursor click) is not in the native build"],+    },+    Verb {+        name: "kicad_fix_keyboard",+        summary: "Reset stuck modifier keys on a KiCad window.",+        mechanism: Mechanism::Local, risk: "read", timeout_sec: 10,+        input: "{}", example: "kicad_fix_keyboard {}",+        hint: "Clears stuck Ctrl/Shift/Alt state after send_key sequences misbehave. The native build never sends global keystrokes, so there is never anything to fix.",+        related: &["kicad_send_key"],+        pitfalls: &[],+    },+    Verb {+        name: "kicad_place_footprint",+        summary: "Deterministically place a footprint into a board preview.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 130,+        input: "{\"fileName\": \"X.kicad_mod\", \"fileContent\": \"<base64>\", \"footprintName\"?: \"X\", \"x\"?: 148, \"y\"?: 105, \"open\"?: true}",+        example: "kicad_place_footprint {\"fileName\":\"BMI423.kicad_mod\",\"fileContent\":\"KGZvb3RwcmludCAu...\"}",+        hint: "Splices a footprint into a fresh preview board via s-expr (no GUI edit) and opens it in pcbnew in the background; good for previews.",+        related: &["kicad_open_board", "kicad_install_footprint"],+        pitfalls: &["operates on the file; close the board in KiCad first or the edit races the GUI"],+    },+    Verb {+        name: "kicad_enable_software_opengl",+        summary: "LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively.",+        mechanism: Mechanism::File, risk: "write", timeout_sec: 240,+        input: "{}", example: "kicad_enable_software_opengl {}",+        hint: "Only when KiCad cannot render at all AND there is no GPU. Drops Mesa opengl32/libgallium DLLs + .local redirection into KiCad bin (needs elevation to write Program Files, or use a per-user install). Close+reopen editors after.",+        related: &["kicad_dismiss_dialogs", "kicad_upgrade"],+        pitfalls: &["a real GPU / Hyper-V GPU-P passthrough is far better; this is CPU rendering, slow", "writing KiCad's bin needs an elevated Bridge"],+    },+    Verb {+        name: "kicad_rescan_libraries",+        summary: "Make newly installed parts visible ONCE: reload every open editor's library tree in place (seconds, nothing closed), then verify each listed footprint by loading it in the Footprint Editor. force:true is the process restart, the last resort.",+        mechanism: Mechanism::Window, risk: "write", timeout_sec: 200,+        input: "{\"footprints\"?: [\"Lib:Name\", ...], \"force\"?: bool, \"confirmRestart\"?: bool}",+        example: "kicad_rescan_libraries {\"footprints\":[\"Adom:LQFP-48_STM32G431\"]}",+        hint: "Wiki #51: install N parts, call this once, then show them all. A restart closes KiCad gracefully first (so it saves its own geometry) and puts every window back on the monitor the user had it on (wiki #48; closeHow and placementRestored report both). Pass footprints so the reply can say what KiCad can load now; without the list nothing is verified. A restart (force) refuses while any KiCad runs unless confirmRestart:true, because the native build cannot yet tell a user-launched KiCad from its own (the ownership ledger arrives with phase 4).",+        related: &["kicad_install_footprint", "kicad_show_footprint", "kicad_list_footprints"],+        pitfalls: &["KiCad 10 caches footprint libraries per process; the tree refresh reveals parts installed after the editor started (measured 2026-09-07), a full restart is only for the cases it does not."],+    },+];++/// Verbs that may spawn or raise a KiCad window: they get the 5-round settle sweep and+/// the foreground etiquette (server.py `_SPAWNS_WINDOW`, this group's members).+const SPAWNS_WINDOW: &[&str] = &["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_place_footprint",+    // loads a footprint into the editor, which KiCad may raise once the load finishes (a late raise on a slow box: winvm)+    "kicad_rescan_libraries"];++/// Verbs that touch no window at all (no sweep, no focus check).+const NO_GUI: &[&str] = &["kicad_fix_keyboard", "kicad_enable_software_opengl"];++pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> {+    if !VERBS.iter().any(|v| v.name == command) {+        return None;+    }+    // Caller identity, so every ab callback made while serving this verb is attributed+    // "<thread> (via kicad)". ab relays it as args.caller; headers are the fallback.+    ab::set_caller_from_args(args);+    let gui = !NO_GUI.contains(&command);+    let fg_before = if gui { native().foreground().ok().filter(|h| *h != 0) } else { None };+    if gui {+        // Phase 4: arm the guard and snapshot the park baseline for the etiquette loop.+        native().etiquette_begin_verb(command, SPAWNS_WINDOW.contains(&command));+    }+    let mut out = match command {+        "kicad_launch" => launch(state, args),+        "kicad_rescan_libraries" => rescan_libraries(state, args),+        "kicad_open_board" => open_file(state, args, WindowKind::PcbEditor),+        "kicad_open_schematic" => open_file(state, args, WindowKind::SchematicEditor),+        "kicad_open_symbol_editor" => open_symbol_editor(state, args),+        "kicad_open_footprint_editor" => open_footprint_editor(state, args),+        "kicad_open_3d_viewer" => open_3d_viewer(args),+        "kicad_3d_view" => view_3d(args),+        "kicad_refresh_board_views" => refresh_board_views(args),+        "kicad_menu_items" => menu_items(args),+        "kicad_open_editors" => open_editors(),+        "kicad_close" => close_kicad(state, args),+        "kicad_close_symbol_editor" => close_kind(WindowKind::SymbolEditor, "Symbol Editor"),+        "kicad_close_footprint_editor" => close_kind(WindowKind::FootprintEditor, "Footprint Editor"),+        "kicad_close_3d_viewer" => close_kind(WindowKind::Viewer3d, "3D Viewer"),+        "kicad_dismiss_dialogs" => dismiss_dialogs(state, args),+        "kicad_foreground" => foreground(args),+        "kicad_dialog_log" => dialog_log(args),+        "kicad_window_info" => window_info(state, args),+        "kicad_state" => kicad_state(state, args),+        "kicad_screenshot_all" => screenshot_all(state),+        "kicad_send_key" => send_key(args),+        "kicad_teardrops" => teardrops(state, args),+        "kicad_check_dialogs" => check_dialogs(args),+        "kicad_save" => save_document(state, args),+        "kicad_close_document" => close_document(args),+        "kicad_close_all_documents" => close_all_documents(state, args),+        "kicad_click" => click(args),+        "kicad_fix_keyboard" => fix_keyboard(),+        "kicad_place_footprint" => place_footprint(state, args),+        "kicad_enable_software_opengl" => enable_software_opengl(state),+        _ => return None,+    };+    if gui && out.get("errorCode") != Some(&json!("not_supported_on_platform")) {+        post_verb(state, command, args, &mut out, fg_before);+    }+    if gui {+        native().etiquette_end_verb(command);+    }+    ab::clear_caller();+    Some(out)+}++/// The server.py dispatch wrapper for a window verb: dialog sweep, foreground etiquette,+/// the one-shot focus check, window labelling.+/// Set when KiCad's own "Could not use OpenGL" box was seen; cleared by kicad_enable_software_opengl.+pub(crate) static NO_OPENGL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);++fn is_3d_verb(command: &str) -> bool {+    matches!(command, "kicad_open_3d_viewer" | "kicad_show_3d_board" | "kicad_show_3d_chip" | "kicad_3d_view")+}++pub(crate) fn post_verb(state: &mut State, command: &str, args: &Value, out: &mut Value, fg_before: Option<u64>) {+    let spawns = SPAWNS_WINDOW.contains(&command);+    let info = state.kicad_info();+    // CONSTANT DIALOG COVERAGE: KiCad throws modal error boxes constantly; sweep after+    // every GUI verb so the AI never runs blind past one and the next verb is unblocked.+    let rounds = if spawns { 5 } else { 1 };+    // Wiki #120: a read-only verb never presses anything. It still reports what it saw.+    let read_only = crate::catalog::find(command).map(|v| v.risk == "read").unwrap_or(false);+    if let Some(swept) = auto_sweep_dialogs(&info, true, rounds, Duration::from_millis(600), !read_only) {+        let had_error = swept["hadError"].as_bool().unwrap_or(false);+        if had_error {+            let titles: Vec<String> = swept["dialogs"].as_array().map(|a| a.iter().filter(|d| !d["benign"].as_bool().unwrap_or(false)).map(|d| format!("\"{}\"", d["title"].as_str().unwrap_or(""))).collect()).unwrap_or_default();+            out["_dialogHint"] = json!(if read_only {+                format!("KiCad has {} dialog(s) open (non-benign: {}). This verb is read-only, so it pressed nothing: read each body in _autoDialogs and decide (kicad_dismiss_dialogs, or answer it yourself).", swept["count"], titles.join(", "))+            } else {+                format!("KiCad popped {} dialog(s) during this verb (non-benign: {}). Each was screenshotted (see _autoDialogs[].screenshot + .body) and auto-expired so you are unblocked. If your verb 'failed'/'did not open', this dialog is almost certainly why: read the body and act on it.", swept["count"], titles.join(", "))+            });+        }+        // KiCad says this once per process start on a machine without usable OpenGL (a VM with no+        // GPU, RDP): 2D falls back to software, the 3D viewer stays BLACK. Remember it, so the 3D+        // verbs stop answering ok over a black canvas (wiki #100, seen on winvm).+        if swept["dialogs"].as_array().is_some_and(|a| a.iter().any(|d| d["body"].as_str().unwrap_or("").contains("Could not use OpenGL"))) {+            NO_OPENGL.store(true, std::sync::atomic::Ordering::Relaxed);+        }+        out["_autoDialogs"] = swept;+    }+    if is_3d_verb(command) && NO_OPENGL.load(std::sync::atomic::Ordering::Relaxed) && out.get("success").and_then(Value::as_bool).unwrap_or(true) {+        out["canvasRendered"] = json!(false);+        out["renderVerified"] = json!(false);+        out["_renderHint"] = json!("KiCad reported \"Could not use OpenGL\" on this machine (no GPU: a VM or a remote session). The PCB and schematic canvases fall back to software, but the 3D viewer needs OpenGL 1.5 and shows a BLACK canvas: do not present its frame as proof. Fix: kicad_close, then kicad_enable_software_opengl (Mesa on the CPU, slow but correct, for GPU-less hosts only), then open again.");+    }+    // Foreground etiquette (John, 2026-08-14 and 2026-08-16): the ONE sanctioned way a+    // KiCad window comes to the front is an explicit foreground flag WITH a reason.+    let wants_fg = user_wants_foreground(args);+    let success = out.get("success").and_then(Value::as_bool).unwrap_or(true);+    if wants_fg && spawns {+        std::thread::sleep(Duration::from_secs(1)); // let the window exist+        let reason = arg_str(args, "foregroundReason").or_else(|| arg_str(args, "reason")).unwrap_or("").trim().to_string();+        let mut shown: Option<u64> = None;+        if !reason.is_empty() {+            if let Some(h) = verb_window(command, out) {+                if bring_to_user(h, &reason) {+                    shown = Some(h);+                }+            }+        }+        out["_broughtToUser"] = json!(shown.is_some());+        out["windowEtiquette"] = json!(if shown.is_some() { "foreground" } else { "background" });+        if shown.is_none() {+            out["errorCode"] = json!("foreground_reason_required");+            out["_foregroundRefused"] = json!("foreground requested without foregroundReason - pass foregroundReason:'<why the user needs to see this>' and the bridge will caption it for 3s, then foreground.");+            out["_hint"] = json!("errorCode foreground_reason_required: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'.");+        }+    } else if spawns && success {+        let o = out.as_object_mut().unwrap();+        o.entry("windowEtiquette").or_insert(json!("background"));+        o.entry("openedInBackground").or_insert(json!(true));+        o.entry("_foregroundHint").or_insert(json!("Window opened in the BACKGROUND - the user's foreground was not touched. To show it to the user: re-call with foreground:true AND foregroundReason:'<why the user needs to see this>'. The bridge captions the reason via an ab toast for 3 seconds, then brings the window forward. Foreground without a reason is refused."));+    }+    // One-shot focus check (the phase 4 guardian's stand-in): did a KiCad window take the+    // foreground from a user window during this verb? Push it back once, and say so.+    let mut events = Vec::new();+    if !(wants_fg && out["_broughtToUser"] == json!(true)) {+        // With the etiquette loop running (Windows) the one-shot push would be a second action+        // on a window the loop already handled once; keep it only where no loop exists.+        // Exception (gate on 2026-09-12, ConfRoomROG and arav-rog): a freshly spawned editor+        // re-raises itself when its board finishes loading WITHOUT a foreground transition+        // (it already was the foreground while loading), so the loop sees nothing to bounce+        // and the user's window stays covered. For spawn verbs the one-shot check runs+        // regardless, twice: now and after a short settle for the late re-raise.+        let loop_running = native().etiquette_debug() != Value::Null;+        if let Some(ev) = if loop_running && !spawns { None } else { one_shot_focus_check(fg_before) } {+            events.push(ev);+        }+        if spawns {+            // Wait for the editor to finish loading (its "Load PCB" / "Loading" progress+            // dialog gone), because that is the moment it raises itself; then check twice.+            for _ in 0..16 {+                let loading = scan_dialogs().iter().any(|d| wm::is_progress_dialog(&d.title, &d.body));+                if !loading {+                    break;+                }+                sleep_ms(500);+            }+            sleep_ms(700);+            if let Some(ev) = one_shot_focus_check(fg_before) {+                events.push(ev);+            }+            sleep_ms(700);+            if let Some(ev) = one_shot_focus_check(fg_before) {+                events.push(ev);+            }+        }+    }+    // kicad_state already carries the loop's ring buffer; never clobber it with the+    // per-verb list (which is empty while the loop runs).+    if out.get("focusEvents").map(|v| v.as_array().map(|a| a.is_empty()).unwrap_or(true)).unwrap_or(true) {+        out["focusEvents"] = json!(events);+    }+    if spawns {+        out["_userFocusRestored"] = json!(false);+    }+    // #12.3: label the KiCad window this verb raised with the owning AI thread, so the+    // user's taskbar/Alt-Tab shows which of their ~20 tabs owns it. Best-effort.+    if spawns && success {+        let thread = ab::thread_name();+        let label = match command {+            "kicad_open_symbol_editor" => Some("Symbol Editor"),+            "kicad_open_footprint_editor" => Some("Footprint Editor"),+            "kicad_open_board" => Some("PCB Editor"),+            "kicad_open_schematic" => Some("Schematic Editor"),+            "kicad_open_3d_viewer" => Some("3D Viewer"),+            _ => None,+        };+        if let (false, Some(word)) = (thread.is_empty(), label) {+            if let Ok((wins, _)) = find_windows(false, true) {+                if let Some(w) = wins.iter().find(|w| w.title.to_lowercase().contains(&word.to_lowercase())) {+                    if ab::desktop_set_window_identity(w.hwnd, &format!("KiCad ({thread})"), &format!("KiCad {word} - driven by {thread}")).is_ok() {+                        out["_windowLabeledFor"] = json!(thread);+                    }+                }+            }+        }+    }+    invalidate();+}++// ── Platform plumbing ─────────────────────────────────────────────────────────++fn is_not_impl(e: &str) -> bool {+    e.contains(": not implemented on ")+}++/// Map a platform Result: "not implemented on <os>" becomes the honest+/// not_supported_on_platform reply; anything else is a platform_error.+fn plat<T>(feature: &str, r: Result<T, String>) -> Result<T, Value> {+    r.map_err(|e| if is_not_impl(&e) { unsupported(feature) } else { fail("platform_error", format!("{feature}: {e}"), "The OS layer refused. kicad_diagnostics shows the platform capabilities; kicad_screenshot_all shows what is on screen.") })+}++fn exe_suffix() -> &'static str {+    if native().cli_file_name().ends_with(".exe") { ".exe" } else { "" }+}++/// `<bin_dir>/<name>[.exe]` for the primary install.+fn kicad_exe(info: &KicadInfo, name: &str) -> Option<PathBuf> {+    let inst = info.primary()?;+    let p = PathBuf::from(&inst.bin_dir).join(format!("{name}{}", exe_suffix()));+    if p.is_file() { Some(p) } else { None }+}++fn utc_stamp() -> String {+    // 2026-09-11T12:34:56Z -> 20260911-123456+    let s = kicad_common::now_utc_iso();+    let date: String = s.chars().take(10).filter(|c| c.is_ascii_digit()).collect();+    let time: String = s.chars().skip(11).take(8).filter(|c| c.is_ascii_digit()).collect();+    format!("{date}-{time}")+}++/// Spawn a KiCad GUI without activating its window, stderr captured to a launch log.+fn spawn_background(exe: &Path, args: &[String], tag: &str, trace_masks: Option<&str>) -> Result<(u32, Option<PathBuf>), Value> {+    skip_setup_wizard();+    let log = bridge_log::log_dir().and_then(|d| bridge_log::new_log_path(&d, &utc_stamp(), tag).ok());+    let env = bridge_log::trace_env(trace_masks);+    let pid = plat("spawn_background", native().spawn_background(exe, args, &env, log.as_deref()))?;+    native().etiquette_register_pid(pid, None);+    invalidate();+    Ok((pid, log))+}++/// Is `name` a row of the user's own fp-lib-table (not only reachable through a nested table)?+fn user_table_row(ctx: &LibCtx, name: &str) -> bool {+    ctx.fp_lib_table()+        .and_then(|p| kicad_core::libraries::LibTable::parse_file(&p).ok())+        .is_some_and(|t| t.entries.iter().any(|e| e.name == name && !e.lib_type.eq_ignore_ascii_case("Table")))+}++/// Before a KiCad process starts, and only while none runs: mark KiCad's setup wizard as done so+/// it never pops up in front of the user (see kicad_common::mark_first_run_done).+fn skip_setup_wizard() {+    if !native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default().is_empty() {+        return;+    }+    let versions: Vec<String> = kicad_core::detect::detect().installs.iter().map(|i| i.version.clone()).collect();+    for v in versions {+        if let Some(cfg) = native().config_dir(&v) {+            match kicad_common::mark_first_run_done(&cfg) {+                Ok(true) => eprintln!("[kicad-bridge] KiCad {v}: setup wizard marked done (kicad.json first_run_shown), it will not pop up again"),+                Ok(false) => {}+                Err(e) => eprintln!("[kicad-bridge] KiCad {v}: could not mark the setup wizard done: {e}"),+            }+        }+    }+}++fn sleep_ms(ms: u64) {+    std::thread::sleep(Duration::from_millis(ms));+}++fn user_wants_foreground(args: &Value) -> bool {+    ["foreground", "showToUser", "bringToFront"].iter().any(|k| args.get(*k).and_then(Value::as_bool).unwrap_or(false))+}++fn user_idle_seconds() -> Option<f64> {+    native().seconds_since_input().ok().filter(|s| *s < 1e8).map(|s| (s * 10.0).round() / 10.0)+}++// ── Window finder (kicad_windows.py): ab first for z-order, local fallback ────++#[derive(Clone, Debug, Default)]+pub(crate) struct Win {+    pub(crate) hwnd: u64,+    pub(crate) title: String,+    class_name: String,+    /// x, y, width, height.+    rect: (i32, i32, i32, i32),+    z: Option<i64>,+    owner: u64,+    pid: u32,+    exe: String,+    minimized: bool,+    maximized: bool,+    hung: bool,+}++impl Win {+    fn kind(&self) -> WindowKind {+        wm::classify(&self.title, &self.class_name, self.owner)+    }+    fn is_kicad(&self) -> bool {+        if !self.exe.is_empty() {+            return wm::is_kicad_exe(&self.exe);+        }+        // No exe known (an ab older than 2.1.12 on a platform with no window_info): fail+        // open on the title, like the Python fallback.+        wm::title_looks_kicad(&self.title)+    }+    fn rect_json(&self) -> Value {+        json!({"x": self.rect.0, "y": self.rect.1, "width": self.rect.2, "height": self.rect.3})+    }+    /// `z` is the window's position in the OS enumeration, which EnumWindows returns front+    /// first, so a caller can tell which of two windows of a kind the user was looking at+    /// (wiki #48). ab's inventory carries its own z; this is the native fallback's.+    fn from_info(w: &WindowInfo, z: usize) -> Win {+        Win { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: Some(z as i64), owner: w.owner, pid: w.pid, exe: w.exe.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung }+    }+}++struct Cache {+    at: Option<Instant>,+    rows: Vec<Win>,+    source: &'static str,+}++static CACHE: Mutex<Cache> = Mutex::new(Cache { at: None, rows: Vec::new(), source: "(not yet queried)" });+const CACHE_TTL: Duration = Duration::from_millis(250);++/// Call after anything that opens, closes, or retitles a KiCad window.+pub(crate) fn invalidate() {+    if let Ok(mut c) = CACHE.lock() {+        c.at = None;+        c.rows.clear();+    }+}++fn list_all_windows() -> Result<(Vec<Win>, &'static str), Value> {+    match ab::desktop_list_windows() {+        Ok(l) if !(l.windows.is_empty() && l.degraded) => {+            let mut rows: Vec<Win> = l.windows.iter().map(|w| Win {+                hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), rect: w.rect, z: w.z, owner: w.owner, pid: w.pid,+                exe: w.image.clone(), minimized: w.minimized, maximized: w.maximized, hung: w.hung,+            }).collect();+            // ab before 2.1.12 carries no image/owner (they arrived together): fill both+            // from the OS layer when it can, only for rows that lack the exe.+            for r in rows.iter_mut() {+                if r.exe.is_empty() {+                    if let Ok(Some(i)) = native().window_info(r.hwnd) {+                        r.exe = i.exe;+                        if r.owner == 0 { r.owner = i.owner; }+                        if r.class_name.is_empty() { r.class_name = i.class_name; }+                    }+                }+            }+            Ok((rows, "ad:desktop_list_windows"))+        }+        _ => {+            let rows = plat("window enumeration (list_windows)", native().list_windows())?;+            Ok((rows.iter().filter(|w| w.visible).enumerate().map(|(z, w)| Win::from_info(w, z)).collect(), "local:EnumWindows"))+        }+    }+}++/// KiCad windows now (all_windows: every top-level window). Cached 250 ms.+fn find_windows(all_windows: bool, fresh: bool) -> Result<(Vec<Win>, &'static str), Value> {+    let mut c = CACHE.lock().map_err(|_| fail("internal", "window cache poisoned", "Retry."))?;+    let stale = fresh || c.at.map(|t| t.elapsed() > CACHE_TTL).unwrap_or(true);+    if stale {+        let (rows, src) = list_all_windows()?;+        c.rows = rows;+        c.source = src;+        c.at = Some(Instant::now());+    }+    let src = c.source;+    if all_windows {+        return Ok((c.rows.clone(), src));+    }+    // A #32770 owned by a KiCad window is KiCad's too (kicad_ui._find_all_kicad_windows).+    let kicad_hwnds: Vec<u64> = c.rows.iter().filter(|r| r.is_kicad()).map(|r| r.hwnd).collect();+    let rows = c.rows.iter().filter(|r| r.is_kicad() || (r.class_name == wm::DIALOG_CLASS && r.owner != 0 && kicad_hwnds.contains(&r.owner))).cloned().collect();+    Ok((rows, src))+}++pub(crate) fn kicad_windows() -> Vec<Win> {+    find_windows(false, false).map(|(w, _)| w).unwrap_or_default()+}++pub(crate) fn windows_of_kind(kind: WindowKind) -> Vec<Win> {+    kicad_windows().into_iter().filter(|w| w.kind() == kind).collect()+}++fn first_of_kind(kind: WindowKind) -> Option<u64> {+    windows_of_kind(kind).first().map(|w| w.hwnd)+}++/// Wiki #48: learn where the user keeps each KiCad window, per window KIND, so a restart can+/// put it back. Called before anything the bridge does that destroys geometry (a restart, a+/// close) and whenever the inventory is in hand anyway. Minimized and parked windows teach+/// nothing, so they are skipped.+pub(crate) fn remember_placement() -> usize {+    let now = utc_stamp();+    let mut mem = window_memory::load();+    let mut learned = 0;+    // Front to back, so that when two windows share a kind (two PCB Editors, say) the one the+    // user was actually looking at is the one whose place is remembered for that kind.+    let mut windows = kicad_windows();+    windows.sort_by_key(|w| w.z.unwrap_or(i64::MAX));+    let mut seen: Vec<WindowKind> = Vec::new();+    for w in windows {+        let kind = w.kind();+        if matches!(kind, WindowKind::Dialog | WindowKind::Other) || seen.contains(&kind) {+            continue;+        }+        seen.push(kind);+        // placement() reports the NORMAL rect plus the maximized flag, which is what has to+        // come back; the enumerated rect is the fallback where that call is unavailable.+        let place = native().placement(w.hwnd).unwrap_or(Placement {+            x: w.rect.0, y: w.rect.1, width: w.rect.2, height: w.rect.3, maximized: w.maximized,+        });+        if window_memory::observe(&mut mem, kind.as_str(), &place, w.minimized, &now) {+            learned += 1;+        }+    }+    if learned > 0 {+        if let Err(e) = window_memory::save(&mem) {+            eprintln!("[placement] could not save the placement memory: {e}");+        }+    }+    learned+}++/// Wiki #48: put each window back where the user had it, without activating it. A fresh+/// KiCad process places every window on the PRIMARY display, which is how a multi-monitor+/// user's PCB Editor kept coming back to the laptop panel. Returns one row per window so the+/// caller's reply can say what moved and what was left alone.+pub(crate) fn restore_placement() -> Vec<Value> {+    let mem = window_memory::load();+    if mem.is_empty() {+        return Vec::new();+    }+    kicad_windows().iter().filter_map(|w| restore_one(&mem, w.kind(), w.hwnd)).collect()+}++/// Wiki #48: one freshly opened window put back on the monitor the user keeps that kind of+/// window on. Called when a verb OPENS an editor, not only when it restarts KiCad, because+/// "the bridge opens or restarts KiCad and it comes back on the laptop screen" is one+/// complaint with two halves. Returns None when nothing is remembered for that kind.+pub(crate) fn restore_placement_one(kind: WindowKind, hwnd: u64) -> Option<Value> {+    restore_one(&window_memory::load(), kind, hwnd)+}++fn restore_one(mem: &window_memory::Memory, kind: WindowKind, hwnd: u64) -> Option<Value> {+    let remembered = mem.get(kind.as_str())?;+    let r = &remembered.place;+    let current = match native().placement(hwnd) {+        Ok(p) => p,+        Err(e) => return Some(json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e})),+    };+    let on_monitor = native().on_a_monitor((r.x, r.y, r.width, r.height)).unwrap_or(false);+    Some(match window_memory::decide(r, &current, on_monitor) {+        Restore::Skip(why) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "why": why}),+        Restore::Move(p) => match native().set_placement(hwnd, &p) {+            Ok(()) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": true,+                "to": {"x": p.x, "y": p.y, "width": p.width, "height": p.height, "maximized": p.maximized},+                "rememberedAt": remembered.at}),+            Err(e) => json!({"window": kind.as_str(), "hwnd": hwnd, "moved": false, "error": e}),+        },+    })+}++fn find_project_manager() -> Option<u64> {+    kicad_windows().iter().find(|w| wm::is_project_manager(&w.title, w.owner)).map(|w| w.hwnd)+}++/// A window's current title without hanging on a modal-blocked thread.+pub(crate) fn title_of(hwnd: u64) -> String {+    if let Ok(Some(i)) = native().window_info(hwnd) {+        return i.title;+    }+    find_windows(true, true).ok().and_then(|(w, _)| w.into_iter().find(|w| w.hwnd == hwnd)).map(|w| w.title).unwrap_or_default()+}++pub(crate) fn window_alive(hwnd: u64) -> bool {+    match native().window_info(hwnd) {+        Ok(Some(i)) => i.visible,+        Ok(None) => false,+        Err(_) => find_windows(true, true).map(|(w, _)| w.iter().any(|w| w.hwnd == hwnd)).unwrap_or(false),+    }+}++/// The window a verb surfaced, for foreground and labelling: the kind the verb opens,+/// else the topmost KiCad window.+fn verb_window(command: &str, out: &Value) -> Option<u64> {+    if let Some(h) = out.get("hwnd").and_then(Value::as_u64).filter(|h| *h != 0) {+        return Some(h);+    }+    let kind = match command {+        "kicad_open_symbol_editor" => Some(WindowKind::SymbolEditor),+        "kicad_open_footprint_editor" => Some(WindowKind::FootprintEditor),+        "kicad_open_board" | "kicad_place_footprint" => Some(WindowKind::PcbEditor),+        "kicad_open_schematic" => Some(WindowKind::SchematicEditor),+        "kicad_open_3d_viewer" => Some(WindowKind::Viewer3d),+        _ => None,+    };+    let wins = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();+    kind.and_then(|k| wins.iter().find(|w| w.kind() == k).map(|w| w.hwnd)).or_else(|| wins.first().map(|w| w.hwnd))+}++// ── Focus etiquette (win_focus.py, the one-shot part only) ────────────────────++static FOCUS_EVENTS: Mutex<Vec<Value>> = Mutex::new(Vec::new());++fn record_focus_event(ev: Value) {+    if let Ok(mut g) = FOCUS_EVENTS.lock() {+        g.push(ev);+        if g.len() > 200 {+            let n = g.len() - 200;+            g.drain(..n);+        }+    }+}++fn is_kicad_hwnd(hwnd: u64) -> bool {+    if let Ok(Some(i)) = native().window_info(hwnd) {+        return wm::is_kicad_exe(&i.exe);+    }+    kicad_windows().iter().any(|w| w.hwnd == hwnd)+}++/// capture_foreground before, compare after: if a KiCad window now holds the foreground+/// and the user had a non-KiCad window before, push KiCad back once (never activate the+/// user's window: Windows re-activates it naturally) and report the event.+fn one_shot_focus_check(fg_before: Option<u64>) -> Option<Value> {+    // Part of the background guard: with the guard OFF the bridge moves no KiCad window at all.+    // Before 1.0.48 this per-verb check ignored the switch, so "guard off" still pushed KiCad back+    // after every verb and the A/B comparison showed nothing (AdomLapper, 2026-09-21).+    if !native().etiquette_enabled() {+        return None;+    }+    let before = fg_before?;+    if is_kicad_hwnd(before) || native().is_shell_window(before) {+        return None;+    }+    // What covers the user's window is the top of the Z order, not only the activated+    // window: a KiCad editor raises itself when its board finishes loading without a+    // foreground transition (gate on 2026-09-12).+    let fg = native().foreground().ok().filter(|h| *h != 0);+    let top = native().z_top().ok().filter(|h| *h != 0);+    let after = match (fg, top) {+        (Some(f), _) if is_kicad_hwnd(f) => f,+        (_, Some(t)) if is_kicad_hwnd(t) => t,+        _ => return None,+    };+    if after == before {+        return None;+    }+    let title = title_of(after);+    let pushed = native().push_to_background(after).is_ok();+    // A Z-order push alone does not hold: the editor still owns the activation and raises+    // itself again on its next paint. Give the user's window its activation back (this+    // restores what the user had before the verb; it is not a foreground of ours).+    // ASK for the user's window back, never force it (no thread-input attach): the forcing+    // bring_to_front here was the same call that crashed Explorer when aimed at the taskbar.+    let restored = native().request_foreground(before);+    let action = match (pushed, restored) {+        (true, true) => "pushed-to-background, user window restored",+        (true, false) => "pushed-to-background",+        (false, true) => "user window restored",+        (false, false) => "push-failed",+    };+    let ev = json!({"event": "self-raise", "hwnd": after, "title": title, "userWindow": before, "action": action, "at": kicad_common::now_utc_iso()});+    record_focus_event(ev.clone());+    Some(ev)+}++/// Explicit, user-requested foreground (win_focus.bring_to_user): caption the reason+/// first, then restore and activate. Refused without a reason.+/// Every dialog a sweep dismissed, newest last (kicad_dialog_log): in memory, the last 200.+static DIALOG_LOG: std::sync::Mutex<std::collections::VecDeque<Value>> = std::sync::Mutex::new(std::collections::VecDeque::new());++fn log_dialog(entry: &Value, benign: bool) {+    let at = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+    let mut e = json!({"at": at, "title": entry["title"], "body": entry["body"], "benign": benign, "action": entry["action"]});+    if let Some(s) = entry["screenshot"].as_str().filter(|s| !s.is_empty()) {+        e["screenshot"] = json!(s);+    }+    if let Ok(mut l) = DIALOG_LOG.lock() {+        l.push_back(e);+        while l.len() > 200 {+            l.pop_front();+        }+    }+}++/// kicad_dialog_log: the last `limit` dismissals.+fn dialog_log(args: &Value) -> Value {+    let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(25).clamp(1, 200) as usize;+    let all: Vec<Value> = DIALOG_LOG.lock().map(|l| l.iter().cloned().collect()).unwrap_or_default();+    let entries: Vec<Value> = all.iter().skip(all.len().saturating_sub(limit)).cloned().collect();+    json!({"success": true, "entries": entries, "total": all.len(),+        "output": format!("{} dialog(s) dismissed since the bridge started; {} shown", all.len(), entries.len()),+        "_hint": "Newest last. at is Unix seconds. Dialogs left open are not here: kicad_window_info lists them."})+}++/// kicad_foreground: one KiCad window to the front, captioned with the reason first (bring_to_user).+fn foreground(args: &Value) -> Value {+    let reason = arg_any(args, &["reason", "foregroundReason"]).unwrap_or("").trim().to_string();+    if reason.chars().count() < 10 {+        return fail("reason_required", "kicad_foreground needs a reason of 10 characters or more, written for the person", "Say why they need to see it, e.g. \"the routed board is ready for your review\".");+    }+    invalidate();+    let kind = |k: &str| match k {+        "pcb" | "board" => Some(WindowKind::PcbEditor),+        "sch" | "schematic" => Some(WindowKind::SchematicEditor),+        "fp" | "footprint" => Some(WindowKind::FootprintEditor),+        "sym" | "symbol" => Some(WindowKind::SymbolEditor),+        "3d" => Some(WindowKind::Viewer3d),+        "manager" | "project" => Some(WindowKind::ProjectManager),+        _ => None,+    };+    let hwnd = match (arg_any_u64(args, &["hwnd"]), arg_str(args, "editor")) {+        (Some(h), _) => Some(h),+        (None, Some(e)) => match kind(&e.to_lowercase()) {+            Some(k) => first_of_kind(k),+            None => return fail("bad_args", format!("unknown editor {e}"), "editor is pcb, sch, fp, sym, 3d or manager."),+        },+        (None, None) => first_of_kind(WindowKind::PcbEditor).or_else(|| first_of_kind(WindowKind::SchematicEditor)).or_else(|| kicad_windows().into_iter().map(|w| w.hwnd).next()),+    };+    let Some(h) = hwnd else {+        return fail("no_window", "no KiCad window is open", "Open one first (kicad_open_board, kicad_open_schematic, kicad_launch).");+    };+    let title = kicad_windows().into_iter().find(|w| w.hwnd == h).map(|w| w.title).unwrap_or_default();+    let shown = bring_to_user(h, &reason);+    json!({"success": shown, "hwnd": h, "title": title, "broughtToUser": shown, "windowEtiquette": if shown { "foreground" } else { "background" },+        "errorCode": if shown { Value::Null } else { json!("foreground_failed") },+        "output": if shown { format!("{title} is in front of the user: {reason}") } else { format!("could not bring {title} forward") },+        "_hint": "Hand the screen back when they are done: every other verb works in the background."})+}++fn bring_to_user(hwnd: u64, reason: &str) -> bool {+    if reason.trim().is_empty() {+        return false;+    }+    // The toast is debounced (settings.foregroundToastMinutes, 0 = never): ab mirrors it to+    // the user's other boxes, and one per verb from a busy thread was worse than the+    // foreground itself. The caption is local to the driven box and always shown.+    let toast = foreground_toast_due();+    if toast {+        let msg = format!("KiCad bridge needs your screen for ~3s: {reason}. Background methods were exhausted; this was explicitly allowed by the calling AI (foreground:true).");+        let _ = ab::notify_user("Taking the foreground briefly", &msg, "warning");+    }+    let _ = ab::desktop_caption("kicad-foreground", reason, 3000, "Announce the foreground the calling AI asked for before the KiCad window moves");+    sleep_ms(1200); // let the caption render BEFORE the window moves+    native().etiquette_sanction(hwnd, 20.0); // the loop must not bounce a foreground the AI asked for+    let ok = match native().bring_to_front(hwnd) {+        Ok(b) => b,+        Err(e) if is_not_impl(&e) => ab::desktop_bring_to_front(hwnd, reason).is_ok(),+        Err(_) => false,+    };+    record_focus_event(json!({"event": "user-foregrounded", "hwnd": hwnd, "reason": reason, "ok": ok, "toast": toast, "at": kicad_common::now_utc_iso()}));+    ok+}++static LAST_FOREGROUND_TOAST: Mutex<Option<Instant>> = Mutex::new(None);++/// One foreground toast per `foregroundToastMinutes`; 0 means never. Records the time+/// when it says yes.+fn foreground_toast_due() -> bool {+    let minutes = kicad_core::settings::get_settings().get("foregroundToastMinutes").and_then(Value::as_u64).unwrap_or(10);+    if minutes == 0 {+        return false;+    }+    let mut last = match LAST_FOREGROUND_TOAST.lock() {+        Ok(g) => g,+        Err(p) => p.into_inner(),+    };+    let due = last.map(|t| t.elapsed() >= Duration::from_secs(minutes * 60)).unwrap_or(true);+    if due {+        *last = Some(Instant::now());+    }+    due+}++// ── Dialogs (close_windows.py) ────────────────────────────────────────────────++#[derive(Clone, Debug, Default)]+pub(crate) struct Dialog {+    pub(crate) hwnd: u64,+    pub(crate) title: String,+    class_name: String,+    owner: u64,+    pub(crate) body: String,+    via_global_sweep: bool,+}++/// A dialog's message text from its Static children (never blocks on a hung window).+fn dialog_body_text(hwnd: u64) -> String {+    let Ok(children) = native().child_windows(hwnd) else { return String::new() };+    let texts: Vec<String> = children.iter().filter(|c| c.class_name == "Static").map(|c| c.title.clone()).collect();+    wm::dialog_body(&texts)+}++/// RELIABLE scan for EVERY dialog around a running KiCad: the KiCad-process windows that+/// are dialogs (Win32 dialog class or owned), plus a GLOBAL #32770/error-title sweep so+/// an error box owned by an untracked pcbnew/eeschema/orphan is caught too.+pub(crate) fn scan_dialogs() -> Vec<Dialog> {+    let Ok((all, _)) = find_windows(true, false) else { return Vec::new() };+    let mut out = Vec::new();+    let mut seen = Vec::new();+    for w in all.iter().filter(|w| w.is_kicad()) {+        if !wm::is_dialog(&w.class_name, w.owner) {+            continue;+        }+        out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: false });+        seen.push(w.hwnd);+    }+    for w in all.iter() {+        if seen.contains(&w.hwnd) {+            continue;+        }+        // Wiki #120: only a dialog OWNED BY A KICAD PROCESS (an orphaned pcbnew/eeschema+        // included) is ever ours. Before 1.0.56 any titled #32770 matched, so a read-only+        // kicad_state pressed a button on an unrelated Remote Desktop Connection dialog.+        if !w.is_kicad() {+            continue;+        }+        if wm::looks_like_global_dialog(&w.title, &w.class_name) {+            out.push(Dialog { hwnd: w.hwnd, title: w.title.clone(), class_name: w.class_name.clone(), owner: w.owner, body: dialog_body_text(w.hwnd), via_global_sweep: true });+            seen.push(w.hwnd);+        }+    }+    out+}++fn find_save_prompt() -> Option<Dialog> {+    scan_dialogs().into_iter().find(|d| wm::is_save_prompt(&d.title, &d.body))+}++/// Dismiss a dialog: a known dismiss button (OK for info boxes, Cancel/Finish for+/// wizards) through a time-boxed click, then Escape + WM_CLOSE. All posted to the exact+/// hwnd, no focus theft.+fn dismiss_dialog(hwnd: u64) -> String {+    for tier in wm::DISMISS_BUTTON_TIERS {+        if native().click_button(hwnd, tier).is_ok() {+            invalidate();+            return "clicked-button".into();+        }+    }+    let _ = native().post_key(hwnd, wm::VK_ESCAPE, None);+    let _ = native().post_close(hwnd);+    invalidate();+    "escape+WM_CLOSE".into()+}++/// Capture a dialog BEFORE we expire it, so the AI can be shown what was auto-closed.+fn screenshot_dialog(hwnd: u64, title: &str) -> String {+    ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad dialog '{title}' before the bridge expires it, as evidence for the calling AI"), None).map(|s| s.full_path).unwrap_or_default()+}++/// Persist the Cairo (software) canvas in every kicad_common.json so KiCad stops+/// attempting OpenGL on this machine. Called only right after dismissing the "could not+/// use OpenGL" notice, which is PROOF OpenGL is unavailable here.+fn force_software_canvas(version: &str) -> bool {+    let Some(root) = native().config_root() else { return false };+    let mut targets: Vec<PathBuf> = Vec::new();+    if !version.is_empty() {+        let d = root.join(kicad_platform::major_minor(version));+        if d.is_dir() {+            targets.push(d.join("kicad_common.json"));+        }+    }+    if targets.is_empty() {+        targets = kicad_common::common_files(&root);+        if targets.is_empty() && !version.is_empty() {+            targets.push(root.join(kicad_platform::major_minor(version)).join("kicad_common.json"));+        }+    }+    let mut changed = false;+    for cfg in targets {+        let mut doc = std::fs::read_to_string(&cfg).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()).unwrap_or(json!({}));+        if !wm::force_software_canvas_doc(&mut doc) {+            continue;+        }+        if let Some(p) = cfg.parent() {+            let _ = std::fs::create_dir_all(p);+        }+        if let Ok(text) = serde_json::to_string_pretty(&doc) {+            if std::fs::write(&cfg, text).is_ok() {+                changed = true;+            }+        }+    }+    changed+}++/// close_windows._win_sweep_dialogs: expire the benign dialogs (or ALL dialogs with+/// dismiss_all), looping because dismissing one often spawns the next.+fn sweep_dialogs(info: &KicadInfo, dismiss_all: bool, screenshot: bool) -> Value {+    let mut dismissed = Vec::new();+    let mut remaining = Vec::new();+    let mut forced = false;+    let mut shot: Vec<u64> = Vec::new();+    let deadline = Instant::now() + Duration::from_secs(25);+    for _pass in 0..5 {+        if Instant::now() > deadline {+            break;+        }+        remaining.clear();+        let mut acted = false;+        for d in scan_dialogs() {+            match wm::sweep_decision(&d.title, &d.body, dismiss_all) {+                DialogAction::LeaveProgress => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body, "progress": true})),+                DialogAction::Dismiss { benign } => {+                    let mut entry = json!({"title": d.title, "body": d.body});+                    if screenshot && !shot.contains(&d.hwnd) {+                        entry["screenshot"] = json!(screenshot_dialog(d.hwnd, &d.title));+                        shot.push(d.hwnd);+                    }+                    entry["action"] = json!(dismiss_dialog(d.hwnd));+                    log_dialog(&entry, benign);+                    dismissed.push(entry);+                    acted = true;+                    if benign && !forced {+                        forced = force_software_canvas(&info.version);+                    }+                }+                DialogAction::Leave => remaining.push(json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})),+            }+        }+        if !acted {+            break;+        }+        sleep_ms(400);+    }+    json!({"dismissed": dismissed, "remaining": remaining, "forcedSoftwareCanvas": forced})+}++/// close_windows.auto_sweep_dialogs: the post-verb sweep. Settle-polls for a late+/// dialog, screenshots and reads each one found, expires it (never a progress dialog),+/// and returns None when nothing was ever seen.+fn auto_sweep_dialogs(_info: &KicadInfo, screenshot: bool, settle_rounds: usize, settle_gap: Duration, dismiss: bool) -> Option<Value> {+    let rounds = settle_rounds.max(1);+    let mut found = Vec::new();+    for r in 0..rounds {+        invalidate();+        found = scan_dialogs();+        if !found.is_empty() {+            break;+        }+        if r + 1 < rounds {+            std::thread::sleep(settle_gap);+        }+    }+    if found.is_empty() {+        return None;+    }+    let mut dialogs = Vec::new();+    let mut had_error = false;+    let mut seen: Vec<u64> = Vec::new();+    let deadline = Instant::now() + Duration::from_secs(25);+    for _pass in 0..4 {+        if Instant::now() > deadline {+            break;+        }+        let mut acted = false;+        invalidate();+        for d in scan_dialogs() {+            if seen.contains(&d.hwnd) {+                continue;+            }+            seen.push(d.hwnd);+            match wm::auto_sweep_decision(&d.title, &d.body) {+                DialogAction::LeaveProgress => dialogs.push(json!({"title": d.title, "body": d.body, "benign": true, "progress": true, "action": "left-running"})),+                DialogAction::Dismiss { benign } if !dismiss => {+                    if !benign {+                        had_error = true;+                    }+                    dialogs.push(json!({"title": d.title, "body": d.body, "benign": benign, "action": "left-open (read-only verb)"}));+                }+                DialogAction::Dismiss { benign } => {+                    if !benign {+                        had_error = true;+                    }+                    let shot = if screenshot { screenshot_dialog(d.hwnd, &d.title) } else { String::new() };+                    let action = dismiss_dialog(d.hwnd);+                    let entry = json!({"title": d.title, "body": d.body, "benign": benign, "action": action, "screenshot": shot});+                    log_dialog(&entry, benign);+                    dialogs.push(entry);+                    acted = true;+                }+                DialogAction::Leave => {}+            }+        }+        if !acted {+            break;+        }+        sleep_ms(350);+    }+    if dialogs.is_empty() {+        return None;+    }+    Some(json!({"count": dialogs.len(), "dialogs": dialogs, "hadError": had_error}))+}++/// The rows `kicad_errors` reads (bridge_log::DialogRow), for the misc group to consume+/// once kicad_errors moves to a group that runs after this one.+pub fn dialog_rows() -> Vec<bridge_log::DialogRow> {+    scan_dialogs().into_iter().map(|d| {+        let texts: Vec<String> = native().child_windows(d.hwnd).map(|c| c.iter().filter(|c| c.class_name != "Button").map(|c| c.title.clone()).filter(|t| !t.trim().is_empty()).collect()).unwrap_or_default();+        bridge_log::DialogRow { hwnd: d.hwnd, title: d.title, texts, uia_text: None }+    }).collect()+}++// ── Menu walk, UIA, posted keys: the rungs ────────────────────────────────────++/// Resolve a menu item whose label contains `needle` from the window's live menu bar and+/// fire it with WM_COMMAND. Ok(Some((id, label))) when fired, Ok(None) when no such item.+fn menu_invoke_by_text(hwnd: u64, needle: &str) -> Result<Option<(u32, String)>, String> {+    let items = native().menu_tree(hwnd)?;+    let want = needle.to_lowercase();+    // an exact label wins over a longer one that contains it: "Zoom to Selection" must never+    // fire "Zoom to Selection Area" (an interactive tool that parks the editor waiting for a drag)+    let clean = |l: &str| l.replace('&', "").split('\t').next().unwrap_or("").trim().to_lowercase();+    if let Some(it) = items.iter().find(|it| it.id != 0 && it.path.last().map(|l| clean(l) == want).unwrap_or(false)) {+        native().menu_invoke(hwnd, it.id)?;+        invalidate();+        return Ok(Some((it.id, it.path.last().unwrap().replace('&', ""))));+    }+    // Wiki #121: a substring that matches more than one command is refused with the choices,+    // never resolved to whichever item comes first.+    let mut hits: Vec<(u32, String)> = Vec::new();+    for it in &items {+        let Some(label) = it.path.last() else { continue };+        if it.id != 0 && clean(label).contains(&want) && !hits.iter().any(|(id, _)| *id == it.id) {+            hits.push((it.id, label.replace('&', "").split('\t').next().unwrap_or("").trim().to_string()));+        }+    }+    match hits.len() {+        0 => Ok(None),+        1 => {+            let (id, label) = hits.remove(0);+            native().menu_invoke(hwnd, id)?;+            invalidate();+            Ok(Some((id, label)))+        }+        _ => Err(format!(+            "ambiguous menu label '{needle}': it matches {}. Pass the exact label of the one you mean.",+            hits.iter().map(|(_, l)| format!("\"{l}\"")).collect::<Vec<_>>().join(", ")+        )),+    }+}++/// win_menu.find_by_accelerator: a chord to the menu command that carries it.+fn menu_invoke_by_accel(hwnd: u64, chord: &str) -> Result<Option<(u32, String)>, String> {+    let items = native().menu_tree(hwnd)?;+    let want = wm::norm_accel(chord);+    for it in items {+        if let Some(a) = &it.accel {+            if wm::norm_accel(a) == want && it.id != 0 {+                native().menu_invoke(hwnd, it.id)?;+                invalidate();+                let label = it.path.last().map(|l| l.replace('&', "")).unwrap_or_default();+                return Ok(Some((it.id, label)));+            }+        }+    }+    Ok(None)+}++/// Poll for a window of `kind` until `deadline`; `repost` fires once at `repost_after`.+fn wait_for_kind(kind: WindowKind, deadline: Instant, repost: Option<(u64, u32)>, repost_after: Duration) -> Option<u64> {+    let t0 = Instant::now();+    let mut reposted = false;+    loop {+        invalidate();+        if let Some(h) = first_of_kind(kind) {+            return Some(h);+        }+        if Instant::now() >= deadline {+            return None;+        }+        if !reposted && t0.elapsed() > repost_after {+            if let Some((host, id)) = repost {+                let _ = native().menu_invoke(host, id);+            }+            reposted = true;+        }+        sleep_ms(500);+    }+}++/// Every descendant (class, text, rect, visible) of a window with its parent handle.+/// The platform's `child_windows` returns the whole subtree with `owner` carrying the+/// PARENT handle (the Windows implementation is built for exactly this join).+#[derive(Clone, Debug)]+struct Child {+    hwnd: u64,+    class_name: String,+    text: String,+    rect: (i32, i32, i32, i32),+    visible: bool,+    parent: u64,+}++fn descendants(root: u64) -> Vec<Child> {+    let Ok(kids) = native().child_windows(root) else { return Vec::new() };+    kids.iter().map(|k| Child { hwnd: k.hwnd, class_name: k.class_name.clone(), text: k.title.clone(), rect: k.rect, visible: k.visible, parent: if k.owner == 0 { root } else { k.owner } }).collect()+}++/// The library tree's filter Edit: class "Edit" under a parent whose text is "searchCtrl".+fn find_search_edit(frame: u64) -> Option<Child> {+    let all = descendants(frame);+    all.iter().find(|c| c.class_name == "Edit" && all.iter().any(|p| p.hwnd == c.parent && p.text == "searchCtrl")).cloned()+}++static TREE_TOGGLED: Mutex<Vec<(u64, Instant)>> = Mutex::new(Vec::new());++/// The tree search control, showing the Library Tree pane through the frame's own View+/// menu when the user has it hidden (toggle at most once per editor per minute).+fn ensure_tree_search(frame: u64, trace: &mut Vec<String>) -> Option<Child> {+    if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {+        return Some(e);+    }+    let may_toggle = TREE_TOGGLED.lock().map(|g| !g.iter().any(|(h, t)| *h == frame && t.elapsed() < Duration::from_secs(60))).unwrap_or(true);+    if may_toggle {+        match menu_invoke_by_text(frame, "library tree") {+            Ok(Some((id, _))) => {+                trace.push(format!("library tree pane was hidden: View menu toggled (id {id})"));+                if let Ok(mut g) = TREE_TOGGLED.lock() {+                    g.retain(|(h, _)| *h != frame);+                    g.push((frame, Instant::now()));+                }+            }+            Ok(None) => trace.push("search Edit not visible and no 'Library Tree' menu item found".into()),+            Err(e) => trace.push(format!("search Edit not visible; menu walk failed: {e}")),+        }+    }+    for _ in 0..16 {+        if let Some(e) = find_search_edit(frame).filter(|e| e.visible) {+            return Some(e);+        }+        sleep_ms(250);+    }+    None+}++/// The rung used to put text in the filter box: "uia" (ValuePattern) else "post" (WM_SETTEXT).+fn set_filter_text(edit: u64, text: &str) -> Result<&'static str, String> {+    if native().uia_set_value(edit, "", text).is_ok() {+        return Ok("uia");+    }+    native().set_text(edit, text).map(|_| "post")+}++fn post_vk(hwnd: u64, vk: u16) {+    let _ = native().post_key(hwnd, vk, None);+}++/// Which editor title check applies during navigation.+#[derive(Clone, Copy)]+enum Verify<'a> {+    Symbol { name: &'a str, library: &'a str },+    Footprint { name: &'a str, library: &'a str },+    Never,+}++fn verify_title(title: &str, v: Verify<'_>) -> bool {+    match v {+        Verify::Symbol { name, library } => wm::title_confirms_symbol(title, name, library),+        Verify::Footprint { name, library } => wm::title_confirms_footprint(title, name, library),+        Verify::Never => false,+    }+}++/// The plugin-free BACKGROUND library-tree load (0.9.83/0.9.89, wiki #71): text into the+/// LIB_TREE filter, Down through the filter box, Enter posted to the tree candidates,+/// Down x1..3 then Enter, a posted double-click on the first row. Zero focus.+/// Returns (confirmed, trace, mechanism).+fn navigate_tree(frame: u64, filter: &str, verify: Verify<'_>) -> (bool, Value, &'static str) {+    let mut steps: Vec<String> = Vec::new();+    let mut trace = json!({"filter": filter, "editorHwnd": frame, "titleBefore": title_of(frame)});+    let mut mechanism = "post";+    let Some(edit) = ensure_tree_search(frame, &mut steps) else {+        steps.push("search Edit under a 'searchCtrl' parent NOT FOUND (library tree pane hidden, or a different child layout)".into());+        trace["steps"] = json!(steps);+        return (false, trace, mechanism);+    };+    trace["editHwnd"] = json!(edit.hwnd);+    let mut set = set_filter_text(edit.hwnd, filter);+    if let Err(e) = &set {+        // A slow box (2 cores, software rendering) spends many seconds loading every library the+        // first time an editor opens, and its UI thread answers nothing meanwhile: the text timed out+        // at 1.5 s and the verb gave up on a symbol that exists (winvm). Wait for the editor to answer+        // again, then set the text once more.+        steps.push(format!("filter text not accepted yet ({e}); waiting for the editor to become responsive"));+        let mut waited = 0;+        while waited < 40 && !native().responsive(frame, 500).unwrap_or(true) {+            sleep_ms(500);+            waited += 1;+        }+        sleep_ms(800);+        set = set_filter_text(edit.hwnd, filter);+    }+    match set {+        Ok(m) => {+            mechanism = m;+            steps.push(format!("filter text set via {m}"));+        }+        Err(e) => {+            steps.push(format!("could not set the filter text: {e}"));+            trace["steps"] = json!(steps);+            return (false, trace, mechanism);+        }+    }+    sleep_ms(1400); // LIB_TREE search debounce on the UI thread + tree rebuild+    // Down THROUGH the filter box: KiCad forwards nav keys to the tree.+    post_vk(edit.hwnd, wm::VK_DOWN);+    sleep_ms(500);+    steps.push(format!("VK_DOWN posted to edit; title now {:?}", title_of(frame)));+    // Tree candidates: tall siblings under the Libraries pane (wx generic controls share+    // bland class names, so geometry is the discriminator) plus any wxDataView.+    let all = descendants(frame);+    let parent = all.iter().find(|c| c.hwnd == edit.hwnd).map(|c| c.parent).unwrap_or(frame);+    let pane = all.iter().find(|c| c.hwnd == parent).map(|c| c.parent).filter(|p| *p != 0).unwrap_or(parent);+    let cands: Vec<Child> = all.iter().filter(|c| c.hwnd != edit.hwnd && (c.parent == pane || all.iter().any(|p| p.hwnd == c.parent && p.parent == pane)) && (c.rect.3 > 200 || c.class_name.starts_with("wxDataView"))).cloned().collect();+    trace["treeCandidates"] = json!(cands.iter().map(|c| json!({"hwnd": c.hwnd, "class": c.class_name, "rect": [c.rect.0, c.rect.1, c.rect.0 + c.rect.2, c.rect.1 + c.rect.3]})).collect::<Vec<_>>());+    let title_ok = || verify_title(&title_of(frame), verify);+    // Round 1: Enter posted straight to each tree candidate.+    for c in &cands {+        post_vk(c.hwnd, wm::VK_RETURN);+    }+    for _ in 0..6 {+        sleep_ms(700);+        if title_ok() {+            steps.push("VK_RETURN to tree candidates -> title confirms".into());+            trace["steps"] = json!(steps);+            return (true, trace, mechanism);+        }+    }+    steps.push(format!("VK_RETURN to {} tree candidate(s); title still {:?}", cands.len(), title_of(frame)));+    // Round 1b/1c (wiki #71): on a FRESH editor nothing is selected, so select from inside+    // the tree: Down (first lands on the library node, second on the symbol row), then Enter.+    for downs in 1..=3 {+        for c in &cands {+            post_vk(c.hwnd, wm::VK_DOWN);+        }+        sleep_ms(400);+        for c in &cands {+            post_vk(c.hwnd, wm::VK_RETURN);+        }+        for _ in 0..5 {+            sleep_ms(600);+            if title_ok() {+                steps.push(format!("Down x{downs} then VK_RETURN to tree candidates -> title confirms"));+                trace["steps"] = json!(steps);+                return (true, trace, mechanism);+            }+        }+        steps.push(format!("Down x{downs} then VK_RETURN to tree; title still {:?}", title_of(frame)));+    }+    // Round 2: a posted double-click on the first visible row (just under the header).+    for c in &cands {+        let x = (c.rect.2 / 3).max(10);+        let y = 58;+        let _ = native().post_click(c.hwnd, x, y, false);+        let _ = native().post_click(c.hwnd, x, y, true);+    }+    for _ in 0..8 {+        sleep_ms(700);+        if title_ok() {+            steps.push("posted double-click on first row -> title confirms".into());+            trace["steps"] = json!(steps);+            return (true, trace, mechanism);+        }+    }+    steps.push(format!("posted double-click on first row; title still {:?}", title_of(frame)));+    trace["steps"] = json!(steps);+    (false, trace, mechanism)+}++// ── Opening an editor frame: menu walk, then UIA ──────────────────────────────++struct Budget {+    t0: Instant,+    limit: Duration,+}++impl Budget {+    fn new(args: &Value) -> Budget {+        let inline = args.get("waitInline").and_then(Value::as_bool).unwrap_or(false);+        Budget { t0: Instant::now(), limit: if inline { Duration::from_secs(1_000_000) } else { Duration::from_secs(110) } }+    }+    fn out(&self) -> bool {+        self.t0.elapsed() > self.limit+    }+    fn elapsed_sec(&self) -> u64 {+        self.t0.elapsed().as_secs()+    }+}++struct OpenSpec {+    kind: WindowKind,+    /// Menu label fragment ("symbol editor") and the UIA accessible name ("Symbol Editor").+    menu_needle: &'static str,+    uia_name: &'static str,+    /// Frames whose menu bar / UIA tree carries the launcher, in preference order+    /// (a running editor of the same family first, the project manager last).+    hosts: &'static [WindowKind],+    error_code: &'static str,+    starting_code: &'static str,+    label: &'static str,+}++const SYMBOL_SPEC: OpenSpec = OpenSpec { kind: WindowKind::SymbolEditor, menu_needle: "symbol editor", uia_name: "Symbol Editor", hosts: &[WindowKind::SchematicEditor, WindowKind::ProjectManager], error_code: "symbol_editor_did_not_open", starting_code: "symbol_editor_starting", label: "Symbol Editor" };+const FOOTPRINT_SPEC: OpenSpec = OpenSpec { kind: WindowKind::FootprintEditor, menu_needle: "footprint editor", uia_name: "Footprint Editor", hosts: &[WindowKind::PcbEditor, WindowKind::ProjectManager], error_code: "footprint_editor_did_not_open", starting_code: "footprint_editor_starting", label: "Footprint Editor" };++/// The early-return contract when a slow cold start would outlive the transport budget+/// (#54 ask 2, wiki #71): honest about whether anything is actually starting.+fn starting_contract(spec: &OpenSpec, budget: &Budget, phase: &str, pathways: &[String]) -> Value {+    invalidate();+    if first_of_kind(spec.kind).is_none() {+        return json!({+            "success": false, "errorCode": spec.error_code, "retryable": true, "retryAfterMs": 5000,+            "phase": phase, "elapsedSec": budget.elapsed_sec(), "pathwaysTried": pathways,+            "error": format!("No {} window exists. The bridge asked KiCad to open one and none appeared, so this is not a slow cold start - nothing is coming up.", spec.label),+            "_hint": format!("Call kicad_open_{} first and check it returns a window, then re-call this verb. If the editor never opens, KiCad is usually busy with a human (it is single-instance and will not service our menu command while someone is driving it) - kicad_window_info shows what is actually open.", spec.kind.as_str()),+        });+    }+    json!({+        "success": false, "errorCode": spec.starting_code, "starting": true, "retryable": true, "statusVerb": "kicad_state",+        "retryAfterMs": 20000, "phase": phase, "elapsedSec": budget.elapsed_sec(),+        "error": format!("The {} is still coming up (cold start indexes every library, which can outlive the transport budget). This call returned early instead of timing out; KiCad keeps working.", spec.label),+        "_hint": "Not a failure - poll kicad_state (or just wait retryAfterMs) and re-call with the SAME args; a warm editor resolves in seconds. Pass waitInline:true to force the old single-call blocking behaviour.",+    })+}++/// Make sure the project manager exists (spawn kicad.exe in the background if not).+fn ensure_project_manager(info: &KicadInfo, pathways: &mut Vec<String>) -> Result<u64, Value> {+    invalidate();+    if let Some(h) = find_project_manager() {+        return Ok(h);+    }+    let Some(exe) = kicad_exe(info, "kicad") else {+        return Err(fail("kicad_exe_not_found", "KiCad executable not found", "Report to the user and ask them to reinstall KiCad; the installation appears corrupted."));+    };+    spawn_background(&exe, &[], "launch", None).map_err(|mut e| { if e["errorCode"] != json!("not_supported_on_platform") { e["_hint"] = json!("Call kicad_close with {\"force\": true} to clear stuck processes, then retry."); } e })?;+    pathways.push("spawned-project-manager".into());+    let deadline = Instant::now() + Duration::from_secs(10);+    loop {+        sleep_ms(500);+        invalidate();+        if let Some(h) = find_project_manager() {+            sleep_ms(2000);+            return Ok(h);+        }+        if Instant::now() > deadline {+            return Err(fail("project_manager_did_not_open", "KiCad launched but project manager window not found after 10s", "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry."));+        }+    }+}++/// Open an editor frame: an existing one wins; else the native menu walk on each host+/// frame (WM_COMMAND, repost once at 10 s); else UIA Invoke. Returns (hwnd, mechanism).+fn open_editor_frame(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {+    let out = open_editor_frame_inner(info, spec, budget, pathways);+    // Wiki #48: a frame the bridge just opened is placed by Windows defaults, which means the+    // primary display. An existing window is the user's own and is never moved.+    if let Ok((hwnd, mechanism)) = &out {+        if *mechanism != "existing" {+            if let Some(row) = restore_placement_one(spec.kind, *hwnd) {+                if row["moved"] == json!(true) {+                    pathways.push(format!("placement-restored:{}", spec.kind.as_str()));+                }+            }+        }+    }+    out+}++fn open_editor_frame_inner(info: &KicadInfo, spec: &OpenSpec, budget: &Budget, pathways: &mut Vec<String>) -> Result<(u64, &'static str), Value> {+    invalidate();+    if let Some(h) = first_of_kind(spec.kind) {+        return Ok((h, "existing"));+    }+    // Hosts: a live editor frame of the same family first, then the project manager+    // (spawned in the background when nothing at all is running).+    let mut hosts: Vec<u64> = Vec::new();+    for k in spec.hosts {+        if *k == WindowKind::ProjectManager {+            continue;+        }+        hosts.extend(windows_of_kind(*k).iter().map(|w| w.hwnd));+    }+    if spec.hosts.contains(&WindowKind::ProjectManager) {+        let pm = match find_project_manager() {+            Some(h) => h,+            None if hosts.is_empty() => ensure_project_manager(info, pathways)?,+            None => 0,+        };+        if pm != 0 {+            hosts.push(pm);+        }+    }+    if hosts.is_empty() {+        return Err(fail(spec.error_code, format!("No window can host the {} launcher: neither a project manager nor a related editor is open.", spec.label), "Call kicad_launch first, then retry."));+    }+    // Rung: native menu walk + WM_COMMAND (background, version-proof ids).+    let mut menu_unsupported: Option<Value> = None;+    for &host in &hosts {+        if budget.out() {+            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));+        }+        match menu_invoke_by_text(host, spec.menu_needle) {+            Ok(Some((id, _label))) => {+                pathways.push(format!("menu-walk-id-{id}"));+                pathways.push("wm_command".into());+                let deadline = Instant::now() + Duration::from_secs(30);+                if let Some(h) = wait_for_kind(spec.kind, deadline, Some((host, id)), Duration::from_secs(10)) {+                    return Ok((h, "menu"));+                }+                pathways.push("wm_command-no-window".into());+            }+            Ok(None) => pathways.push(format!("menu-walk-miss:{host}")),+            Err(e) if is_not_impl(&e) => { menu_unsupported = Some(unsupported("native menu walk (menu_tree)")); }+            Err(e) => pathways.push(format!("menu-walk-error:{}", e.chars().take(60).collect::<String>())),+        }+    }+    // Rung: UI Automation Invoke by accessible name (no focus, no cursor).+    let mut uia_ran = false;+    for &host in &hosts {+        if budget.out() {+            return Err(starting_contract(spec, budget, "waiting-for-editor-window", pathways));+        }+        match native().uia_invoke(host, spec.uia_name) {+            Ok(_) => {+                uia_ran = true;+                pathways.push("uia".into());+                let deadline = Instant::now() + Duration::from_secs(30);+                if let Some(h) = wait_for_kind(spec.kind, deadline, None, Duration::from_secs(10)) {+                    return Ok((h, "uia"));+                }+                pathways.push("uia-no-window".into());+            }+            Err(e) if is_not_impl(&e) => {+                if let Some(u) = menu_unsupported.take() {+                    return Err(u);+                }+                pathways.push("uia-unsupported".into());+            }+            Err(e) => {+                uia_ran = true;+                pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>()));+            }+        }+    }+    if let (Some(u), false) = (menu_unsupported, uia_ran) {+        return Err(u);+    }+    let idle = user_idle_seconds();+    let busy = idle.map(|i| i < 120.0).unwrap_or(false);+    Err(json!({+        "success": false, "errorCode": spec.error_code, "pathwaysTried": pathways, "userIdleSeconds": idle,+        "error": format!("KiCad accepted the {} command but no editor window appeared within 30s.{}", spec.label,+            if busy { format!(" A human was active on this machine {}s ago, which is the most likely reason: KiCad is single-instance, and while someone is driving it, it does not service our menu command promptly.", idle.unwrap_or(0.0) as i64) } else { String::new() }),+        "_hint": "Retry - this is usually a busy KiCad rather than a broken one, and it succeeds on a machine nobody is touching (measured: 63s cold, 57s with the project manager already running). If it repeats on an IDLE box, that is a real bug worth reporting. Otherwise check for a modal dialog with kicad_screenshot_all and dismiss it with send_key escape.",+    }))+}++/// Ask an open editor to close so the next open re-indexes the libraries: ab's+/// desktop_close_window first (its bookkeeping), local WM_CLOSE otherwise; the WAIT is+/// measured here. Returns (gone, how).+fn close_and_wait(hwnd: u64, reason: &str, timeout: Duration) -> (bool, &'static str) {+    let how = match ab::desktop_close_window(hwnd, reason) {+        Ok(_) => "ad:desktop_close_window",+        Err(_) => {+            if native().post_close(hwnd).is_err() {+                return (false, "none");+            }+            "local:WM_CLOSE"+        }+    };+    invalidate();+    let deadline = Instant::now() + timeout;+    while Instant::now() < deadline {+        sleep_ms(300);+        invalidate();+        if !window_alive(hwnd) {+            return (true, how);+        }+    }+    (false, how)+}++// ── kicad_launch ──────────────────────────────────────────────────────────────++pub(crate) fn launch(state: &mut State, args: &Value) -> Value {+    let existing = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    if !existing.is_empty() {+        return json!({+            "success": true, "alreadyRunning": true,+            "windows": existing.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),+            "output": format!("KiCad is already running ({} window(s)); nothing launched.", existing.len()),+            "_hint": "Idempotent no-op: KiCad was already up. Use kicad_open_editors to inventory editors, or the open_* verbs to open a specific one.",+        });+    }+    let project = arg_str(args, "project").unwrap_or("").trim().to_string();+    if !project.is_empty() && !Path::new(&project).exists() {+        return fail("project_not_found", format!("Project not found: {project}"), "Absolute Windows path required; %VAR% expansion is not performed.");+    }+    let info = state.kicad_info();+    let Some(exe) = kicad_exe(&info, "kicad") else {+        return fail("kicad_exe_not_found", "kicad.exe not found", "No KiCad installation detected. kicad_readiness reports install state; kicad_upgrade installs KiCad silently (per-user, no UAC).");+    };+    let argv: Vec<String> = if project.is_empty() { vec![] } else { vec![project.clone()] };+    let (pid, log) = match spawn_background(&exe, &argv, "launch", arg_str(args, "traceMasks")) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] != json!("not_supported_on_platform") {+                e["error"] = json!(format!("Failed to launch KiCad: {}", e["error"].as_str().unwrap_or("")));+            }+            return e;+        }+    };+    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(15.0).max(3.0);+    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);+    let mut windows = Vec::new();+    while Instant::now() < deadline {+        windows = find_windows(false, true).map(|(w, _)| w).unwrap_or_default();+        if !windows.is_empty() {+            break;+        }+        sleep_ms(500);+    }+    let log_path = log.as_ref().map(|p| norm(p));+    if !windows.is_empty() {+        // Wiki #48 ask 2: a cold spawn on a box that has run KiCad before lands on the monitor+        // the user last used, not on whatever Windows defaults to.+        sleep_ms(1200);+        let restored = restore_placement();+        return json!({+            "success": true, "launched": true, "pid": pid, "mechanism": "spawn",+            "windows": windows.iter().take(10).map(|w| w.title.clone()).collect::<Vec<_>>(),+            "output": format!("KiCad launched; {} window(s) up.", windows.len()),+            "logPath": log_path, "placementRestored": restored,+            "_hint": "Launched in the BACKGROUND (no focus steal). First run on a fresh install may show a setup wizard; kicad_window_info self-heals benign dialogs; kicad_screenshot_all shows every window if something looks stuck.",+            "_next": ["kicad_window_info", "kicad_open_editors", "kicad_open_board"],+        });+    }+    json!({+        "success": false, "pid": pid, "mechanism": "spawn", "logPath": log_path,+        "error": format!("kicad.exe started but no KiCad window appeared within {}s", wait_s as i64),+        "_hint": "Cold start on a slow box (or software-OpenGL) can exceed the wait; retry kicad_window_info in a few seconds before treating this as a failure. A modal (crash-recovery / first-run) can also hold the window back: kicad_screenshot_all will show it.",+        "statusVerb": "kicad_window_info",+    })+}++// ── kicad_open_board / kicad_open_schematic ───────────────────────────────────++pub(crate) fn open_file(state: &mut State, args: &Value, kind: WindowKind) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let Some(file_path) = arg_str(args, "filePath") else {+        return fail("missing_arg", "No filePath specified", "Pass filePath: the absolute path of the file on the desktop.");+    };+    let path = PathBuf::from(file_path);+    if !path.exists() {+        return fail("file_not_found", format!("File not found: {file_path}"), "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.");+    }+    let (exe_name, label, lint_verb) = match kind {+        WindowKind::PcbEditor => ("pcbnew", "PCB board", "kicad_lint_board"),+        _ => ("eeschema", "schematic", "kicad_lint_schematic"),+    };+    let Some(exe) = kicad_exe(&info, exe_name) else {+        return fail("kicad_exe_not_found", format!("KiCad executable not found: {exe_name}_exe"), "Report to the user and ask them to reinstall KiCad; the installation appears corrupted.");+    };+    let stem = path.file_stem().map(|s| s.to_string_lossy().to_lowercase()).unwrap_or_default();+    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    // Already open: the existing window, not a fresh reload.+    invalidate();+    if let Some(w) = windows_of_kind(kind).into_iter().find(|w| !stem.is_empty() && w.title.to_lowercase().contains(&stem)) {+        // Wiki #122: a title match is only a basename match. For a board, ask KiCad which+        // file it really has open before reusing the window.+        let mut path_verified = false;+        if kind == WindowKind::PcbEditor {+            if let Ok(open) = open_board_paths(Duration::from_secs(3)) {+                let wanted = norm(&path);+                if open.iter().any(|p| same_board_path(p, &wanted)) {+                    path_verified = true;+                } else if !open.is_empty() {+                    // the API serves one editor: a board of the same file name elsewhere, or another editor's+                    // board entirely (a second pcbnew holds the API, and this window cannot be verified)+                    let same_name = open.iter().any(|p| p.replace('\\', "/").rsplit('/').next().map(|f| f.eq_ignore_ascii_case(&name)).unwrap_or(false));+                    let (code, error) = if same_name {+                        ("different_board_same_name", format!("The PCB editor has a different board open with the same file name ({name}): {}", open.join(", ")))+                    } else {+                        ("api_serves_another_board", format!("A PCB editor titled {name} is open, but KiCad's API answers for another editor's board ({}), so this window's board cannot be verified", open.join(", ")))+                    };+                    return json!({+                        "success": false, "errorCode": code, "hwnd": w.hwnd, "title": w.title,+                        "error": error,+                        "openBoards": open, "requested": wanted,+                        "_hint": "KiCad titles show only the file name and its API serves one editor, so the bridge will not guess. Close the other board (save it first if it has changes), or open a copy of yours under a unique file name.",+                    });+                }+            }+        }+        return json!({+            "success": true, "alreadyOpen": true, "hwnd": w.hwnd, "title": w.title, "mechanism": "existing",+            "pathVerified": path_verified,+            "output": format!("{label} editor already has {name} open"),+            "_hint": if path_verified { "The existing window was reused, not reloaded (KiCad confirmed the exact path). kicad_close first if you need a fresh load from disk." } else { "The existing window was reused on a file-name match that could not be confirmed against the full path (no KiCad API answer). If a same-named file from another folder could be open, kicad_close it first." },+        });+    }+    let (pid, log) = match spawn_background(&exe, &[norm(&path)], exe_name, None) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] != json!("not_supported_on_platform") {+                e["error"] = json!(format!("Failed to launch: {}", e["error"].as_str().unwrap_or("")));+                e["_hint"] = json!("Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.");+            }+            return e;+        }+    };+    // Verify by window title with one deadline (no nested sleeps).+    let wait_s = args.get("waitSeconds").and_then(Value::as_f64).unwrap_or(30.0).clamp(3.0, 110.0);+    let deadline = Instant::now() + Duration::from_secs_f64(wait_s);+    if !native().capabilities().window_control {+        return open_file_without_windows(kind, &path, pid, log, deadline, wait_s, exe_name, label, lint_verb);+    }+    let mut appeared: Option<Win> = None;+    while Instant::now() < deadline {+        invalidate();+        let wins = windows_of_kind(kind);+        appeared = wins.iter().find(|w| w.title.to_lowercase().contains(&stem)).or_else(|| wins.iter().find(|w| w.pid == pid)).cloned();+        if appeared.is_some() {+            break;+        }+        if native().process_alive(pid) == Some(false) {+            break;+        }+        sleep_ms(500);+    }+    let hint = format!(+        "GUI launched. For unfamiliar files (ones you did not generate / have not validated), prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info -> kicad_screenshot_all -> kicad_send_key to handle it; or close + retry after lint."+    );+    let mut out = json!({+        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),+        "output": format!("Launched {label} editor with {name}"),+        "_hint": hint,+    });+    match appeared {+        Some(w) => {+            out["hwnd"] = json!(w.hwnd);+            out["title"] = json!(w.title);+            out["windowAppeared"] = json!(true);+            // Wiki #48: this window was spawned by us, so Windows placed it on the primary+            // display; put it back on the monitor the user keeps this editor on.+            if let Some(row) = restore_placement_one(kind, w.hwnd) {+                out["placementRestored"] = row;+            }+        }+        None => {+            let alive = native().process_alive(pid);+            out["windowAppeared"] = json!(false);+            if alive == Some(false) {+                out["success"] = json!(false);+                out["errorCode"] = json!("editor_exited");+                out["error"] = json!(format!("{exe_name} exited before a {label} window appeared"));+                out["_hint"] = json!(format!("The file probably failed to load: the launch log (logPath) and kicad_log_tail carry KiCad's own message; {lint_verb} says what is wrong with the file."));+            } else {+                out["statusVerb"] = json!("kicad_window_info");+                out["_hint"] = json!(format!("{exe_name} is running but no {label} window titled after the file appeared within {}s (a cold start or a large file can take longer). Poll kicad_window_info; a modal dialog (kicad_screenshot_all) can also be holding it. {hint}", wait_s as i64));+            }+        }+    }+    out+}++/// Wiki #127: no window list on this OS (Linux for now). A board is confirmed through KiCad's+/// own API (the editor reports the exact path it has open); a schematic by the process staying up.+#[allow(clippy::too_many_arguments)]+fn open_file_without_windows(kind: WindowKind, path: &Path, pid: u32, log: Option<PathBuf>, deadline: Instant, wait_s: f64, exe_name: &str, label: &str, lint_verb: &str) -> Value {+    let wanted = norm(path);+    let name = path.file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let mut out = json!({+        "success": true, "pid": pid, "mechanism": "spawn", "logPath": log.as_ref().map(|p| norm(p)),+        "output": format!("Launched {label} editor with {name}"),+        "windowControl": false,+    });+    let mut confirmed = false;+    let mut last_open: Vec<String> = Vec::new();+    while Instant::now() < deadline {+        if native().process_alive(pid) == Some(false) {+            out["success"] = json!(false);+            out["errorCode"] = json!("editor_exited");+            out["error"] = json!(format!("{exe_name} exited before the {label} loaded"));+            out["_hint"] = json!(format!("The launch log (logPath) carries KiCad's own message; {lint_verb} says what is wrong with the file."));+            return out;+        }+        if kind == WindowKind::PcbEditor {+            if let Ok(open) = open_board_paths(Duration::from_secs(2)) {+                if open.iter().any(|p| same_board_path(p, &wanted)) {+                    confirmed = true;+                    break;+                }+                last_open = open;+            }+        } else if Instant::now() + Duration::from_secs_f64(wait_s) > deadline + Duration::from_secs(5) {+            // A schematic: no API answer to wait for on KiCad 10; five seconds alive is the signal.+            confirmed = true;+            break;+        }+        sleep_ms(500);+    }+    out["loaded"] = json!(confirmed);+    out["_hint"] = json!(if confirmed {+        if kind == WindowKind::PcbEditor { "KiCad's API confirms the board is open, so the live verbs (routing, placement, zones) can use it now. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_render_board or kicad_export_svg for a picture." } else { "The schematic editor is running. Window verbs (screenshots, keys, menus) are not available on this OS yet: use kicad_export_svg or kicad_export_pdf for a picture." }+    } else if last_open.is_empty() {+        "The editor is running but KiCad's API did not report the board in time. A cold start can take longer: poll kicad_routing_state with filePath. If it answers api_server_off, run kicad_ipc_api {\"enable\":true} with KiCad closed."+    } else {+        "The editor is running, but KiCad's API reports a different board (openBoards). KiCad serves one API per machine: the first KiCad that started owns it."+    });+    if !last_open.is_empty() && !confirmed {+        out["openBoards"] = json!(last_open);+    }+    out+}++// ── kicad_open_symbol_editor ──────────────────────────────────────────────────++fn lib_ctx(info: &KicadInfo) -> LibCtx {+    let inst = info.primary();+    let version = inst.map(|i| i.version.clone()).unwrap_or_default();+    let mut ctx = LibCtx {+        version: version.clone(),+        base_dir: inst.map(|i| i.base_dir.clone()).filter(|b| !b.is_empty()),+        config_dir: if version.is_empty() { None } else { native().config_dir(&version) },+        user_dir: if version.is_empty() { None } else { native().user_dir(&version) },+        kicad_cli: inst.map(|i| PathBuf::from(&i.kicad_cli)).filter(|p| p.is_file()),+        env_overrides: Default::default(),+    };+    ctx.load_env_overrides();+    ctx+}++/// open_symbol_editor._resolve_symbol_alias: exact, case-insensitive, then a UNIQUE+/// prefix match in either direction against the Adom library's real names.+fn resolve_symbol_alias(info: &KicadInfo, requested: &str) -> (Option<String>, String) {+    let ctx = lib_ctx(info);+    let Some((_table, lib_path)) = libraries::adom_paths(&ctx) else { return (None, "library file not found".into()) };+    if !lib_path.is_file() {+        return (None, "library file not found".into());+    }+    let names = libraries::list_symbols(&lib_path);+    if names.iter().any(|n| n == requested) {+        return (Some(requested.to_string()), "exact".into());+    }+    if let Some(n) = names.iter().find(|n| n.eq_ignore_ascii_case(requested)) {+        return (Some(n.clone()), "case-insensitive".into());+    }+    let r = requested.to_lowercase();+    let pref: Vec<&String> = names.iter().filter(|n| { let l = n.to_lowercase(); l.starts_with(&r) || r.starts_with(&l) }).collect();+    match pref.len() {+        1 => (Some(pref[0].clone()), "unique prefix match".into()),+        0 => (None, "no match".into()),+        _ => (None, format!("ambiguous: {:?}", pref.iter().take(5).collect::<Vec<_>>())),+    }+}++fn symbol_on_disk(info: &KicadInfo, name: &str) -> bool {+    let ctx = lib_ctx(info);+    libraries::adom_paths(&ctx).map(|(_, p)| p.is_file() && libraries::list_symbols(&p).iter().any(|n| n.eq_ignore_ascii_case(name))).unwrap_or(false)+}++pub(crate) fn open_symbol_editor(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let mut symbol_name = arg_str(args, "symbolName").unwrap_or("").to_string();+    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();+    // Wiki #62: the file is named for the MPN but the symbol inside may not be. Resolve+    // the caller's name against the library's real names BEFORE navigating and say so.+    let mut note = Map::new();+    if !symbol_name.is_empty() && (library_name.is_empty() || library_name.eq_ignore_ascii_case("adom")) {+        let (resolved, how) = resolve_symbol_alias(&info, &symbol_name);+        match resolved {+            Some(r) if r != symbol_name => {+                note.insert("aliasedFrom".into(), json!(symbol_name));+                note.insert("aliasHow".into(), json!(how));+                symbol_name = r;+            }+            None if how.starts_with("ambiguous") => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not in the library and the prefix match is {how}"))); }+            None if how == "no match" => { note.insert("aliasNote".into(), json!(format!("'{symbol_name}' is not a symbol name in the Adom library (no exact, case-insensitive or prefix match)"))); }+            _ => {}+        }+    }+    let mut out = open_symbol_editor_inner(&info, args, &symbol_name, &library_name);+    if !note.is_empty() {+        if let Some(o) = out.as_object_mut() {+            for (k, v) in &note {+                o.insert(k.clone(), v.clone());+            }+            if let Some(from) = note.get("aliasedFrom").and_then(Value::as_str) {+                let prev = o.get("_hint").and_then(Value::as_str).unwrap_or("").to_string();+                o.insert("_hint".into(), json!(format!("Resolved '{from}' to symbol '{symbol_name}' ({}). {prev}", note["aliasHow"].as_str().unwrap_or(""))));+            }+        }+    }+    out+}++fn open_symbol_editor_inner(info: &KicadInfo, args: &Value, symbol_name: &str, library_name: &str) -> Value {+    let spec = &SYMBOL_SPEC;+    let budget = Budget::new(args);+    let rescan = args.get("rescan").and_then(Value::as_bool).unwrap_or(false);+    let mut pathways: Vec<String> = Vec::new();+    invalidate();+    // Already loaded? (#49: the library is a CONSTRAINT, exact library:symbol match only.)+    if !symbol_name.is_empty() && !rescan {+        for w in windows_of_kind(WindowKind::SymbolEditor) {+            if wm::title_confirms_symbol(&w.title, symbol_name, library_name) {+                let _ = native().show_no_activate(w.hwnd);+                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Symbol '{symbol_name}' is already loaded in the Symbol Editor.")});+                merge(&mut v, wm::resolved_fields(&w.title, library_name, "Symbol Editor"));+                return v;+            }+        }+    }+    // rescan:true (0.9.192): the remedy for library_cache_stale. Close the editor so the+    // open below indexes a fresh one. Opt-in: it closes a window the user may be using.+    let mut rescanned = false;+    let mut close_via = "";+    if rescan {+        if let Some(h) = first_of_kind(WindowKind::SymbolEditor) {+            let (gone, how) = close_and_wait(h, "Close the Symbol Editor so its next open re-indexes the symbol libraries", Duration::from_secs(12));+            close_via = how;+            if gone {+                rescanned = true;+                pathways.push("rescan-closed-stale-editor".into());+            } else {+                pathways.push("rescan-close-failed".into());+            }+        }+    }+    let (frame, mechanism) = match open_editor_frame(info, spec, &budget, &mut pathways) {+        Ok(x) => x,+        Err(e) => return e,+    };+    if mechanism != "existing" {+        sleep_ms(1500); // let the frame settle+    }+    let _ = native().show_no_activate(frame);+    if symbol_name.is_empty() && library_name.is_empty() {+        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Symbol Editor is already open.".to_string() } else { format!("Symbol Editor opened successfully ({mechanism}).") }});+    }+    // Library-only browse mode (0.9.92): filter the tree to the library, no part load.+    if symbol_name.is_empty() {+        let (_, trace, nav_mech) = navigate_tree(frame, library_name, Verify::Never);+        return json!({+            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,+            "output": format!("Symbol Editor open with the '{library_name}' library filtered and selected. No symbol was requested, so none was loaded into the canvas."),+            "via": "win32-library-browse",+            "_hint": "Library browse mode: the tree is filtered to the library. To load a part into the canvas, call again with symbolName too.",+        });+    }+    let filter = if library_name.is_empty() { symbol_name.to_string() } else { format!("{library_name} {symbol_name}") };+    let verify = Verify::Symbol { name: symbol_name, library: library_name };+    let mut attempts = 0;+    let mut last_trace = Value::Null;+    let mut last_mech = "post";+    for attempt in 1..=2 {+        attempts = attempt;+        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);+        last_trace = trace;+        last_mech = nav_mech;+        if ok {+            let title = title_of(frame);+            let mut v = json!({+                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,+                "output": format!("Symbol '{symbol_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),+                "navigation": last_trace, "pathwaysTried": pathways, "rescanned": rescanned,+                "editorCloseVia": if rescanned { json!(close_via) } else { Value::Null },+            });+            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+            return v;+        }+        if budget.out() {+            // Wiki #71: a navigation that ran against an editor that is up is not "starting".+            let title = title_of(frame);+            let mut v = json!({+                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,+                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "rescanned": rescanned, "mechanism": nav_mech,+                "navigation": last_trace,+                "error": format!("The Symbol Editor is open and one background navigation ran, but the title did not confirm '{symbol_name}' before this call's time budget ended (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds). Read navigation.steps and navigation.treeCandidates: zero candidates means Enter reached no window (report it with the trace).",+            });+            merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+            return v;+        }+    }+    // Two background attempts did not confirm. Is the symbol even in the editor's world?+    invalidate();+    if first_of_kind(WindowKind::SymbolEditor).is_none() {+        return json!({+            "success": false, "editorOpened": false, "symbolLoaded": false, "errorCode": "symbol_editor_not_open",+            "error": format!("No Symbol Editor is open, so '{symbol_name}' could not be loaded (it was open earlier in this call if rescan was requested)."),+            "_hint": "The editor is gone rather than confused: nothing to navigate. Call again without rescan to open a fresh one. If this followed rescan:true, the close succeeded and the reopen did not - that is a bridge bug worth reporting, not a library problem.",+        });+    }+    let title = title_of(frame);+    // Wiki #54, #71 (the 0.9.287 staleVeto, ported): an editor THIS call opened indexed the+    // libraries after the library file was last written, so its cache cannot be stale. Saying+    // library_cache_stale there sends the caller to rescan:true, which only closes and reopens+    // an already fresh editor and burns the budget again. The verdict is navigation_failed.+    let on_disk = symbol_on_disk(info, symbol_name);+    let editor_is_ours = mechanism != "existing";+    let stale = on_disk && !rescan && !editor_is_ours;+    if stale {+        let ctx = lib_ctx(info);+        let paths = libraries::adom_paths(&ctx);+        return json!({+            "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,+            "errorCode": "library_cache_stale", "mechanism": last_mech, "navigation": last_trace,+            "symbolInLibraryFile": true,+            "symLibTable": paths.as_ref().map(|(t, _)| norm(t)),+            "libraryFile": paths.as_ref().map(|(_, l)| norm(l)),+            "error": format!("'{symbol_name}' is in the Adom library file on disk, but two background navigations did not load it (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+            "_hint": "The verdict is only 'navigation failed while the symbol is in the Adom file'. Check the sym-lib-table row points at libraryFile (a OneDrive-redirected Documents folder is the classic split), then re-call with rescan:true to close and reopen the editor so it re-indexes; kicad_rescan_libraries force:true restarts KiCad when a reopen is not enough.",+        });+    }+    let mut v = json!({+        "success": false, "editorOpened": true, "symbolLoaded": false, "attempts": attempts, "hwnd": frame,+        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,+        "error": format!("Symbol Editor open but '{symbol_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+        "_hint": "Two background attempts did not confirm, so a third is unlikely to help - check that the symbol really exists in this library. The keystroke fallback (allowFocusSteal) is not in the native build; pass foreground:true with a foregroundReason to bring the editor to the user and let them load it.",+    });+    if on_disk && editor_is_ours {+        v["symbolInLibraryFile"] = json!(true);+        v["staleVeto"] = json!(format!("this call opened the Symbol Editor itself ({mechanism}), so it indexed the libraries after the library file was last written: the index cannot be stale, and rescan:true would close and reopen an already fresh editor"));+        v["_hint"] = json!("The symbol IS in the Adom library file and this call opened the editor itself, so the index is fresh: this is a navigation failure, not a stale cache, and rescan:true cannot help. Read navigation.steps and navigation.treeCandidates (zero candidates means Enter reached no window, worth reporting with the trace), then pass foreground:true with a foregroundReason to hand the editor to the user.");+    }+    merge(&mut v, wm::resolved_fields(&title, library_name, "Symbol Editor"));+    v+}++fn merge(into: &mut Value, extra: Value) {+    if let (Some(o), Some(e)) = (into.as_object_mut(), extra.as_object()) {+        for (k, v) in e {+            o.insert(k.clone(), v.clone());+        }+    }+}++// ── kicad_open_footprint_editor ───────────────────────────────────────────────++pub(crate) fn open_footprint_editor(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.");+    }+    let spec = &FOOTPRINT_SPEC;+    let budget = Budget::new(args);+    let footprint_name = arg_any(args, &["footprintName", "footprint"]).unwrap_or("").to_string();+    let library_name = arg_any(args, &["libraryName", "library"]).unwrap_or("").to_string();+    let mut pathways: Vec<String> = Vec::new();+    invalidate();+    if !footprint_name.is_empty() {+        for w in windows_of_kind(WindowKind::FootprintEditor) {+            if wm::title_confirms_footprint(&w.title, &footprint_name, &library_name) {+                let _ = native().show_no_activate(w.hwnd);+                let mut v = json!({"success": true, "hwnd": w.hwnd, "mechanism": "existing", "output": format!("Footprint '{footprint_name}' is already loaded in the Footprint Editor.")});+                merge(&mut v, wm::resolved_fields(&w.title, &library_name, "Footprint Editor"));+                return v;+            }+        }+    }+    if args.get("allowCursorClick").and_then(Value::as_bool).unwrap_or(false) {+        pathways.push("icon_click_not_in_native_build".into());+    } else {+        pathways.push("icon_click_skipped_background_mode".into());+    }+    let (frame, mechanism) = match open_editor_frame(&info, spec, &budget, &mut pathways) {+        Ok(x) => x,+        Err(mut e) => {+            if e["errorCode"] == json!(spec.error_code) {+                e["error"] = json!("Could not open the Footprint Editor via the background path.");+                e["_hint"] = json!("The background rungs (the host frame's native menu, then UI Automation) fired without a Footprint Editor appearing. Open a board first (kicad_open_board) so a PCB Editor hosts the launcher, check kicad_screenshot_all for a modal dialog, then retry. The mouse-moving launcher click (allowCursorClick) is not in the native build.");+            }+            return e;+        }+    };+    if mechanism != "existing" {+        sleep_ms(1500);+    }+    let _ = native().show_no_activate(frame);+    if footprint_name.is_empty() && library_name.is_empty() {+        return json!({"success": true, "hwnd": frame, "mechanism": mechanism, "pathwaysTried": pathways, "output": if mechanism == "existing" { "Footprint Editor is already open.".to_string() } else { format!("Footprint Editor opened successfully ({mechanism}).") }});+    }+    if footprint_name.is_empty() {+        let (_, trace, nav_mech) = navigate_tree(frame, &library_name, Verify::Never);+        return json!({+            "success": true, "hwnd": frame, "libraryShown": library_name, "mechanism": nav_mech, "openedVia": mechanism, "navigation": trace,+            "output": format!("Footprint Editor open with the '{library_name}' library filtered and selected. No footprint was requested, so none was loaded into the canvas."),+            "via": "win32-library-browse",+            "_hint": "Library browse mode: the tree is filtered to the library. To load a part, call again with footprintName too.",+        });+    }+    let filter = if library_name.is_empty() { footprint_name.clone() } else { format!("{library_name} {footprint_name}") };+    let verify = Verify::Footprint { name: &footprint_name, library: &library_name };+    let mut last_trace = Value::Null;+    let mut last_mech = "post";+    for attempt in 1..=2 {+        let (ok, trace, nav_mech) = navigate_tree(frame, &filter, verify);+        last_trace = trace;+        last_mech = nav_mech;+        if ok {+            let title = title_of(frame);+            let mut v = json!({+                "success": true, "hwnd": frame, "mechanism": nav_mech, "openedVia": mechanism, "via": "win32-background", "attempts": attempt,+                "output": format!("Footprint '{footprint_name}' loaded (background {nav_mech} path{}): {title}", if attempt == 2 { ", 2nd attempt" } else { "" }),+                "navigation": last_trace, "pathwaysTried": pathways,+            });+            merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));+            return v;+        }+        if budget.out() {+            return json!({+                "success": false, "errorCode": "navigation_unconfirmed_within_budget", "retryable": true, "retryAfterMs": 3000,+                "elapsedSec": budget.elapsed_sec(), "editorOpened": true, "hwnd": frame, "mechanism": nav_mech, "navigation": last_trace,+                "error": format!("The Footprint Editor is open and one background navigation ran, but the title did not confirm '{footprint_name}' before this call's time budget ended (title: {})", title_of(frame)),+                "_hint": "Re-call with the SAME args: the editor is warm now, so the whole call is the navigation alone (seconds).",+            });+        }+    }+    let title = title_of(frame);+    let mut v = json!({+        "success": false, "editorOpened": true, "footprintLoaded": false, "attempts": 2, "hwnd": frame,+        "errorCode": "navigation_failed", "mechanism": last_mech, "navigation": last_trace, "pathwaysTried": pathways,+        "error": format!("Footprint Editor open but '{footprint_name}' didn't load in the background after two attempts (title: {})", if title.is_empty() { "unknown".into() } else { title.clone() }),+        "_hint": "Two background attempts did not confirm - check that the footprint really exists in this library (kicad_list_footprints). If it was installed after the editor opened, KiCad's library index predates it: kicad_close_footprint_editor and re-call, or kicad_rescan_libraries force:true. The keystroke fallback (allowFocusSteal) is not in the native build.",+    });+    // Wiki #111: tell the three causes apart from the DISK, which the bridge can read and the running+    // KiCad may not have: no such library row, no such footprint in the library, or both are there and+    // this KiCad simply started before the library was registered.+    if !library_name.is_empty() {+        let ctx = lib_ctx(&state.kicad_info());+        let (libs, _) = libraries::list_footprints(&ctx, Some(&library_name));+        match libs.first() {+            None => {+                v["errorCode"] = json!("library_not_registered");+                v["error"] = json!(format!("No footprint library named '{library_name}' in the fp-lib-table of KiCad {} (or its folder is missing), so the Footprint Editor cannot load '{footprint_name}'", ctx.version));+                v["_hint"] = json!("kicad_list_footprints lists the registered libraries (names are case-sensitive in KiCad). Register one with kicad_install_library {libraryPath, libraryType:\"footprint\"}.");+            }+            Some((_, info)) if !info.footprints.iter().any(|f| f.eq_ignore_ascii_case(&footprint_name)) => {+                v["errorCode"] = json!("footprint_not_in_library");+                v["error"] = json!(format!("Library '{library_name}' is registered ({}), but it holds no footprint named '{footprint_name}'", info.path));+                v["availableFootprints"] = json!(info.footprints.iter().take(40).collect::<Vec<_>>());+                v["_hint"] = json!("Pick a name from availableFootprints (kicad_list_footprints {library} has the full list).");+            }+            // Only a row in the user's OWN table can have been added after KiCad started. A stock+            // library (reached through KiCad's nested template table) is loaded by every KiCad at+            // start, so for those the failure is the navigation itself: keep navigation_failed+            // (1.0.37 blamed Resistor_SMD on "registered after launch", AdomLapper 2026-09-21).+            Some(_) if !user_table_row(&ctx, &library_name) => {}+            Some((_, info)) => {+                v["errorCode"] = json!("library_not_loaded_by_running_kicad");+                v["libraryOnDisk"] = json!(info.path);+                v["error"] = json!(format!("'{library_name}:{footprint_name}' exists on disk and the library is in fp-lib-table, but the running KiCad did not load it (title: {}). KiCad reads its library tables when it starts, so a library registered after launch is unknown to it", if title.is_empty() { "unknown" } else { title.as_str() }));+                v["_hint"] = json!(format!("kicad_rescan_libraries {{\"footprints\":[\"{library_name}:{footprint_name}\"],\"force\":true,\"confirmRestart\":true}} restarts KiCad so it re-reads the tables (windows go back where they were; ask the user first if they have unsaved work), then call this verb again. Retrying without that will fail the same way."));+            }+        }+    }+    merge(&mut v, wm::resolved_fields(&title, &library_name, "Footprint Editor"));+    v+}++// ── kicad_open_3d_viewer ──────────────────────────────────────────────────────++static VIEWER_SOURCE_BINDINGS: Mutex<BTreeMap<u64,(u32,u64)>> = Mutex::new(BTreeMap::new());+fn viewer_for_frame(frame:u64, source_pid:u32, windows:Vec<Win>, before:&[u64], editor_count:usize, known:Option<u64>)->Option<Win>{+    let eligible:Vec<_>=windows.into_iter().filter(|w|source_pid!=0 && w.pid==source_pid && (w.owner==0 || w.owner==frame)).collect();+    let exact:Vec<_>=eligible.iter().filter(|w|w.owner==frame).cloned().collect();+    if exact.len()==1{return exact.into_iter().next()}+    if exact.len()>1{return None}+    if let Some(id)=known {if let Some(w)=eligible.iter().find(|w|w.hwnd==id){return Some(w.clone())}}+    let created:Vec<_>=eligible.iter().filter(|w|!before.contains(&w.hwnd)).cloned().collect();+    if created.len()==1{return created.into_iter().next()}+    if editor_count==1 && eligible.len()==1{return eligible.into_iter().next()}+    None+}+fn poll_3d_window(frame:u64,source_pid:u32,before:&[u64],rounds:usize)->Option<Win>{+    for _ in 0..rounds.max(1){+        sleep_ms(750);invalidate();+        let editor_count=kicad_windows().iter().filter(|w|w.pid==source_pid && matches!(w.kind(),WindowKind::PcbEditor|WindowKind::FootprintEditor)).count();+        let known=VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m|m.get(&frame).filter(|(pid,_)|*pid==source_pid).map(|(_,h)|*h));+        if let Some(w)=viewer_for_frame(frame,source_pid,windows_of_kind(WindowKind::Viewer3d),before,editor_count,known){+            if let Ok(mut map)=VIEWER_SOURCE_BINDINGS.lock(){map.insert(frame,(source_pid,w.hwnd));}+            return Some(w)+        }+    }+    None+}++/// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a+/// 3D Viewer window appeared.+fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {+    let source_pid = match kicad_windows().into_iter().find(|w| w.hwnd == frame) {+        Some(w) => w.pid,+        None => return Err(fail("source_editor_missing", "The requested source editor is no longer open.", "Call kicad_open_editors and pass the current pcbHwnd or fpHwnd.")),+    };+    let before:Vec<u64>=windows_of_kind(WindowKind::Viewer3d).iter().map(|w|w.hwnd).collect();+    let mut unsupported_menu: Option<Value> = None;+    match menu_invoke_by_text(frame, "3d viewer") {+        Ok(Some((id, _))) => {+            pathways.push(format!("win32menu(id={id})"));+            if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {+                return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));+            }+        }+        Ok(None) => pathways.push("win32menu-not-found".into()),+        Err(e) if is_not_impl(&e) => unsupported_menu = Some(unsupported("native menu walk (menu_tree)")),+        Err(e) => pathways.push(format!("win32menu-{}", e.chars().take(40).collect::<String>())),+    }+    match native().uia_invoke(frame, "3D Viewer") {+        Ok(_) => {+            pathways.push("uia".into());+            if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {+                return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));+            }+        }+        Err(e) if is_not_impl(&e) => {+            // Neither rung exists on this OS: say so instead of "did not open".+            if let Some(u) = unsupported_menu {+                return Err(u);+            }+            pathways.push("uia-unsupported".into());+        }+        Err(e) => pathways.push(format!("uia-{}", e.chars().take(40).collect::<String>())),+    }+    Ok(None)+}+++/// `kicad_3d_view`: the 3D Viewer's View-menu commands by name, repeated with pauses, or a tour.++/// `kicad_menu_items`: the native menu bar of a KiCad window, read-only.+fn is_view_refresh(path: &[String]) -> bool {+    let clean = |s: &str| {+        s.replace('&', "")+            .split('\t')+            .next()+            .unwrap_or("")+            .trim()+            .to_lowercase()+    };+    path.len() == 2 && clean(&path[0]) == "view" && clean(&path[1]) == "refresh"+}++pub(crate) fn refresh_board_views(args: &Value) -> Value {+    let Some(frame) = arg_any_u64(args, &["pcbHwnd"]) else {+        return fail(+            "source_editor_required",+            "Pass the exact pcbHwnd.",+            "Read kicad_open_editors first.",+        );+    };+    invalidate();+    let windows = kicad_windows();+    let Some(source) = windows+        .iter()+        .find(|w| w.hwnd == frame && w.kind() == WindowKind::PcbEditor)+    else {+        return fail(+            "source_editor_missing",+            "Requested PCB editor is not open.",+            "No substitute was used.",+        );+    };+    let viewers = windows_of_kind(WindowKind::Viewer3d);+    let before: Vec<_> = viewers.iter().map(|w| w.hwnd).collect();+    let count = windows+        .iter()+        .filter(|w| {+            w.pid == source.pid+                && matches!(+                    w.kind(),+                    WindowKind::PcbEditor | WindowKind::FootprintEditor+                )+        })+        .count();+    let known = VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m| {+        m.get(&frame)+            .filter(|(pid, _)| *pid == source.pid)+            .map(|(_, h)| *h)+    });+    let Some(viewer) = viewer_for_frame(frame, source.pid, viewers, &before, count, known) else {+        return fail(+            "viewer_binding_ambiguous",+            "No uniquely linked open 3D viewer.",+            "Open the viewer from this exact PCB editor first; nothing was refreshed.",+        );+    };+    let mut commands = Vec::new();+    for hwnd in [frame, viewer.hwnd] {+        let items = match native().menu_tree(hwnd) {+            Ok(items) => items,+            Err(e) => return fail("menu_walk_failed", &e, "No refresh was invoked."),+        };+        let matches: Vec<_> = items+            .iter()+            .filter(|m| m.id != 0 && is_view_refresh(&m.path))+            .collect();+        if matches.len() != 1 {+            return fail(+                "refresh_action_missing",+                "Exact View > Refresh menu not uniquely available.",+                "No fallback menu or keyboard action was invoked.",+            );+        }+        commands.push((hwnd, matches[0].id));+    }+    let mut invoked = Vec::new();+    for (hwnd, id) in commands {+        if let Err(e) = native().menu_invoke(hwnd, id) {+            return json!({"success":false,"errorCode":"refresh_invoke_failed","error":e,"refreshRequestedFor":invoked,"sourceHwnd":frame,"viewerHwnd":viewer.hwnd});+        }+        invoked.push(hwnd);+    }+    invalidate();+    json!({"success":true,"refreshRequested":true,"sourceHwnd":frame,"sourcePid":source.pid,"viewerHwnd":viewer.hwnd,"viewerPid":viewer.pid,"renderVerified":false,"_hint":"Both refresh commands were requested without foreground or cursor actions. Inspect kicad_state screenshots after rebuilding; command acknowledgement is not render completion."})+}++pub(crate) fn menu_items(args: &Value) -> Value {+    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| foreground_kicad_hwnd()) {+        Some(h) => h,+        None => return fail("no_window", "No KiCad window found.", "kicad_window_info lists the windows; pass hwnd."),+    };+    let needle = arg_str(args, "contains").map(|s| s.to_lowercase()).unwrap_or_default();+    match native().menu_tree(hwnd) {+        Ok(items) => {+            let list: Vec<Value> = items.iter().filter(|it| needle.is_empty() || it.path.iter().any(|p| p.replace('&', "").to_lowercase().contains(&needle))).map(|it| json!({"path": it.path.iter().map(|p| p.replace('&', "")).collect::<Vec<_>>(), "id": it.id, "accel": it.accel})).collect();+            json!({"success": true, "hwnd": hwnd, "title": title_of(hwnd), "count": list.len(), "items": list})+        }+        Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),+        Err(e) => fail("menu_walk_failed", &e, "Retry once the window is idle."),+    }+}++pub(crate) fn view_3d(args: &Value) -> Value {+    const ACTIONS: &[(&str, &str)] = &[+        ("pan_left", "Move Board Left"), ("pan_right", "Move Board Right"),+        ("pan_up", "Move Board Up"), ("pan_down", "Move Board Down"),+        ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"),+        ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"),+        ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),+        ("rotate_z_cw", "Rotate Z Clockwise"), ("rotate_z_ccw", "Rotate Z Counterclockwise"),+        ("view_top", "View Top"), ("view_bottom", "View Bottom"), ("view_front", "View Front"), ("view_back", "View Back"),+        ("view_left", "View Left"), ("view_right", "View Right"), ("flip", "Flip Board"),+    ];+    let action = arg_str(args, "action").unwrap_or("").trim().to_lowercase();+    if action == "actions" || action.is_empty() {+        return json!({"success": true, "actions": ACTIONS.iter().map(|(a, l)| json!({"action": a, "menuItem": l})).collect::<Vec<_>>(), "tour": "fit, view_top, rotate_x_ccw x2, rotate_z_cw x8, zoom_in x3, rotate_z_ccw x4, zoom_out x3, view_front, rotate_z_cw x4, view_bottom, flip, view_top, fit", "_hint": "Pass action (and count / pauseMs), or action:\"tour\" with an optional steps list of [action, count] pairs."});+    }+    invalidate();+    let hwnd = match arg_any_u64(args, &["hwnd"]).or_else(|| first_of_kind(WindowKind::Viewer3d)) {+        Some(h) => h,+        None => return fail("viewer_not_open", "No 3D Viewer window is open.", "kicad_open_3d_viewer {editor:\"pcb\"} opens it from the PCB Editor's View menu; it takes a few seconds to render."),+    };+    let pause = args.get("pauseMs").and_then(Value::as_u64).unwrap_or(700).clamp(0, 10_000);+    let count = args.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize;+    let steps: Vec<(String, usize)> = if action == "tour" {+        match args.get("steps").and_then(Value::as_array) {+            Some(a) => a.iter().filter_map(|e| { let a = e.as_array()?; Some((a.first()?.as_str()?.to_lowercase(), a.get(1).and_then(Value::as_u64).unwrap_or(1).clamp(1, 60) as usize)) }).collect(),+            None => vec![("fit", 1), ("view_top", 1), ("rotate_x_ccw", 2), ("rotate_z_cw", 8), ("zoom_in", 3), ("rotate_z_ccw", 4), ("zoom_out", 3), ("view_front", 1), ("rotate_z_cw", 4), ("view_bottom", 1), ("flip", 1), ("view_top", 1), ("fit", 1)].into_iter().map(|(a, n)| (a.to_string(), n)).collect(),+        }+    } else {+        vec![(action.clone(), count)]+    };+    let mut done: Vec<Value> = Vec::new();+    let started = Instant::now();+    for (act, n) in &steps {+        let Some((_, label)) = ACTIONS.iter().find(|(a, _)| *a == act.as_str()) else {+            return fail("bad_action", format!("Unknown action '{act}'."), "kicad_3d_view {action:\"actions\"} lists them.");+        };+        for _ in 0..*n {+            match menu_invoke_by_text(hwnd, label) {+                Ok(Some((id, _))) => { done.push(json!({"action": act, "cmdId": id})); }+                Ok(None) => return fail("menu_item_not_found", format!("The 3D Viewer has no menu item '{label}' (KiCad version?)."), "kicad_send_key {hwnd, menu:...} with a substring finds what this build calls it."),+                Err(e) => return fail("menu_invoke_failed", &e, "Retry once the viewer has finished rendering."),+            }+            std::thread::sleep(Duration::from_millis(pause));+        }+    }+    json!({"success": true, "hwnd": hwnd, "commands": done.len(), "steps": steps.iter().map(|(a, n)| json!([a, n])).collect::<Vec<_>>(), "seconds": (started.elapsed().as_secs_f64() * 10.0).round() / 10.0, "foreground": false,+        "_hint": "Every command was posted to the viewer's own menu in the background. kicad_screenshot_all shows the view; a window recording of the viewer has the motion."})+}++pub(crate) fn open_3d_viewer(args: &Value) -> Value {+    let choice = arg_str(args, "editor").unwrap_or("auto").to_lowercase();+    if !["auto", "fp", "footprint", "pcb", "board"].contains(&choice.as_str()) {+        return fail("bad_arg", format!("Unknown editor '{choice}'. Use 'auto', 'fp', or 'pcb'."), "editor selects which open editor's View menu opens the viewer.");+    }+    invalidate();+    let mut fp = first_of_kind(WindowKind::FootprintEditor);+    let mut pcb = first_of_kind(WindowKind::PcbEditor);+    // Explicit source editors (tour, 2026-09-03): a caller that knows its editor's hwnd+    // passes pcbHwnd / fpHwnd and we never guess among several PCB Editors.+    if let Some(h) = arg_any_u64(args, &["pcbHwnd", "pcb_hwnd"]) {+        pcb = Some(h);+    }+    if let Some(h) = arg_any_u64(args, &["fpHwnd", "fp_hwnd"]) {+        fp = Some(h);+    }+    match choice.as_str() {+        "fp" | "footprint" => {+            if fp.is_none() {+                return fail("footprint_editor_not_open", "Footprint Editor is not open.", "Call open_footprint_editor first or pass editor='pcb'.");+            }+            pcb = None;+        }+        "pcb" | "board" => {+            if pcb.is_none() {+                return fail("pcb_editor_not_open", "PCB Editor is not open.", "Call open_board first or pass editor='fp'.");+            }+            fp = None;+        }+        _ => {+            if pcb.is_some() && fp.is_some() {+                fp = None; // auto: prefer PCB when both are open (tour-like flow)+            }+        }+    }+    let mut pathways: Vec<String> = Vec::new();+    if let Some(f) = fp {+        match open_3d_from(f, "Footprint Editor", &mut pathways) {+            Ok(Some(v)) => return v,+            Err(e) => return e,+            Ok(None) => {}+        }+        if choice == "auto" && pcb.is_none() {+            pcb = first_of_kind(WindowKind::PcbEditor);+        }+        if pcb.is_none() {+            return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open from Footprint Editor.", "_hint": "Call open_footprint_editor with a footprintName first, OR pass editor='pcb' to target the board's 3D viewer instead."});+        }+    }+    if let Some(p) = pcb {+        match open_3d_from(p, "PCB Editor", &mut pathways) {+            Ok(Some(v)) => return v,+            Err(e) => return e,+            Ok(None) => {}+        }+        return json!({"success": false, "errorCode": "3d_viewer_did_not_open", "pathwaysTried": pathways, "error": "3D Viewer did not open via the background path.", "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry; kicad_screenshot_all shows whether a dialog is holding the editor."});+    }+    fail("no_source_editor", "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_board.", "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.")+}++fn arg_any_u64(args: &Value, keys: &[&str]) -> Option<u64> {+    keys.iter().find_map(|k| args.get(*k)).and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).filter(|h| *h != 0)+}++// ── kicad_open_editors ────────────────────────────────────────────────────────++fn open_editors() -> Value {+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut by_kind: BTreeMap<String, Vec<Value>> = BTreeMap::new();+    for w in &wins {+        let kind = w.kind();+        if !kind.is_editor() && kind != WindowKind::ProjectManager {+            continue;+        }+        let row = json!({"kind": kind.as_str(), "exeName": w.exe, "pid": w.pid, "frameClass": w.class_name, "frameTitle": w.title, "hwnd": w.hwnd, "isShown": !w.minimized});+        by_kind.entry(kind.as_str().to_string()).or_default().push(row.clone());+        editors.push(row);+    }+    let running = !wins.is_empty();+    let hint = if editors.is_empty() && !running {+        "0 editors and no KiCad window: KiCad is almost certainly NOT RUNNING. This verb only INVENTORIES editors inside a running KiCad (despite its name); it never launches anything. To cold-start KiCad use kicad_launch; kicad_open_board / kicad_open_symbol_editor also cold-launch when opening a specific target."+    } else {+        "Inventory from window enumeration (the native build has no in-process plugin): each KiCad top-level frame is classified by its title. kind is one of project_manager, schematic_editor, pcb_editor, symbol_editor, footprint_editor, 3d_viewer."+    };+    json!({+        "success": true, "editors": editors, "byKind": by_kind, "kicadRunning": running, "windowSource": source,+        "summary": {"totalEditors": editors.len(), "probedPlugins": 0, "failedPlugins": 0, "byKindCounts": by_kind.iter().map(|(k, v)| (k.clone(), json!(v.len()))).collect::<Map<String, Value>>()},+        "_hint": hint,+    })+}++// ── kicad_close* ──────────────────────────────────────────────────────────────++fn close_kind(kind: WindowKind, label: &str) -> Value {+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w.into_iter().filter(|w| w.kind() == kind).collect::<Vec<_>>(),+        Err(e) => return e,+    };+    if wins.is_empty() {+        return json!({"success": true, "output": format!("{label} is not open.")});+    }+    let mut closed = Vec::new();+    let mut failed = Vec::new();+    let mut hows = Vec::new();+    for w in &wins {+        let (gone, how) = close_and_wait(w.hwnd, &format!("Close the KiCad {label} the calling AI asked to close"), Duration::from_secs(5));+        hows.push(how);+        if gone { closed.push(w.title.clone()) } else { failed.push(w.title.clone()) }+    }+    if !failed.is_empty() {+        let prompt = find_save_prompt();+        let mut v = fail("close_failed", format!("Failed to close: {}", failed.join(", ")), "Call close_kicad with {\"force\": true} to force-kill the window, then retry.");+        if let Some(p) = prompt {+            v["errorCode"] = json!("unsaved_changes");+            v["unsavedChanges"] = json!([{"window": failed[0], "dialog": p.title, "body": p.body}]);+            v["_hint"] = json!("KiCad is asking whether to save changes. Ask the user, then save in KiCad or call kicad_close {\"discardChanges\": true} to press Discard on their behalf.");+        }+        return v;+    }+    json!({+        "success": true, "closeVia": hows,+        "output": if closed.len() == 1 { format!("{label} closed: {}", closed[0]) } else { format!("Closed {} {label} window(s).", closed.len()) },+    })+}++/// kicad_close {force:true}: the ONLY path that kills a process.+fn force_kill_kicad() -> Result<Vec<String>, Value> {+    let procs = plat("process enumeration (processes)", native().processes(wm::KILL_PROCESS_NAMES))?;+    let mut killed = Vec::new();+    for name in wm::KILL_PROCESS_NAMES {+        let mut any = false;+        for p in procs.iter().filter(|p| p.exe.eq_ignore_ascii_case(name) || p.exe.eq_ignore_ascii_case(name.trim_end_matches(".exe"))) {+            if native().kill_process(p.pid).is_ok() {+                any = true;+            }+        }+        if any {+            killed.push(name.to_string());+        }+    }+    invalidate();+    Ok(killed)+}++#[derive(Default)]+struct CloseState {+    discard: bool,+    closed: Vec<String>,+    failed: Vec<String>,+    unsaved: Vec<Value>,+    discard_log: Vec<Value>,+}++/// One window: WM_CLOSE and wait; on a save prompt press Discard (discardChanges) or+/// report unsavedChanges. Some(true) closed, Some(false) failed, None already recorded.+fn close_one(st: &mut CloseState, w: &Win) -> Option<bool> {+    let (gone, _) = close_and_wait(w.hwnd, "Close KiCad as the calling AI asked", Duration::from_secs(8));+    if gone {+        return Some(true);+    }+    let Some(prompt) = find_save_prompt() else { return Some(false) };+    if st.discard {+        let mut attempts = Vec::new();+        let mut pressed = match native().click_button(prompt.hwnd, wm::DISCARD_LABELS) {+            Ok(m) => { attempts.push(json!({"win32Button": true, "method": m})); true }+            Err(e) => { attempts.push(json!({"win32Button": false, "error": e.chars().take(120).collect::<String>()})); false }+        };+        if !pressed {+            for label in wm::DISCARD_LABELS_UIA {+                match native().uia_invoke(prompt.hwnd, label) {+                    Ok(m) => { attempts.push(json!({"label": label, "uia": m})); pressed = true; break; }+                    Err(e) => attempts.push(json!({"label": label, "uia": e.chars().take(120).collect::<String>()})),+                }+            }+        }+        st.discard_log.push(json!({"window": w.title, "dialog": prompt.title, "attempts": attempts}));+        if pressed {+            let deadline = Instant::now() + Duration::from_secs(8);+            while Instant::now() < deadline {+                sleep_ms(300);+                invalidate();+                if !window_alive(w.hwnd) {+                    st.closed.push(format!("{} (changes discarded)", w.title));+                    return None;+                }+            }+        }+    }+    st.unsaved.push(json!({"window": w.title, "dialog": prompt.title, "body": prompt.body}));+    Some(false)+}++fn close_kicad(_state: &mut State, args: &Value) -> Value {+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    let discard = args.get("discardChanges").and_then(Value::as_bool).unwrap_or(false);+    // Wiki #48: a close is the last moment these windows exist, so learn where they are+    // before the geometry is gone. A kill in particular denies KiCad its own save.+    remember_placement();+    if force {+        return match force_kill_kicad() {+            Ok(k) if !k.is_empty() => json!({"success": true, "output": format!("Force-killed: {}", k.join(", ")), "data": {"forceKilled": k}}),+            Ok(_) => json!({"success": true, "output": "KiCad is not running (nothing to kill)."}),+            Err(e) => e,+        };+    }+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut manager: Option<Win> = None;+    for w in wins.into_iter().filter(|w| w.owner == 0) {+        if wm::is_manager_title(&w.title) {+            manager = Some(w);+        } else {+            editors.push(w);+        }+    }+    if editors.is_empty() && manager.is_none() {+        return json!({"success": true, "output": "KiCad is not running."});+    }+    // Issue #84: a dirty board answers WM_CLOSE with "Save changes?". Read it; with+    // discardChanges press Discard and stay graceful; otherwise leave the window open and+    // report unsavedChanges. Nothing is ever killed here.+    let mut st = CloseState::default();+    st.discard = discard;+    for w in &editors {+        match close_one(&mut st, w) {+            Some(true) => st.closed.push(w.title.clone()),+            Some(false) => st.failed.push(w.title.clone()),+            None => {}+        }+    }+    if let (Some(m), true) = (&manager, st.unsaved.is_empty()) {+        match close_one(&mut st, m) {+            Some(true) => st.closed.push(m.title.clone()),+            Some(false) => st.failed.push(m.title.clone()),+            None => {}+        }+    }+    let CloseState { closed, failed, unsaved, discard_log, .. } = st;+    if !unsaved.is_empty() {+        return json!({+            "success": false, "errorCode": "unsaved_changes", "unsavedChanges": unsaved,+            "error": format!("KiCad is asking whether to save changes; nothing was killed: {}", unsaved.iter().map(|u| format!("{}: {}", u["window"].as_str().unwrap_or(""), u["body"].as_str().unwrap_or("").chars().take(80).collect::<String>())).collect::<Vec<_>>().join("; ")),+            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },+            "_hint": "The user's unsaved work is still open. Ask them, then either save in KiCad (Ctrl+S via desktop_press_key) and call kicad_close again, or call kicad_close {\"discardChanges\": true} to press Discard on their behalf. force:true kills the process, which discards silently.",+            "discard": discard_log,+        });+    }+    if !failed.is_empty() {+        // No save prompt and no close: the native build never kills here (force:true is+        // the only kill path); say what did not close and how to force it.+        // Wiki #109 ask 3: a dirty Symbol Editor refused to go and the reply named no cause.+        // Report every dialog that is up (the unrecognised save prompt is among them) and+        // the editor's own close verb.+        let seen: Vec<Value> = scan_dialogs().iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body.chars().take(240).collect::<String>()})).collect();+        let editor_verbs: Vec<&str> = [("symbol editor", "kicad_close_symbol_editor"), ("footprint editor", "kicad_close_footprint_editor"), ("3d viewer", "kicad_close_3d_viewer")]+            .iter().filter(|(word, _)| failed.iter().any(|t| t.to_lowercase().contains(word))).map(|(_, verb)| *verb).collect();+        return json!({+            "success": false, "errorCode": "close_failed", "dialogsSeen": seen, "editorCloseVerbs": editor_verbs,+            "error": format!("Failed to close: {}", failed.join(", ")),+            "output": if closed.is_empty() { String::new() } else { format!("Closed: {}", closed.join(", ")) },+            "_hint": "A title starting with * has unsaved work: its editor is waiting on a prompt. dialogsSeen lists every dialog that is up right now; answer it (kicad_dismiss_dialogs, or discardChanges:true to press Discard) or close that editor with the verb in editorCloseVerbs, then call kicad_close again. force:true kills the process and discards unsaved work silently. kicad_screenshot_all shows the windows.",+        });+    }+    let mut out = json!({"success": true, "output": format!("KiCad closed ({} window{}).", closed.len(), if closed.len() != 1 { "s" } else { "" }), "data": {}});+    if !discard_log.is_empty() {+        out["discard"] = json!(discard_log);+    }+    out+}++// ── kicad_dismiss_dialogs / kicad_window_info ─────────────────────────────────++fn dismiss_dialogs(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if args.get("forceSoftwareCanvas").and_then(Value::as_bool).unwrap_or(false) {+        let changed = force_software_canvas(&info.version);+        return json!({+            "success": true, "forcedSoftwareCanvas": changed,+            "output": if changed { "forced Cairo software canvas (canvas_type=2)" } else { "software canvas already set / no config" },+            "_hint": "Close and reopen KiCad editors; they will render via Cairo (no OpenGL) so no GPU is needed.",+        });+    }+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    if args.get("debug").and_then(Value::as_bool).unwrap_or(false) {+        let wins = scan_dialogs();+        let pids: Vec<u32> = native().processes(wm::KICAD_EXES).map(|p| p.iter().map(|p| p.pid).collect()).unwrap_or_default();+        return json!({+            "success": true, "debug": true, "kicadPids": pids,+            "windows": wins.iter().map(|w| json!({"title": w.title, "className": w.class_name, "owner": w.owner, "isDialog": true, "body": w.body.chars().take(80).collect::<String>(), "viaGlobalSweep": w.via_global_sweep})).collect::<Vec<_>>(),+            "kicadInfoHasBinDir": info.primary().map(|i| !i.bin_dir.is_empty()).unwrap_or(false),+        });+    }+    let all = args.get("all").and_then(Value::as_bool).unwrap_or(false);+    let screenshot = args.get("screenshot").and_then(Value::as_bool).unwrap_or(true);+    let res = sweep_dialogs(&info, all, screenshot);+    let n = res["dismissed"].as_array().map(|a| a.len()).unwrap_or(0);+    let remaining = res["remaining"].as_array().map(|a| a.len()).unwrap_or(0);+    let forced = res["forcedSoftwareCanvas"].as_bool().unwrap_or(false);+    json!({+        "success": true,+        "output": format!("dismissed {n} dialog(s), {remaining} remaining{}", if forced { "; forced Cairo software canvas (no GPU); close+reopen editors" } else { "" }),+        "dismissed": res["dismissed"], "remaining": res["remaining"], "forcedSoftwareCanvas": forced,+        "_hint": format!("Expired {n} dialog(s). Now retry your open/screenshot verb. Benign-only by default; pass {{\"all\":true}} to clear every dialog. The OpenGL notice on a GPU-less host is auto-expired by window_info too."),+    })+}++fn window_info(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    let mut auto_dismissed = json!([]);+    let mut forced_sw = false;+    if args.get("autoDismiss").and_then(Value::as_bool).unwrap_or(true) {+        let s = sweep_dialogs(&info, false, false);+        auto_dismissed = s["dismissed"].clone();+        forced_sw = s["forcedSoftwareCanvas"].as_bool().unwrap_or(false);+    }+    invalidate();+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let mut editors = Vec::new();+    let mut dialogs = Vec::new();+    let mut manager = Value::Null;+    for w in &wins {+        let base = json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "minimized": w.minimized, "maximized": w.maximized, "hung": w.hung, "kind": w.kind().as_str()});+        if w.owner != 0 || w.class_name == wm::DIALOG_CLASS {+            let mut d = base;+            d["ownerHwnd"] = json!(w.owner);+            d["body"] = json!(dialog_body_text(w.hwnd));+            dialogs.push(d);+        } else if wm::is_manager_title(&w.title) {+            manager = base;+        } else {+            editors.push(base);+        }+    }+    let mut data = json!({+        "projectManager": manager, "editors": editors, "modalDialogs": dialogs, "hasModalDialogs": !dialogs.is_empty(),+        "autoDismissed": auto_dismissed, "windowSource": source,+    });+    if forced_sw {+        data["forcedSoftwareCanvas"] = json!(true);+    }+    let n_auto = data["autoDismissed"].as_array().map(|a| a.len()).unwrap_or(0);+    if n_auto > 0 {+        data["_autoDismissHint"] = json!(format!("Auto-expired {n_auto} benign blocking dialog(s) (e.g. the OpenGL/software-rendering notice) so editors are not stalled; no action needed.{}", if forced_sw { " This host lacks OpenGL, so KiCad was switched to the Cairo software canvas (kicad_common.json canvas_type=2): CLOSE and REOPEN the editor for it to render without a GPU." } else { "" }));+    }+    if !dialogs.is_empty() {+        data["_dialogHint"] = json!("hasModalDialogs is true: KiCad has a BLOCKING modal dialog. Read each modalDialogs[].body for the message text; if body is empty, screenshot modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel).");+    }+    json!({"success": true, "output": format!("{} editor(s), {} dialog(s)", editors.len(), dialogs.len()), "data": data})+}++// ── Screenshots (ab) and the canvas probe (local) ─────────────────────────────++/// Uniformity verdict for the editor canvas: one local capture, sampled, discarded.+fn canvas_probe(hwnd: u64) -> Value {+    let Ok(cap) = native().capture_window(hwnd) else { return Value::Null };+    let rect = native().gl_canvas_rect(hwnd).ok().flatten().map(|(x, y, w, h)| (x, y, x + w, y + h));+    wm::canvas_uniformity(&cap.rgb, cap.width, cap.height, rect)+}++/// Capture a window by hwnd: ab takes the picture (owned popups, coordMap, auto-pull);+/// the canvas verdict is always ours.+pub(crate) fn screenshot_hwnd(hwnd: u64, label: &str, resize_max: Option<u32>) -> Value {+    let mut v = match ab::desktop_screenshot_window(hwnd, &format!("Capture the KiCad window {label} as evidence for the calling AI"), resize_max) {+        Ok(shot) => shot.to_json(),+        Err(e) => json!({"success": false, "error": e.message(), "abUnavailable": e.is_unavailable(), "_hint": "The native build takes every screenshot through ab's desktop_screenshot_window; with ab unreachable no image is written. Only the canvas probe (canvas.rendered) answers locally."}),+    };+    v["canvas"] = canvas_probe(hwnd);+    v+}++fn screenshot_all(state: &mut State) -> Value {+    let info = window_info(state, &json!({}));+    if info["success"] != json!(true) {+        return info;+    }+    let data = &info["data"];+    let mut windows: Vec<(u64, String, &str)> = Vec::new();+    for ed in data["editors"].as_array().into_iter().flatten() {+        windows.push((ed["hwnd"].as_u64().unwrap_or(0), ed["title"].as_str().unwrap_or("").into(), "editor"));+    }+    if let Some(h) = data["projectManager"]["hwnd"].as_u64() {+        windows.push((h, data["projectManager"]["title"].as_str().unwrap_or("").into(), "main"));+    }+    for d in data["modalDialogs"].as_array().into_iter().flatten() {+        windows.push((d["hwnd"].as_u64().unwrap_or(0), d["title"].as_str().unwrap_or("").into(), "dialog"));+    }+    if windows.is_empty() {+        return fail("no_windows", "No KiCad windows found.", "kicad_launch or an open_* verb first; kicad_window_info lists what is up.");+    }+    let mut shots = Vec::new();+    let mut ab_unavailable = false;+    for (hwnd, title, kind) in &windows {+        let label = wm::safe_label(title, kind);+        let r = screenshot_hwnd(*hwnd, &label, None);+        let mut entry = json!({"type": kind, "title": title, "hwnd": hwnd, "canvas": r["canvas"]});+        if r["success"] == json!(true) {+            for k in ["savedTo", "fullPath", "safePath", "localFullPath", "sizeKB", "capturedBy", "shotId", "coordMap", "ownedPopupCount", "popups", "screenshots"] {+                if let Some(v) = r.get(k) {+                    entry[k] = v.clone();+                }+            }+        } else {+            entry["error"] = json!(r["error"].as_str().unwrap_or("screenshot failed"));+            if r["abUnavailable"] == json!(true) {+                ab_unavailable = true;+            }+        }+        shots.push(entry);+    }+    let captured = shots.iter().filter(|s| s.get("fullPath").is_some()).count();+    let mut out = json!({+        "success": captured > 0 || !ab_unavailable,+        "output": format!("Captured {captured} of {} KiCad window(s)", shots.len()),+        "data": {"screenshots": shots},+        "_hint": "Each entry is a background PrintWindow capture by ab with its owned popups (popups[]) and a coordMap: click what you see with desktop_click {space:\"image\", shotId, x, y}. canvas.rendered false means the GL canvas has not painted yet (or there is no usable GPU).",+    });+    if ab_unavailable {+        out["abUnavailable"] = json!(true);+        out["errorCode"] = json!("ab_unavailable");+        out["_hint"] = json!("ab's direct API is unreachable, so no image was written (the native build has no local PNG path). ADOM_DIRECT_API_URL missing from the spawn env is the usual cause; ab's own desktop_screenshot_window from the CLI still works.");+    }+    out+}++// ── kicad_state ───────────────────────────────────────────────────────────────++pub(crate) fn b64_encode(bytes: &[u8]) -> String {+    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";+    let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);+    for chunk in bytes.chunks(3) {+        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];+        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;+        out.push(T[(n >> 18) as usize & 63] as char);+        out.push(T[(n >> 12) as usize & 63] as char);+        out.push(if chunk.len() > 1 { T[(n >> 6) as usize & 63] as char } else { '=' });+        out.push(if chunk.len() > 2 { T[n as usize & 63] as char } else { '=' });+    }+    out+}++fn kicad_state(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    let (wins, source) = match find_windows(false, true) {+        Ok(x) => x,+        Err(e) => return e,+    };+    let include_images = args.get("includeImages").and_then(Value::as_bool).unwrap_or(true);+    let max_width = args.get("maxWidth").and_then(Value::as_u64).unwrap_or(1100) as u32;+    let mut shots = Vec::new();+    let mut ab_unavailable = false;+    if include_images {+        for w in &wins {+            let mut entry = json!({"title": w.title, "hwnd": w.hwnd, "className": w.class_name});+            let r = screenshot_hwnd(w.hwnd, "state", Some(max_width));+            entry["canvas"] = r["canvas"].clone();+            if r["success"] == json!(true) {+                let path = r["safePath"].as_str().filter(|p| !p.is_empty()).or_else(|| r["fullPath"].as_str()).unwrap_or("");+                match std::fs::read(path) {+                    Ok(bytes) if path.to_lowercase().ends_with(".png") => {+                        entry["image"] = json!(b64_encode(&bytes));+                        entry["path"] = json!(path);+                        if let Some(c) = r.get("coordMap") {+                            if let (Some(w), Some(h)) = (c["image"]["w"].as_u64(), c["image"]["h"].as_u64()) {+                                entry["width"] = json!(w);+                                entry["height"] = json!(h);+                            }+                        }+                        if let Some(s) = r.get("shotId") { entry["shotId"] = s.clone(); }+                        if let Some(p) = r.get("popups") { entry["popups"] = p.clone(); }+                    }+                    Ok(_) => { entry["error"] = json!("capture is not a png"); entry["path"] = json!(path); }+                    Err(e) => { entry["error"] = json!(format!("could not read {path}: {e}")); }+                }+            } else {+                entry["error"] = json!(r["error"].as_str().unwrap_or("capture failed"));+                if r["abUnavailable"] == json!(true) {+                    ab_unavailable = true;+                }+            }+            shots.push(entry);+        }+    }+    let editors = open_editors();+    let modal: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"hwnd": d.hwnd, "title": d.title, "body": d.body})).collect();+    let loop_events = native().etiquette_events();+    let mut events: Vec<Value> = FOCUS_EVENTS.lock().map(|g| g.iter().cloned().collect()).unwrap_or_default();+    events.extend(loop_events);+    let events: Vec<Value> = events.into_iter().rev().take(40).rev().collect();+    let focus_debug = match native().etiquette_debug() {+        Value::Null => json!({"guardian": "no etiquette loop on this OS; one-shot check per verb only", "oneShotCheck": true}),+        v => v,+    };+    let mut out = json!({+        "success": true, "running": !wins.is_empty(), "focusEvents": events,+        // PRESENCE GATE (John, 2026-08-17): automation that spawns windows checks this+        // first and defers while the user is active (< 600 = touched in the last 10 min).+        "userIdleSeconds": user_idle_seconds(),+        "focusDebug": focus_debug,+        "activeVersion": info.version,+        "windows": wins.iter().map(|w| json!({"hwnd": w.hwnd, "title": w.title, "className": w.class_name, "rect": w.rect_json(), "z": w.z, "kind": w.kind().as_str()})).collect::<Vec<_>>(),+        "windowSource": source,+        "shots": shots,+        "editors": {"kicadRunning": editors["kicadRunning"], "list": editors["editors"], "summary": editors["summary"]},+        "modalDialogs": modal,+        "_hint": "focusEvents is the focus log: a self-raise (pushed-to-background) entry is a KiCad window that took the foreground and was pushed back once; user-foregrounded means the caller asked with a reason. The full state of the union: every KiCad-owned window is in shots[] as inline base64 PNG (render them all; one window is NOT the app). modalDialogs non-empty means something is blocking; editors.list is the window inventory by kind. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll.",+    });+    if ab_unavailable {+        out["abUnavailable"] = json!(true);+    }+    out+}++// ── kicad_send_key / kicad_click / kicad_fix_keyboard ─────────────────────────++fn foreground_kicad_hwnd() -> Option<u64> {+    if let Ok(fg) = native().foreground() {+        if fg != 0 && is_kicad_hwnd(fg) {+            return Some(fg);+        }+    }+    kicad_windows().first().map(|w| w.hwnd)+}++fn send_key(args: &Value) -> Value {+    // `menu`: a menu-item label (substring, case-insensitive) fired as WM_COMMAND on the+    // target window's menu bar, fully background. The way the PCM dialog is opened.+    if let Some(needle) = arg_str(args, "menu").map(str::trim).filter(|s| !s.is_empty()) {+        let target = match arg_any_u64(args, &["hwnd"]) {+            Some(h) => h,+            None => match foreground_kicad_hwnd() {+                Some(h) => h,+                None => return fail("no_window", "No KiCad window found to send the menu command to.", "kicad_window_info lists the windows; pass hwnd."),+            },+        };+        let title = title_of(target);+        return match menu_invoke_by_text(target, needle) {+            Ok(Some((id, label))) => json!({+                "success": true, "mechanism": "menu",+                "output": format!("Menu item '{label}' fired on '{title}' (background)"),+                "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},+                "_hint": "Resolved from the window's menu bar by label and fired with WM_COMMAND; no focus was touched. A dialog it opens is a child of this window: kicad_screenshot_all captures it.",+            }),+            Ok(None) => fail("menu_item_not_found", &format!("No menu item on '{title}' contains '{needle}'."), "kicad_window_info lists windows; the label match is a case-insensitive substring of the item text without '&'."),+            Err(e) if is_not_impl(&e) => unsupported("native menu walk (menu_tree)"),+            Err(e) => fail("menu_invoke_failed", &e, "Retry once the window is idle; pass hwnd explicitly."),+        };+    }+    let Some(key) = arg_str(args, "key").map(str::trim) else {+        return fail("missing_arg", "Missing required arg: key", "Pass key: a named key (enter/escape/tab/space/f1-f12), a single character, or a chord like 'ctrl+s'.");+    };+    let target = match arg_any_u64(args, &["hwnd"]) {+        Some(h) => h,+        None => match foreground_kicad_hwnd() {+            Some(h) => h,+            None => return fail("no_window", "No KiCad window found to send key to.", "kicad_window_info lists the windows; pass hwnd."),+        },+    };+    let title = title_of(target);+    // NEVER steal the user's screen: show without activating and post to THIS hwnd's queue.+    let _ = native().show_no_activate(target);+    sleep_ms(50);+    let allow_fg = args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false);+    if let Some((_mods, _base)) = wm::parse_modifier_combo(key) {+        // A CHORD usually IS a menu accelerator (v0.9.106): resolve it from the window's+        // menu bar and fire WM_COMMAND, fully background.+        match menu_invoke_by_accel(target, key) {+            Ok(Some((id, label))) => {+                return json!({+                    "success": true, "mechanism": "menu",+                    "output": format!("Chord '{key}' delivered as menu command '{label}' (background)"),+                    "data": {"method": "menu-command", "cmdId": id, "menuItem": label, "hwnd": target, "title": title, "foreground": false},+                    "_hint": "Resolved via the window's menu-bar accelerator table and fired with WM_COMMAND; no focus was touched. This is the preferred path for every chord that maps to a menu item.",+                });+            }+            Err(e) if is_not_impl(&e) => return unsupported("native menu walk (menu_tree)"),+            _ => {}+        }+        if allow_fg {+            return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");+        }+        return json!({+            "success": false, "errorCode": "foreground_required",+            "error": format!("send the key chord '{key}' to KiCad needs the foreground, which would take over the user's screen: no menu item carries this accelerator, so only real (focused) keystrokes can deliver it"),+            "_hint": "Use ab's desktop_press_key (it owns focus-first chord delivery and asks the user), or find the menu item and call it by name. The native bridge never sends global keystrokes.",+        });+    }+    if allow_fg {+        return unsupported("kicad_send_key allowForeground (real keystrokes via SendInput)");+    }+    let (vk, ch) = match wm::vk_for_key(key) {+        Some(vk) => (vk, None),+        None => {+            let mut chars = key.chars();+            match (chars.next(), chars.next()) {+                (Some(c), None) => (wm::vk_for_char(c).unwrap_or(0), Some(c)),+                _ => return fail("unknown_key", format!("Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."), "Chords resolve through the menu bar; anything else needs desktop_press_key."),+            }+        }+    };+    match native().post_key(target, vk, ch) {+        Ok(()) => json!({+            "success": true, "mechanism": "post",+            "output": format!("Posted '{key}' to '{title}' (background)"),+            "data": {"hwnd": target, "title": title, "method": "postmessage", "foreground": false, "vk": vk},+            "_hint": "Delivered without touching the user's focus. If KiCad ignored it, the control may need real input: prefer a menu command by name; only as a last resort ask the user and use desktop_press_key.",+        }),+        Err(e) if is_not_impl(&e) => unsupported("posted keys (post_key)"),+        Err(e) => fail("post_failed", format!("Could not post '{key}' to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),+    }+}++fn click(args: &Value) -> Value {+    let Some(hwnd) = arg_any_u64(args, &["hwnd"]) else {+        return fail("missing_arg", "Missing required arg: hwnd", "kicad_window_info lists the windows; pass hwnd.");+    };+    let (Some(x), Some(y)) = (args.get("x").and_then(Value::as_f64), args.get("y").and_then(Value::as_f64)) else {+        return fail("missing_arg", "Missing required args: x, y", "Pass x and y as fractions of the window (relative:true, default) or pixels (relative:false).");+    };+    let relative = args.get("relative").and_then(Value::as_bool).unwrap_or(true);+    if relative && !(0.0..=1.0).contains(&x) || relative && !(0.0..=1.0).contains(&y) {+        return json!({+            "success": false, "errorCode": "bad_arg",+            "error": format!("relative=True expects x/y as fractions 0.0-1.0 of the window, but got x={x}, y={y}. Pass fractions, or set relative=false to use pixels."),+            "_hint": "e.g. center = {\"x\":0.5,\"y\":0.5}; toolbar button ~ {\"x\":0.35,\"y\":0.09}. For exact pixel coords add \"relative\": false.",+        });+    }+    if args.get("allowForeground").and_then(Value::as_bool).unwrap_or(false) {+        return unsupported("kicad_click allowForeground (a real cursor click via SendInput)");+    }+    let info = match native().window_info(hwnd) {+        Ok(Some(i)) => i,+        Ok(None) => return fail("bad_hwnd", format!("HWND {hwnd} is not a valid window."), "kicad_window_info lists the current handles."),+        Err(e) => return plat::<()>("window lookup (window_info)", Err(e)).err().unwrap(),+    };+    let (w, h) = (info.rect.2, info.rect.3);+    let _ = native().show_no_activate(hwnd);+    sleep_ms(50);+    let (cx, cy) = if relative { ((x * w as f64) as i32, (y * h as f64) as i32) } else { (x as i32, y as i32) };+    match native().post_click(hwnd, cx, cy, false) {+        Ok(()) => json!({+            "success": true, "mechanism": "post",+            "output": format!("Posted click at ({cx},{cy}) in '{}' (background)", info.title),+            "data": {"hwnd": hwnd, "title": info.title, "client": [cx, cy], "screen": [info.rect.0 + cx, info.rect.1 + cy], "method": "postmessage", "foreground": false, "relative": relative},+            "_hint": "Clicked without moving the user's mouse or taking focus. If the canvas ignored it, that control may need real input: ab's desktop_click {space:\"image\", shotId, x, y} on a kicad_screenshot_all coordMap asks the user first.",+        }),+        Err(e) if is_not_impl(&e) => unsupported("posted clicks (post_click)"),+        Err(e) => fail("post_failed", format!("Could not post the click to the window: {e}"), "kicad_window_info shows whether the hwnd is still valid."),+    }+}++fn fix_keyboard() -> Value {+    json!({+        "success": true, "stuckKeys": [], "released": false,+        "output": "Nothing to fix: the native bridge never sends global keystrokes (no SendInput/keybd_event path), so it can never leave a modifier key held. Keys and clicks are posted to one window's queue, which touches no global keyboard state.",+        "_hint": "If keys ARE stuck on this desktop, another tool held them: ab's desktop_press_key path or a release of each modifier through it clears the OS state.",+    })+}++// ── kicad_place_footprint ─────────────────────────────────────────────────────++const BLANK_BOARD: &str = include_str!("../../../../templates/blank-board.kicad_pcb");++fn pseudo_uuid() -> String {+    // Not cryptographic: a KiCad item id only has to be unique within the board.+    let mut x: u64 = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos() as u64).unwrap_or(0x9E3779B97F4A7C15) ^ ((std::process::id() as u64) << 32);+    let mut bytes = [0u8; 16];+    for b in bytes.iter_mut() {+        x ^= x << 13;+        x ^= x >> 7;+        x ^= x << 17;+        *b = (x & 0xff) as u8;+    }+    bytes[6] = (bytes[6] & 0x0f) | 0x40;+    bytes[8] = (bytes[8] & 0x3f) | 0x80;+    let h: String = bytes.iter().map(|b| format!("{b:02x}")).collect();+    format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])+}++/// Quote an unquoted top-level `(layer X)` token: `(layer F.Cu)` -> `(layer "F.Cu")`.+fn quote_first_layer(text: &str) -> String {+    let Some(i) = text.find("(layer ") else { return text.to_string() };+    let rest = &text[i + 7..];+    let trimmed = rest.trim_start();+    if trimmed.starts_with('"') {+        return text.to_string();+    }+    let end = trimmed.find(')').unwrap_or(0);+    let name = trimmed[..end].trim();+    if name.is_empty() || !name.chars().next().map(|c| c.is_ascii_alphabetic()).unwrap_or(false) {+        return text.to_string();+    }+    format!("{}(layer \"{}\"){}", &text[..i], name, &trimmed[end + 1..])+}++/// Inject `(uuid ...)` and `(at x y)` right after the footprint's first quoted `(layer "...")`.+fn place_in_board(fp_text: &str, uid: &str, x: f64, y: f64) -> Option<String> {+    let i = fp_text.find("(layer \"")?;+    let close = fp_text[i..].find(')')? + i + 1;+    Some(format!("{}\n\t\t(uuid \"{uid}\")\n\t\t(at {x} {y}){}", &fp_text[..close], &fp_text[close..]))+}++fn place_footprint(state: &mut State, args: &Value) -> Value {+    let info = state.kicad_info();+    if !info.installed {+        return fail("not_installed", "KiCad not installed", "Install KiCad from https://www.kicad.org/download/ then retry.");+    }+    let file_name = arg_str(args, "fileName").unwrap_or("");+    let Some(content) = arg_str(args, "fileContent") else {+        return fail("missing_arg", "No fileContent (base64 .kicad_mod) specified", "Pass fileContent: the base64 of the .kicad_mod text.");+    };+    let mut fp_text = match kicad_core::install::b64_decode(content) {+        Ok(b) => String::from_utf8_lossy(&b).trim().to_string(),+        Err(e) => return fail("bad_content", format!("could not decode fileContent: {e}"), "fileContent must be standard base64."),+    };+    // Legacy `(module ...)` footprints (KiCad <= 5 / EAGLE imports): rename the wrapper+    // and quote the top-level layer token the splice relies on.+    if fp_text.starts_with("(module") {+        fp_text = format!("(footprint{}", &fp_text["(module".len()..]);+        fp_text = quote_first_layer(&fp_text);+    }+    if !fp_text.starts_with("(footprint") {+        return fail("bad_content", "fileContent is not a .kicad_mod (must start with '(footprint' or '(module')", "Send the raw .kicad_mod text, base64-encoded.");+    }+    let fp_name = arg_str(args, "footprintName").map(str::to_string)+        .or_else(|| kicad_core::install::footprint_name_in(&fp_text))+        .or_else(|| Path::new(file_name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()))+        .unwrap_or_else(|| "footprint".into());+    let x = args.get("x").and_then(Value::as_f64).unwrap_or(148.0);+    let y = args.get("y").and_then(Value::as_f64).unwrap_or(105.0);+    let uid = pseudo_uuid();+    let Some(fp_board) = place_in_board(&fp_text, &uid, x, y) else {+        return fail("bad_content", "footprint has no (layer \"...\") token to anchor the placement", "A .kicad_mod carries (layer \"F.Cu\") near the top; this one does not parse.");+    };+    let skel = BLANK_BOARD.trim_end();+    let idx = skel.rfind(')').unwrap_or(skel.len());+    let combined = format!("{}\n{}\n)\n", skel[..idx].trim_end(), fp_board);+    let user_dir = info.primary().and_then(|i| native().user_dir(&i.version)).or_else(|| std::env::var_os("USERPROFILE").or_else(|| std::env::var_os("HOME")).map(PathBuf::from)).unwrap_or_else(|| PathBuf::from("."));+    let _ = std::fs::create_dir_all(&user_dir);+    let out_path = user_dir.join(format!("adom-place-{fp_name}.kicad_pcb"));+    if let Err(e) = std::fs::write(&out_path, combined) {+        return fail("write_failed", format!("could not write board: {e}"), "The preview board goes to Documents/KiCad/<version>; check it is writable.");+    }+    let board_path = norm(&out_path);+    let open_it = args.get("open").and_then(Value::as_bool).unwrap_or(true);+    let mut opened = false;+    let mut extra = Map::new();+    if open_it {+        match kicad_exe(&info, "pcbnew") {+            Some(exe) => match spawn_background(&exe, &[board_path.clone()], "pcbnew", None) {+                Ok((pid, _)) => {+                    opened = true;+                    extra.insert("pid".into(), json!(pid));+                    // Verify the frame by title with one deadline; the sweep runs after.+                    let stem = format!("adom-place-{fp_name}").to_lowercase();+                    let deadline = Instant::now() + Duration::from_secs(20);+                    while Instant::now() < deadline {+                        invalidate();+                        if let Some(w) = windows_of_kind(WindowKind::PcbEditor).into_iter().find(|w| w.title.to_lowercase().contains(&stem) || w.pid == pid) {+                            extra.insert("hwnd".into(), json!(w.hwnd));+                            extra.insert("title".into(), json!(w.title));+                            break;+                        }+                        sleep_ms(500);+                    }+                }+                Err(e) => {+                    if e["errorCode"] == json!("not_supported_on_platform") {+                        return e;+                    }+                    return json!({"success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": false, "output": format!("Board written but pcbnew launch failed: {}", e["error"].as_str().unwrap_or("")), "_hint": format!("Open it manually: {board_path}")});+                }+            },+            None => {}+        }+    }+    let mut v = json!({+        "success": true, "boardPath": board_path, "footprintName": fp_name, "placedAt": [x, y], "opened": opened, "mechanism": if opened { "spawn" } else { "file" },+        "output": format!("Placed footprint '{fp_name}' at ({x}, {y}) mm on a preview board{}", if opened { " and opened it in pcbnew" } else { "" }),+    });+    merge(&mut v, Value::Object(extra));+    v+}++// ── kicad_enable_software_opengl ──────────────────────────────────────────────++const MESA_URL: &str = "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.60/mesa-llvmpipe-x64.zip";+const MESA_DLLS: &[&str] = &["opengl32.dll", "libgallium_wgl.dll", "dxil.dll"];+const MESA_EXES: &[&str] = &["kicad.exe", "pcbnew.exe", "eeschema.exe", "gerbview.exe", "pl_editor.exe", "bitmap2component.exe", "pcb_calculator.exe"];++fn enable_software_opengl(state: &mut State) -> Value {+    if native().capabilities().os != "windows" {+        return json!({"success": false, "error": "Windows-only", "errorCode": "unsupported_platform", "_hint": "Mesa llvmpipe DotLocal redirection only applies to KiCad's Windows build."});+    }+    let info = state.kicad_info();+    let mut bins: Vec<PathBuf> = Vec::new();+    for i in &info.installs {+        let mut b = PathBuf::from(if i.bin_dir.is_empty() { &i.base_dir } else { &i.bin_dir });+        if b.file_name().map(|n| n.to_string_lossy().to_lowercase() != "bin").unwrap_or(true) {+            b = b.join("bin");+        }+        if b.is_dir() && !bins.contains(&b) {+            bins.push(b);+        }+    }+    if bins.is_empty() {+        return json!({"success": false, "error": "No KiCad bin dir found; is KiCad installed?", "errorCode": "host_app_not_installed", "errorCodeLegacy": "kicad_not_installed", "hostApp": "KiCad", "_hint": "Run kicad_list_versions. Install KiCad first via kicad_upgrade."});+    }+    eprintln!("[kicad-bridge] enable_software_opengl: downloading Mesa llvmpipe...");+    let data = match pcm::http_fetch(MESA_URL, Duration::from_secs(180)) {+        Ok(d) => d,+        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),+    };+    let zip = match pcm::ZipFile::parse(&data) {+        Ok(z) => z,+        Err(e) => return json!({"success": false, "error": format!("Mesa download/unzip failed: {e}"), "errorCode": "download_failed"}),+    };+    let mut enabled = Vec::new();+    let mut errors = Vec::new();+    for bd in &bins {+        let mut ok = true;+        for dll in MESA_DLLS {+            let Some(entry) = zip.entries.iter().find(|e| e.name.rsplit('/').next() == Some(dll)) else {+                errors.push(format!("{}: {dll} missing from the Mesa zip", norm(bd)));+                ok = false;+                break;+            };+            let bytes = match zip.read(entry) {+                Ok(b) => b,+                Err(e) => { errors.push(format!("{}: {dll}: {e}", norm(bd))); ok = false; break; }+            };+            if let Err(e) = std::fs::write(bd.join(dll), bytes) {+                errors.push(if e.kind() == std::io::ErrorKind::PermissionDenied { format!("{}: permission denied (writing KiCad's bin needs elevation)", norm(bd)) } else { format!("{}: {e}", norm(bd)) });+                ok = false;+                break;+            }+        }+        if !ok {+            continue;+        }+        for exe in MESA_EXES {+            if bd.join(exe).exists() {+                let _ = std::fs::write(bd.join(format!("{exe}.local")), "");+            }+        }+        enabled.push(norm(bd));+        eprintln!("[kicad-bridge] enable_software_opengl: deployed to {}", norm(bd));+    }+    let ok = !enabled.is_empty();+    if !enabled.is_empty() {+        NO_OPENGL.store(false, std::sync::atomic::Ordering::Relaxed); // the next start renders through Mesa+    }+    json!({+        "success": ok, "enabledIn": enabled, "errors": errors, "renderer": "Mesa llvmpipe (CPU software OpenGL)",+        "_hint": if ok {+            "LAST-RESORT software-OpenGL fallback deployed: KiCad will render via Mesa llvmpipe on the CPU (SLOW). This is ONLY for a GPU-less host (Hyper-V/RDP/headless) where KiCad cannot render at all; do NOT enable it on a normal machine. CLOSE and REOPEN KiCad editors to load it. To undo, delete opengl32.dll/libgallium_wgl.dll/dxil.dll and the *.local files from KiCad's bin."+        } else {+            "Could not deploy; see errors[]. Writing KiCad's bin (Program Files) needs an elevated Bridge; a per-user KiCad install (kicad_upgrade scope=user) avoids that."+        },+    })+}++#[cfg(test)]+mod tests {+    use super::*;+    #[test]+    fn viewer_selection_is_bound_to_source_and_refuses_ambiguity(){+        let win=|hwnd,owner,pid|Win{hwnd,owner,pid,title:"3D Viewer".into(),class_name:"wxWindowNR".into(),rect:(0,0,100,100),z:None,exe:"pcbnew.exe".into(),minimized:false,maximized:false,hung:false};+        assert_eq!(viewer_for_frame(2,200,vec![win(10,1,200),win(20,2,200)],&[10,20],2,None).unwrap().hwnd,20);+        assert!(viewer_for_frame(2,200,vec![win(10,1,200)],&[10],2,None).is_none());+        assert!(viewer_for_frame(2,200,vec![win(10,0,200)],&[10],2,None).is_none());+        assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10],2,None).unwrap().hwnd,20);+        assert!(viewer_for_frame(2,200,vec![win(20,0,200),win(21,0,200)],&[],2,None).is_none());+        assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10,20],2,Some(20)).unwrap().hwnd,20);+    }+++    #[test]+    fn footprint_splice_matches_python() {+        let fp = "(footprint \"X\"\n\t(version 20240108)\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect)\n)";+        let placed = place_in_board(fp, "u-u-i-d", 148.0, 105.0).unwrap();+        assert!(placed.contains("(layer \"F.Cu\")\n\t\t(uuid \"u-u-i-d\")\n\t\t(at 148 105)"));+        let legacy = "(module X (layer F.Cu) (pad 1 smd rect))";+        let modern = format!("(footprint{}", &legacy["(module".len()..]);+        let q = quote_first_layer(&modern);+        assert!(q.starts_with("(footprint X (layer \"F.Cu\")"));+        assert_eq!(quote_first_layer("(footprint X (layer \"B.Cu\"))"), "(footprint X (layer \"B.Cu\"))");+        assert!(place_in_board("(footprint X)", "u", 1.0, 2.0).is_none());+        let u = pseudo_uuid();+        assert_eq!(u.len(), 36);+        assert_eq!(&u[14..15], "4");+    }++    #[test]+    fn base64_round_trip() {+        assert_eq!(b64_encode(b""), "");+        assert_eq!(b64_encode(b"f"), "Zg==");+        assert_eq!(b64_encode(b"fo"), "Zm8=");+        assert_eq!(b64_encode(b"foo"), "Zm9v");+        assert_eq!(b64_encode(b"foobar"), "Zm9vYmFy");+        let back = kicad_core::install::b64_decode(&b64_encode(b"\x89PNG\r\n\x1a\n")).unwrap();+        assert_eq!(back, b"\x89PNG\r\n\x1a\n");+    }++    #[test]+    fn stamp_and_foreground_args() {+        let s = utc_stamp();+        assert_eq!(s.len(), 15);+        assert_eq!(&s[8..9], "-");+        assert!(user_wants_foreground(&json!({"foreground": true})));+        assert!(user_wants_foreground(&json!({"showToUser": true})));+        assert!(user_wants_foreground(&json!({"bringToFront": true})));+        assert!(!user_wants_foreground(&json!({"foreground": false, "reason": "x"})));+        assert_eq!(arg_any_u64(&json!({"pcbHwnd": "123"}), &["pcbHwnd", "pcb_hwnd"]), Some(123));+        assert_eq!(arg_any_u64(&json!({"pcb_hwnd": 0}), &["pcbHwnd", "pcb_hwnd"]), None);+    }++    #[test]+    fn dispatch_claims_only_its_verbs_and_is_honest_off_windows() {+        let mut state = State::new();+        assert!(dispatch(&mut state, "kicad_errors", &json!({})).is_none());+        assert!(dispatch(&mut state, "kicad_run_drc", &json!({})).is_none());+        let fk = dispatch(&mut state, "kicad_fix_keyboard", &json!({})).unwrap();+        assert_eq!(fk["success"], json!(true));+        assert_eq!(fk["stuckKeys"], json!([]));+        // Without window control (this host) the window verbs answer not_supported_on_platform+        // or an ab-unavailable error, never a hang and never a fake success.+        if !native().capabilities().window_control {+            std::env::remove_var("ADOM_DIRECT_API_URL");+            let r = dispatch(&mut state, "kicad_window_info", &json!({})).unwrap();+            assert_eq!(r["success"], json!(false));+            let code = r["errorCode"].as_str().unwrap_or("");+            assert!(code == "not_supported_on_platform" || code == "ab_unavailable", "{r}");+            let r = dispatch(&mut state, "kicad_open_editors", &json!({})).unwrap();+            assert_eq!(r["success"], json!(false));+            let r = dispatch(&mut state, "kicad_send_key", &json!({"key": "escape", "hwnd": 5})).unwrap();+            assert_eq!(r["success"], json!(false));+        }+        let r = dispatch(&mut state, "kicad_click", &json!({"hwnd": 5, "x": 300, "y": 2})).unwrap();+        assert_eq!(r["errorCode"], json!("bad_arg"));+        let r = dispatch(&mut state, "kicad_send_key", &json!({})).unwrap();+        assert_eq!(r["errorCode"], json!("missing_arg"));+        let r = dispatch(&mut state, "kicad_open_3d_viewer", &json!({"editor": "sideways"})).unwrap();+        assert_eq!(r["errorCode"], json!("bad_arg"));+    }++    #[test]+    fn catalog_covers_the_python_window_group() {+        let names: Vec<&str> = VERBS.iter().map(|v| v.name).collect();+        for n in ["kicad_launch", "kicad_open_board", "kicad_open_schematic", "kicad_open_symbol_editor", "kicad_open_footprint_editor", "kicad_open_3d_viewer", "kicad_open_editors", "kicad_close", "kicad_close_symbol_editor", "kicad_close_footprint_editor", "kicad_close_3d_viewer", "kicad_dismiss_dialogs", "kicad_window_info", "kicad_state", "kicad_screenshot_all", "kicad_send_key", "kicad_click", "kicad_fix_keyboard", "kicad_place_footprint", "kicad_enable_software_opengl"] {+            assert!(names.contains(&n), "{n} missing from the catalog");+        }+        assert!(!names.contains(&"kicad_errors"));+        for v in VERBS {+            assert!(!v.summary.contains('\u{2014}') && !v.hint.contains('\u{2014}'), "{} carries an em dash", v.name);+        }+    }+}+++/// kicad_rescan_libraries: tree refresh first (#51), restart only on force plus confirmation.+fn rescan_libraries(state: &mut State, args: &Value) -> Value {+    let force = args.get("force").and_then(Value::as_bool).unwrap_or(false);+    if !force {+        let mut refreshed: Vec<u64> = Vec::new();+        for kind in [WindowKind::FootprintEditor, WindowKind::SymbolEditor] {+            if let Some(h) = first_of_kind(kind) {+                if let Ok(Some(_)) = menu_invoke_by_text(h, "refresh") {+                    refreshed.push(h);+                }+            }+        }+        if !refreshed.is_empty() {+            // The tree reloads asynchronously; the Python waited 3 s before probing.+            std::thread::sleep(std::time::Duration::from_secs(3));+            invalidate();+            let mut wanted: Vec<(String, String)> = Vec::new();+            for spec in args.get("footprints").and_then(Value::as_array).cloned().unwrap_or_default() {+                match spec {+                    Value::String(s) if s.contains(':') => {+                        let (lib, name) = s.split_once(':').unwrap();+                        wanted.push((lib.to_string(), name.to_string()));+                    }+                    Value::Object(o) => {+                        if let Some(name) = o.get("name").and_then(Value::as_str) {+                            wanted.push((o.get("library").and_then(Value::as_str).unwrap_or("Adom").to_string(), name.to_string()));+                        }+                    }+                    _ => {}+                }+            }+            let mut probed: Vec<Value> = Vec::new();+            if !wanted.is_empty() {+                let fp = first_of_kind(WindowKind::FootprintEditor);+                for (lib, name) in &wanted {+                    let (ok, trace, _mech) = match fp {+                        Some(h) => navigate_tree(h, &format!("{lib} {name}"), Verify::Footprint { name, library: lib }),+                        None => (false, json!({}), "none"),+                    };+                    probed.push(json!({+                        "library": lib, "name": name, "visible": ok,+                        "error": if ok { Value::Null } else if fp.is_none() { json!("no Footprint Editor open") } else { json!("editor tree did not load it") },+                        "navigation": trace,+                    }));+                }+            }+            let verified = !wanted.is_empty() && probed.iter().all(|p| p["visible"].as_bool().unwrap_or(false));+            let mut out = json!({"success": true, "method": "tree-refresh", "refreshedEditors": refreshed, "verified": verified, "probed": probed});+            out["_hint"] = json!(if !wanted.is_empty() && !verified {+                format!("Tree refreshed but KiCad still cannot load: {}. Check the name with kicad_list_footprints; if it is on disk, call again with force:true and confirmRestart:true for the process restart (about a minute).",+                    probed.iter().filter(|p| !p["visible"].as_bool().unwrap_or(false)).map(|p| format!("{}:{}", p["library"].as_str().unwrap_or(""), p["name"].as_str().unwrap_or(""))).collect::<Vec<_>>().join(", "))+            } else if !wanted.is_empty() {+                "Verified in the editor: every listed footprint loaded after the refresh, no restart. The last one is now shown in the Footprint Editor.".to_string()+            } else {+                "Refresh invoked on the open editor tree(s). Pass footprints:[\"Lib:Name\"] to have each loaded in the editor as proof; without that list nothing is verified. force:true is the process restart, the last resort.".to_string()+            });+            post_verb(state, "kicad_rescan_libraries", args, &mut out, None);+            return out;+        }+    }+    // Restart path: the native build has no ownership ledger yet (phase 4), so a running+    // KiCad is treated as the user's unless the caller confirms.+    let running = native().processes(&["kicad.exe", "pcbnew.exe", "eeschema.exe"]).unwrap_or_default();+    let confirm = args.get("confirmRestart").and_then(Value::as_bool).unwrap_or(false);+    if !running.is_empty() && !confirm {+        return json!({"success": false, "errorCode": "rescan_refused",+            "reason": "KiCad is running and the native build cannot yet tell a user-launched KiCad from its own",+            "runningPids": running.iter().map(|p| p.pid).collect::<Vec<_>>(),+            "_hint": "Ask the user whether KiCad may be restarted, then call again with force:true and confirmRestart:true. Without force the verb refreshes the open editors' trees in place, which is enough for footprints installed after the editor started."});+    }+    // Wiki #48: a fresh process places every window on the primary display, so the user's+    // monitor choice is captured here, while the windows still exist, and restored below.+    let remembered = remember_placement();+    let mut close_how = "nothing was running";+    let mut graceful_failure = Value::Null;+    if !running.is_empty() {+        // Wiki #48 ask 3: graceful first, so KiCad writes its own geometry and its own files;+        // the kill is the fallback for a window that will not go.+        let mut closed = dispatch(state, "kicad_close", &json!({})).unwrap_or_else(|| json!({}));+        close_how = "graceful";+        if !closed["success"].as_bool().unwrap_or(false) {+            if closed["errorCode"] == json!("unsaved_changes") {+                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed,+                    "_hint": "KiCad is asking whether to save the user's work, so nothing was closed or killed. Ask them, then save (or call kicad_close {\"discardChanges\": true}) before restarting."});+            }+            // Keep WHY the graceful close failed: otherwise the kill overwrites the only+            // evidence and the next person sees "force-kill" with no cause.+            graceful_failure = json!({"errorCode": closed.get("errorCode").cloned().unwrap_or(Value::Null), "error": closed.get("error").cloned().unwrap_or(Value::Null), "output": closed.get("output").cloned().unwrap_or(Value::Null)});+            closed = dispatch(state, "kicad_close", &json!({"force": true})).unwrap_or_else(|| json!({}));+            close_how = "force-kill after the graceful close failed";+            if !closed["success"].as_bool().unwrap_or(false) {+                return json!({"success": false, "errorCode": "restart_close_failed", "close": closed, "closeHow": close_how, "gracefulClose": graceful_failure});+            }+        }+        std::thread::sleep(std::time::Duration::from_secs(4));+    }+    let launched = launch(state, &json!({}));+    if !launched["success"].as_bool().unwrap_or(false) {+        return json!({"success": false, "errorCode": "restart_launch_failed", "launch": launched});+    }+    for _ in 0..80 {+        std::thread::sleep(std::time::Duration::from_millis(750));+        invalidate();+        if !kicad_windows().is_empty() {+            sleep_ms(1200); // let the fresh windows settle before moving them+            let restored = restore_placement();+            let moved = restored.iter().filter(|r| r["moved"] == json!(true)).count();+            return json!({"success": true, "restarted": true, "ready": true, "method": "process-restart",+                "placementRemembered": remembered, "placementRestored": restored, "closeHow": close_how, "gracefulClose": graceful_failure,+                "_hint": format!("Library caches rebuilt. Every part installed before this call is now visible; show verbs will not trigger another restart until the next install-while-open. Placement: {moved} window(s) put back where the user had them (wiki #48), without activating anything.")});+        }+    }+    json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window",+        "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."})+}+++#[cfg(test)]+mod linked_refresh_tests {+    #[test]+    fn exact_view_refresh_excludes_plugins_and_partial_labels() {+        let path = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();+        assert!(super::is_view_refresh(&path(&["&View", "&Refresh\tF5"])));+        for a in [+            vec!["Tools", "Refresh Plugins"],+            vec!["View", "Refresh All"],+            vec!["Refresh"],+        ] {+            assert!(!super::is_view_refresh(&path(&a)));+        }+    }+}++/// The boards KiCad's API says are open; without the ipc feature (the macOS type-check build) there is+/// no API to ask, and the callers fall back to the window title.+#[cfg(feature = "ipc")]+fn open_board_paths(timeout: Duration) -> Result<Vec<String>, String> {+    kicad_core::ipc::open_board_paths(timeout)+}++#[cfg(not(feature = "ipc"))]+fn open_board_paths(_timeout: Duration) -> Result<Vec<String>, String> {+    Err("this build has the ipc feature off".into())+}++#[cfg(feature = "ipc")]+fn same_board_path(a: &str, b: &str) -> bool {+    kicad_core::ipc::same_board_path(a, b)+}++#[cfg(not(feature = "ipc"))]+fn same_board_path(a: &str, b: &str) -> bool {+    a.replace('\\', "/").eq_ignore_ascii_case(&b.replace('\\', "/"))+}++// ---------------------------------------------------------------------------+// The standard's document verbs: check_dialogs, save, close_document, close_all_documents+// ---------------------------------------------------------------------------++/// What a dialog is, in the standard's words: (category, safeAction, meaning).+fn classify_dialog(title: &str, body: &str) -> (&'static str, &'static str, &'static str) {+    if wm::is_save_prompt(title, body) {+        return ("save_prompt", "ask the person; never answer it for them", "KiCad is asking whether to keep unsaved changes, which may be someone's work.");+    }+    match wm::sweep_decision(title, body, false) {+        DialogAction::LeaveProgress => ("progress", "wait", "KiCad is working; it closes itself."),+        DialogAction::Dismiss { benign: true } => ("benign", "kicad_dismiss_dialogs", "A notice that blocks nothing of value; the sweep clears it."),+        _ if wm::is_tool_dialog(title) => ("tool", "leave it, or close it when finished", "A tool window someone opened on purpose."),+        _ => ("other", "read it, then answer it", "KiCad is waiting on this; read the body or the shot before answering."),+    }+}++fn check_dialogs(args: &Value) -> Value {+    if let Err(e) = find_windows(true, true) {+        return e;+    }+    let shots = args.get("screenshots").and_then(Value::as_bool).unwrap_or(true);+    let rows: Vec<Value> = scan_dialogs().iter().map(|d| {+        let info = native().window_info(d.hwnd).ok().flatten();+        let buttons: Vec<String> = native().child_windows(d.hwnd).map(|c| c.into_iter().filter(|w| w.class_name == "Button").map(|w| w.title.replace('&', "")).collect()).unwrap_or_default();+        let (category, safe, meaning) = classify_dialog(&d.title, &d.body);+        json!({"hwnd": d.hwnd, "pid": info.as_ref().map(|i| i.pid), "title": d.title, "className": d.class_name, "body": d.body, "buttons": buttons,+            "category": category, "safeAction": safe, "meaning": meaning, "ownedByMainInstance": d.owner != 0 && !d.via_global_sweep,+            "rect": info.as_ref().map(|i| json!(i.rect)), "shot": if shots { json!(screenshot_dialog(d.hwnd, &d.title)) } else { Value::Null }})+    }).collect();+    json!({"success": true, "dialogsDetected": rows.len(), "dialogs": rows})+}++/// The editor holding a board or schematic path, matched on the whole file name, not a prefix of it.+fn editor_of(path: &str) -> Option<Win> {+    let p = Path::new(path);+    let kind = match p.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {+        Some("kicad_pcb") => WindowKind::PcbEditor,+        Some("kicad_sch") => WindowKind::SchematicEditor,+        _ => return None,+    };+    let stem = p.file_stem()?.to_string_lossy().to_string();+    windows_of_kind(kind).into_iter().find(|w| crate::verbs_std_more::parse_title(&w.title).is_some_and(|t| t.0.eq_ignore_ascii_case(&stem)))+}++/// Ask an editor to save itself; true once the unsaved mark has gone from its title.+fn save_editor(state: &mut State, w: &Win, path: Option<&str>) -> Result<&'static str, String> {+    if w.kind() == WindowKind::PcbEditor {+        let args = path.map_or(json!({}), |p| json!({"filePath": p}));+        if ipc_save(state, &args).is_ok() {+            return Ok("IPC SaveDocument");+        }+    }+    match menu_invoke_by_text(w.hwnd, "Save") {+        Ok(Some(_)) => {}+        Ok(None) => return Err("the editor has no File > Save".into()),+        Err(e) => return Err(e),+    }+    let started = Instant::now();+    while started.elapsed() < Duration::from_secs(60) {+        sleep_ms(250);+        invalidate();+        let title = native().window_info(w.hwnd).ok().flatten().map(|i| i.title).unwrap_or_default();+        if !title.starts_with('*') {+            return Ok("File > Save");+        }+        if !scan_dialogs().is_empty() {+            break;+        }+    }+    Err("KiCad did not finish saving".into())+}++fn save_document(state: &mut State, args: &Value) -> Value {+    let started = Instant::now();+    let given = arg_any(args, &["filePath", "path"]);+    let win = match given {+        Some(p) => editor_of(p),+        None => windows_of_kind(WindowKind::PcbEditor).into_iter().chain(windows_of_kind(WindowKind::SchematicEditor)).min_by_key(|w| w.z),+    };+    let Some(win) = win else {+        return match given {+            Some(p) => json!({"success": true, "saved": false, "path": p, "note": format!("not open: {p}. The file already holds what it holds; nothing to save."), "data": {"process": Value::Null, "params": {}}}),+            None => fail("no_document", "no board or schematic is open", "kicad_open_document opens one; kicad_state lists KiCad's windows."),+        };+    };+    let was_dirty = win.title.starts_with('*');+    let mut out = match save_editor(state, &win, given) {+        Ok(how) => json!({"success": true, "saved": true, "wasUnsaved": was_dirty, "title": win.title, "data": {"process": how, "params": {}}}),+        Err(e) => {+            let mut v = fail("save_failed", e, "dialogs lists anything KiCad is asking. A never-saved document asks for a name.");+            v["saved"] = json!(false);+            v+        }+    };+    out["dialogs"] = json!(scan_dialogs().iter().map(|d| json!({"title": d.title, "body": d.body})).collect::<Vec<_>>());+    out["roundtripMs"] = json!(started.elapsed().as_millis() as u64);+    out+}++fn close_document(args: &Value) -> Value {+    let started = Instant::now();+    let Some(path) = arg_any(args, &["path", "filePath"]) else {+        return fail("missing_arg", "path is required", "The absolute path of the open .kicad_pcb or .kicad_sch.");+    };+    let Some(win) = editor_of(path) else {+        return json!({"success": true, "data": {"closed": false, "path": path, "note": format!("not open: {path}")}, "roundtripMs": started.elapsed().as_millis() as u64});+    };+    let mut st = CloseState { discard: true, ..Default::default() };+    let gone = close_one(&mut st, &win);+    let discarded = gone.is_none();+    if gone == Some(false) {+        let mut v = fail("close_failed", format!("{} did not close", win.title), "dialogsSeen lists what is up; kicad_check_dialogs reads them.");+        v["dialogsSeen"] = json!(scan_dialogs().iter().map(|d| json!({"title": d.title, "body": d.body})).collect::<Vec<_>>());+        v["unsavedChanges"] = json!(st.unsaved);+        return v;+    }+    json!({"success": true, "data": {"closed": true, "path": path, "discarded": discarded}, "roundtripMs": started.elapsed().as_millis() as u64})+}++fn close_all_documents(state: &mut State, args: &Value) -> Value {+    let flag = |k: &str| args.get(k).and_then(Value::as_bool).unwrap_or(false);+    let (save, discard, scratch) = (flag("save"), flag("discardUnsaved"), flag("includeScratch"));+    invalidate();+    let wins = match find_windows(false, true) {+        Ok((w, _)) => w,+        Err(e) => return e,+    };+    let (mut closed, mut saved, mut kept) = (Vec::new(), Vec::new(), Vec::new());+    for w in wins.iter().filter(|w| w.owner == 0 && matches!(w.kind(), WindowKind::PcbEditor | WindowKind::SchematicEditor | WindowKind::SymbolEditor | WindowKind::FootprintEditor)) {+        let name = crate::verbs_std_more::parse_title(&w.title).map(|t| t.0).unwrap_or_else(|| w.title.clone());+        let dirty = w.title.starts_with('*');+        let never_saved = w.title.contains("[Unsaved]") || name.is_empty() || name.eq_ignore_ascii_case("untitled") || name.eq_ignore_ascii_case("noname");+        if never_saved && !scratch {+            kept.push(json!({"name": name, "why": "scratch"}));+            continue;+        }+        if dirty && !never_saved && save {+            match save_editor(state, w, None) {+                Ok(_) => saved.push(name.clone()),+                Err(e) => {+                    kept.push(json!({"name": name, "why": format!("unsaved changes: the save failed ({e})")}));+                    continue;+                }+            }+        } else if dirty && !discard {+            kept.push(json!({"name": name, "why": "unsaved changes"}));+            continue;+        }+        let mut st = CloseState { discard, ..Default::default() };+        match close_one(&mut st, w) {+            Some(false) => kept.push(json!({"name": name, "why": if st.unsaved.is_empty() { "did not close" } else { "unsaved changes" }})),+            _ => closed.push(name),+        }+    }+    json!({"success": true, "closed": closed, "saved": saved, "keptOpen": kept})+}++// ---------------------------------------------------------------------------+// kicad_teardrops: KiCad's own Edit > Edit Teardrops+// ---------------------------------------------------------------------------++/// Teardrop areas on the board the editor serves (zones carrying `(teardrop (type ..))`), or+/// None without the IPC API. Read straight from KiCad, not through another verb: this runs+/// under GUI_LOCK, which verbs::call would try to take again.+/// (teardrop areas, the board's revision) for the board the editor serves; None without IPC.+#[cfg(feature = "ipc")]+fn teardrop_count(state: &mut State, args: &Value) -> Option<(usize, String)> {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    };+    let (text, _, _) = ipc::board_text(&ctx, args).ok()?;+    Some((text.match_indices("(teardrop").filter(|(i, _)| !text[i + 9..].starts_with('s')).count(), ipc::revision_of(&text)))+}++#[cfg(not(feature = "ipc"))]+fn teardrop_count(_state: &mut State, _args: &Value) -> Option<(usize, String)> {+    None+}++#[cfg(feature = "ipc")]+fn ipc_save(state: &mut State, args: &Value) -> Result<(), String> {+    use kicad_core::{detect, ipc};+    let info = state.kicad_info();+    let ctx = ipc::Ctx {+        kicad_cli: info.primary().map(|p| PathBuf::from(&p.kicad_cli)).filter(|p| p.is_file()),+        config_dir: info.primary().and_then(|p| detect::config_dir(&p.version)),+    };+    ipc::save_board(&ctx, args).map(|_| ()).map_err(|e| e.to_string())+}++#[cfg(not(feature = "ipc"))]+fn ipc_save(_state: &mut State, _args: &Value) -> Result<(), String> {+    Err("saving needs the IPC API".into())+}++/// KiCad's one "Track to track" box answers both of the standard's tracks and tJunctions: an explicit+/// answer on either decides it (either true turns it on), and neither given leaves it on.+fn track_to_track(args: &Value) -> bool {+    let (t, j) = (args.get("tracks").and_then(Value::as_bool), args.get("tJunctions").and_then(Value::as_bool));+    match (t, j) {+        (None, None) => true,+        _ => t == Some(true) || j == Some(true),+    }+}++fn teardrops(state: &mut State, args: &Value) -> Value {+    if args.get("force") == Some(&json!(true)) {+        return fail("not_supported", "KiCad's Set Teardrops has no way to force a teardrop that breaks a rule", "Leave force off, then check with kicad_run_drc: KiCad reports a teardrop that breaks clearance there.");+    }+    let editors = windows_of_kind(WindowKind::PcbEditor);+    let editor = match arg_str(args, "filePath") {+        Some(path) => {+            let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+            match editors.into_iter().find(|w| w.title.trim_start_matches('*').starts_with(&stem)) {+                Some(w) => w,+                None => return fail("board_not_open", format!("{stem} is not open in a PCB editor"), "Open it with kicad_open_board: teardrops run in KiCad's PCB editor."),+            }+        }+        None => match editors.into_iter().next() {+            Some(w) => w,+            None => return fail("board_not_open", "no PCB editor is open", "Pass filePath after kicad_open_board: teardrops run in KiCad's PCB editor."),+        },+    };+    let flag = |k: &str, d: bool| args.get(k).and_then(Value::as_bool).unwrap_or(d);+    let remove = flag("remove", false);+    let save = flag("save", true);+    let before = teardrop_count(state, args).map(|(n, _)| n);+    match menu_invoke_by_text(editor.hwnd, "Edit Teardrops...") {+        Ok(Some(_)) => {}+        Ok(None) => return fail("menu_not_found", "the PCB editor has no Edit Teardrops... command", "KiCad 8 or newer has it under Edit."),+        Err(e) => return fail("menu_failed", e, "Check the editor with kicad_window_info."),+    }+    let mut dialog = None;+    for _ in 0..60 {+        if let Some(d) = scan_dialogs().into_iter().find(|d| d.title.trim() == "Set Teardrops") {+            dialog = Some(d.hwnd);+            break;+        }+        sleep_ms(250);+    }+    let Some(dlg) = dialog else {+        return fail("dialog_not_shown", "KiCad did not show the Set Teardrops dialog within 15 s", "kicad_window_info lists what KiCad shows; another dialog may be in the way.");+    };+    let mut steps = Vec::new();+    let mut set = |label: &str, on: bool| -> Result<(), String> { native().uia_set_checked(dlg, label, on).map(|r| steps.push(r)) };+    let vias = flag("vias", true);+    let options = [("PTH pads", flag("pthPads", vias)), ("SMD pads", flag("smdPads", true)), ("Vias", vias), ("Track to track", track_to_track(args)), ("Selected items only", flag("selectedOnly", false))];+    let mut failed = None;+    for (label, on) in options {+        if let Err(e) = set(label, on) {+            failed = Some(e);+            break;+        }+    }+    if failed.is_none() {+        let action = if remove { "Remove all teardrops" } else { "Add teardrops with default values for shape" };+        match native().uia_invoke(dlg, action) {+            Ok(r) => steps.push(r),+            Err(e) => failed = Some(e),+        }+    }+    if let Some(e) = failed {+        let _ = native().click_button(dlg, &["close"]);+        return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed. kicad_screenshot_all shows KiCad's windows.");+    }+    if let Err(e) = native().click_button(dlg, &["apply and close"]) {+        let _ = native().click_button(dlg, &["close"]);+        return fail("dialog_control_failed", format!("Set Teardrops: {e}"), "Nothing was applied; the dialog was closed.");+    }+    steps.push("apply and close".into());+    let started = Instant::now();+    while window_alive(dlg) && started.elapsed() < Duration::from_secs(150) {+        sleep_ms(250);+    }+    if window_alive(dlg) {+        return fail("dialog_still_open", "Set Teardrops is still open after 150 s", "KiCad may still be working; check with kicad_window_info before calling again.");+    }+    let save_error = if save { ipc_save(state, args).err() } else { None };+    if save && save_error.is_none() {+        steps.push("save".into());+    }+    let dialogs_left: Vec<Value> = scan_dialogs().into_iter().map(|d| json!({"title": d.title, "body": d.body})).collect();+    let after_read = teardrop_count(state, args);+    let after = after_read.as_ref().map(|(n, _)| *n);+    let revision = after_read.map(|(_, r)| r);+    let delta = match (before, after) {+        (Some(b), Some(a)) => Some(a as i64 - b as i64),+        _ => None,+    };+    json!({+        "success": true,+        "output": match delta {+            Some(d) => format!("Teardrops {}: {} before, {} after", if remove { "removed" } else { "applied" }, before.unwrap_or(0), after.unwrap_or(0)) + if d == 0 && !remove { " (nothing new: every item in scope already had one, or none qualified)" } else { "" },+            None => format!("Teardrops {} through KiCad's dialog (counts need the IPC API)", if remove { "removed" } else { "applied" }),+        },+        "before": before, "after": after, "added": delta.filter(|d| *d > 0), "removed": delta.filter(|d| *d < 0).map(|d| -d),+        "teardropsBefore": before, "teardropsAfter": after,+        "mutated": delta.map(|d| d != 0),+        "dialogSteps": steps, "steps": steps,+        "dialogsLeft": dialogs_left,+        "revision": revision,+        "saved": save && save_error.is_none(),+        "saveError": save_error,+        "_hint": "One KiCad Undo step; revision is the board's new revision (KiCad touches the board even when it adds none), so pass it to the next edit. saved says whether it reached the file (save:false leaves that to kicad_save_board). Then kicad_run_drc: teardrops are copper areas and take part in the fill and the checks.",+    })+}+--- a/skills/kicad-bridge-test/SKILL.md+++ b/skills/kicad-bridge-test/SKILL.md@@ -1,209 +1,220 @@⋯ 25 unchanged lines ⋯ passed AND coverage is complete. Runtime for a full pass: ~6-10 min (exports dominate; STEP and molecule are the slow ones). +## The edit drill: did the edit really happen?++The battery proves a verb answers, and many of its edit checks stop at `success:true` or a dry run. `edit_drill.py` runs the edit verbs for real on scratch copies, then confirms each change with a separate read: a different verb, or KiCad's own netlist through kicad-cli.++```+python3 skills/kicad-bridge-test/edit_drill.py --target DESKTOP-BAR6S0F [--part sheet,library,file,live] [--out drill.json]+```++The `sheet`, `library` and `file` parts touch only files. `live` opens KiCad, so take the laptop in turn. Run it before a release that changes an edit verb, and add a step when you add one.+ ## What it does, phase by phase  0. **Fixture push** — sends the rp2040 tour pack (board/schematic/project — the⋯ 175 unchanged lines ⋯   list_symbols Device and list_footprints Resistor_SMD are permanent stock-library regression checks. - **Full replies are kept** under results[verb].response; response_head is only the summary. - The rp2040 fixture (tour-pack-rp2040/template/*) ships in the repo; it had never been pushed.+--- a/skills/kicad-bridge-test/edit_drill.py+++ b/skills/kicad-bridge-test/edit_drill.py@@ -0,0 +1,458 @@+#!/usr/bin/env python3+"""Run every kicad-bridge edit verb for real, on scratch copies, and confirm each change with a separate read.++The battery (run_verb_tests.py) proves a verb answers. Many of its edit checks stop at success:true or a dry run,+so a verb that answers and changes nothing, or changes the wrong thing, passes. This drill runs the edit, then asks+a different verb, or KiCad itself through kicad-cli's netlist, whether the change happened.++  python3 edit_drill.py --target DESKTOP-BAR6S0F [--part sheet,library,file,live] [--out drill.json]++Exit 1 when any step fails. Files live under Documents/adom-kicad-drill/<stamp> on the target. The live part opens+KiCad, so take the laptop in turn; the other parts touch only files.+"""+import argparse+import json+import pathlib+import subprocess+import sys+import time++AI_THREAD = "kicad-lane"+REPO = pathlib.Path(__file__).resolve().parents[2]+ROUTE_FIXTURE = REPO / "rust/crates/kicad-core/tests/fixtures/konnect-route"+LIVE_BOARD = REPO / "demo/placement/esc-g431-unrouted.kicad_pcb"+++class Drill:+    def __init__(self, target):+        self.target = target+        self.results = []++    def call(self, verb, args=None, timeout=240):+        cmd = ["adom-bridge", "--ai-thread", AI_THREAD, "--target", self.target, verb if verb.startswith(("kicad_", "send_", "desktop_")) else "kicad_" + verb, json.dumps(args or {})]+        try:+            p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+            return json.loads(p.stdout.strip() or p.stderr.strip())+        except subprocess.TimeoutExpired:+            return {"success": False, "error": f"no reply in {timeout}s"}+        except Exception as e:+            return {"success": False, "error": f"unreadable reply: {e}"}++    def check(self, verb, what, reply, good, seen=None):+        """Record one step: the edit's reply and what the separate read saw."""+        passed = bool(good)+        self.results.append({"verb": verb, "what": what, "pass": passed, "seen": seen, "reply": None if passed else {k: reply.get(k) for k in ("success", "errorCode", "error", "_hint") if k in reply}})+        print(f"  {'PASS' if passed else 'FAIL'}  {verb:32} {what}" + ("" if passed else f"\n        seen: {json.dumps(seen)[:300]}\n        reply: {json.dumps(reply.get('error') or reply.get('errorCode'))[:300]}"), flush=True)+        return passed+++def near(a, b, tol=0.02):+    if a is None or b is None or len(a) != len(b) or None in a or None in b:+        return False+    return all(abs(x - y) <= tol for x, y in zip(a, b))+++def same_net(nets, *pins):+    return any(all(p in members for p in pins) for members in nets.values())+++def sheet_part(d, base):+    print("[sheet] sch_* verbs on a new project")+    pd = base + "/sheet/dr"+    pro, sch = pd + "/dr.kicad_pro", pd + "/dr.kicad_sch"+    r = d.call("project_create", {"parentDir": base + "/sheet", "name": "dr", "layers": 2})+    if not d.check("project_create", "the project's sheet reads", r, d.call("sch_items", {"filePath": sch}).get("success") is True):+        return++    def nets():+        return d.call("netlist", {"filePath": sch}, 120).get("nets") or {}++    def items(*kinds):+        return d.call("sch_items", {"filePath": sch, "kinds": list(kinds)})++    def comp(ref):+        for c in d.call("sch_components", {"filePath": sch}).get("components") or []:+            if c.get("reference") == ref:+                return c+        return {}++    def field(c, name):+        return next((f.get("value") for f in c.get("fields") or [] if f.get("name") == name), None)++    r = d.call("sch_page", {"filePath": sch, "paper": "A3", "title": "drill title"})+    pg = d.call("sch_page", {"filePath": sch})+    d.check("sch_page", "paper A3 reads back", r, (pg.get("page") or {}).get("paper") == "A3", pg.get("page"))++    # The anchors sit on the grid. Device:R's pins are 3.81 mm above and below its anchor.+    for ref, x in (("R1", 50.8), ("R2", 76.2)):+        r = d.call("sch_place_symbol", {"filePath": sch, "libId": "Device:R", "reference": ref, "at": [x, 50.8]})+        c = comp(ref)+        d.check("sch_place_symbol", f"{ref} is at its point", r, near((c.get("units") or [{}])[0].get("at"), [x, 50.8]), c.get("units"))++    r = d.call("sch_connect_pins", {"filePath": sch, "from": "R1.2", "to": "R2.2"})+    n = nets()+    d.check("sch_connect_pins", "KiCad's netlist joins R1.2 and R2.2", r, same_net(n, "R1.2", "R2.2"), n)++    r = d.call("sch_split_wire", {"filePath": sch, "at": [63.5, 54.61], "junction": False})+    w = items("wires").get("wires") or []+    d.check("sch_split_wire", "one wire is now two", r, len(w) == 2, w)++    r = d.call("sch_junction", {"filePath": sch, "at": [63.5, 54.61]})+    j = items("junctions").get("junctions") or []+    d.check("sch_junction", "the junction is there", r, len(j) == 1 and near(j[0].get("at"), [63.5, 54.61]), j)+    if j:+        r = d.call("sch_junction", {"filePath": sch, "uuid": j[0].get("uuid") or j[0].get("id"), "remove": True})+        j2 = items("junctions").get("junctions") or []+        d.check("sch_junction", "remove by id takes it away", r, not j2, j2)++    r = d.call("sch_delete_wire", {"filePath": sch, "at": [57.15, 54.61]})+    n = nets()+    d.check("sch_delete_wire", "the netlist no longer joins R1.2 and R2.2", r, not same_net(n, "R1.2", "R2.2"), n)++    r = d.call("sch_wire", {"filePath": sch, "points": [[50.8, 54.61], [63.5, 54.61]]})+    n = nets()+    d.check("sch_wire", "a drawn wire joins them again", r, same_net(n, "R1.2", "R2.2"), n)++    r = d.call("sch_connect_to_net", {"filePath": sch, "pin": "R1.1", "net": "VIN"})+    n = nets()+    d.check("sch_connect_to_net", "R1.1 is on VIN", r, "R1.1" in (n.get("VIN") or n.get("/VIN") or []), n)++    lab = next((x for x in items("labels").get("labels") or [] if x.get("text") == "VIN"), {})+    r = d.call("sch_edit_label", {"filePath": sch, "net": "VIN", "rename": "VIN2"})+    n = nets()+    d.check("sch_edit_label", "rename: the net is VIN2", r, "R1.1" in (n.get("VIN2") or n.get("/VIN2") or []) and "VIN" not in n and "/VIN" not in n, n)+    r = d.call("sch_edit_label", {"filePath": sch, "net": "VIN2", "by": [2.54, 0]})+    lab2 = next((x for x in items("labels").get("labels") or [] if x.get("text") == "VIN2"), {})+    d.check("sch_edit_label", "by: the label moved 2.54 mm in x", r, lab.get("at") and lab2.get("at") and abs(lab2["at"][0] - lab["at"][0] - 2.54) < 0.02, [lab.get("at"), lab2.get("at")])++    r = d.call("sch_label", {"filePath": sch, "text": "SIGX", "at": [57.15, 54.61], "kind": "local"})+    n = nets()+    d.check("sch_label", "the wire's net is named SIGX", r, same_net({k: v for k, v in n.items() if "SIGX" in k}, "R1.2"), n)+    r = d.call("sch_delete", {"filePath": sch, "label": "SIGX", "at": [57.15, 54.61]})+    n = nets()+    d.check("sch_delete", "the label is gone from the netlist", r, not any("SIGX" in k for k in n), n)++    r = d.call("sch_no_connect", {"filePath": sch, "pin": "R2.1"})+    nc = items("noConnects").get("noConnects") or []+    d.check("sch_no_connect", "a marker on R2.1's end", r, len(nc) == 1 and near(nc[0].get("at"), [76.2, 46.99]), nc)+    if nc:+        r = d.call("sch_no_connect", {"filePath": sch, "uuid": nc[0].get("uuid") or nc[0].get("id"), "remove": True})+        nc2 = items("noConnects").get("noConnects") or []+        d.check("sch_no_connect", "remove by id takes it away", r, not nc2, nc2)++    r = d.call("sch_text", {"filePath": sch, "text": "drill note", "at": [30.48, 30.48]})+    tx = [t for t in items("texts").get("texts") or [] if t.get("text") == "drill note"]+    d.check("sch_text", "the note is there at its point", r, len(tx) == 1 and near(tx[0].get("at"), [30.48, 30.48]), tx)+    if tx:+        r = d.call("sch_delete", {"filePath": sch, "uuid": tx[0].get("uuid") or tx[0].get("id")})+        tx2 = [t for t in items("texts").get("texts") or [] if t.get("text") == "drill note"]+        d.check("sch_delete", "delete by id takes the note away", r, not tx2, tx2)++    r = d.call("sch_bus", {"filePath": sch, "points": [[101.6, 38.1], [101.6, 76.2]], "label": "D[0..1]"})+    b = items("buses").get("buses") or []+    d.check("sch_bus", "the bus runs between its points", r, len(b) == 1, b)+    r = d.call("sch_bus_entry", {"filePath": sch, "at": [99.06, 68.58], "direction": "up-right"})+    be = items("busEntries").get("busEntries") or []+    d.check("sch_bus_entry", "the entry is there", r, len(be) == 1, be)+    r = d.call("sch_connect_to_bus", {"filePath": sch, "pins": ["R2.1"], "prefix": "D", "busLabel": "D[0..1]"})+    n = nets()+    d.check("sch_connect_to_bus", "R2.1 is on the bus member D0", r, any("D0" in k and "R2.1" in v for k, v in n.items()), n)++    before = (comp("R1").get("units") or [{}])[0].get("at")+    r = d.call("sch_move", {"filePath": sch, "reference": "R1", "by": [0, -2.54]})+    after = (comp("R1").get("units") or [{}])[0].get("at")+    d.check("sch_move", "R1 moved by the amount asked", r, before and after and near([after[0], after[1] + 2.54], before[:2]), [before, after])+    r = d.call("sch_move", {"filePath": sch, "reference": "R1", "by": [0, 2.54]})++    r = d.call("sch_rotate", {"filePath": sch, "reference": "R2", "rotation": 90})+    pr = [p for p in d.call("sch_pins", {"filePath": sch, "reference": "R2"}).get("pins") or []]+    d.check("sch_rotate", "R2's pins now lie left and right", r, sorted(p.get("direction") for p in pr) == ["left", "right"], [(p.get("number"), p.get("direction")) for p in pr])+    r = d.call("sch_rotate", {"filePath": sch, "reference": "R2", "rotation": 0})++    wires0 = items("wires").get("wires") or []+    r = d.call("sch_move_region", {"filePath": sch, "box": [40, 40, 90, 60], "by": [0, 2.54]})+    r1 = (comp("R1").get("units") or [{}])[0].get("at")+    d.check("sch_move_region", "R1 inside the box moved", r, r1 and before and near([r1[0], r1[1] - 2.54], before[:2]), [before, r1, len(wires0)])+    d.call("sch_move_region", {"filePath": sch, "box": [40, 40, 90, 65], "by": [0, -2.54]})++    r = d.call("sch_set_property", {"filePath": sch, "reference": "R1", "name": "Value", "value": "4k7"})+    d.check("sch_set_property", "R1's Value is 4k7", r, field(comp("R1"), "Value") == "4k7", comp("R1").get("fields"))++    d.call("sch_place_symbol", {"filePath": sch, "libId": "Device:R", "reference": "R?", "at": [50.8, 88.9]})+    r = d.call("sch_annotate", {"filePath": sch})+    d.check("sch_annotate", "R? became R3", r, comp("R3") and not comp("R?"), [c.get("reference") for c in d.call("sch_components", {"filePath": sch}).get("components") or []])++    r = d.call("sch_replace_symbol", {"filePath": sch, "reference": "R3", "libId": "Device:R_Small"})+    d.check("sch_replace_symbol", "R3 is now Device:R_Small", r, comp("R3").get("libId") == "Device:R_Small", comp("R3").get("libId"))++    r = d.call("sch_set_property", {"filePath": sch, "reference": "R3", "name": "Value", "value": "1k"})+    r = d.call("sch_reset_fields", {"filePath": sch, "references": ["R3"]})+    d.check("sch_reset_fields", "R3 keeps its reference and value", r, r.get("success") is True and field(comp("R3"), "Value") == "1k" and comp("R3"), comp("R3").get("fields"))++    r = d.call("sch_update_from_library", {"filePath": sch})+    d.check("sch_update_from_library", "the parts still read, same symbols", r, r.get("success") is True and comp("R3").get("libId") == "Device:R_Small", {k: r.get(k) for k in ("updated", "changed", "symbols")})++    # a wire that stops 0.2 mm short of R3's pin: fix_connectivity must join it, and the netlist must say so+    pins = {p["number"]: p for p in d.call("sch_pins", {"filePath": sch, "reference": "R3"}).get("pins") or []}+    pa = pins.get("1", {}).get("at") or [50.8, 85.09]+    d.call("sch_wire", {"filePath": sch, "points": [[pa[0] + 0.2, pa[1]], [pa[0] + 12.7, pa[1]]], "junctions": False})+    d.call("sch_connect_to_net", {"filePath": sch, "pin": "R3.2", "net": "FIXNET"})+    d.call("sch_label", {"filePath": sch, "text": "FARNET", "at": [pa[0] + 12.7, pa[1]], "kind": "local"})+    n0 = nets()+    r = d.call("sch_fix_connectivity", {"filePath": sch, "dryRun": False, "tolerance": 0.5})+    n = nets()+    d.check("sch_fix_connectivity", "the 0.2 mm gap is closed: R3.1 on FARNET", r, not any("FARNET" in k and "R3.1" in v for k, v in n0.items()) and any("FARNET" in k and "R3.1" in v for k, v in n.items()), {k: v for k, v in n.items() if "FARNET" in k or "R3" in str(v)})++    r = d.call("sch_sheet", {"filePath": sch, "action": "add", "name": "Child", "file": "child.kicad_sch", "at": [127, 30.48], "size": [25.4, 20.32], "projectPath": pro})+    h = d.call("sch_hierarchy", {"projectPath": pro})+    d.check("sch_sheet", "the hierarchy has Child", r, h.get("sheetCount") == 2 and any(str(f).endswith("child.kicad_sch") for f in h.get("files") or []), h.get("files"))+    r = d.call("sch_sheet_pins", {"filePath": sch, "sheet": "Child", "action": "add", "name": "VIO", "shape": "input", "side": "left"})+    sp = d.call("sch_sheet_pins", {"filePath": sch, "sheet": "Child", "action": "list"})+    d.check("sch_sheet_pins", "the sheet has pin VIO", r, "VIO" in json.dumps(sp), sp.get("pins"))++    had = bool(comp("R3"))+    r = d.call("sch_delete", {"filePath": sch, "reference": "R3"})+    d.check("sch_delete", "R3 was there and is gone", r, had and not comp("R3"))+    # KiCad itself must still read the finished sheet+    e = d.call("run_erc", {"filePath": sch}, 200)+    d.check("run_erc", "kicad-cli still reads the sheet", e, e.get("success") is True, {k: e.get(k) for k in ("errors", "warnings", "error")})+++def library_part(d, base):+    print("[library] symbol and footprint verbs on new libraries")+    lib, pretty = base + "/lib/D.kicad_sym", base + "/lib/D.pretty"+    pins = [{"number": 1, "name": "A"}, {"number": 2, "name": "B", "type": "output"}]++    def info(name):+        return d.call("sym_info", {"libraryPath": lib, "name": name})++    def fp(name):+        return d.call("fp_info", {"libraryPath": pretty, "name": name})++    r = d.call("sym_create", {"libraryPath": lib, "name": "P", "pins": pins})+    i = info("P")+    d.check("sym_create", "two pins read back with their types", r, len(i.get("pins") or []) == 2 and any(p.get("type") == "output" for p in i.get("pins") or []), i.get("pins"))+    r = d.call("set_params", {"libraryPath": lib, "symbolName": "P", "params": {"MPN": "X1"}, "comment": "10k", "footprint": "Lib:FP"})+    f = info("P").get("fields") or {}+    d.check("set_params", "MPN, Value and Footprint read back", r, (f.get("MPN"), f.get("Value"), f.get("Footprint")) == ("X1", "10k", "Lib:FP"), f)+    r = d.call("set_visibility", {"libraryPath": lib, "symbolName": "P", "param": "MPN", "visible": True})+    d.check("set_visibility", "the verb says MPN is shown, and the symbol still reads", r, r.get("success") is True and info("P").get("success") is True, r.get("visible"))+    r = d.call("clone_symbol", {"libraryPath": lib, "from": "P", "to": "Q"})+    d.check("clone_symbol", "Q exists with P's pins", r, len(info("Q").get("pins") or []) == 2)+    r = d.call("rename_symbol", {"libraryPath": lib, "from": "Q", "to": "Q2"})+    d.check("rename_symbol", "Q2 exists, Q is gone", r, info("Q2").get("success") is True and info("Q").get("success") is not True)+    r = d.call("sym_delete", {"libraryPath": lib, "name": "Q2"})+    d.check("sym_delete", "Q2 is gone, P stays", r, info("Q2").get("success") is not True and info("P").get("success") is True)+    r = d.call("set_models", {"libraryPath": lib, "symbolName": "P", "footprint": "Lib:FP2"})+    d.check("set_models", "the footprint link was replaced", r, (info("P").get("fields") or {}).get("Footprint") == "Lib:FP2" and r.get("removed") == 1, r.get("removed"))+    r = d.call("add_models", {"libraryPath": lib, "symbolName": "P", "models": [{"name": "FP3"}], "description": "d"})+    f = info("P").get("fields") or {}+    d.check("add_models", "the filter and description read back", r, f.get("ki_fp_filters") == "FP3" and f.get("Description") == "d", f)++    pads = [{"number": 1, "size": [1, 1.5], "at": [-1, 0]}, {"number": 2, "size": [1, 1.5], "at": [1, 0]}]+    r = d.call("fp_create", {"libraryPath": pretty, "name": "F", "pads": pads, "bodySize": [3, 2]})+    i = fp("F")+    d.check("fp_create", "two pads read back at their points", r, len(i.get("pads") or []) == 2, i.get("pads"))+    r = d.call("fp_edit", {"libraryPath": pretty, "name": "F", "pads": [{"number": "1", "set": {"size": [2, 2]}}]})+    p1 = next((p for p in fp("F").get("pads") or [] if str(p.get("number")) == "1"), {})+    d.check("fp_edit", "pad 1 is now 2 by 2", r, near(p1.get("size"), [2, 2]), p1)+    r = d.call("copy_footprint", {"libraryPath": pretty, "from": "F", "to": "G"})+    d.check("copy_footprint", "G exists", r, fp("G").get("success") is True)+    r = d.call("rename_footprint", {"libraryPath": pretty, "from": "G", "to": "G2"})+    d.check("rename_footprint", "G2 exists, G is gone", r, fp("G2").get("success") is True and fp("G").get("success") is not True)+    r = d.call("xcopy_footprint", {"fromLib": pretty, "from": "G2", "toLib": base + "/lib/E.pretty", "to": "H"})+    d.check("xcopy_footprint", "H exists in the other library", r, d.call("fp_info", {"libraryPath": base + "/lib/E.pretty", "name": "H"}).get("success") is True)+    r = d.call("remove_footprint", {"libraryPath": pretty, "footprintName": "G2"})+    d.check("remove_footprint", "G2 is gone, F stays", r, fp("G2").get("success") is not True and fp("F").get("success") is True)+    r = d.call("footprint_from_land_pattern", {"libraryPath": pretty, "name": "S8", "family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6})+    d.check("footprint_from_land_pattern", "eight pads read back", r, len(fp("S8").get("pads") or []) == 8, len(fp("S8").get("pads") or []))+    # a thru-hole pad with no plating: plate_vias must plate it+    d.call("fp_create", {"libraryPath": pretty, "name": "V", "pads": [{"number": 1, "type": "thru_hole", "shape": "circle", "size": 1, "drill": 0.5, "at": [0, 0]}], "bodySize": [2, 2]})+    r = d.call("plate_vias", {"libraryPath": pretty, "footprintName": "V"})+    d.check("plate_vias", "the verb reports and the footprint still reads", r, r.get("success") is True and fp("V").get("success") is True, {k: r.get(k) for k in ("changed", "plated", "footprints", "pads")})+++def file_part(d, base):+    print("[file] closed-board edits on a copy")+    bdir = base + "/board"+    src = [str(ROUTE_FIXTURE / n) for n in ("route-fixture.kicad_pcb", "route-fixture.kicad_pro", "route-fixture.net", "fp-lib-table")]+    d.call("send_files", {"filePaths": src, "dest": bdir}, 200)+    d.call("send_files", {"filePaths": [str(LIVE_BOARD)], "dest": bdir}, 200)+    d.call("send_files", {"filePaths": [str(p) for p in (ROUTE_FIXTURE / "Fixture.pretty").iterdir()], "dest": bdir + "/Fixture.pretty"}, 200)+    b = bdir + "/esc-g431-unrouted.kicad_pcb"+    rf = bdir + "/route-fixture.kicad_pcb"++    def part(path, ref):+        s = d.call("routing_state", {"filePath": path, "source": "file", "model": "standard"})+        return next((c for c in s.get("components") or [] if c.get("ref") == ref), {})++    def layers(path):+        return d.call("layers", {"filePath": path, "source": "file"})++    r = d.call("set_layer_count", {"filePath": rf, "count": 4})+    l = layers(rf)+    d.check("set_layer_count", "the board now has 4 copper layers", r, r.get("countAfter") == 4 and "In1.Cu" in json.dumps(l), {k: l.get(k) for k in ("copper", "copperLayers", "count")})++    r = d.call("update_pcb_from_schematic", {"projectPath": bdir + "/route-fixture.kicad_pro", "filePath": bdir + "/route-fixture.kicad_pcb", "netlistPath": bdir + "/route-fixture.net", "execute": True, "allowRemovals": True})+    p = d.call("update_pcb_from_schematic", {"filePath": bdir + "/route-fixture.kicad_pcb", "netlistPath": bdir + "/route-fixture.net"})+    d.check("update_pcb_from_schematic", "execute: a second compare says in sync", r, r.get("executed") is True and (p.get("plan") or {}).get("inSync") is True, {"exec": r.get("error"), "after": (p.get("plan") or {}).get("changeCount"), "changes": p.get("changeOrder")})+++def live_part(d, base):+    print("[live] edits on a copy open in the PCB editor")+    ldir = base + "/live"+    d.call("send_files", {"filePaths": [str(LIVE_BOARD)], "dest": ldir}, 200)+    b = ldir + "/esc-g431-unrouted.kicad_pcb"+    def file_tracks():+        return [t for t in d.call("routing_state", {"filePath": b, "source": "file", "model": "standard"}).get("tracks") or [] if t.get("net") == "GND"]+    t0 = file_tracks()+    r = d.call("add_track", {"filePath": b, "net": "GND", "layer": "F.Cu", "width": 0.2, "start": [100, 60], "end": [104, 60]})+    t1 = file_tracks()+    d.check("add_track", "closed file: one more GND track", r, len(t1) == len(t0) + 1, [len(t0), len(t1), r.get("errorCode"), r.get("error")])+    o = d.call("open_board", {"filePath": b}, 240)+    if o.get("success") is not True:+        d.check("open_board", "the copy opened", o, False, o.get("error"))+        return+    st = {}+    for _ in range(10):+        st = d.call("routing_state", {"filePath": b})+        if st.get("revision"):+            break+        time.sleep(5)+    rev = [st.get("revision")]++    def state():+        s = d.call("routing_state", {"filePath": b, "model": "standard"})+        if s.get("revision"):+            rev[0] = s["revision"]+        return s++    def comp(ref):+        return next((c for c in state().get("components") or [] if c.get("ref") == ref), {})++    def outline():+        o = d.call("read_board", {"filePath": b, "sections": "outline"}).get("data", {}).get("outline", {})+        pts = [p for e in o.get("edges") or [] for p in (e.get("start"), e.get("end")) if p]+        return (max(p[0] for p in pts) - min(p[0] for p in pts), max(p[1] for p in pts) - min(p[1] for p in pts)) if pts else None++    def edit(verb, **args):+        return d.call(verb, dict({"filePath": b, "expectedRevision": rev[0], "validate": False}, **args))++    # edits take KiCad's frame (y down), the standard model reads y up+    M = "C7"+    c0 = comp(M)+    r = edit("move_footprint", ref=M, x=c0.get("x", 0) + 1, y=-c0.get("y", 0), allowOverlap=True, allowOutside=True)+    c1 = comp(M)+    d.check("move_footprint", f"live: {M} moved 1 mm in x", r, near([c1.get("x"), c1.get("y")], [c0.get("x", 0) + 1, c0.get("y")], 0.01), [c0.get("x"), c1.get("x"), r.get("errorCode"), r.get("error")])++    r = edit("align_footprints", refs=[M, "C12"], mode="top", by="anchor", allowOverlap=True, allowOutside=True)+    a, c = comp(M), comp("C12")+    d.check("align_footprints", f"live: {M} and C12 share a y", r, a.get("y") is not None and abs(a.get("y") - c.get("y")) < 0.01, [a.get("y"), c.get("y"), r.get("errorCode"), r.get("error")])++    pool = [c.get("ref") for c in state().get("components") or [] if str(c.get("ref")).startswith("C") and c.get("ref") not in (M, "C12")]+    ta, tb = pool[0], pool[1]+    t0 = comp(ta)+    r = edit("replicate_layout", source=[M, "C12"], targets=[[ta, tb]], anchor=M, allowOverlap=True, allowOutside=True)+    t2, t3, s0, s1 = comp(ta), comp(tb), comp(M), comp("C12")+    # the target group keeps the source's shape, turned by the difference between the anchors' rotations+    dist = lambda p, q: round(((p.get("x", 0) - q.get("x", 0)) ** 2 + (p.get("y", 0) - q.get("y", 0)) ** 2) ** 0.5, 2)+    d.check("replicate_layout", f"live: {ta} and {tb} are as far apart as {M} and C12", r, r.get("success") is True and abs(dist(s0, s1) - dist(t2, t3)) < 0.02, [dist(s0, s1), dist(t2, t3), t0.get("x"), t2.get("x"), s0.get("rotation"), t2.get("rotation"), r.get("movedRefs"), r.get("errorCode"), r.get("error")])++    before = {c.get("ref"): (c.get("x"), c.get("y")) for c in state().get("components") or []}+    r = edit("place_decoupling", ic="U5", dryRun=False, allowOverlap=True)+    after = {c.get("ref"): (c.get("x"), c.get("y")) for c in state().get("components") or []}+    moved = [k for k in before if before[k] != after.get(k)]+    d.check("place_decoupling", "live: the capacitors really moved", r, r.get("success") is True and moved, [moved[:6], r.get("errorCode"), r.get("error"), r.get("mutated")])++    r = edit("flip_footprint", ref=M)+    a = comp(M)+    d.check("flip_footprint", f"live: {M} is on the back", r, a.get("side") == "bottom", [a.get("side"), r.get("errorCode"), r.get("error")])++    r = edit("edit_footprint", ref=M, value="drillval")+    a = comp(M)+    d.check("edit_footprint", f"live: {M}'s value reads back", r, a.get("value") == "drillval", [a.get("value"), r.get("errorCode"), r.get("error")])++    def silk_fields():+        return [f for c in d.call("silk_state", {"filePath": b}).get("parts") or [] for f in c.values() if isinstance(f, dict) and f.get("id") and f.get("at")]+    fld = next((x for x in silk_fields() if x.get("layer") == "F.SilkS"), None)+    if fld:+        nx = round(fld["at"][0] + 0.5, 3)+        r = edit("silk_fields_batch", update=[{"id": fld["id"], "x": nx, "y": -fld["at"][1], "layer": "F.SilkS", "height": fld.get("heightMm") or 0.8, "stroke": fld.get("thicknessMm") or 0.12}])+        fld2 = next((x for x in silk_fields() if x["id"] == fld["id"]), {})+        moved = fld2.get("at") and abs(fld2["at"][0] - nx) < 0.01+        refused = r.get("errorCode") in ("native_field_update_unsupported", "drc_rejected") and fld2.get("at") == fld["at"]+        d.check("silk_fields_batch", "live: the field moved 0.5 mm, or a named refusal with nothing changed", r, moved or refused, [fld["at"], fld2.get("at"), r.get("errorCode"), r.get("error")])+    else:+        d.check("silk_fields_batch", "live: a field to move", {}, False, None)++    n0 = len(state().get("components") or [])+    hx, hy = c0.get("x", 0) - 20, -c0.get("y", 0) - 30+    r = edit("add_mounting_hole", x=hx, y=hy, drill=2.2)+    s1 = state()+    d.check("add_mounting_hole", "live: one more part on the board", r, len(s1.get("components") or []) == n0 + 1, [n0, len(s1.get("components") or []), r.get("errorCode"), r.get("error")])++    g0 = [t for t in state().get("tracks") or [] if t.get("net") == "GND"]+    r = edit("copy_routing", source={"minX": 99, "minY": 59, "maxX": 105, "maxY": 61}, offset={"dx": 0, "dy": 3}, keepUnmappedNets=True)+    g1 = [t for t in state().get("tracks") or [] if t.get("net") == "GND"]+    d.check("copy_routing", "live: the GND track was copied", r, len(g1) == len(g0) + 1, [len(g0), len(g1), r.get("errorCode"), r.get("error")])++    nets = [n.get("name") if isinstance(n, dict) else n for n in state().get("nets") or []]+    nets = [n for n in nets if n and n not in ("GND", "")]+    pa, pb = nets[0], nets[1]+    p0 = len([t for t in state().get("tracks") or [] if t.get("net") in (pa, pb)])+    r = edit("route_diff_pair", netP=pa, netN=pb, centerline=[[100, 70], [106, 70]], width=0.2, gap=0.2)+    p1 = len([t for t in state().get("tracks") or [] if t.get("net") in (pa, pb)])+    d.check("route_diff_pair", "live: both nets got copper", r, p1 >= p0 + 2, [pa, pb, p0, p1, r.get("errorCode"), r.get("error")])++    r = edit("ripup_nets", nets=["GND"], dryRun=False)+    t2 = [t for t in state().get("tracks") or [] if t.get("net") == "GND"]+    d.check("ripup_nets", "live: GND has no tracks left", r, not t2 and len(g1) > 0, [len(g1), len(t2), r.get("errorCode"), r.get("error")])++    r = edit("board_outline", shape="rectangle", width=90, height=80, x=90, y=50, replace=True)+    ol = outline()+    d.check("board_outline", "live: the outline is 90 by 80", r, ol and near(list(ol), [90, 80], 0.1), [ol, r.get("errorCode"), r.get("error")])++    r = d.call("save_board", {"filePath": b})+    fs = d.call("routing_state", {"filePath": b, "source": "file", "model": "standard"})+    cf = next((c for c in fs.get("components") or [] if c.get("ref") == M), {})+    d.check("save_board", f"the file holds the live edits ({M} on the back)", r, cf.get("side") == "bottom" and cf.get("value") == "drillval", [cf.get("side"), cf.get("value"), r.get("error")])++    r = d.call("teardrops", {"filePath": b})+    d.check("teardrops", "the reply counts, and the board saved", r, r.get("success") is True and r.get("saved") is True and isinstance(r.get("teardropsAfter"), int), {k: r.get(k) for k in ("teardropsBefore", "teardropsAfter", "saved", "errorCode", "error")})++    d.call("close", {"discardChanges": True}, 120)+++def main():+    ap = argparse.ArgumentParser()+    ap.add_argument("--target", required=True)+    ap.add_argument("--part", default="sheet,library,file,live")+    ap.add_argument("--out")+    a = ap.parse_args()+    d = Drill(a.target)+    diag = d.call("readiness", {})+    user = None+    for inst in diag.get("installs") or []:+        parts = str(inst.get("baseDir") or "").split("/")+        if len(parts) > 2 and parts[1].lower() == "users":+            user = parts[2]+    print(f"bridge {diag.get('bridgeVersion')} on {a.target}, KiCad {diag.get('kicadVersion') or (diag.get('installs') or [{}])[0].get('version')}")+    if not user:+        print("FATAL: could not learn the Windows user from kicad_readiness")+        return 2+    base = f"C:/Users/{user}/Documents/adom-kicad-drill/{time.strftime('%m%d%H%M%S')}"+    parts = {"sheet": sheet_part, "library": library_part, "file": file_part, "live": live_part}+    for name in a.part.split(","):+        parts[name.strip()](d, base)+    fails = [r for r in d.results if not r["pass"]]+    print(f"\n{len(d.results) - len(fails)} passed, {len(fails)} failed")+    for f in fails:+        print(f"  FAIL {f['verb']}: {f['what']}")+    if a.out:+        pathlib.Path(a.out).write_text(json.dumps(d.results, indent=1))+    return 1 if fails else 0+++if __name__ == "__main__":+    sys.exit(main())+--- a/skills/kicad-bridge-test/run_verb_tests.py+++ b/skills/kicad-bridge-test/run_verb_tests.py@@ -1,2030 +1,2087 @@⋯ 205 unchanged lines ⋯             "library_views", "generate_body", "plan_route", "export_spice", "simulate",             "license_status", "eval", "run_script", "dialog_log", "foreground", "schematic_review", "review_board", "variants", "sch_zoom", "swap_footprint", "plate_vias", "normalize_bodies", "symbol_mode", "add_symbol_art", "build_symbol_modes", "cleanup_libs",             "export_3d", "edit_pours", "import_design", "sch_part_verbs_standard", "tool_update", "import_step", "cross_check",-            "list_library", "render_board_layers", "export_bom", "open_document", "activate_document", "open_project", "routing_drc", "set_track_width", "footprint_primitives", "rules_by_kind", "net_connections", "remove_items", "document_info")},+            "list_library", "render_board_layers", "export_bom", "open_document", "activate_document", "open_project", "routing_drc", "set_track_width", "footprint_primitives", "rules_by_kind", "net_connections", "remove_items", "document_info", "read_board", "read_schematic", "schematic_data", "pcb_report", "instances", "ensure_licensed", "signin_status", "setup_trial", "set_models", "add_models", "check_dialogs", "save", "close_document", "close_all_documents")},         "panelize": dict(phase=8, scenario=True),         # ---- phase 8: the 1.0.61 verbs (Konnect parity), run in order by k161_phase() on scratch files         "add_footprint": dict(phase=8, scenario=True),⋯ 1027 unchanged lines ⋯         r = K("render_schematic", {"filePath": sch, "outputDir": OUT + "/render-sch"}, 220); rec("render_schematic", r, ok(r))         r = K("design_review", {"projectPath": pro}, 220); rec("design_review", r, ok(r))         r = K("update_pcb_from_schematic", {"filePath": pcb, "schematicPath": sch}, 220)-        rec("update_pcb_from_schematic", r, ok(r) and r.get("mutated") is not True)+        rec("update_pcb_from_schematic", r, ok(r) and r.get("mutated") is not True and r.get("executed") is False and isinstance(r.get("preview"), dict) and isinstance((r.get("preview") or {}).get("addParts"), list) and isinstance(r.get("changeOrder"), list) and r.get("board"))         r = K("netclasses", {"projectPath": pro, "set": {"name": "PWR", "trackWidth": 0.5}, "assign": {"netclass": "PWR", "patterns": ["VIN"]}})         rec("netclasses", r, ok(r))         al = ab(a.target, "kicad_net_classes", {"projectPath": pro}, timeout=120)   # the standard's name for it⋯ 98 unchanged lines ⋯         # 1.0.66: teardrops through KiCad's own Edit Teardrops dialog, added then removed again         r = E("teardrops", {"filePath": L2}, 240)         r2 = E("teardrops", {"filePath": L2, "remove": True}, 240) if ok(r) else {}-        record("teardrops", {"add": r, "remove": r2}, ok(r) and ok(r2) and (r.get("after") or 0) >= (r.get("before") or 0) and r2.get("after") == 0, "teardrops were not added and removed again")+        r3 = E("teardrops", {"filePath": L2, "force": True}, 60, chain=False)+        record("teardrops", {"add": r, "remove": r2, "force": r3}, ok(r) and ok(r2) and (r.get("teardropsAfter") or 0) >= (r.get("teardropsBefore") or 0) and r2.get("teardropsAfter") == 0+               and r.get("saved") is True and "save" in (r.get("steps") or []) and isinstance(r.get("dialogsLeft"), list) and r3.get("errorCode") == "not_supported",+               "teardrops were not added, saved and removed again, or force:true was not refused")         # 1.0.67: thermal vias in Q1's drain tab, live as one Undo step after a DRC preflight         r = E("thermal_vias", {"ref": "Q1"}, 300)         record("thermal_vias_live", r, ok(r) and r.get("source") == "live-editor" and (r.get("applied") or 0) > 0 and r.get("undoSteps") == 1, "thermal vias were not committed live")⋯ 452 unchanged lines ⋯         di = C("document_info", {}, 60)         own("document_info", di, ok(di) and di.get("kind") in ("board", "schematic", "project") and str(di.get("path") or "").startswith(xd) and di.get("pathFrom") != "none" and isinstance(di.get("modified"), bool),             "the active KiCad document was not named with its kind and its path in the scratch project")+        # 1.0.99: the standard's read_board, read_schematic, schematic_data, pcb_report and instances+        ins = C("instances", {}, 60)+        own("instances", ins, ok(ins) and ins.get("count", 0) >= 1 and all(i.get("pid") and i.get("licensed") is True and "KiCad" in str(i.get("edition")) for i in ins.get("instances") or []), "the running KiCad was not listed")+        xpcb, xsch = xd + "/rp2040-breakout.kicad_pcb", xd + "/rp2040-breakout.kicad_sch"+        rb = C("read_board", {"filePath": xpcb, "source": "file"}, 120)+        rbd = rb.get("data") or {}+        rb2 = C("read_board", {"filePath": xpcb, "source": "file", "sections": "components,rules", "limit": 3}, 120)+        rb2d = rb2.get("data") or {}+        own("read_board", {"all": {"counts": rb.get("counts"), "sections": sorted(rbd)}, "some": sorted(rb2d)}, ok(rb) and all(k in rbd for k in ("components", "nets", "layers", "copper", "rules", "occupancy", "outline")) and len(rbd["components"]) > 3+            and ok(rb2) and len(rb2d.get("components") or []) == 3 and "copper" not in rb2d and "rulesByKind" in (rb2d.get("rules") or {}) and rb2.get("truncated") == ["components"], "the board's sections did not come back, or sections and limit were ignored")+        rs = C("read_schematic", {"filePath": xsch}, 120)+        rsr = next((c for c in rs.get("components") or [] if c.get("designator") == "U1"), {})+        own("read_schematic", rs, ok(rs) and rs.get("count", 0) >= 2 and "U1" in (rs.get("designators") or []) and rs.get("document") == xsch and rsr.get("libReference") and rsr.get("xMil", 0) > 0 and rsr.get("yMil", 0) > 0 and "pins" not in rsr,+            "the sheet's parts did not come back with designator, library reference and position in mils")+        sd = C("schematic_data", {"filePath": xsch, "designator": "U1"}, 120)+        sdc = ((sd.get("data") or {}).get("components") or [{}])[0]+        spins = sdc.get("pins") or []+        own("schematic_data", {"count": (sd.get("data") or {}).get("count"), "pins": len(spins), "first": spins[:2], "error": sd.get("error")}, ok(sd) and sdc.get("designator") == "U1" and len(spins) >= 56 and all(isinstance(q.get("electrical"), int) and q.get("orientation") in (0, 90, 180, 270) and q.get("lengthMil", 0) > 0 for q in spins) and any(q.get("electrical") == 7 for q in spins),+            "U1's pins did not come back with the standard's electrical ordinal, orientation and length")+        pr = C("pcb_report", {"path": twd + "/route-fixture.kicad_pcb", "source": "file"}, 120)+        prl = C("pcb_report", {"path": kb + "/share/kicad/footprints/Resistor_SMD.pretty", "name": "R_0603"}, 120)+        prd, prld = pr.get("data") or {}, prl.get("data") or {}+        own("pcb_report", {"boardCount": len(prd.get("components") or []), "census": prd.get("census"), "lib": (prld.get("components") or [])[:2], "error": pr.get("error") or prl.get("error")},+            ok(pr) and prd.get("kind") == "board" and any(c.get("pads", 0) > 0 and c.get("padsBboxMil") and c.get("nets") for c in prd.get("components") or []) and ok(prl) and prld.get("kind") == "library" and prld.get("count", 0) >= 1 and all(c.get("pads") == 2 and c.get("padsBboxMil") for c in prld.get("components") or []),+            "the board or library report did not give pads, boxes and nets")+        # 1.0.99: the standard's account answers, library models, dialogs, save and close, with KiCad up on the scratch board+        cdg = C("check_dialogs", {"screenshots": False}, 60)+        own("check_dialogs", cdg, ok(cdg) and cdg.get("dialogsDetected") == len(cdg.get("dialogs") or []) and all(d.get("category") and d.get("safeAction") for d in cdg.get("dialogs") or []), "the dialog list did not come back classified")+        el = C("ensure_licensed", {}, 120)+        own("ensure_licensed", el, ok(el) and el.get("alreadyLicensed") is True and "already running" in (el.get("steps") or []), "KiCad was not reported ready")+        sgn = C("signin_status", {}, 30)+        own("signin_status", sgn, ok(sgn) and sgn.get("licensed") is True and sgn.get("workspaceConnected") is False and sgn.get("running") is True, "KiCad's account state was wrong")+        stt = C("setup_trial", {}, 30)+        own("setup_trial", stt, ok(stt) and stt.get("stage") == "done" and stt.get("needsHuman") is None, "a running KiCad was not at stage done")+        mlib = w["out"] + "/ModelTest.kicad_sym"+        msc = C("sym_create", {"libraryPath": mlib, "name": "MT", "pins": [{"number": 1, "name": "A"}, {"number": 2, "name": "B"}]}, 60)+        am = C("add_models", {"libraryPath": mlib, "symbolName": "MT", "models": [{"name": "R_0603_1608Metric", "pcblib": "Resistor_SMD"}, {"name": "R_0805_2012Metric"}], "params": {"MPN": "X1"}, "comment": "10k"}, 60)+        smi = C("sym_info", {"libraryPath": mlib, "name": "MT"}, 60)+        own("add_models", {"create": msc, "add": am, "fields": smi.get("fields")}, ok(am) and (am.get("data") or {}).get("models") == 2 and (smi.get("fields") or {}).get("Footprint") == "Resistor_SMD:R_0603_1608Metric" and (smi.get("fields") or {}).get("ki_fp_filters") == "R_0603_1608Metric R_0805_2012Metric",+            "the footprint link and filters were not written")+        sm = C("set_models", {"libraryPath": mlib, "symbolName": "MT", "footprint": "Resistor_SMD:R_0805_2012Metric"}, 60)+        smi2 = C("sym_info", {"libraryPath": mlib, "name": "MT"}, 60)+        own("set_models", sm, ok(sm) and sm.get("removed") == 1 and (smi2.get("fields") or {}).get("Footprint") == "Resistor_SMD:R_0805_2012Metric", "the footprint link was not replaced")+        osch = C("open_document", {"filePath": xsch}, 200)+        sv = C("save", {"filePath": xsch}, 120)+        sv2 = C("save", {"filePath": w["out"] + "/nothing-open-here.kicad_sch"}, 60)+        own("save", {"open": osch.get("success"), "sheet": sv, "notOpen": sv2}, ok(sv) and sv.get("saved") is True and (sv.get("data") or {}).get("process") and ok(sv2) and sv2.get("saved") is False, "the open sheet was not saved, or a document not open was not told apart")+        cd1 = C("close_document", {"path": xsch}, 60)+        cd2 = C("close_document", {"path": xsch}, 60)+        own("close_document", {"first": cd1, "second": cd2}, ok(cd1) and (cd1.get("data") or {}).get("closed") is True and ok(cd2) and (cd2.get("data") or {}).get("closed") is False and "not open" in str((cd2.get("data") or {}).get("note")), "the sheet did not close, or closing it again did not say it was not open")+        cad = C("close_all_documents", {"discardUnsaved": True}, 180)+        left = ab(a.target, "kicad_window_info", {}, timeout=60).get("editors") or []+        own("close_all_documents", {"reply": cad, "editorsLeft": [e.get("title") for e in left]}, ok(cad) and "rp2040-breakout" in (cad.get("closed") or []) and not [e for e in left if "PCB Editor" in str(e.get("title")) or "Schematic Editor" in str(e.get("title"))], "the scratch board was not closed")         ab(a.target, "kicad_close", {"force": True, "reason": "kicad-bridge verb test: close the scratch board open_document opened"}, timeout=120)         op = C("open_project", {"filePath": xd + "/rp2040-breakout.kicad_pro"}, 200)         own("open_project", op, ok(op) and (op.get("data") or {}).get("objectKind") == "Project", "the scratch project did not open in the project manager")⋯ 218 unchanged lines ⋯  if __name__ == "__main__":     sys.exit(main())+

Comments

No comments yet.

Log in to comment.