The analyze screen's Issues pane now lists the model-driven checks (check_pin_specs / check_jtag_chain / check_source_conflicts) alongside the pin-role, net-mix and structural ones, with an "N model" count in the header; the dashboard gains a "model:" health row. check_pin_specs/check_jtag_chain take an optional precomputed net list, so verify, analyze and the dashboard each compute the nets once and reuse them across checks instead of redoing the transitive closure per check. Unit tests (75) green; verify output unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
55 KiB
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 inbuild/_deps/. - System dependencies (resolved via
find_package):libzip(targetlibzip::zip) andpugixml(targetpugixml::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 viaadd_subdirectory(path overridable with-DBSDL_DIR=...) and linked dynamically (bsdl::bsdl; an LGPL.sois fine from EUPL essim). Powers the BSDL ingest behindattach-bsdl.- Sources are collected with
file(GLOB_RECURSE ALL_SOURCES "src/*.cpp"). After adding a new.cpp, re-runcmake -S . -B build— CMake does not re-glob automatically and link will fail with "undefined reference". - Headless / batch:
essim --batch --source FILEruns a script and prints its console output to stdout, then exits without the TUI (good for CI / capturingverify). Also--restore FILEand--commands-md [FILE].BootDispatchruns--restore/--sourcesynchronously before the event loop (Sourcetakes its headless drain branch when no screen is attached), so the console buffer is complete by the time--batchdumps 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 (commitd8122d1: "everything is pointer"). Containers storeT*, ownership lives with the container. SystemElementContainer<T>::merge(name)is the get-or-create primitive — call it instead ofaddwhen you don't know whether the element already exists.addthrows on duplicate names or empty names.using namespace std;is present insyselmts.hpp— pre-existing, don't add moreusing namespacein headers.- Include guards
_NAME_HPP_and#pragma onceare both used. Match the existing style.
TUI
Tui::Run() enters an FTXUI fullscreen loop:
- Top: scrollable visualisation area (
window+vscroll_indicator | yframe, last line getsfocusso it auto-scrolls). - Bottom: single-line
Inputinside a border. Label switches between>(normal) and<question>?(during a multi-step prompt). CatchEventis wrapped outside theRenderer(not betweenRendererandInput). Pattern:Renderer(input_component, lambda)thenCatchEvent(renderer, handler). WrappingCatchEvent(input, …)inside aContainer::Vertical({…})and thenRenderer(container, …)was found to break theInput's content rendering on at least one terminal — typed characters didn't show even thoughon_enterfired correctly.InputOption::transformis overridden to avoid hard-coded colors (default setsWhite on Blackwhen focused, which is unreadable on light terminal themes). The custom transform only appliesdimto the placeholder; everything else inherits terminal default fg/bg, so the UI adapts to any theme.InputOption::multilinemust be set tofalsefor the command prompt. Default istrue, and FTXUI'sHandleReturn()then both inserts\ninto the content and fireson_enter— soSubmit()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 inRegisterCommands(). EachCommandSpeccarriesparams(list ofParam{name, completion}wherecompletionis theTui::Completionenum:None | Path | Command), anaction(args)lambda, aprompt_for_missingbool (defaulttrue), a one-linedescriptionstring, ascriptablebool (defaulttrue), and aninteractivebool (defaultfalse) that flags screen-opening commands. Settingscriptable = false(e.g.explore) excludes the command from the script-save buffer; if a non-scriptable command is invoked from a sourced file theSourceloop still aborts via thescreen_idx != 0check, since these commands are typically screen-openers.interactive = trueis consumed only byhelp: the listing splits commands into two sections (Interactive (open a full-screen mode)andOther), andhelp <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 whenprompt_for_missing) pushes aPromptonto the queue for each missing param. The last prompt's callback callsFinalize(). Setprompt_for_missing = falsefor 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. Tabcompletes byParam::completion:PathtriggersCompletePath()(filesystem listing with~/expansion),CommandtriggersCompleteCommand()(matches against the registry),Nonedoes nothing. Both work inline (CompleteInline()figures out the arg position) and inside a multi-step prompt.help(no args) iterates the registry and printsname — descriptionfor every command.help <command>describes a single command, including eachParam's name. The argument hasCompletion::Command, sohelp <Tab>lists registered commands.Finalize()rebuilds the canonical inline form (name arg1 "arg with spaces" arg3) and writes that to history — so aloadcommand answered via interactive prompts still shows up inhistory(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 inRegisterCommands(); 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 withpath_completion = true(e.g. thefilenamestep ofload), completes file paths viastd::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/Finalizeskip writing to memory + on-disk history.- After each
Submit, ifscreen_idx != 0(a screen was opened by an "interactive" command like bareconnect/explore/set-connector-type), the script is aborted with an error message andscreen_idxis 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:
infer_signal_types(System*)(src/system/analysis.{hpp,cpp}) runs at the end of everyload(afterdrop_singleton_signals). It assigns:GndShieldwhen the name alone is unambiguous (GND,SHIELD,CHASSIS,EARTH, …) — false-positive rate is essentially zero on these.Powerrequires (a) the name heuristic (infer_signal_typesays Power), (b) a hard fan-out floor: signals with fewer thanPOWER_FANOUT_HARD_FLOOR = 3pins 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 = 4or a voltage pattern in the name (3V3,5V,12V, …; detector: aVadjacent to a digit). This catchesVSEL_*,PWR_OK,_VDD_SENSEetc. which look like Power by name but aren't real rails. Both thresholds are exposed inanalysis.hppso the analyze screen can render the same reasoning without duplicating constants.Otherotherwise. The "name-said-Power-but-refuted-by-structure" count is reported byload.
set-signal-type <module> <signal> <power|gnd|other>is the user override and wins over any inference.restorereadsSrecords, 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 accessor — to_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). Sources are ranked (spec_source_rank in pin_spec.hpp: UserOverride > Bsdl > ConnectorModel > Inferred > Imported) and apply_model refuses to overwrite a spec owned by a higher-rank source — so one source never clobbers a more authoritative one, which is also the basis for check_source_conflicts. 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 (six 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); (6) source conflicts (check_source_conflicts): a pin the BSDL declares power/ground (a must-connect rail) that the netlist leaves unconnected — a rail floated in the schematic (SourceConflict; the reverse, a BSDL no-connect that is wired, is the NcWired check). 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→ outerMDI+ indices). The strict_rule from plain buses does NOT apply to this trailing-index split:_P/_Nwas already stripped, so we know remaining digits are an index. Two index variants accepted: contiguous (MDI0) and underscore-separated (PCIE_TX_0). Emitted asSignalGroup{kind=DiffBus, lo, hi}with labelOUTER[lo..hi]_P/N. Members include all 2·N constituent signals. A "bus" of size 1 falls back toDiffPair(single index does not a bus make). - Buses: two accepted forms — bracketed
NAME[N]or strict-underscoreNAME_N. The strict_rule before the digits is what avoids matching names likeGETH_01_VDD12(no_before12). A stem with ≥ 2 entries becomesSignalGroup{kind=Bus, lo, hi}. - Anomalies detected:
DiffPairOrphan,DiffBusGap(missing lane inMDI[0..3]_P/N), andBusGap(missing index inside a plain bus[lo..hi]). The diff-pair orphan reporter is asymmetric on purpose: only_Pwithout_Nis reported, because_Nis 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$Nxxxxnet 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 unconnected → ImportedUnconnected; (b) drop_singleton_signals(Signals*) called at the end of load → DroppedSingleton 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 removed — explore 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 inverifypass 2), the structural anomalies fromanalyze_system(DiffPairOrphan,BusGap,DiffBusGap), and the model-driven checks (check_pin_specs/check_jtag_chain/check_source_conflicts, taggedmodelin the header). Header counts each category. - Groups pane lists every detected
SignalGroupsorted bymodule / labelwith 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)forpersist_keyif 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)underpersist_key, closes the modal, then callson_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.odsarchive withmimetype(stored uncompressed, magic header),META-INF/manifest.xml, andcontent.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), four health rows (verify pin-role mismatches, bridged-net inconsistencies, NC orphans, and BSDL/JTAG model anomalies — 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 moduleMenu, a part filterInput, and a filtered partMenu. AButton(" Connect ")at the bottom commits.Tabcycles focus across the 7 components,Escleaves.
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 explore — explore_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:
- Compute
named = infer_signal_type(name)(src/system/signals.cpp):GndShieldif name matches case-insensitively:GND,GROUND,EARTH,SHIELD,CHASSIS, or starts withGND_/GROUND_/EARTH_/SHIELD_/CHASSIS_.Powerif the name contains any ofPWR,POWER,VCC,VDD,VEE,VSS,VBAT, or starts withVS_,VS3_,+,-followed by a digit.- Else
Other.
- If
named == GndShield→ final =GndShield. (Single criterion: name. False positives near zero.) - If
named == Power:- Hard floor: fan-out
< POWER_FANOUT_HARD_FLOOR(= 3) → final =Other. Always overrides. A net touching only 1–2 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 (anyVadjacent to a digit, case-insensitive — catches3V3,5V,12V,0V9). If either holds → final =Power. Otherwise →Other.
- Hard floor: fan-out
- 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 anExplicit Pin:row starts case-insensitively withunconnected(e.g.'unconnected','unconnected (by TERM)'). The pin is kept on the part with no signal.NcOrigin::DroppedSingleton—drop_singleton_signals(Signals*)(src/system/signals.cpp) called at the end ofload: any signal whose pin set has size exactly 1 is unconnected by definition. The pin is detached (sig = nullptr) and tagged; theSignalobject is deleted.NcOrigin::None(no tag) — pins materialised byFillIdentityNCsatconnecttime. These are unconnected locally but bridged to a real signal on the peer module viaConnection::pin_map; they are explicitly excluded from the "orphan" count inverifyand 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
_Por_N(case-insensitive). The character before the suffix must be_. - Group by stem (name minus
_P/_N). Both halves present → emitDiffPairwith two members.
DiffBus (GroupKind::DiffBus):
- Take every matched
DiffPaircandidate and split its stem withsplit_trailing_index: trailing digit run becomes the index, the rest the outer-stem. Two forms accepted (MDI0→ outerMDI+ 0;PCIE_TX_0→ outerPCIE_TX_+ 0). The strict_rule of plain buses does not apply here (the_P/_Nwas already stripped, so any trailing digit is an index). - Group by outer-stem. ≥ 2 pairs →
DiffBuswith labelOUTER[lo..hi]_P/Nand 2·N members. Exactly 1 → falls back toDiffPair(a single index does not make a bus).
Bus (GroupKind::Bus):
- Two accepted forms: bracketed
NAME[N](whereNis digits between[and]), and underscoreNAME_N(where_is strictly required between the stem and the trailing digit run — this is what rejectsGETH_01_VDD12from being parsed as a bus). - Group by stem. ≥ 2 signals →
Buswith labelSTEM[lo..hi].
Analysis anomalies
analyze_system reports the following:
DiffPairOrphan— a stem has_Pbut no matching_N. The reverse (_Norphan) is deliberately not reported because_Nis overloaded with active-low semantics (RESET_N,BOOTMODE_N,PRESENT_N); flagging it would flood the output with false positives.BusGap— within aBus[lo..hi], one or more indices in the interior are missing.DiffBusGap— within aDiffBus[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 itsPinSpec, set byset-connector-typeviapin_role(connector_type, pin_name)) disagrees with the actual signal type. - net-mix — a bridged net (BFS over
Connection::pin_map, ≥ 2 members) wherenet_type_consistent(net, &dominant)returns false. Specifically, the net contains bothPowerandGndShieldsignals.
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); and SourceConflict (check_source_conflicts — a BSDL power/ground pin the netlist leaves unconnected). They consume the BSDL-populated PinSpec plus compute_all_nets, and are surfaced in three places: the verify command, the analyze screen's Issues pane (counted as … N model), and a model: health row on the dashboard. check_pin_specs/check_jtag_chain accept an optional precomputed net list, so verify, analyze and the dashboard each compute the nets once and reuse them across checks.
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 (A1→A001,A001→A001,A1B→A1B,VCC→VCC). 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 inSystem::Load. Wrap calls intry/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 becomePins. Mentor (viaExplicit Pin:) and ODS (one row per pin) materialise NC. This is the asymmetry that motivatesFillIdentityNCsatconnecttime and (eventually)FillPartFromLayoutatset-connector-typetime. - Mentor importer + NC: the Mentor
.qcvformat names every pin's signal explicitly. Sentinel values like'unconnected'or'unconnected (by TERM)'mean NC — the parser detects them viais_nc_signal_name(lowercase prefix match) and keeps the pin on the part with no signal, taggedImportedUnconnected. Additionally, after eachloadthe system runsdrop_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 taggedDroppedSingleton. The count is shown inline in theloadoutput. The semantics covers both Mentor patterns and the fewNC_*-prefixed signals that turn out to be singletons in real-world boards — the nameNC_*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::Loadthrowsstd::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 onSystemElementContainer<T>are available butbegin()/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 tobuild/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).