Files
essim/DESIGN.md
François cb61e9b084 P3: unify connector layout + BSDL behind one PinModel provider
New PinModel interface (spec_for / layout / source) + a single apply_model(
Part*, const PinModel&) that materialises missing layout pins and sets each
pin's spec only where the model speaks (spec.source != None), so one source
never clobbers another's. ConnectorModel wraps pin_role/pin_layout;
BsdlPinModel wraps a parsed BsdlModel (indexed by port name and physical pad).
set-connector-type and screen_settype now use ConnectorModel + apply_model;
attach-bsdl and the restore re-apply keep calling apply_bsdl, now a thin
adapter over apply_model. Behaviour-preserving: unit tests (73 cases) green and
the real 8-card system re-runs identically (1517/1517 bound, same JTAG
findings). Covered by test_pin_model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:02:39 +02:00

54 KiB
Raw Blame History

essim — notes for Claude

System digital twin: simulator for the interconnections between cards/boards. C++17, FTXUI for the TUI, importers for external netlist formats.

Build

cmake -S . -B build
cmake --build build -j
./build/essim
  • CMake 3.14+ required (uses FetchContent_MakeAvailable).
  • FTXUI is fetched at configure time from GitHub (v6.1.9, shallow clone). First configure pays ~20 s for the clone; subsequent ones are cached in build/_deps/.
  • System dependencies (resolved via find_package): libzip (target libzip::zip) and pugixml (target pugixml::pugixml). Used by the ODS importer. Arch: pacman -S libzip pugixml; Debian/Ubuntu: libzip-dev libpugixml-dev.
  • libbsdl (standalone BSDL parser, LGPL-2.1) is the sibling repo at ../libbsdl, pulled in via add_subdirectory (path overridable with -DBSDL_DIR=...) and linked dynamically (bsdl::bsdl; an LGPL .so is fine from EUPL essim). Powers the BSDL ingest behind attach-bsdl.
  • Sources are collected with file(GLOB_RECURSE ALL_SOURCES "src/*.cpp"). After adding a new .cpp, re-run cmake -S . -B build — CMake does not re-glob automatically and link will fail with "undefined reference".
  • Headless / batch: essim --batch --source FILE runs a script and prints its console output to stdout, then exits without the TUI (good for CI / capturing verify). Also --restore FILE and --commands-md [FILE]. BootDispatch runs --restore/--source synchronously before the event loop (Source takes its headless drain branch when no screen is attached), so the console buffer is complete by the time --batch dumps it (Tui::DumpOutput).

Layout

src/
  main.cpp               -- launches Tui
  system/                -- domain model
    syselmts.hpp           SystemElement + SystemElementContainer<T> (templated, get/merge/iterate)
    modules.{hpp,cpp}      Module, Modules
    parts.{hpp,cpp}        Part (carries `kind` + `connector_type`), Parts
    pins.{hpp,cpp}         Pin (carries PinSpec `spec`), Pins
    signals.{hpp,cpp}      Signal, Signals
    signal_type.hpp        SignalType + helpers
    pin_spec.hpp           PinSpec (function/direction/pad/source) + SignalType mapping
    component_kind.{hpp,cpp} ComponentKind enum + infer_component_kind(name)
    pin_name.{hpp,cpp}     canonical_pin_name(s) — zero-pad digit suffix to 3
    connect.{hpp,cpp}      Connection, Connections
    transform.{hpp,cpp}    Transform / IdentityTransform / TransformRegistry +
                           CheckIdentityCompatible + FillIdentityNCs
    pin_role.{hpp,cpp}     pin_role(kind, name) → PinSpec, pin_layout(kind),
                           FillPartFromLayout(part, kind)
    pin_model.{hpp,cpp}    PinModel + apply_model(Part*, model) + ConnectorModel
    bsdl_model.{hpp,cpp}   BsdlModel (libbsdl wrapper) + BsdlPinModel + apply_bsdl
    bsdl_check.{hpp,cpp}   check_pin_specs / check_jtag_chain → vector<Anomaly>
    nets.{hpp,cpp}         find_net / compute_all_nets / net_type_consistent
    analysis.{hpp,cpp}     analyze_system → AnalysisReport (diff pairs, buses, anomalies)
    persist.{hpp,cpp}      save / restore (tab-delimited)
    system.{hpp,cpp}       System: owns Modules + Connections, exposes Load()
  imports/               -- adapters that populate or emit the domain
    import_base.hpp        ImportBase interface
    import_mentor.{hpp,cpp} Mentor Graphics netlist parser
    import_altium.{hpp,cpp} Altium netlist parser (`[ ]` parts, `( )` signals)
    import_ods.{hpp,cpp}   ODS spreadsheet pinout (libzip + pugixml)
    ods_writer.{hpp,cpp}   Minimal .ods writer (multi-sheet, string cells)
  tui/                   -- FTXUI shell, split by responsibility
    tui.{hpp,cpp}          Class Tui (state + Run() orchestrator + screen-mode event dispatcher)
    tui_helpers.{hpp,cpp}  Free helpers: ToLower, NaturalLess, LongestCommonPrefix
    shell.cpp              Print, Submit, Dispatch, Finalize, Tokenize, history persistence
    completion.cpp         CompleteCommand, CompletePath, CompleteInline
    commands.cpp           RegisterCommands (orchestrator + lifecycle / shell / topology commands)
    commands_export.cpp    RegisterExportCommands (export → CSV / ODS, file-dialog hook)
    screen_main.cpp        BuildMainScreen (visualisation area + bottom input)
    screen_connect.cpp     BuildConnectScreen + shared RefreshFilteredPartList helper
    screen_settype.cpp     BuildSettypeScreen
    screen_explore.cpp     BuildExploreScreen (browse modules → parts/signals/connections → details; not scriptable)
    screen_dashboard.cpp   BuildDashboardScreen (read-only system overview)
    screen_analyze.cpp     BuildAnalyzeScreen (anomalies / groups / power decisions)
    screen_palette.cpp     BuildPaletteModal (global Ctrl-P fuzzy launcher)
    screen_filedialog.cpp  BuildFileDialog + OpenFileDialog (reusable file picker)
    screen_error.cpp       BuildErrorModal + ShowError (centred error popup)
    screen_help.cpp        BuildHelpScreen (topic-driven feature reference)
    screen_sigtype_modal.cpp BuildSignalTypeModal (popup attached to explore via Modal())
doc/classes.puml         -- PlantUML class diagram

include/ and lib/ are kept empty by design — FTXUI used to live there as precompiled .a + headers, now it comes through FetchContent.

Domain conventions

  • Everything in system/ is pointer-based (commit d8122d1: "everything is pointer"). Containers store T*, ownership lives with the container.
  • SystemElementContainer<T>::merge(name) is the get-or-create primitive — call it instead of add when you don't know whether the element already exists. add throws on duplicate names or empty names.
  • using namespace std; is present in syselmts.hpp — pre-existing, don't add more using namespace in headers.
  • Include guards _NAME_HPP_ and #pragma once are both used. Match the existing style.

TUI

Tui::Run() enters an FTXUI fullscreen loop:

  • Top: scrollable visualisation area (window + vscroll_indicator | yframe, last line gets focus so it auto-scrolls).
  • Bottom: single-line Input inside a border. Label switches between > (normal) and <question>? (during a multi-step prompt).
  • CatchEvent is wrapped outside the Renderer (not between Renderer and Input). Pattern: Renderer(input_component, lambda) then CatchEvent(renderer, handler). Wrapping CatchEvent(input, …) inside a Container::Vertical({…}) and then Renderer(container, …) was found to break the Input's content rendering on at least one terminal — typed characters didn't show even though on_enter fired correctly.
  • InputOption::transform is overridden to avoid hard-coded colors (default sets White on Black when focused, which is unreadable on light terminal themes). The custom transform only applies dim to the placeholder; everything else inherits terminal default fg/bg, so the UI adapts to any theme.
  • InputOption::multiline must be set to false for the command prompt. Default is true, and FTXUI's HandleReturn() then both inserts \n into the content and fires on_enter — so Submit() would receive "help\n" instead of "help" and command lookups would all fall through to "unknown command".
  • Commands live in a std::map<std::string, CommandSpec> registry built in RegisterCommands(). Each CommandSpec carries params (list of Param{name, completion} where completion is the Tui::Completion enum: None | Path | Command), an action(args) lambda, a prompt_for_missing bool (default true), a one-line description string, a scriptable bool (default true), and an interactive bool (default false) that flags screen-opening commands. Setting scriptable = false (e.g. explore) excludes the command from the script-save buffer; if a non-scriptable command is invoked from a sourced file the Source loop still aborts via the screen_idx != 0 check, since these commands are typically screen-openers. interactive = true is consumed only by help: the listing splits commands into two sections (Interactive (open a full-screen mode) and Other), and help <name> adds an [interactive] tag plus a note about the bare-vs-inline duality. Dispatch(raw) tokenises the input (whitespace split with "…" quoting), takes inline args first, and (only when prompt_for_missing) pushes a Prompt onto the queue for each missing param. The last prompt's callback calls Finalize(). Set prompt_for_missing = false for commands that accept either zero args or a full arg list (e.g. search: bare → interactive, full → inline) — the action gets called immediately with whatever it received and decides what to do.
  • Tab completes by Param::completion: Path triggers CompletePath() (filesystem listing with ~/ expansion), Command triggers CompleteCommand() (matches against the registry), None does nothing. Both work inline (CompleteInline() figures out the arg position) and inside a multi-step prompt.
  • help (no args) iterates the registry and prints name — description for every command. help <command> describes a single command, including each Param's name. The argument has Completion::Command, so help <Tab> lists registered commands.
  • Finalize() rebuilds the canonical inline form (name arg1 "arg with spaces" arg3) and writes that to history — so a load command answered via interactive prompts still shows up in history (and on disk) as a single inline line, ready to be replayed via ↑.
  • Multi-step prompts work via a std::deque<Prompt> queue. Submit() pops them one by one before falling back to dispatch. Adding a new command = one entry in RegisterCommands(); the prompt-flow and inline-flow are both handled automatically.
  • Tab completion: at the top-level prompt (no pending), completes built-in command names. Inside a prompt with path_completion = true (e.g. the filename step of load), completes file paths via std::filesystem::directory_iterator (handles ~/, dirs get a trailing /). Logic: 1 match → replace; multiple with progress on the longest common prefix → extend; multiple stuck at LCP → list candidates in the visualisation area.

Built-in commands: new, set, load, duplicate, save, restore, source, script-save, connect (alias plug), set-connector-type, attach-bsdl, set-signal-type, explore, verify, analyze, dashboard, clear, help, quit/exit. Esc cancels an in-progress multi-step prompt.

set <name> <value> declares a session-scoped variable. Subsequent commands expand $name and ${name} in their args (substitution happens in Finalize between canonical-form recording and spec.action(args) — so history and script-save keep the unexpanded form, while the action sees resolved values). Unknown variables are left literal. vars is reset by new. Validation: [A-Za-z_][A-Za-z0-9_]*.

duplicate <source> <newname> deep-copies a module: signals (with type overrides), parts (with connector_type and kind), pins (with their PinSpec spec), and rewires each pin to the equivalent same-named signal in the new module. Connections are NOT copied (they're cross-module topology).

script-save <file> writes a replay-ready script of every command issued since the last new (the recorded buffer is cleared inside the new action). The buffer is appended to inside Finalize() after the action runs, with a denylist of commands that aren't useful in a replay: clear, help, quit, exit, source, script-save. Additionally, a bare invocation of an interactive command is skipped (opens_screen = spec.interactive && args.empty()) — those open a full-screen mode rather than mutating state. Mutating actions taken inside a screen record their own canonical line (e.g. the signal-type popup pushes set-signal-type <m> <s> <t>). Note the source exclusion: when a script is sourced, the individual lines inside the script are recorded (because each goes through Finalize), so the saved script reproduces the same end state without the indirection.

source <file> reads a script line by line and feeds each line through Submit(). While the script is running, in_source = true is set on the Tui and:

  • Dispatch / Finalize skip writing to memory + on-disk history.
  • After each Submit, if screen_idx != 0 (a screen was opened by an "interactive" command like bare connect / explore / set-connector-type), the script is aborted with an error message and screen_idx is reset to 0 — interactive screen-opening commands are explicitly disallowed in scripts.

Pending prompts (from incomplete inline commands) are NOT considered interactive and are filled by subsequent script lines, the way you'd expect. Lines starting with # and blank lines are skipped; leading/trailing whitespace is trimmed; ~/ is expanded.

save / restore (src/system/persist.{hpp,cpp}): tab-delimited line format, no extra deps. Tags: M (module), P (part with connector_type), B (part's attached BSDL .bsd path — re-parsed and re-applied on restore; the path is persisted, not the derived pin specs), N (pin → signal name; empty = NC; optional 4th field carries nc_origin_tag(): U = ImportedUnconnected, D = DroppedSingleton — omitted when the pin has a signal or when origin is None), S (signal → type override; only emitted for non-default), C (connection header with endpoints + transform_name), W (wire pair within the current connection). The 4th N field is backward-compatible: pre-existing snapshots without it restore with nc_origin = None.

Signals carry a type (SignalType::Power | GndShield | Other). The Signal constructor defaults to Other — auto-inference no longer happens at construction. Types are set in three ways, in priority order:

  1. infer_signal_types(System*) (src/system/analysis.{hpp,cpp}) runs at the end of every load (after drop_singleton_signals). It assigns:
    • GndShield when the name alone is unambiguous (GND, SHIELD, CHASSIS, EARTH, …) — false-positive rate is essentially zero on these.
    • Power requires (a) the name heuristic (infer_signal_type says Power), (b) a hard fan-out floor: signals with fewer than POWER_FANOUT_HARD_FLOOR = 3 pins are always refused, regardless of name or voltage pattern (a real rail physically cannot land on just 1-2 pads), and (c) at least one positive structural signal — fan-out ≥ POWER_FANOUT_CONFIRM_MIN = 4 or a voltage pattern in the name (3V3, 5V, 12V, …; detector: a V adjacent to a digit). This catches VSEL_*, PWR_OK, _VDD_SENSE etc. which look like Power by name but aren't real rails. Both thresholds are exposed in analysis.hpp so the analyze screen can render the same reasoning without duplicating constants.
    • Other otherwise. The "name-said-Power-but-refuted-by-structure" count is reported by load.
  2. set-signal-type <module> <signal> <power|gnd|other> is the user override and wins over any inference.
  3. restore reads S records, which only exist for non-default (Other) types — so save/restore round-trips both inferred and overridden types.

The explore screen shows the type in the signal detail header.

Pin spec (expected attributes): every Pin carries a PinSpec spec (src/system/pin_spec.hpp) — the expected half of verification, set from a model: function (Power/Ground/Signal/Clock/NoConnect/Jtag*), direction (In/Out/Bidir/Passive/Power), pad (physical package terminal, e.g. a BSDL ball), and source (which model wrote it). set-connector-type populates it via pin_role(connector_type, pin_name) → PinSpec. Pin::expected_signal_type() is now a derived accessorto_signal_type(spec.function) (Power→Power, Ground→GndShield, else Other) — not a stored field; the observed half stays Pin::signal() + the net + inference, and verify diffs the two. The VPX 3U connector lookup (vpx_3u_role) is still a stub returning Other, so connector-typed pins resolve to function Unknown until that table is filled. direction/function/pad are populated from BSDL via attach-bsdl (see below) and consumed by the model-driven checks (check_pin_specs: contention / undriven / NC-wired) and the JTAG chain check (check_jtag_chain), both run by verify.

Connector pin layout (preparation): pin_layout(connector_type) returns the canonical full pin-name list for a known connector kind, and FillPartFromLayout(part, kind) materialises NC pins for any layout position absent from the imported netlist. set-connector-type calls it after setting connector_type (no-op today since pin_layout is a stub returning {} for everything — populate alongside vpx_3u_role). End-to-end chain in place: set-connector-type → FillPartFromLayout → pin_role.

BSDL models (attach-bsdl): attach-bsdl <module> <part> <file.bsd> parses a BSDL device through libbsdl (wrapped by BsdlModel, src/system/bsdl_model.{hpp,cpp}), then apply_bsdl(part, model) binds each port to a Pin by port name first, then by physical pad — so a netlist that names IC pins either by signal or by package ball both bind. Each bound pin gets its spec set: direction (BSDL in/out/inout/linkage), function (TAP role → Jtag*, linkage → Power/Ground/NoConnect by name, else Signal), pad (PIN_MAP ball), source = Bsdl. The .bsd path is stored on Part::bsdl_path, persisted via the B tag and re-applied on restore. Real-world check: an xcku15p FPGA in a VPX system binds 1517/1517 ports.

Unified application (apply_model): connector layout and BSDL are two implementations of one PinModel interface (src/system/pin_model.{hpp,cpp}: spec_for(pin_name), layout(), source()). ConnectorModel wraps pin_role/pin_layout; BsdlPinModel wraps a parsed BsdlModel, indexed by both port name and physical pad. A single apply_model(Part*, const PinModel&) materialises the layout pins missing from the netlist, then sets each pin's spec only where the model speaks (spec.source != None) — so one source never clobbers another's. set-connector-type and attach-bsdl both funnel through it (the latter via the thin apply_bsdl adapter); verify stays agnostic of where a spec came from. A future SPICE/Modelica source would be a third PinModel.

verify (five passes): (1) typed pins — local mismatch between each pin's expected_signal_type() (derived from its PinSpec) and the actual signal type; (2) bridged nets — Power↔GndShield inconsistencies; (3) orphan summary N orphan pin(s) at import (X imported NC, Y dropped singleton) (filters out pins bridged via any Connection::pin_map — typically FillIdentityNCs-materialised); (4) model-driven pin checks (check_pin_specs): DriveContention (≥2 push-pull Out on a net), UndrivenNet (a fully-modelled net with input(s) but no driver — nets with any Unknown-direction pin are skipped, so un-modelled drivers don't cause false positives), NcWired (a no-connect pin on a multi-pin net); (5) JTAG chain (check_jtag_chain): collects TAP pins by spec.function, maps each to its net, emits JtagTapIncomplete / JtagBusUnbridged (TMS or TCK not common to all TAP devices) / JtagChainBreak (dangling TDO/TDI, chain fan-out, or not a single head→tail daisy chain). The BFS-reached (module, signal) set for any signal is shown live in explore's detail pane when a signal entry is selected.

analyze (post-processing pass): analyze_system(System*) → AnalysisReport (src/system/analysis.{hpp,cpp}) is a stateless read-only pass that detects structural signal groups and anomalies. Per-module (signals are module-scoped):

  • Diff pairs: signal names ending _P / _N (case-insensitive) grouped by stem. Both halves present → candidate matched pair.
  • Diff buses: ≥ 2 matched diff pairs whose pair-stems share a common outer-stem after stripping a trailing integer (MDI0 / MDI1 / MDI2 → outer MDI + indices). The strict _ rule from plain buses does NOT apply to this trailing-index split: _P/_N was already stripped, so we know remaining digits are an index. Two index variants accepted: contiguous (MDI0) and underscore-separated (PCIE_TX_0). Emitted as SignalGroup{kind=DiffBus, lo, hi} with label OUTER[lo..hi]_P/N. Members include all 2·N constituent signals. A "bus" of size 1 falls back to DiffPair (single index does not a bus make).
  • Buses: two accepted forms — bracketed NAME[N] or strict-underscore NAME_N. The strict _ rule before the digits is what avoids matching names like GETH_01_VDD12 (no _ before 12). A stem with ≥ 2 entries becomes SignalGroup{kind=Bus, lo, hi}.
  • Anomalies detected: DiffPairOrphan, DiffBusGap (missing lane in MDI[0..3]_P/N), and BusGap (missing index inside a plain bus [lo..hi]). The diff-pair orphan reporter is asymmetric on purpose: only _P without _N is reported, because _N is overloaded with active-low semantics (RESET_N, BOOTMODE_N) and reporting both directions floods the output with false positives.
  • Filters to keep noise low: signals starting with $ are skipped (Mentor's internal $Nxxxx net names).

Exposed as the analyze shell command which prints groups (sorted by module + label) followed by anomalies. Designed to be consumed by the upcoming dashboard so the summary is visible at a glance. Tests: tests/test_analysis.cpp.

Component classification: every Part carries a ComponentKind kind (Passive | Semiconductor | IntegratedCircuit | Connector | TestPoint | Switch | Crystal | Mechanical | Other) inferred at construction by infer_component_kind(name) from the leading reference-designator letter(s) (longest-match: LED/TP/SW/FB/MK/MP/MH/HS/RA/RN/RP/RV first, then single-letter R/C/L/F/D/Q/U/J/P/Y/X/S). Recomputed on restore (no persistence tag). Not yet exposed in TUI commands — branchpoints will be set-connector-type guard, explore filter, and explore header.

SignalType lives in its own header src/system/signal_type.hpp (extracted from signals to avoid a pins↔signals include cycle).

Pins are either NC (signal() == nullptr) or connected to exactly one signal. The ODS importer creates a Pin for every row that has a non-empty pin name, even when the signal column is empty or "NC" — the pin stays in the Part as NC. restore replaces Tui::sys entirely (unique_ptr::reset). Names are stored as-is — must not contain TAB or newline (true for the EE netlists we ingest). Format is versioned by the # essim system snapshot v1 header for future compatibility.

NC origin tag: each Pin carries NcOrigin nc_origin (None | ImportedUnconnected | DroppedSingleton, default None). Set in three places: (a) Mentor importer when the signal field starts with unconnectedImportedUnconnected; (b) drop_singleton_signals(Signals*) called at the end of loadDroppedSingleton on each detached pin (signals with exactly one pin are NC by definition — see commits motivating this); (c) duplicate propagates the tag. Pins materialised by FillIdentityNCs keep None — they have no local signal but are bridged via pin_map and shouldn't be counted as orphans. The tag is persisted (see N record), reported as a total in verify, and tested in tests/test_nc_origin.cpp.

Connector types & transforms: every Part carries a connector_type string (default "", set via the set-connector-type command — inline set-connector-type m p kind or bare which opens a TUI screen with module menu, part filter+menu, type input, list of types already in use, and an Apply button). When connect validates a pair, it consults TransformRegistry::lookup(p1->connector_type, p2->connector_type) (defined in src/system/transform.{hpp,cpp}) — both directions of the pair are tried. If neither is registered, an IdentityTransform fallback wires each pin of A to the canonical-equivalent pin of B (when present). The resulting (Pin*, Pin*) list and the transform's name are stored on the Connection (pin_map, transform_name). To register a real transform: define a Transform subclass in transform.cpp and call TransformRegistry::get().add("kindA", "kindB", new MyTransform()) at init — there's no startup hook for this yet, so a small RegisterBuiltinTransforms() helper is the natural place to add when more types appear.

Identity wiring uses canonical names: IdentityTransform::apply builds unordered_map<canonical, Pin*> for side B and looks up each side-A pin by its canonical form. So A1 (one card) auto-pairs with A001 (the other) thanks to canonical_pin_name (pre + zero-padded(3) digit suffix; mixed/non-numeric returns the original). Same canonicalisation in CheckIdentityCompatible. pin_role doesn't need canonicalisation because parse_pin extracts (col, row) via stoi which already strips leading zeros.

Subset wiring + NC backfill: CheckIdentityCompatible(a, b, info=&s) accepts the case where one side's canonical pin set is a subset of the other's — typical when one importer drops NC pins (Altium) and the other doesn't (Mentor). It populates info with a non-fatal "N pin(s) only on ''" message. Bidirectional mismatch (both sides have orphans) is still refused. After acceptance, connect calls FillIdentityNCs(p1, p2) which materialises the orphan canonical positions on the missing side as NC pins (new Pin(other_side_name)) — so Connection::pin_map.size() matches the larger side's count. Idempotent.

screen_idx mapping: 4 = dashboard (home, set in the constructor), 0 = console (textual shell + log view), 1 = connect, 2 = set-connector-type, 3 = explore, 5 = analyze. The dashboard is the boot screen; the console is the secondary screen reachable via the [c] shortcut and used to display textual output from verify/analyze/etc. plus collect arguments for multi-step commands. The label was renamed from "log" to "console" because the screen is also where commands are typed — "log" only described half of what it does. The previous search and net screens (formerly screen_idx = 1 and 5) have been removedexplore is a superset of both. Search by pattern: use explore's child filter. Net tracing: select a signal in explore and the detail pane lists both the local pins and the BFS-reached (module, signal) members across connections. The palette (Ctrl-P) also covers the "jump to this signal / module" use case.

Dashboard letter conflicts: with the screen renames, [c] now opens the console rather than connect. The connect command is surfaced as [p]lug on the dashboard (a UI rename only — the canonical command stays connect for script + save/restore stability, with plug registered as an alias so the palette finds it under either name).

Esc navigation: every non-home screen returns to the dashboard. The dashboard itself swallows Esc (no parent) — use [q]uit or the quit command via the palette / shell to leave. From the shell, Esc cancels a pending multi-step prompt if one is in flight, otherwise returns to the dashboard.

Quit propagation: the quit / exit commands set Tui::quit = true and call screen_ptr->Exit() directly, so quitting works from any screen (including via the palette while sitting on the dashboard). The legacy if (quit) screen.Exit(); in the shell Renderer stays as a belt-and-braces backup. Adding a new screen mode is the same recipe each time: state members, Container::Vertical of focusable components, a Renderer lambda that recomputes derived state per frame (e.g. filtered part lists), an entry in Container::Tab, and Tab/Esc handling in the outer CatchEvent.

Analyze screen (screen_analyze.cpp, dashboard shortcut a, screen_idx = 7): unified verify + analyze view with a tabbed layout — horizontal tab bar at the top (Issues (…) │ Groups (…) │ Types: …), and a single scrollable detail panel below showing the active tab's list. Tab swap is handled at the outer CatchEvent (Tab / cycle forward, Shift-Tab / cycle back). The detail uses Container::Tab({issues_menu, groups_menu, types_menu}, &analyze_focus_idx) so ↑/↓ always navigate the visible list; each tab preserves its own selection idx.

  • Issues pane merges: pin-role mismatches (typed pins whose actual signal type disagrees with the role from connector_type), bridged-net Power↔Gnd inconsistencies (the BFS check formerly in verify pass 2), and the structural anomalies from analyze_system (DiffPairOrphan, BusGap, DiffBusGap). Header counts each category.
  • Groups pane lists every detected SignalGroup sorted by module / label with kind tag and member count.
  • Types pane lists per-signal Power decisions ([Power confirmed] / [Power REFUTED] / [Gnd]) plus a trailing [NC] orphan rollup line. The pane header summarises counts (N pwr-ok, M refuted, K gnd).

Everything is recomputed every frame so manual overrides via the signal-type popup are reflected immediately. Esc returns to the dashboard. The dashboard's previous [v]erify letter shortcut was removed — its content is fully covered by this screen. The textual verify / analyze commands still exist for scripts.

Command group factorisation: RegisterCommands() in commands.cpp owns most built-ins, but self-contained groups live in their own files (one Register<X>Commands() member each). Today only RegisterExportCommands() in commands_export.cpp follows the pattern. Adding a new group is mechanical: declare a new member in tui.hpp's private: section, define it in commands_<group>.cpp, and call it from the orchestrator. Each group can have file-local helpers (e.g. commands_export.cpp has its own anonymous-namespace csv_quote and pin_side).

Generic file-picker dialog (screen_filedialog.cpp): one reusable modal for every "pick a path" interaction. State lives in Tui::file_dialog (a single FileDialogState); attached to the tab tree via Modal(BuildFileDialog(), &file_dialog.open) in Run(). API:

  • OpenFileDialog(title, persist_key, default_filename, filters, on_confirm) — opens the modal, restoring the last-used (dir, filename) for persist_key if previously saved.
  • ConfirmFileDialog() runs on Enter on the OK button: validates against the filter whitelist (rejects unknown extensions with an in-dialog status message), persists (dir, filename) under persist_key, closes the modal, then calls on_confirm(full_path).

The optional filters vector ({label, extension} pairs) renders a horizontal Toggle at the top of the dialog. The first frame after Open seeds the filter index from the filename's current extension; subsequent index changes rewrite the filename extension so the caller's extension-based dispatch picks the right format. Empty filter list ⇒ no Toggle shown, no extension validation.

Today the only caller is export ({"CSV", ".csv"}, {"ODS", ".ods"} filters, key export.connections), but future callers (save, restore, source, …) plug in by changing four arguments.

Generic error modal (screen_error.cpp, Tui::ShowError(msg)): centred borderRounded popup with red title, the message (wrapped via paragraph), and an OK button. Esc / Enter dismiss. Stacked at the top of the Modal chain in Run() so it overlays every screen and every other modal. The error is also Print()-ed to the console log, so it remains inspectable after dismissal. Used by the export action (unknown extension, open-for-write failure, ODS save failure, unknown kind); other actions can adopt by replacing user-visible Print("...failed...") calls with ShowError(...).

Per-key path persistence (SaveLastUsed(key, dir, filename) / LoadLastUsed(key, &dir, &filename) in shell.cpp): each key writes a tiny two-line file (dir\nfilename\n) under UserDataDir() / <key>.last. UserDataDir() is the cross-platform XDG_DATA_HOME / LOCALAPPDATA etc. helper also used by the command history file. Free functions, not Tui members, so any module (the file dialog today; could be the script-save buffer or the save command tomorrow) can use them with the same minimal API.

ODS writer (src/imports/ods_writer.{hpp,cpp}): minimal OpenDocument Spreadsheet writer. Backed by libzip + pugixml (both already pulled in by the ODS importer). Class hierarchy is two structs:

  • OdsSheet — sparse row-major grid of string cells (set(row, col, value)).
  • OdsWriter — owns the sheets, emits a valid .ods archive with mimetype (stored uncompressed, magic header), META-INF/manifest.xml, and content.xml.

content.xml is built with pugixml: one <table:table> per sheet, each row a <table:table-row> of string <table:table-cell> cells. String-only by design (no numbers, dates, formulas, styles, merges) — the format minimum for "rectangular text data" is concise enough to live in ~200 lines without depending on a heavyweight ODS library.

Command palette (screen_palette.cpp): a global modal launcher attached to the whole tab tree via tab | Modal(BuildPaletteModal(), &palette_open) in Run(). Trigger: Event::CtrlP (FTXUI Input does not consume Ctrl-P, so the outer CatchEvent reliably picks it up first). Behaviour: a single Input bound to palette_query plus a result list rebuilt on every frame. Indexes three kinds of entries: commands (from the commands map), modules and per-module signals (qualified as module/signal). Fuzzy match is subsequence-based, case-insensitive: lower score wins, computed as first_match_position * 100 + sum_of_gaps. Kinds are biased by a constant offset (commands +0, modules +1000, signals +2000) so command matches come first when scores tie. Output capped at 20 rows to keep render cheap on big systems. Activation (Enter): commands → Dispatch(name) (which dispatches like the shell, including opening interactive screens), module → prefill explore_* state and jump to screen_idx = 4, signal → prefill net_modules + seed net_sig_filter to the exact signal name and jump to screen_idx = 5. Esc closes the palette. While the palette is open, the outer CatchEvent cedes events to it so Tab/Esc/etc. don't leak into the underlying screen.

Dashboard (screen_dashboard.cpp, dashboard command, screen_idx = 4): read-only system overview. Single Renderer, no Input child. Recomputes everything per frame (cheap on realistic sizes): counters (modules/parts/signals/connections), three health rows (verify pin-role mismatches, bridged-net inconsistencies, NC orphans — green check / yellow warning prefix), an analysis summary line (diff pairs / buses / anomaly count, coloured if non-zero), and a per-module table (parts / signals / connector_type-tagged parts). Letter shortcuts handled in the outer CatchEvent: c=console, p=plug (connect), t=set-connector-type, e=explore, a=analyze, q=quit. Esc is swallowed on the dashboard (home). The dashboard is interactive = true, scriptable = false; running dashboard inside source aborts the script.

Screen titles (shared idiom): every interactive screen renders a top bar in the form " essim " (bold) + "→ " (dim) + "<screen-name>" (bold) + " — <short description>" (dim), followed by a separator(). The main screen has its own variant that adds a live N module(s), M connection(s) counter on the right. Aim is to make the breadcrumb between essim and the current mode visible at all times.

Focus highlighting: each interactive screen reuses FocusLabel(elem, focused) from tui_helpers.hpp (inline helper, focused ? e | inverted : e) on the label of the currently-focused field so the user sees at a glance where the next keystroke lands. Indices match the Container::Vertical order — e.g. connect has 7 (m1, p1-filter, p1-menu, m2, p2-filter, p2-menu, Button), explore has 6 (module, type, child-filter, child, detail-filter, detail). Buttons (Connect, Apply) get the highlight on the button itself, not a separate label.

Context help panel: every screen renders a right-column help panel via RenderHelpPanel(title, entries) (tui_helpers.{hpp,cpp}). Fixed width 30 cols, key column 9 chars, then a flex description. The entries list is per-screen so it acts as a context cheat-sheet: dashboard advertises its letter shortcuts + Ctrl-P + q; the shell advertises history / scroll / Ctrl-P / common commands; the interactive screens advertise Tab / ↑↓ / Enter / Esc semantics. Helps replace the previous one-line dim footers, which were truncated at small widths.

Main-screen title bar: top of BuildMainScreen renders " essim " (bold) + "— system digital twin" (dim) on the left and a live "N module(s), M connection(s)" (or "no system loaded") on the right, then a separator(). Built from sys->modules()->size() / sys->connections()->size() each frame — cheap. screen_main.cpp therefore needs to include system/system.hpp, system/modules.hpp, system/connect.hpp directly (forward-declared in tui.hpp).

Main-screen scrollback: the visualisation area lets you scroll through past output. State is Tui::scroll_offset (0 = follow tail). Keys (default branch of the outer CatchEvent in Run()): PageUp / PageDown step 10 lines, Home jumps to top, End returns to tail. Print() resets scroll_offset = 0 so any new output snaps the view back to the tail (otherwise late errors would be hidden). Render: instead of focusPositionRelative(0, 1) always anchoring to the bottom, the screen places | focus on output[size - 1 - scroll_offset] and shows a [scroll: -N / PgUp PgDn Home End] indicator next to the prompt when offset > 0. vscroll_indicator | yframe for the FTXUI-side scroll bar.

connect is dual-mode (prompt_for_missing = false):

  • Inline: connect m1 p1 m2 p2. Each part argument is resolved as exact name first, then case-insensitive substring; ambiguous → lists candidates and aborts.
  • Bare: opens a dedicated full-screen layout (screen_idx = 2) with two columns (endpoint 1 / endpoint 2). Each column has a module Menu, a part filter Input, and a filtered part Menu. A Button(" Connect ") at the bottom commits. Tab cycles focus across the 7 components, Esc leaves.

The connect screen rebuilds the filtered part lists inside the Renderer lambda each frame (cheap; lets the part menus react live to module changes and filter typing). Selection indices are clamped if a list shrinks below the previous index.

The Connection record stores Module*/Part* for both endpoints (added to Connection for this — minimal struct fields, no behaviour change).

Signal-type popup (shared): Enter on a signal entry in the explore screen opens a modal (Tui::sigtype_dialog_open) that lets the user pick power | gnd | other for the currently-selected signal. Built in screen_sigtype_modal.cpp::BuildSignalTypeModal(), attached to both screens via the Modal(...) decorator in Run(). Inside the modal, Enter applies + closes + records set-signal-type <m> <s> <t> in the script-save buffer (recorded); Esc closes without applying. Two safeguards: (a) re-selecting the type the pin already has is a no-op and records nothing; (b) the recorder collapses consecutive edits of the same (module, signal) — if the previous line in recorded already targets the same pair, it is replaced rather than appended. The outer CatchEvent in Run() cedes Tab/Esc to the modal whenever sigtype_dialog_open is true, so the underlying screen doesn't yank focus back. In explore the popup also fires from the detail pane when browsing parts: each pin → signal row carries its signal name in the parallel explore_detail_sig vector, and Enter on a non-(NC) row opens the popup for that signal.

Net tracing in explore: when the user selects a signal entry in explore (type tab = signals), the detail pane shows two sections: the local pins of that signal (module/part/pin labels), then — if find_net(sys, module, signal) returns ≥ 2 members — a Net members (across connections) section listing every (module, signal, type) reachable through Connection::pin_map. The signal-detail header includes K net members across N module(s) (or INCONSISTENT if net_type_consistent returns false). This is what replaced the former dedicated net screen.

Enter-to-edit matrix in exploreexplore_detail_sig stores a module\tsignal payload per detail row (empty = read-only). The detail-menu on_enter parses the \t and calls OpenSignalTypeDialog(mod, sig), so Enter works correctly for peer-module rows. Today:

Focus parts signals connections
children_menu jump to set-connector-type screen pre-filled on this part (focus on type input) open signal-type popup for the selected signal
detail_menu open signal-type popup for the pin's signal (skipped on (NC)) open signal-type popup for the row's (module, signal) — works for both local pins (= current signal) and cross-module net members

Module / type / filter focuses do not have an Enter binding.

Long-running scripts (source): Source(file) is event-paced. It reads all lines, sets loading = true, then spawns a detached pacing thread that posts Event::Special("\x02tick") every ~30 ms — but only one tick at a time: the main thread acks each one (tick_in_flight = false at the end of ProcessNextSourceLine) before the ticker sleeps and posts the next. Without this, FTXUI batches all queued events into one render pass; a long line (e.g. a heavy Mentor parse) would let the ticker queue many ticks and the modal would freeze. Each tick triggers ProcessNextSourceLine, which processes one effective line (skipping comments/blanks) via Submit(). A centred borderDouble modal (" Computing… " + filename + N / M lines) is overlaid via dbox while loading is true.

Adding a new screen mode = drop a screen_<name>.cpp defining Component Tui::BuildXxxScreen(), add corresponding state members in tui.hpp, register a BuildXxxScreen declaration in the screen-builders block, then in tui.cpp::Run() add a child to Container::Tab and a case in the screen-mode switch of the outer CatchEvent (Tab cycling + Esc-leave). Commands lived in commands.cpp set screen_idx to enter the new mode.

Command history is persisted on disk and loaded on startup. Path resolution is platform-aware:

  • Linux/macOS: $XDG_DATA_HOME/essim/history, falling back to ~/.local/share/essim/history.
  • Windows: %LOCALAPPDATA%\essim\history, falling back to %APPDATA%\… then %USERPROFILE%\AppData\Local\….

Each successful submission appends a single line to the file (so a crash doesn't lose history). Multi-step prompt answers are NOT persisted — only top-level commands.

Categorization rules (normative)

Everything in this section is a precise description of how signals, pins, parts, and analysis groups are classified. Each rule lists its input, its check, its output, and the source file. Code and doc are kept in sync — when a threshold changes, update both.

Signal type (Power / GndShield / Other)

Default: Signal::type = SignalType::Other (constructor does no inference).

Type is set by infer_signal_types(System*) (src/system/analysis.cpp), called at the end of every load and after duplicate. The decision per signal:

  1. Compute named = infer_signal_type(name) (src/system/signals.cpp):
    • GndShield if name matches case-insensitively: GND, GROUND, EARTH, SHIELD, CHASSIS, or starts with GND_ / GROUND_ / EARTH_ / SHIELD_ / CHASSIS_.
    • Power if the name contains any of PWR, POWER, VCC, VDD, VEE, VSS, VBAT, or starts with VS_, VS3_, +, - followed by a digit.
    • Else Other.
  2. If named == GndShield → final = GndShield. (Single criterion: name. False positives near zero.)
  3. If named == Power:
    • Hard floor: fan-out < POWER_FANOUT_HARD_FLOOR (= 3) → final = Other. Always overrides. A net touching only 12 pads physically cannot be a power rail.
    • Else: confirmation requires at least one of: fan-out ≥ POWER_FANOUT_CONFIRM_MIN (= 4), or a voltage pattern in the name (any V adjacent to a digit, case-insensitive — catches 3V3, 5V, 12V, 0V9). If either holds → final = Power. Otherwise → Other.
  4. If named == Other → final = Other.

set-signal-type <module> <signal> <power|gnd|other> always wins. save/restore round-trips the final type via S records (only non-Other are persisted).

NC pin origin

Pin::nc_origin (src/system/pins.hpp). Default NcOrigin::None. Set by:

  • NcOrigin::ImportedUnconnected — Mentor importer (src/imports/import_mentor.cpp): the signal field of an Explicit Pin: row starts case-insensitively with unconnected (e.g. 'unconnected', 'unconnected (by TERM)'). The pin is kept on the part with no signal.
  • NcOrigin::DroppedSingletondrop_singleton_signals(Signals*) (src/system/signals.cpp) called at the end of load: any signal whose pin set has size exactly 1 is unconnected by definition. The pin is detached (sig = nullptr) and tagged; the Signal object is deleted.
  • NcOrigin::None (no tag) — pins materialised by FillIdentityNCs at connect time. These are unconnected locally but bridged to a real signal on the peer module via Connection::pin_map; they are explicitly excluded from the "orphan" count in verify and the analyze screen.

Signal groups

analyze_system(System*) (src/system/analysis.cpp) emits SignalGroups per module (signals are module-scoped). Internal names (starting with $ — Mentor's $Nxxxx convention) are skipped wholesale at every step.

DiffPair (GroupKind::DiffPair):

  • Signal name ends _P or _N (case-insensitive). The character before the suffix must be _.
  • Group by stem (name minus _P/_N). Both halves present → emit DiffPair with two members.

DiffBus (GroupKind::DiffBus):

  • Take every matched DiffPair candidate and split its stem with split_trailing_index: trailing digit run becomes the index, the rest the outer-stem. Two forms accepted (MDI0 → outer MDI + 0; PCIE_TX_0 → outer PCIE_TX_ + 0). The strict _ rule of plain buses does not apply here (the _P/_N was already stripped, so any trailing digit is an index).
  • Group by outer-stem. ≥ 2 pairs → DiffBus with label OUTER[lo..hi]_P/N and 2·N members. Exactly 1 → falls back to DiffPair (a single index does not make a bus).

Bus (GroupKind::Bus):

  • Two accepted forms: bracketed NAME[N] (where N is digits between [ and ]), and underscore NAME_N (where _ is strictly required between the stem and the trailing digit run — this is what rejects GETH_01_VDD12 from being parsed as a bus).
  • Group by stem. ≥ 2 signals → Bus with label STEM[lo..hi].

Analysis anomalies

analyze_system reports the following:

  • DiffPairOrphan — a stem has _P but no matching _N. The reverse (_N orphan) is deliberately not reported because _N is overloaded with active-low semantics (RESET_N, BOOTMODE_N, PRESENT_N); flagging it would flood the output with false positives.
  • BusGap — within a Bus[lo..hi], one or more indices in the interior are missing.
  • DiffBusGap — within a DiffBus[lo..hi], one or more lane indices in the interior are missing.

The analyze screen additionally surfaces two "verify-class" issues, computed the same way as the textual verify command:

  • pin-role mismatch — a pin whose expected_signal_type() (derived from its PinSpec, set by set-connector-type via pin_role(connector_type, pin_name)) disagrees with the actual signal type.
  • net-mix — a bridged net (BFS over Connection::pin_map, ≥ 2 members) where net_type_consistent(net, &dominant) returns false. Specifically, the net contains both Power and GndShield signals.

The verify command (not the analyze screen, yet) also emits the model-driven AnomalyKinds from bsdl_check.{hpp,cpp}: DriveContention / UndrivenNet / NcWired (check_pin_specs) and JtagTapIncomplete / JtagChainBreak / JtagBusUnbridged (check_jtag_chain). They consume the BSDL-populated PinSpec plus compute_all_nets. Surfacing them in the analyze/dashboard Issues pane is a TODO.

Component kind

Part::kind is inferred at construction (src/system/component_kind.cpp) from the leading reference-designator letter(s) of the part name. Longest-match wins:

  • Two-letter prefixes (checked first, case-insensitive): LED → Semiconductor, TP → TestPoint, SW → Switch, FB → Passive, MK / MP / MH → Mechanical, HS → Mechanical, RA / RN / RP / RV → Passive.
  • Single-letter fallback: R / C / L / F → Passive, D / Q → Semiconductor, U → IntegratedCircuit, J / P → Connector, Y / X → Crystal, S → Switch.
  • No match → Other.

Recomputed on restore (no persistence tag). Currently not used by any decision flow — branch points are search filter / set-connector-type guard / explore header.

Connector wiring (transforms)

connect looks up a registered transform for (p1->connector_type, p2->connector_type) via TransformRegistry::lookup, tried in both directions. Fall-through is IdentityTransform:

  • Compares pin sets by canonical name (canonical_pin_name(s), src/system/pin_name.cpp): split into prefix + digit suffix; if the suffix is pure digits, zero-pad to width 3 (A1A001, A001A001, A1BA1B, VCCVCC).
  • CheckIdentityCompatible(a, b) accepts the subset case (one side's canonical set is a subset of the other's — typical because Altium drops NC, Mentor doesn't). Bidirectional mismatch (both sides have orphans) is refused.
  • After acceptance, FillIdentityNCs(p1, p2) materialises the missing canonical positions on the smaller side as new NC pins (new Pin(other_side_name), no signal attached, nc_origin = None). Idempotent.

Pins materialised this way are bridged via Connection::pin_map to a real signal on the peer module — the orphan counter in verify and the analyze screen excludes them.

Gotchas

  • All three importers (IMPORT_MENTOR, IMPORT_ALTIUM, IMPORT_ODS) are wired in System::Load. Wrap calls in try/catch (the TUI does).
  • Altium importer drops NC pins entirely: the source format only enumerates pins inside (signal …) blocks, so positions not connected to any signal on this card never become Pins. Mentor (via Explicit Pin:) and ODS (one row per pin) materialise NC. This is the asymmetry that motivates FillIdentityNCs at connect time and (eventually) FillPartFromLayout at set-connector-type time.
  • Mentor importer + NC: the Mentor .qcv format names every pin's signal explicitly. Sentinel values like 'unconnected' or 'unconnected (by TERM)' mean NC — the parser detects them via is_nc_signal_name (lowercase prefix match) and keeps the pin on the part with no signal, tagged ImportedUnconnected. Additionally, after each load the system runs drop_singleton_signals(mod->signals): any signal whose pin set has size 1 is unconnected by definition (electrically nowhere to go), so it is detached and the lone pin is tagged DroppedSingleton. The count is shown inline in the load output. The semantics covers both Mentor patterns and the few NC_*-prefixed signals that turn out to be singletons in real-world boards — the name NC_* alone is not enough (most of them connect two or more parts and are real bridges, even if cosmetically called NC).
  • ODS importer: each spreadsheet sheet becomes a Part (sheet name = part name). Rows are pin/signal pairs; the first non-empty row of each sheet is dropped as a header (no validation of header content). Empty cells skip the row; "NC" keeps the pin in the part but doesn't connect it to a signal. Pins or parts whose name collides (rare in well-formed sheets) are silently dropped.
  • System::Load throws std::runtime_error("Unknown import type") for any value outside the three enum cases.
  • Modules/Parts/etc. have no const-correct iteration on the * accessor; iterators on SystemElementContainer<T> are available but begin()/end() are non-const-safe in some places.

Documentation

doc/ hosts:

  • api/ — auto-generated Markdown C++ API reference (developer-facing).
  • user/ — hand-written intro + auto-generated command reference (user-facing).
  • Doxyfile.in — CMake-templated Doxygen config (XML-only output to build/doc/xml/).
  • gen_api_md.py — custom XML → Markdown emitter (~330 lines, stdlib-only Python).
  • README.md, classes.puml — meta + diagram.

API reference pipeline: doxygen extracts the XML from src/**, then gen_api_md.py walks index.xml plus each class*.xml / *_8hpp.xml and emits one Markdown file per class/struct/file plus an index. Source-code references use relative paths (../../../../src/...#L42) so gitea's renderer turns them into clickable line-anchored links. Chosen over doxybook2 (not in Arch/AUR) and moxygen (Node dep) to keep the toolchain pure-C++/Python and trivially modifiable.

Command reference pipeline: essim --commands-md <out> instantiates a Tui (which calls RegisterCommands()), then Tui::DumpCommandsMd(ostream&) iterates the commands map and writes Markdown — two groups (interactive / other), each command with description, numbered arguments, completion mode per arg, scriptability/interactivity notes. The binary is the single source of truth, so a new CommandSpec field is reflected in the doc as soon as DumpCommandsMd is taught to render it (no separate parser to drift). The doc target DEPENDS essim so a stale binary is rebuilt automatically.

doc/user/index.md and doc/user/scripting.md are hand-written (tutorial + scripting semantics); only doc/user/commands.md is regenerated. Regenerate everything with cmake --build build --target doc — the target is auto-created when both Doxygen and Python 3 are present at configure time; otherwise the regular build target is unaffected and a status line records which tool is missing. The Markdown output is committed (doc/api/, doc/user/commands.md) so gitea readers see fresh docs without building.

Memory layout for sessions

Persistent notes live in ~/.claude/projects/-home-francois-Projets-essim/memory/. Index: MEMORY.md. Add project/feedback/user/reference entries there when relevant — see top-level Claude Code memory rules.

This file is DESIGN.md (renamed from the original CLAUDE.md).