29 Commits

Author SHA1 Message Date
dceb61237d tui: fix nested source abandoning the calling script
Tui::Source kept the script position in single member fields
(loading_lines/idx/...), so a sourced line that was itself `source inner`
overwrote them: when the inner file finished, the outer script's remaining
lines were silently dropped.

The state is now a stack of SourceFrames — the stack is the call chain. A
nested source just pushes a frame (the running driver, ticker thread or
headless drain, picks it up next line) and the caller's frame resumes when
it pops. Each frame still prints its own "source: <file> (N line(s))"
summary; an interactive-line abort clears the whole chain; depth capped at
32 like the core script engine. The Computing modal shows the top frame.

Regression-tested headless via BootDispatch (tests/tui/test_source.cpp):
nested-then-continue, and self-recursion hitting the depth guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:32:36 +02:00
0b10e1c1b7 Verify: check diff pairs/buses crossing connections (swap, incomplete)
New pass 8 (core/domain/diff_check.{hpp,cpp}): every complete local diff
pair (X_P/X_N, name-based) resolves its legs to two bridged nets; peer
pairs on those nets must match leg for leg.
- DiffPolaritySwap: P legs meet N legs across a connection (sometimes
  intentional - reported for review), or both legs joined onto one net.
- DiffCrossIncomplete: pairs sharing only one leg, and diff-bus lanes
  crossing NOWHERE while sibling lanes cross (distributed/fan-out buses
  stay silent - validated against the real 7-card VPX system: 21 noisy
  findings down to 3 genuine dangling-lane reports, 0 false swaps).

diff_suffix/split_trailing_index/is_internal_name promoted out of
analysis.cpp's anonymous namespace for reuse. VerifyReport.diff_anomalies
wired into model_total() and all four renderers (TUI verify, script
engine, wx log, analyze Issues). 8 new test cases (466 assertions).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:05:43 +02:00
9cf43696a2 Rename the power-adjacent category to "power management"
"Adjacent" read as jargon; "power management" is the standard EE umbrella
for enable/power-good/sense/fault/seq signals (cf. PMIC). Renamed across
the board: NameVerdict::PowerMgmt, stats/LoadResult field `mgmt`, analyze
tag [Power mgmt] + header "Pwr-mgmt" + glossary, load lines now say
"power-management (control/measure — kept as Other)" (TUI / script / wx
kept in sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:21:25 +02:00
e914c84c18 Power-control lexicon: adjust/trim/set, feedback variants, cmd, led, ref
ADJ/ADJUST/VADJ/TRIM, MARG/MARGIN, SET/VSET/ISET (regulator set-point),
FBK/FDB/VFB (feedback variants), CMD, LED (indicator drive), REF/VREF.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:46:29 +02:00
1943f1f88a Power inference: classify rail+control names as power-adjacent, not suspect
Names holding both a rail token (VCC/VDD/PWR/...) and a control token
(SENSE, EN, PG, FB, OK, FAULT, ...) are signals ABOUT a rail - feedback,
enable, power-good - so their non-Power classification is confident.
They used to land in the Suspect bucket, drowning the genuine ambiguities.

- classify_signal_name(): 3-state name verdict (Rail / PowerAdjacent /
  GndShield / Other) with whole-token matching (trailing digits stripped,
  long lexemes also match as suffix: VSENSE, PWRGOOD, NFAULT).
  infer_signal_type() becomes a thin wrapper, so the dashboard suspect
  count and the export suspect column shrink automatically.
- infer_signal_types(): PowerAdjacent -> Other + new `adjacent` stat,
  before the structural gate (a big-fanout sense net stays Other).
- LoadResult.adjacent rendered by all three consumers (TUI command,
  script engine, wx log) - outputs kept in sync.
- analyze Types tab: new [Pwr-adjacent] rows with the deciding token,
  deliberate sort order (Power, Suspect, Adjacent, Gnd), glossary entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:41:19 +02:00
c2b1f4c4ae Extract duplicate into core; support it in the script engine + wx GUI
A script using `duplicate` failed with "unsupported command 'duplicate'"
because the clone logic was still inline in the tui command. Extract it to
core/app/edit.hpp::duplicate_module(System*, src, dst) -> {ok, error, parts,
signals}: a deep clone of a module (parts, pins with spec + nc_origin, signals
with type overrides, pin→signal wiring; no connections), refusing on an unknown
source or an already-taken destination name.

  - the tui `duplicate` command renders the result (output unchanged);
  - the script engine dispatches `duplicate` to it — the failing script now runs;
  - the wx GUI gains Edit ▸ Duplicate module… (PickModule + a name prompt).

tests/test_edit.cpp: deep clone wires to the clone's own signal (not the
source's) and preserves the type; unknown source / existing destination
refused. 412 core assertions green; tui + wx build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:04:45 +02:00
794430e86c wx: stop tree/log content from freezing the layout after a script
Real cause of the log not resizing: on GTK a wxTreeCtrl/wxTextCtrl reports a
natural size that grows with its content. Once a script populated the tree with
many pins, the top area's minimum ballooned and consumed the vertical space,
pinning the log at its minimum so it no longer tracked the window (it worked on
an empty tree, hence "only after running a script").

Cap each control's min size (tree/overview 260x120, log 420x90) so content
can't inflate the sizer minimum; the proportions then govern and content
scrolls inside the controls. Keeps the frame sizer from the previous fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:59:47 +02:00
a9039a8eea wx: fix layout — log adapts and stays at the bottom on resize
The frame had no sizer; it relied on the implicit single-child fill, which
didn't reliably resize the panel, so the panel's vertical sizer never
redistributed and the log kept its size when the window grew. Add a frame
sizer holding the panel at proportion 1 + wxEXPAND, so a resize re-lays-out
the panel and the log (1/3) tracks the window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:55:44 +02:00
b0e260a2ec wx: run scripts at boot via the engine (--source / --batch)
Now that core has run_script, wire WxFrontend::BootDispatch's `source` to it
instead of the "no script interpreter" note. `essim --source bring-up.essim`
(and with --batch) now executes the script in the wx frontend too, accumulating
the same output the tui produces into the console buffer surfaced by DumpOutput.

Verified: `essim --batch --source script.essim` (wx) runs load + set-signal-type
+ verify and prints the source summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:50:22 +02:00
fc71cce647 Add a core script engine; wire File ▸ Run script in the wx GUI.
The wx frontend had no way to run essim command scripts (only the tui shell
did). Add a frontend-agnostic engine in core/app/script.{hpp,cpp}:
run_script(unique_ptr<System>&, path, ostream&) -> {ok, error, lines, errors}.
It parses essim scripts (# comments, blank lines, "quoted" args, set + $var /
${var} expansion, nested source with a depth guard) and dispatches the
scriptable, system-building commands — new, load, connect, set-connector-type,
set-signal-type, attach-bsdl, verify, export, save, restore — to the existing
app::* operations, writing their (TUI-identical) output to the stream.
Unsupported/interactive commands are reported and counted, execution continues.
System is taken by reference so new/restore can replace it.

wx gains File ▸ Run script…: pick a .essim, run it, echo the output into the
log and refresh. WxFrontend exposes system_ptr() for the engine.

tests/test_script.cpp: a full script (comment + set/$var + new + load + set-
signal-type + verify) builds the system and produces the expected log; missing
file reported; unsupported command flagged and skipped. 400 core assertions
green; tui + wx build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:49:05 +02:00
184b0d306f wx: drive the edit ops from the tree selection + a right-click menu
Each tree node now carries a NodeData (kind + Module/Part/Signal pointers), so
edits can act on what's selected instead of always re-asking:
  - Set connector type / Attach BSDL / Connect act on the selected Part (or the
    Pin's owning part); Connect uses it as the first endpoint, then prompts for
    the second. Set signal type acts on the selected Signal.
  - With nothing relevant selected, each falls back to its modal picker, so the
    menu-driven flow still works.
  - Right-click a Part or Signal → a context menu of the actions valid for it;
    the items reuse the menu IDs and select the clicked node first, so they run
    the same handlers.

wx-only; builds clean, window opens with no asserts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:38:23 +02:00
d4eac9557b wx: enrich the explore tree down to pins and signals
The model tree was modules → parts only. Drill it down:
  - each Part now lists its Pins, each labelled with the signal it is wired to
    and that signal's type, or "(NC[, imported|dropped])";
  - each Module gains a "Signals (N)" branch — the per-module net view, each
    signal labelled with its type and fan-out.
Pin/part/signal lists sort in natural order ("2" < "10") via a small helper.
Modules expand to show their parts + Signals node; pins and the signal list
stay collapsed (revealed on demand) so large parts don't flood the view.

wx-only change (no core, no tui); builds clean, window opens with no asserts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:32:21 +02:00
19dbec9672 Extract set-signal-type into core; add it to the wx GUI.
Fourth editing op into the wx frontend. Extract the type-name parse + apply
into core/app/edit.hpp::set_signal_type(Signal*, name) -> {ok, error, type},
failing without mutation on an unrecognised name. The interactive sigtype modal
keeps its own SignalType-cycling path (different interaction, trivial mutation).

The TUI `set-signal-type` command now renders that result (output unchanged).
The wx GUI gains Edit ▸ Set signal type…: a shared PickModule() helper (PickPart
now builds on it) + inline signal choice + a power/gnd/other dropdown, then the
core op, logged as "module/signal: signal type = …" and reflected.

tests/test_edit.cpp: name parsed and applied; unknown name refused without
mutation. 387 core assertions green; tui + wx build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:27:44 +02:00
fc3ef333fa wx: add Connect parts to the Edit menu
Third editing op in the wx GUI. No core change — app::connect_parts was already
extracted and unit-tested; this is pure wiring. Edit ▸ Connect parts… picks two
parts (PickPart twice, now caption-parameterised to label "first/second part"),
derives their parent modules from Part::prnt, calls app::connect_parts and
renders the same outcomes the TUI does: refused / identity NC fill / connected
(N wires) / failed.

wx builds clean, window opens with no asserts; tui + tests unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:21:49 +02:00
b999446151 Extract attach-bsdl into core; add it to the wx GUI.
Second editing op into the wx frontend. Extract the logic (parse the .bsd,
apply it to the part, record bsdl_path) into core/app/edit.hpp::attach_bsdl
(Part*, path) -> {ok, error, entity, bound, unbound, ports_total}, failing
without mutation when the file can't be parsed.

The TUI `attach-bsdl` command now renders that result (output unchanged); the
wx GUI gains an Edit ▸ Attach BSDL… menu reusing PickPart() + a .bsd file
dialog. Prune the now-dead bsdl_model include from commands.cpp.

tests/test_edit.cpp: parse failure leaves the part untouched; null part. The
success path is covered by a batch run (entity + bound/total ports). 381 core
assertions green; tui + wx build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:18:46 +02:00
7e88f82446 Extract set-connector-type into core; add it to the wx GUI.
First of the editing ops to reach the wx frontend. Extract the business logic
(validate the kind, tag the part, apply the connector model) into
core/app/edit.{hpp,cpp}: app::set_connector_type(Part*, kind) -> {ok, error,
materialised}, refusing without mutation when the kind is invalid for the part.

Both TUI call sites now use it: the `set-connector-type` command and the
interactive settype screen (de-dup) — output unchanged. The wx GUI gains an
Edit ▸ Set connector type… menu: a reusable PickPart() (module → part choice
dialogs) + a kind prompt, then the same core op, logged and reflected in the
model tree. Prune the now-dead pin_model/transform_vpx includes from
commands.cpp.

Unit-tested by tests/test_edit.cpp (free-form kind tags; invalid kind refused
without mutation; null part). tui + wx build clean; 376 core assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:13:08 +02:00
76807b0307 docs: README + DESIGN for the wx frontend and essim_add_frontend
Document the wxWidgets GUI frontend (wx dependency + -DESSIM_FRONTEND=wx in
README; layout tree + architecture note in DESIGN) and the factored
essim_add_frontend() per-frontend CMake helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:03:06 +02:00
4803d7d01c Add a wxWidgets GUI frontend (second frontend, proves the split).
A native wx GUI built entirely on essim_core via the Frontend interface — no
Tui reuse, no command shell. Demonstrates the core/frontends architecture by
adding a real second frontend:

  - WxFrontend : public Frontend — owns the System + a console buffer; handles
    boot headlessly (restore for --restore/--batch; honest note for source);
    Run() boots wx without a generated main (SetInstance + wxEntryStart/
    CallOnInit/OnRun) so the shared frontend_main stays in control.
  - EssimFrame (wxFrame) — menu-driven window: Load (app::load_module), Restore/
    Save (persist), Export (app::export_connections), Verify (app::verify),
    rendered into a model tree (modules → parts), an overview + verify-health
    panel, and a log. Each handler is a thin wrapper over a core/app op.
  - main.cpp: construct WxFrontend, call frontend_main — same 4 lines as tui.
  - CMakeLists.txt: find_package(wxWidgets) + essim_add_frontend(wx LIBS …);
    select with -DESSIM_FRONTEND=wx. ESSIM_FRONTEND gains wx in its STRINGS.

Set LC_CTYPE from the environment at GUI init so wxString decodes the UTF-8 in
narrow literals (em dash, ellipsis); LC_NUMERIC stays "C". .gitignore: build*/.

Needs libwxgtk3.2-dev. Verified: builds clean (wx 3.2.9 GTK3), window opens and
renders with no wx asserts; --commands-md/--restore/--batch behave headlessly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:01:02 +02:00
e561c0f960 build: factor per-frontend CMake into essim_add_frontend()
Every frontend repeated the same target wiring (glob sources minus main.cpp →
essim_<name> lib linking essim_core; essim exe from main.cpp linking the lib +
essim_frontend; RUNTIME_OUTPUT_DIRECTORY=build/). Move it into a reusable
helper cmake/EssimFrontend.cmake::essim_add_frontend(name LIBS ...), included
once at the top level.

The tui CMakeLists now just fetches FTXUI and calls
essim_add_frontend(tui LIBS ftxui::screen ftxui::dom ftxui::component). A new
frontend's CMakeLists is its toolkit setup + one call. No behaviour change;
binary stays ./build/essim, tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:46:10 +02:00
091ef6fe4b Merge separate-core-ui: split business logic from the T/GUI layer.
Reorganises essim around a hard core/frontends boundary so multiple GUI/TUI
engines can be built on the same business logic:

  - src/core/{domain,imports,app} — frontend-agnostic essim_core (no GUI
    toolkit); src/frontends/<name>/ — per-frontend UI. CMake selects one with
    -DESSIM_FRONTEND=<name> (default tui; "none" = core+tests, no FTXUI).
  - Application layer (core/app/): export / verify / connect / load extracted
    as System-in, result-struct-out operations; TUI commands and screens are
    thin wrappers. The analyze screen and dashboard now share app::verify
    instead of recomputing the passes (de-dup).
  - Frontend interface (frontends/frontend.hpp) + shared frontend_main: main.cpp
    is frontend-agnostic; a new frontend is a 4-line main plus a Frontend impl.
  - ImportBase hardened (read-only open, fail-fast on a missing file) and made
    leak-free (valgrind: no leaks, 0 errors).

Tests: 78 core cases / 368 assertions + the TUI suite, all green. Binary stays
./build/essim; --batch/--commands-md/--help behaviour unchanged.
2026-06-03 20:37:35 +02:00
3b6e626c8f docs: DESIGN — Frontend interface + frontend_main, app/ ops, layout
Reflect the new shared frontends layer (frontend.hpp / frontend_main) in the
Architecture section and layout tree, and list the verify/connect/load app ops
alongside export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:35:24 +02:00
af36f7c150 Add a Frontend interface; make the process entry frontend-agnostic.
main.cpp was entirely TUI-specific (constructed Tui, parsed argv, drove
BootDispatch/DumpOutput/Run directly). Introduce a shared frontends layer so a
second frontend can reuse the whole launch flow:

  - src/frontends/frontend.hpp — abstract Frontend interface (BootDispatch,
    DumpCommandsMd, DumpOutput, Run), header-only, no GUI toolkit, no core dep.
  - src/frontends/frontend_main.{hpp,cpp} — frontend_main(argc, argv, Frontend&):
    all the argv parsing (--source/--restore/--batch/--commands-md/--help) and
    the boot → batch/run flow, driving any frontend through the interface.
  - Tui now implements Frontend (the four methods already matched; just marked
    override).
  - The TUI main.cpp shrinks to: construct Tui, call frontend_main. A second
    frontend's main() is identical with its own Frontend type.

Build: a small GUI-toolkit-free static lib essim_frontend (frontend_main.cpp)
is added at the top level when a frontend is selected, and the essim exe links
it. ESSIM_FRONTEND=none still builds core+tests only (no essim_frontend, no
FTXUI). Binary stays ./build/essim.

Behaviour unchanged across --batch/--commands-md/--help/exit codes; only the
usage text is genericised ("the TUI" → "the interface", "console screen" →
"console") now that the launcher is shared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:34:29 +02:00
0517a82a5c Fix importer leak: own Parts in ~ImportBase and delete the importer.
System::Load never deleted the ImportBase it allocated, and ~ImportBase was
defaulted so the Parts container it held leaked too. Give ~ImportBase a body
that deletes prts (the container only — the Part objects have been transferred
to the Module via add(), which owns them; the default ~Parts frees the map
without touching the elements, so no double free), and delete the importer at
the end of System::Load (and on the early unreadable-file error path).

Drop ImportMentor's explicit destructor, which called ImportBase::~ImportBase()
in its body — harmless when the base dtor was empty, but a double delete of
prts now that it frees memory. The implicit destructor calls the base once.

Verified with valgrind on a batch load (mentor + a missing-file error path):
"All heap blocks were freed — no leaks are possible", 0 errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:29:43 +02:00
4ef110ab70 Harden ImportBase: open read-only and fail fast on an unreadable file.
ImportBase opened the input with a default std::fstream (in|out), which had
two consequences: a missing file silently produced an empty module (no error),
and a present-but-read-only file failed to open and also loaded as empty. Open
the stream read-only (std::ios::in) instead, and expose is_open().

System::Load now builds the importer first, checks is_open(), and throws
"cannot open file: <path>" before creating the module — so a failed load
surfaces as `load failed: …` and leaves no empty module behind. A read-only
but present file now loads correctly.

Flip the test that pinned the old silent-empty behaviour to assert the clean
failure (error + no module created).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:24:11 +02:00
b36af3167a Extract load into core (app::load_module); thin the command.
Move the import orchestration — System::Load + drop_singleton_signals +
infer_signal_types + the post-import counts — out of the `load` command into
core/app/load.{hpp,cpp}: app::load_module(System*, name, path, ImportType)
returns a LoadResult (ok/error, parts, signals, dropped, power/gnd/kept_other)
with no Print/dialog/FTXUI. The "mentor|altium|ods" string→enum mapping moves
to app::import_type_from_name (mirrors export_format_from_path). The command
only parses the type and renders the counts; output is byte-identical.

Add tests/test_load.cpp (core, no UI): the name mapping; a minimal Mentor
netlist that imports two parts and drops one singleton signal; and a pin test
of the pre-existing missing-file behaviour (ImportBase doesn't check is_open(),
so a missing file yields an empty module rather than an error — preserved by
the extraction and pinned so any future hardening is a deliberate change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:18:35 +02:00
a040cc1957 Extract connect into core (app::connect_parts); thin the command + screen.
Move the wiring orchestration — transform lookup, identity-compatibility
check, identity NC fill, transform apply, Connection creation/add — out of
the `connect` command and the interactive connect screen into
core/app/connect.{hpp,cpp}: app::connect_parts(System*, m1,p1, m2,p2) returns
a ConnectResult (ok/refused/error, connection_name, transform_name, wires,
identity_info, nc_added) with no Print/dialog/FTXUI. Name/pattern resolution
and ambiguity reporting stay in the command — that is arg-parsing, not the op.

Both frontends now call the one core op, removing the duplicated logic. This
also unifies a divergence: the interactive screen previously called
CheckIdentityCompatible without the info pointer and so never filled identity
NC pins (unlike the command); routing it through app::connect_parts makes the
screen fill NCs and surface the same warning, matching the scriptable path.

Command output is unchanged. Prune the now-dead transform.hpp / domain
connect.hpp includes from the frontends (commands.cpp keeps transform_vpx.hpp
only for ValidatePartForKind).

Add tests/test_connect.cpp (core, no UI): identity-compatible pair wires,
unknown type pairing is refused with nothing created, subset side gets NC
pins filled and the warning reported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:12:11 +02:00
25939998ab De-dup verify passes: drive analyze screen + dashboard from app::verify.
The analyze Issues pane and the dashboard Health rows each recomputed the
same verify passes inline (pin-role mismatches, Power/Gnd net-mix, NC orphan
rollup, model-driven checks) — the third and second copies of what the verify
command also did. Route both screens through app::verify(System*) instead, so
the passes live in exactly one place.

Enrich VerifyReport with a per-pin OrphanPin detail list (module/part/pin +
dropped flag) so the dashboard can still nest its dropped-singleton breakdown
under the NC health line without re-walking modules/parts/pins. Output is
unchanged in both screens (same label formats, same numbers).

Prune the now-dead includes (nets/bsdl_check/connect/parts/pins as applicable,
<unordered_set>) from both screens. Extend tests/test_verify.cpp to cover the
new orphans detail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:04:31 +02:00
e3350b8d95 Extract verify into core (app::verify); thin the TUI command.
Move the 7-pass verify orchestration out of the TUI command lambda and
into core/app/verify.{hpp,cpp}: app::verify(System*) returns a structured
VerifyReport (role mismatches, net inconsistencies, orphan counts, the four
model-driven anomaly vectors) with no Print/dialog/FTXUI. The nets are
computed once and fed to the net-based checks.

The verify command is now a thin renderer over the report, byte-identical
output. Prune the now-dead nets.hpp / bsdl_check.hpp / <unordered_set>
includes from commands.cpp.

Add tests/test_verify.cpp: builds small systems by hand and asserts the
report (empty system, Power/GndShield bridged-net inconsistency, orphan
counts by import origin) — pure core, no UI.

This is the structuring extraction: the same VerifyReport can now back the
analyze screen's Issues pane and the dashboard health rows, removing the
triple duplication of passes 1-3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:51:53 +02:00
cccc5f131d docs: rewrite DESIGN + README for the core/frontends structure
DESIGN.md: new layered Build section (essim_core + ESSIM_FRONTEND, FTXUI per
frontend, split tests), a rewritten Layout tree (src/core/{domain,imports,app},
src/frontends/tui, tests/{,tui}), and a new "Architecture — core vs frontends"
section stating the rule (core never depends on a frontend) with the export
operation as the worked example. README: layered-build note, FTXUI-is-the-tui-
frontend's dependency, core-vs-frontend test split, nested project-layout tree.

Also keep the binary at ./build/essim via RUNTIME_OUTPUT_DIRECTORY now that the
exe is produced from the frontend subdir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:39:21 +02:00
53 changed files with 3631 additions and 699 deletions

2
.gitignore vendored
View File

@@ -1 +1 @@
build/
build*/

View File

@@ -11,6 +11,10 @@ project(essim
include(FetchContent)
# Shared CMake helpers (essim_add_frontend — per-frontend target boilerplate).
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
include(EssimFrontend)
# ----------------------------------------------------------------- core deps
# libbsdl — standalone BSDL parser (LGPL-2.1), dynamically linked (EUPL-1.2,
# which the LGPL permits). Override its path with -DBSDL_DIR=...
@@ -44,12 +48,18 @@ target_link_libraries(essim_core
# src/frontends/gui/ and configure with -DESSIM_FRONTEND=gui.
set(ESSIM_FRONTEND "tui" CACHE STRING
"Frontend to build: a directory name under src/frontends/, or 'none'")
set_property(CACHE ESSIM_FRONTEND PROPERTY STRINGS tui none)
set_property(CACHE ESSIM_FRONTEND PROPERTY STRINGS tui wx none)
if(ESSIM_FRONTEND STREQUAL "none")
message(STATUS "essim: ESSIM_FRONTEND=none — core + tests only (no frontend, no GUI toolkit)")
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/frontends/${ESSIM_FRONTEND}/CMakeLists.txt")
message(STATUS "essim: building frontend '${ESSIM_FRONTEND}'")
# Shared, GUI-toolkit-free frontend support: the abstract Frontend interface
# (header-only) and the frontend-agnostic launcher frontend_main(). Every
# frontend's main() links this and forwards argv to it.
add_library(essim_frontend STATIC
"${CMAKE_CURRENT_SOURCE_DIR}/src/frontends/frontend_main.cpp")
target_include_directories(essim_frontend PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src)
add_subdirectory(src/frontends/${ESSIM_FRONTEND})
else()
message(FATAL_ERROR

176
DESIGN.md
View File

@@ -10,72 +10,120 @@ 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".
- CMake **3.14+** (uses `FetchContent_MakeAvailable`).
- **Layered build** (see *Architecture* below). `essim_core` is the
frontend-agnostic business library; a frontend under `src/frontends/<name>/`
links it and produces the `essim` binary. Choose it with
`-DESSIM_FRONTEND=<name>` (default `tui`). **`-DESSIM_FRONTEND=none` builds the
core + tests only — no GUI toolkit is fetched.**
- **Core system dependencies** (via `find_package`): `libzip` (`libzip::zip`)
and `pugixml` (`pugixml::pugixml`) for the ODS importer. Arch:
`pacman -S libzip pugixml`; Debian/Ubuntu: `libzip-dev libpugixml-dev`.
- **`libbsdl`** (standalone BSDL parser, LGPL-2.1) — sibling repo at
`../libbsdl`, `add_subdirectory` (override `-DBSDL_DIR=...`), linked
dynamically **into the core** (`bsdl::bsdl`).
- **FTXUI** is fetched by the **tui frontend only**
(`src/frontends/tui/CMakeLists.txt`), never by the core.
- Sources are globbed per layer: `src/core/*.cpp``essim_core`,
`src/frontends/<fe>/*.cpp` → that frontend's lib + the `essim` binary.
**After adding a `.cpp`, re-run `cmake -S . -B build`** — CMake doesn't re-glob.
- **Tests** are split: `essim_tests` links `essim_core` (no FTXUI) from
`tests/*.cpp`; per-frontend tests like `essim_tui_tests` link `essim_tui` from
`tests/<frontend>/*.cpp`.
- **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)
core/ -- business logic; NO GUI toolkit (builds libessim_core)
domain/ -- the model + read-only analyses
syselmts.hpp SystemElement + SystemElementContainer<T> (get/merge/iterate)
modules.{hpp,cpp} Module, Modules
parts.{hpp,cpp} Part (carries `kind` + `connector_type`), Parts
parts.{hpp,cpp} Part (kind, connector_type, bsdl_path; PinSpec per pin)
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
pin_spec.hpp PinSpec (function/direction/pad/source), mappings, spec_source_rank
component_kind.{hpp,cpp} ComponentKind + infer_component_kind(name)
pin_name.{hpp,cpp} canonical_pin_name (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)
transform*.{hpp,cpp} Transform / IdentityTransform / TransformRegistry, VPX transform
pin_role.{hpp,cpp} pin_role(kind,name) -> PinSpec, pin_layout, FillPartFromLayout
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>
bsdl_check.{hpp,cpp} check_pin_specs / _jtag_chain / _source_conflicts / _bsdl_completeness
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
analysis.{hpp,cpp} analyze_system -> AnalysisReport (diff pairs, buses, anomalies)
persist.{hpp,cpp} save / restore (tab-delimited; `B` tag = bsdl_path)
system.{hpp,cpp} System: owns Modules + Connections, Load()
imports/ -- netlist / pinout adapters
import_base.hpp / import_{mentor,altium,ods}.{hpp,cpp} / ods_writer.{hpp,cpp}
app/ -- application operations (UI-independent use cases)
export.{hpp,cpp} export_connections(System*, path, format) -> ExportResult
verify.{hpp,cpp} verify(System*) -> VerifyReport (the 7 verify passes)
connect.{hpp,cpp} connect_parts(System*, m1,p1, m2,p2) -> ConnectResult
load.{hpp,cpp} load_module(System*, name, path, ImportType) -> LoadResult
frontends/ -- one directory per GUI/TUI engine; each links essim_core
frontend.hpp -- abstract Frontend interface (BootDispatch/Dump*/Run)
frontend_main.{hpp,cpp} -- frontend_main(argc,argv,Frontend&): argv + boot/batch/run
tui/ -- FTXUI shell (builds libessim_tui + the `essim` binary)
CMakeLists.txt fetches FTXUI; builds essim_tui + essim
main.cpp entry point: construct Tui, call frontend_main
tui.{hpp,cpp} class Tui (state + Run() + screen-mode event dispatch)
tui_helpers.{hpp,cpp} ToLower, NaturalLess, RenderHelpPanel
shell.cpp Print, Submit, Dispatch, Source / ProcessNextSourceLine
completion.cpp CompleteCommand / CompletePath / CompleteInline
commands.cpp RegisterCommands (thin: resolve args -> call core -> render)
commands_export.cpp thin wrapper over app::export_connections
screen_*.cpp dashboard, connect, settype, explore, analyze, help, main
(console), palette, file dialog, error/confirm, sigtype modal
wx/ -- wxWidgets GUI (builds libessim_wx + the `essim` binary)
CMakeLists.txt find_package(wxWidgets) + essim_add_frontend(wx ...)
main.cpp entry point: construct WxFrontend, call frontend_main
wx_frontend.{hpp,cpp} WxFrontend : Frontend (owns System; boots wx in Run())
wx_frame.{hpp,cpp} EssimFrame: menu/tree/overview/log over core + app::*
cmake/EssimFrontend.cmake -- essim_add_frontend(name LIBS ...) per-frontend wiring
tests/ -- core tests (link essim_core)
tui/ -- frontend tests (link essim_tui)
doc/ , test/ -- docs; sample netlists + system.essim bring-up script
```
`include/` and `lib/` are kept empty by design — FTXUI used to live there as precompiled `.a` + headers, now it comes through FetchContent.
## Architecture — core vs frontends
The hard rule: **`src/core/` never depends on a frontend** — no `#include
"frontends/…"`, no GUI toolkit. Frontends depend on the core, never the reverse
(`essim_core` links libzip / pugixml / bsdl only).
- **Domain** (`core/domain/`) — the model and the read-only analyses
(`analyze_system`, the `check_*` passes, `compute_all_nets`).
- **Application** (`core/app/`) — use-case operations a frontend invokes, e.g.
`export_connections(System*, path, format) -> ExportResult`. An operation
builds its artefact and returns data/stats; it **never** prints or opens a
dialog. (Anti-pattern being removed: the export command used to build the file
inside its lambda. The TUI command is now a thin wrapper — resolve args/dialog
→ call the core op → render the result.)
- **Frontends** (`frontends/<name>/`) — thin: map UI events to core calls and
render results. Each implements the **`Frontend`** interface
(`frontends/frontend.hpp`: `BootDispatch`, `DumpCommandsMd`, `DumpOutput`,
`Run`). The process entry is shared and frontend-agnostic:
`frontend_main(argc, argv, Frontend&)` (`frontends/frontend_main.cpp`, built
into the toolkit-free `essim_frontend` lib) parses the CLI flags and drives the
boot → batch/run flow through the interface; a frontend's `main()` is just
*construct the concrete Frontend, call `frontend_main`*. Two frontends ship
today: **tui** (FTXUI, default) and **wx** (a wxWidgets GUI, menu-driven over
`app::*`). Add another by creating `src/frontends/<name>/CMakeLists.txt` — its
toolkit setup (FetchContent / find_package) plus one call to
`essim_add_frontend(<name> LIBS …)` (the shared helper in
`cmake/EssimFrontend.cmake` that builds `essim_<name>` + the `essim` binary) —
and configuring `-DESSIM_FRONTEND=<name>`.
Because the core links no toolkit, the suite links `essim_core` directly and
`-DESSIM_FRONTEND=none` builds + tests the whole core with FTXUI never fetched.
## Domain conventions
- Everything in `system/` is **pointer-based** (commit `d8122d1`: "everything is pointer"). Containers store `T*`, ownership lives with the container.
- Everything in `core/domain/` 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.
@@ -110,11 +158,11 @@ Built-in commands: `new`, `set`, `load`, `duplicate`, `save`, `restore`, `source
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`.
`save` / `restore` (`src/core/domain/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:
1. **`infer_signal_types(System*)`** (`src/core/domain/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`.
@@ -123,17 +171,17 @@ Pending prompts (from incomplete inline commands) are NOT considered interactive
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`.
**Pin spec (expected attributes)**: every Pin carries a `PinSpec spec` (`src/core/domain/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.
**BSDL models (`attach-bsdl`)**: `attach-bsdl <module> <part> <file.bsd>` parses a BSDL device through `libbsdl` (wrapped by `BsdlModel`, `src/core/domain/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`.
**Unified application (`apply_model`)**: connector layout and BSDL are two implementations of one `PinModel` interface (`src/core/domain/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` (seven 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); (7) **BSDL completeness** (`check_bsdl_completeness`): device power/ground ports (from the attached `.bsd`, re-parsed) with no matching pin on the netlist part — a rail the schematic symbol is missing (`BsdlPinMissing`, one aggregated finding per part). 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):
**`analyze` (post-processing pass)**: `analyze_system(System*) → AnalysisReport` (`src/core/domain/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).
@@ -145,13 +193,13 @@ Exposed as the `analyze` shell command which prints groups (sorted by module + l
**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).
`SignalType` lives in its own header `src/core/domain/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.
**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/core/domain/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.
@@ -188,7 +236,7 @@ Today the only caller is `export` (`{"CSV", ".csv"}, {"ODS", ".ods"}` filters, k
**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:
**ODS writer** (`src/core/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`.
@@ -248,9 +296,9 @@ Everything in this section is a precise description of how signals, pins, parts,
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:
Type is set by `infer_signal_types(System*)` (`src/core/domain/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`):
1. Compute `named = infer_signal_type(name)` (`src/core/domain/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`.
@@ -264,15 +312,15 @@ Type is set by `infer_signal_types(System*)` (`src/system/analysis.cpp`), called
### NC pin origin
`Pin::nc_origin` (`src/system/pins.hpp`). Default `NcOrigin::None`. Set by:
`Pin::nc_origin` (`src/core/domain/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::DroppedSingleton`** — `drop_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::ImportedUnconnected`** — Mentor importer (`src/core/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::DroppedSingleton`** — `drop_singleton_signals(Signals*)` (`src/core/domain/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 `SignalGroup`s **per module** (signals are module-scoped). Internal names (starting with `$` — Mentor's `$Nxxxx` convention) are skipped wholesale at every step.
`analyze_system(System*)` (`src/core/domain/analysis.cpp`) emits `SignalGroup`s **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 `_`.
@@ -303,7 +351,7 @@ The `verify` command (not the analyze screen, yet) also emits the **model-driven
### 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**:
`Part::kind` is inferred at construction (`src/core/domain/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`.
@@ -315,7 +363,7 @@ Recomputed on `restore` (no persistence tag). Currently not used by any decision
`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`).
- Compares pin sets by **canonical name** (`canonical_pin_name(s)`, `src/core/domain/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.

View File

@@ -15,6 +15,13 @@ cmake --build build -j
./build/essim
```
The build is **layered**: `essim_core` is the frontend-agnostic business
library (domain + importers + operations); the `essim` binary comes from a
**frontend** under `src/frontends/<name>/` that links it. Select one with
`-DESSIM_FRONTEND=<name>` (default `tui`); `-DESSIM_FRONTEND=none` builds the
core + tests only, with no GUI toolkit fetched. Architecture in
[`DESIGN.md`](DESIGN.md).
Inside the shell, type `help` for the live command list — or read the
auto-generated reference at [`doc/user/commands.md`](doc/user/commands.md).
A worked bring-up script is at [`test/system.essim`](test/system.essim);
@@ -41,10 +48,21 @@ Step-by-step walkthroughs for both the batch and TUI workflows are in
`../libbsdl`, pulled in via `add_subdirectory` and linked dynamically.
Override its location with `-DBSDL_DIR=/path/to/libbsdl`. Powers the
`attach-bsdl` command and the pin/JTAG checks.
- Fetched automatically at configure time via `FetchContent` (nothing to
install): **FTXUI** v6.1.9 and **doctest** v2.4.11.
- Fetched automatically via `FetchContent` (nothing to install): **FTXUI**
v6.1.9 — only when building the **tui** frontend — and **doctest** v2.4.11
for the tests.
- **wxWidgets** (≥ 3.2) — only for the **wx** GUI frontend
(`-DESSIM_FRONTEND=wx`). Install the development package:
- Debian/Ubuntu — `sudo apt install libwxgtk3.2-dev`
- Arch — `sudo pacman -S wxwidgets-gtk3`
- Fedora — `sudo dnf install wxGTK-devel`
- Optional, only for the `doc` target: **doxygen** and **python3**.
libzip, pugixml and libbsdl are the **core** dependencies; each frontend pulls
its own toolkit (FTXUI for tui, wxWidgets for wx), so a `-DESSIM_FRONTEND=none`
build needs neither. Pick a GUI/TUI with `-DESSIM_FRONTEND=tui|wx` (default
`tui`).
## Tests
```sh
@@ -53,6 +71,9 @@ Step-by-step walkthroughs for both the batch and TUI workflows are in
ctest --test-dir build
```
`ctest` runs `essim_tests` (core — links `essim_core`, no GUI toolkit) and
`essim_tui_tests` (the FTXUI frontend's tests, under `tests/tui/`).
Skip building tests entirely:
```sh
@@ -76,11 +97,16 @@ cmake --build build --target doc # needs doxygen + python3
## Project layout
```
src/system/ domain model (Module/Part/Pin/Signal, Connection, Transform, …)
src/imports/ Mentor / Altium / ODS netlist importers
src/tui/ FTXUI shell (commands, screens, completion, history)
tests/ doctest suite
doc/ api/ + user/ Markdown trees, Doxyfile.in, gen_api_md.py
src/
core/ business logic, NO GUI toolkit (→ libessim_core)
domain/ model (Module/Part/Pin/Signal, Connection, Transform…) + analyses
imports/ Mentor / Altium / ODS netlist importers + ODS writer
app/ use-case operations (export → CSV/ODS, …)
frontends/ one dir per GUI/TUI engine, each links essim_core
tui/ FTXUI shell + main.cpp (→ libessim_tui + the `essim` binary)
tests/ core tests (link essim_core)
tui/ frontend tests (link essim_tui)
doc/ api/ + user/ Markdown, Doxyfile.in, gen_api_md.py
test/ sample netlists + system.essim bring-up script
```

30
cmake/EssimFrontend.cmake Normal file
View File

@@ -0,0 +1,30 @@
# essim_add_frontend(<name> [LIBS <toolkit link targets>...])
#
# Builds the boilerplate shared by every frontend under src/frontends/<name>/:
# * a static library essim_<name> from every .cpp in the current directory
# except main.cpp, linking essim_core plus the frontend's own GUI/TUI
# toolkit (LIBS);
# * the `essim` executable from main.cpp, linking essim_<name> and the shared,
# toolkit-free launcher essim_frontend, emitted at the top of the build tree
# (./build/essim) whichever frontend produced it.
#
# A per-frontend CMakeLists only sets up its toolkit (FetchContent /
# find_package, and any directory-scoped include dirs / definitions) and then
# calls this with the toolkit's link targets — no target wiring repeated.
function(essim_add_frontend name)
cmake_parse_arguments(FE "" "" "LIBS" ${ARGN})
set(dir "${CMAKE_CURRENT_SOURCE_DIR}")
# Frontend library = every .cpp here except the entry point.
file(GLOB FE_SOURCES "${dir}/*.cpp")
list(REMOVE_ITEM FE_SOURCES "${dir}/main.cpp")
add_library(essim_${name} STATIC ${FE_SOURCES})
target_link_libraries(essim_${name} PUBLIC essim_core ${FE_LIBS})
add_executable(essim "${dir}/main.cpp")
target_link_libraries(essim PRIVATE essim_${name} essim_frontend)
set_target_properties(essim PROPERTIES
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}")
endfunction()

67
src/core/app/connect.cpp Normal file
View File

@@ -0,0 +1,67 @@
#include "core/app/connect.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/system.hpp"
#include "core/domain/transform.hpp"
#include <exception>
#include <utility>
namespace app {
ConnectResult connect_parts(System *sys, Module *m1, Part *p1,
Module *m2, Part *p2)
{
ConnectResult r;
auto &reg = TransformRegistry::get();
Transform *t = reg.lookup(p1->connector_type, p2->connector_type);
bool both_empty = p1->connector_type.empty() && p2->connector_type.empty();
if (t == reg.identity()) {
if (!both_empty) {
r.refused = true;
r.error = "no transform for types '"
+ (p1->connector_type.empty() ? std::string("(none)")
: p1->connector_type)
+ "' ↔ '"
+ (p2->connector_type.empty() ? std::string("(none)")
: p2->connector_type)
+ "'. Set matching types via 'set-connector-type' first.";
return r;
}
std::string info;
std::string err = CheckIdentityCompatible(p1, p2, &info);
if (!err.empty()) {
r.refused = true;
r.error = err;
return r;
}
if (!info.empty()) {
r.identity_info = info;
r.nc_added = FillIdentityNCs(p1, p2);
}
}
auto pin_map = t->apply(p1, p2);
r.connection_name = m1->name + "/" + p1->name
+ " <-> " + m2->name + "/" + p2->name;
r.transform_name = t->name;
try {
Connection *c = new Connection(r.connection_name, m1, p1, m2, p2);
c->transform_name = t->name;
c->pin_map = std::move(pin_map);
sys->connections()->add(c);
r.wires = (int)c->pin_map.size();
r.ok = true;
} catch (const std::exception &e) {
r.error = e.what();
}
return r;
}
} // namespace app

42
src/core/app/connect.hpp Normal file
View File

@@ -0,0 +1,42 @@
#ifndef _APP_CONNECT_HPP_
#define _APP_CONNECT_HPP_
#include <string>
class System;
class Module;
class Part;
// Application layer: UI-independent operations that any frontend (TUI, GUI, …)
// can call. No console, no dialogs, no FTXUI — just System in, result out.
namespace app {
// Outcome of connecting two parts. The side effects (filling identity NC pins,
// creating the Connection and adding it to the system) all happen in core; the
// caller only renders the fields.
struct ConnectResult {
bool ok = false; ///< a Connection was created and added
bool refused = false; ///< a business rule rejected it (vs. an exception)
std::string error; ///< why refused/failed; empty when ok
std::string connection_name;
std::string transform_name;
int wires = 0; ///< pin_map size of the created connection
// Identity-transform path only: the compatibility info line and how many NC
// pins were materialised so both sides match. Empty / 0 otherwise.
std::string identity_info;
int nc_added = 0;
};
// Wire part `p1` (in module `m1`) to part `p2` (in module `m2`): look up the
// transform for their connector types, refuse on an unknown pairing or an
// identity-incompatible layout, fill identity NC pins when needed, apply the
// transform and create the Connection. Pure core — no resolution of names or
// patterns (the frontend turns user input into the Module*/Part* it passes).
ConnectResult connect_parts(System *sys, Module *m1, Part *p1,
Module *m2, Part *p2);
} // namespace app
#endif // _APP_CONNECT_HPP_

137
src/core/app/edit.cpp Normal file
View File

@@ -0,0 +1,137 @@
#include "core/app/edit.hpp"
#include "core/domain/bsdl_model.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pin_model.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
#include "core/domain/transform_vpx.hpp" // ValidatePartForKind
#include <exception>
namespace app {
SetConnectorTypeResult set_connector_type(Part *part, const std::string &kind)
{
SetConnectorTypeResult r;
if (!part) {
r.error = "no part";
return r;
}
std::string err = ValidatePartForKind(part, kind);
if (!err.empty()) {
r.error = err;
return r;
}
part->connector_type = kind;
ConnectorModel model(kind);
ApplyReport rep = apply_model(part, model);
r.materialised = rep.materialised;
r.ok = true;
return r;
}
AttachBsdlResult attach_bsdl(Part *part, const std::string &path)
{
AttachBsdlResult r;
if (!part) {
r.error = "no part";
return r;
}
BsdlModel model = BsdlModel::from_file(path);
if (!model.valid()) {
r.error = "cannot parse " + path
+ (model.error().empty() ? "" : (": " + model.error()));
return r;
}
BsdlApplyReport rep = apply_bsdl(part, model);
part->bsdl_path = path;
r.entity = model.entity();
r.bound = rep.bound;
r.unbound = rep.unbound;
r.ports_total = (int)model.ports().size();
r.ok = true;
return r;
}
SetSignalTypeResult set_signal_type(Signal *sig, const std::string &type_name)
{
SetSignalTypeResult r;
if (!sig) {
r.error = "no signal";
return r;
}
SignalType t;
if (!signal_type_from_name(type_name, t)) {
r.error = "type must be one of: power, gnd, other (got: " + type_name + ")";
return r;
}
sig->type = t;
r.type = t;
r.ok = true;
return r;
}
DuplicateResult duplicate_module(System *sys, const std::string &src_name,
const std::string &dst_name)
{
DuplicateResult r;
if (!sys) { r.error = "no system"; return r; }
Module *src;
try { src = sys->modules()->get(src_name); }
catch (const std::exception &) {
r.error = "unknown module: " + src_name;
return r;
}
if (sys->modules()->exists(dst_name)) {
r.error = "duplicate refused: module '" + dst_name + "' already exists.";
return r;
}
Module *dst = new Module(dst_name);
// Signals first (preserve type overrides), so pins can re-wire to them.
for (auto &skv : *src->signals) {
Signal *ss = skv.second;
Signal *ds = new Signal(ss->name);
ds->type = ss->type;
dst->signals->add(ds);
}
// Parts, pins (spec + nc_origin), and the pin→signal wiring.
for (auto &pkv : *src) {
Part *sp = pkv.second;
Part *dp = new Part(sp->name);
dp->connector_type = sp->connector_type;
for (auto &nkv : *sp) {
Pin *sn = nkv.second;
Pin *dn = new Pin(sn->name);
dn->spec = sn->spec;
dn->nc_origin = sn->nc_origin;
dp->add(dn);
if (sn->signal()) {
Signal *ds = dst->signals->get(sn->signal()->name);
ds->add(dn);
dn->connect(ds);
}
}
dst->add(dp);
}
sys->modules()->add(dst);
r.parts = (int)dst->size();
r.signals = (int)dst->signals->size();
r.ok = true;
return r;
}
} // namespace app

73
src/core/app/edit.hpp Normal file
View File

@@ -0,0 +1,73 @@
#ifndef _APP_EDIT_HPP_
#define _APP_EDIT_HPP_
#include "core/domain/signal_type.hpp" // SignalType
#include <string>
class Part;
class Signal;
class System;
// Application layer: UI-independent part-editing operations any frontend can
// call. No console, no dialogs, no FTXUI — Part in, result struct out.
namespace app {
// Outcome of tagging a part's connector type. The op validates the kind, sets
// the type and applies the connector model (which may materialise the layout's
// missing NC pins); the caller renders the result.
struct SetConnectorTypeResult {
bool ok = false;
std::string error; ///< set when refused (kind invalid for the part)
int materialised = 0; ///< NC pins created from the connector layout
};
// Tag `part`'s connector type and apply the matching connector model. Refuses
// (ok=false, error set, no mutation) when the kind is invalid for the part.
SetConnectorTypeResult set_connector_type(Part *part, const std::string &kind);
// Outcome of attaching a BSDL model to a part. On success the part's pin specs
// are filled from the model and its bsdl_path is recorded.
struct AttachBsdlResult {
bool ok = false;
std::string error; ///< set when the .bsd cannot be parsed
std::string entity; ///< the BSDL entity name
int bound = 0; ///< ports matched to a pin
int unbound = 0; ///< ports with no matching pin
int ports_total = 0; ///< ports declared in the model
};
// Parse the BSDL file at `path` and apply it to `part` (fills each pin's role
// and direction; records bsdl_path). Fails (ok=false, error set, no mutation)
// when the file cannot be parsed.
AttachBsdlResult attach_bsdl(Part *part, const std::string &path);
// Outcome of overriding a signal's type from a user-supplied name.
struct SetSignalTypeResult {
bool ok = false;
std::string error; ///< set when the name isn't power/gnd/other
SignalType type = SignalType::Other; ///< the resolved type (for rendering)
};
// Set `sig`'s type from `type_name` (power | gnd | other, case-insensitive).
// Fails (ok=false, error set, no mutation) on an unrecognised name.
SetSignalTypeResult set_signal_type(Signal *sig, const std::string &type_name);
// Outcome of cloning a module under a new name.
struct DuplicateResult {
bool ok = false;
std::string error; ///< unknown source, or destination name already taken
int parts = 0;
int signals = 0;
};
// Deep-clone module `src_name` as `dst_name`: parts, pins (spec + nc_origin),
// signals (with type overrides) and the pin→signal wiring — but not the
// system's connections. Fails (ok=false, error set, no change) when the source
// is unknown or the destination name already exists.
DuplicateResult duplicate_module(System *sys, const std::string &src_name,
const std::string &dst_name);
} // namespace app
#endif // _APP_EDIT_HPP_

47
src/core/app/load.cpp Normal file
View File

@@ -0,0 +1,47 @@
#include "core/app/load.hpp"
#include "core/domain/analysis.hpp" // infer_signal_types, SignalTypeInferenceStats
#include "core/domain/modules.hpp"
#include "core/domain/signals.hpp" // drop_singleton_signals
#include "core/domain/system.hpp"
#include <cctype>
#include <exception>
namespace app {
bool import_type_from_name(const std::string &name, ImportType &out)
{
std::string ls;
ls.reserve(name.size());
for (char c : name) ls += (char)std::tolower((unsigned char)c);
if (ls == "mentor") { out = ImportType::IMPORT_MENTOR; return true; }
if (ls == "altium") { out = ImportType::IMPORT_ALTIUM; return true; }
if (ls == "ods") { out = ImportType::IMPORT_ODS; return true; }
return false;
}
LoadResult load_module(System *sys, const std::string &module_name,
const std::string &path, ImportType type)
{
LoadResult r;
if (!sys) { r.error = "no system"; return r; }
try {
sys->Load(module_name, path, type);
Module *mod = sys->modules()->get(module_name);
r.dropped = drop_singleton_signals(mod->signals);
SignalTypeInferenceStats inf = infer_signal_types(sys);
r.parts = (int)mod->size();
r.signals = (int)mod->signals->size();
r.power = inf.power;
r.gnd = inf.gnd;
r.kept_other = inf.kept_other;
r.mgmt = inf.mgmt;
r.ok = true;
} catch (const std::exception &e) {
r.error = e.what();
}
return r;
}
} // namespace app

37
src/core/app/load.hpp Normal file
View File

@@ -0,0 +1,37 @@
#ifndef _APP_LOAD_HPP_
#define _APP_LOAD_HPP_
#include "core/domain/system.hpp" // ImportType
#include <string>
// Application layer: UI-independent operations that any frontend (TUI, GUI, …)
// can call. No console, no dialogs, no FTXUI — just System in, result out.
namespace app {
// Map an import-type name (mentor / altium / ods, case-insensitive) to an
// ImportType. Returns false if the name is none of those.
bool import_type_from_name(const std::string &name, ImportType &out);
// Outcome of loading a module: the post-import counts the caller renders.
struct LoadResult {
bool ok = false;
std::string error; ///< human-readable, set when !ok
int parts = 0;
int signals = 0;
int dropped = 0; ///< singleton/NC signals removed after import
int power = 0; ///< signals inferred Power (name + structure)
int gnd = 0; ///< signals inferred GndShield (name)
int kept_other = 0; ///< name said Power but evidence too weak → kept Other
int mgmt = 0; ///< power-management signal (rail + SENSE/EN/PG/… token) → Other, not suspect
};
// Import a module from a netlist/pinout file into `sys`, drop singleton signals,
// then infer signal types. Returns the counts or an error. Pure core — safe to
// call from any frontend.
LoadResult load_module(System *sys, const std::string &module_name,
const std::string &path, ImportType type);
} // namespace app
#endif // _APP_LOAD_HPP_

332
src/core/app/script.cpp Normal file
View File

@@ -0,0 +1,332 @@
#include "core/app/script.hpp"
#include "core/app/connect.hpp"
#include "core/app/edit.hpp"
#include "core/app/export.hpp"
#include "core/app/load.hpp"
#include "core/app/verify.hpp"
#include "core/domain/analysis.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/persist.hpp"
#include "core/domain/signal_type.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
#include <cctype>
#include <fstream>
#include <map>
#include <ostream>
#include <string>
#include <vector>
namespace app {
namespace {
// Whitespace split with "double quotes" grouping — same rules as the TUI shell.
std::vector<std::string> tokenize(const std::string &s) {
std::vector<std::string> out;
std::string cur;
bool in_q = false;
bool has = false;
for (char c : s) {
if (c == '"') { in_q = !in_q; has = true; continue; }
if (!in_q && std::isspace((unsigned char)c)) {
if (has) { out.push_back(std::move(cur)); cur.clear(); has = false; }
} else {
cur.push_back(c);
has = true;
}
}
if (has) out.push_back(std::move(cur));
return out;
}
// One script execution: holds the variable table, the System reference (so new/
// restore can replace it) and the output stream.
class Runner {
public:
Runner(std::unique_ptr<System> &sys, std::ostream &out) : sys_(sys), out_(out) {}
// Run a file; `opened` reports whether it could be opened. Returns the count
// of effective lines; accumulates command errors into `r.errors`.
int run_file(const std::string &path, int depth, ScriptResult &r, bool &opened) {
opened = false;
if (depth > 32) {
emit("source: nesting too deep, skipping " + path);
return 0;
}
std::ifstream f(path);
if (!f) return 0;
opened = true;
int count = 0;
std::string line;
while (std::getline(f, line)) {
std::size_t s = line.find_first_not_of(" \t");
if (s == std::string::npos) continue;
if (line[s] == '#') continue;
std::string t = line.substr(s);
while (!t.empty() && std::isspace((unsigned char)t.back())) t.pop_back();
if (t.empty()) continue;
++count;
if (!exec(t, depth, r)) ++r.errors;
}
return count;
}
private:
void emit(const std::string &line) { out_ << line << '\n'; }
// $name / ${name} → variable value; unknown names kept literally.
std::string expand(const std::string &s) const {
std::string out;
std::size_t i = 0;
while (i < s.size()) {
if (s[i] != '$') { out.push_back(s[i++]); continue; }
std::size_t j = i + 1;
bool braces = (j < s.size() && s[j] == '{');
if (braces) ++j;
std::size_t start = j;
while (j < s.size() && (std::isalnum((unsigned char)s[j]) || s[j] == '_')) ++j;
std::string name = s.substr(start, j - start);
if (braces) {
if (j >= s.size() || s[j] != '}') { out.push_back('$'); ++i; continue; }
++j;
}
if (name.empty()) { out.push_back('$'); ++i; continue; }
auto it = vars_.find(name);
if (it != vars_.end()) out += it->second;
else out += s.substr(i, j - i);
i = j;
}
return out;
}
Module *resolve_module(const std::string &name) {
try { return sys_->modules()->get(name); }
catch (const std::exception &) { emit("unknown module: " + name); return nullptr; }
}
Part *resolve_part(Module *m, const std::string &name) {
try { return m->get(name); }
catch (const std::exception &) {
emit("part in " + m->name + " not found: " + name);
return nullptr;
}
}
void render_verify() {
VerifyReport r = verify(sys_.get());
for (const auto &m : r.role_mismatches)
emit(" " + m.module + "/" + m.part + "/" + m.pin + ": expected "
+ signal_type_name(m.expected) + ", got " + signal_type_name(m.actual)
+ " (signal: " + m.signal + ")");
emit("verify: " + std::to_string(r.role_mismatches.size())
+ " local mismatch(es) over " + std::to_string(r.typed_pins)
+ " typed pin(s).");
for (const auto &ni : r.net_inconsistencies) {
std::string line = " net mixes Power and GndShield:";
for (const auto &mem : ni.members)
line += " " + mem.module + "/" + mem.signal
+ "(" + signal_type_name(mem.type) + ")";
emit(line);
}
emit("verify: " + std::to_string(r.net_inconsistencies.size())
+ " inconsistent net(s) over " + std::to_string(r.bridged_nets)
+ " bridged net(s) (" + std::to_string(r.total_nets) + " total).");
emit("verify: " + std::to_string(r.orphan_total())
+ " orphan pin(s) at import (" + std::to_string(r.orphan_imported)
+ " imported NC, " + std::to_string(r.orphan_dropped)
+ " dropped singleton).");
auto grp = [&](const std::vector<Anomaly> &v, const char *tail) {
for (const auto &a : v)
emit(" [" + std::string(anomaly_kind_name(a.kind)) + "] " + a.message);
emit("verify: " + std::to_string(v.size()) + tail);
};
grp(r.pin_anomalies, " model-driven pin anomaly(ies).");
grp(r.jtag_anomalies, " JTAG chain anomaly(ies).");
grp(r.conflict_anomalies, " source-conflict(s).");
grp(r.completeness_anomalies, " BSDL completeness issue(s).");
grp(r.diff_anomalies, " diff-pair crossing anomaly(ies).");
}
// Execute one already-trimmed line. Returns false on a hard error.
bool exec(const std::string &raw, int depth, ScriptResult &top) {
std::vector<std::string> tok = tokenize(raw);
if (tok.empty()) return true;
const std::string cmd = tok[0];
std::vector<std::string> a;
for (std::size_t i = 1; i < tok.size(); ++i) a.push_back(expand(tok[i]));
auto need = [&](std::size_t n) {
if (a.size() == n) return true;
emit(cmd + ": expected " + std::to_string(n) + " argument(s)");
return false;
};
if (cmd == "set") {
if (a.size() != 2) { emit("set: usage: set <name> <value>"); return false; }
vars_[a[0]] = a[1];
return true;
}
if (cmd == "new") {
sys_ = std::make_unique<System>();
emit("system created.");
return true;
}
if (cmd == "load") {
if (!need(3)) return false;
ImportType t;
if (!import_type_from_name(a[2], t)) { emit("unknown import type: " + a[2]); return false; }
LoadResult r = load_module(sys_.get(), a[0], a[1], t);
if (!r.ok) { emit("load failed: " + r.error); return false; }
emit("loaded '" + a[0] + "' from " + a[1]);
emit(" parts: " + std::to_string(r.parts));
emit(" signals: " + std::to_string(r.signals)
+ (r.dropped ? " (dropped " + std::to_string(r.dropped)
+ " singleton/NC signal(s))" : ""));
emit(" types: " + std::to_string(r.power) + " power, "
+ std::to_string(r.gnd) + " gnd, " + std::to_string(r.kept_other)
+ " suspect Power (name only — kept as Other), "
+ std::to_string(r.mgmt)
+ " power-management (control/measure — kept as Other)");
return true;
}
if (cmd == "connect" || cmd == "plug") {
if (!need(4)) return false;
Module *m1 = resolve_module(a[0]); if (!m1) return false;
Part *p1 = resolve_part(m1, a[1]); if (!p1) return false;
Module *m2 = resolve_module(a[2]); if (!m2) return false;
Part *p2 = resolve_part(m2, a[3]); if (!p2) return false;
ConnectResult r = connect_parts(sys_.get(), m1, p1, m2, p2);
if (r.refused) { emit("connect refused: " + r.error); return false; }
if (!r.identity_info.empty()) {
emit("connect: " + r.identity_info);
if (r.nc_added > 0)
emit("connect: added " + std::to_string(r.nc_added)
+ " NC pin(s) so both sides match");
}
if (!r.ok) { emit("connect failed: " + r.error); return false; }
emit("connected: " + r.connection_name + " via " + r.transform_name
+ " (" + std::to_string(r.wires) + " wires)");
return true;
}
if (cmd == "set-connector-type") {
if (!need(3)) return false;
Module *m = resolve_module(a[0]); if (!m) return false;
Part *p = resolve_part(m, a[1]); if (!p) return false;
SetConnectorTypeResult r = set_connector_type(p, a[2]);
if (!r.ok) { emit("set-connector-type refused: " + r.error); return false; }
emit(m->name + "/" + p->name + ": connector_type = "
+ (a[2].empty() ? "(none)" : a[2]));
if (r.materialised > 0)
emit("set-connector-type: added " + std::to_string(r.materialised)
+ " NC pin(s) from the connector layout");
return true;
}
if (cmd == "set-signal-type") {
if (!need(3)) return false;
Module *m = resolve_module(a[0]); if (!m) return false;
Signal *sig;
try { sig = m->signals->get(a[1]); }
catch (const std::exception &) {
emit("unknown signal: " + m->name + "/" + a[1]); return false;
}
SetSignalTypeResult r = set_signal_type(sig, a[2]);
if (!r.ok) { emit(r.error); return false; }
emit(m->name + "/" + sig->name + ": signal type = " + signal_type_name(r.type));
return true;
}
if (cmd == "attach-bsdl") {
if (!need(3)) return false;
Module *m = resolve_module(a[0]); if (!m) return false;
Part *p = resolve_part(m, a[1]); if (!p) return false;
AttachBsdlResult r = attach_bsdl(p, a[2]);
if (!r.ok) { emit("attach-bsdl: " + r.error); return false; }
emit(m->name + "/" + p->name + ": attached BSDL '" + r.entity + "' — "
+ std::to_string(r.bound) + "/" + std::to_string(r.ports_total)
+ " ports bound" + (r.unbound ? (", " + std::to_string(r.unbound) + " unbound") : ""));
return true;
}
if (cmd == "duplicate") {
if (!need(2)) return false;
DuplicateResult r = duplicate_module(sys_.get(), a[0], a[1]);
if (!r.ok) { emit(r.error); return false; }
emit("duplicate: '" + a[0] + "' → '" + a[1] + "' ("
+ std::to_string(r.parts) + " part(s), "
+ std::to_string(r.signals) + " signal(s))");
return true;
}
if (cmd == "verify") {
render_verify();
return true;
}
if (cmd == "export") {
if (!need(1)) return false;
ExportFormat fmt;
if (!export_format_from_path(a[0], fmt)) {
emit("export: unknown extension (use .csv or .ods): " + a[0]); return false;
}
ExportResult r = export_connections(sys_.get(), a[0], fmt);
if (!r.ok) { emit("export failed: " + r.error); return false; }
emit("exported " + std::to_string(r.rows) + " row(s) to " + a[0]);
return true;
}
if (cmd == "save") {
if (!need(1)) return false;
std::string err;
if (!save_system(sys_.get(), a[0], err)) { emit("save failed: " + err); return false; }
emit("saved to " + a[0]);
return true;
}
if (cmd == "restore") {
if (!need(1)) return false;
std::string err;
System *fresh = restore_system(a[0], err);
if (!fresh) { emit("restore failed: " + err); return false; }
sys_.reset(fresh);
emit("restored from " + a[0] + " ("
+ std::to_string(sys_->modules()->size()) + " module(s), "
+ std::to_string(sys_->connections()->size()) + " connection(s))");
return true;
}
if (cmd == "source") {
if (!need(1)) return false;
bool opened;
int n = run_file(a[0], depth + 1, top, opened);
if (!opened) { emit("source: cannot open " + a[0]); return false; }
emit("source: " + a[0] + " (" + std::to_string(n) + " line(s))");
return true;
}
emit("script: unsupported command '" + cmd + "'");
return false;
}
std::unique_ptr<System> &sys_;
std::ostream &out_;
std::map<std::string, std::string> vars_;
};
} // namespace
ScriptResult run_script(std::unique_ptr<System> &sys, const std::string &path,
std::ostream &out)
{
ScriptResult r;
Runner runner(sys, out);
bool opened = false;
int n = runner.run_file(path, 0, r, opened);
if (!opened) {
r.error = "cannot open " + path;
return r;
}
r.ok = true;
r.lines = n;
return r;
}
} // namespace app

35
src/core/app/script.hpp Normal file
View File

@@ -0,0 +1,35 @@
#ifndef _APP_SCRIPT_HPP_
#define _APP_SCRIPT_HPP_
#include <iosfwd>
#include <memory>
#include <string>
class System;
// Application layer: a frontend-agnostic runner for essim command scripts.
// Dispatches the scriptable, system-building commands to the app::* operations
// and writes their output to a stream — no console, no dialogs, no FTXUI. Any
// frontend (and batch mode) can drive a script through it.
namespace app {
// Outcome of running a script file.
struct ScriptResult {
bool ok = false; ///< the top-level file opened and ran
std::string error; ///< set when the top-level file can't be opened
int lines = 0; ///< effective (non-comment, non-blank) lines run
int errors = 0; ///< commands that failed / were unsupported
};
// Run the script at `path` against `sys`, writing per-command output to `out`.
// `sys` is taken by reference because `new` / `restore` replace the System.
// Supported: # comments, blank lines, set + $var/${var} expansion, new, load,
// connect, set-connector-type, set-signal-type, attach-bsdl, verify, export,
// save, restore, source (nested). Unsupported commands are reported and counted
// in `errors`, and execution continues.
ScriptResult run_script(std::unique_ptr<System> &sys, const std::string &path,
std::ostream &out);
} // namespace app
#endif // _APP_SCRIPT_HPP_

107
src/core/app/verify.cpp Normal file
View File

@@ -0,0 +1,107 @@
#include "core/app/verify.hpp"
#include "core/domain/bsdl_check.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/diff_check.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/nets.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
#include <unordered_set>
#include <utility>
#include <vector>
namespace app {
VerifyReport verify(System *sys)
{
VerifyReport r;
if (!sys)
return r;
// Pass 1 — typed pins: expected (model) vs actual (net) signal type.
for (auto &mkv : *sys->modules()) {
Module *mod = mkv.second;
for (auto &pkv : *mod) {
Part *prt = pkv.second;
if (prt->connector_type.empty())
continue;
for (auto &nkv : *prt) {
Pin *pin = nkv.second;
++r.typed_pins;
SignalType expected = pin->expected_signal_type();
if (expected == SignalType::Other)
continue;
Signal *s = pin->signal();
SignalType actual = s ? s->type : SignalType::Other;
if (actual == expected)
continue;
RoleMismatch m;
m.module = mod->name;
m.part = prt->name;
m.pin = pin->name;
m.signal = s ? s->name : std::string("(NC)");
m.expected = expected;
m.actual = actual;
r.role_mismatches.push_back(std::move(m));
}
}
}
// Pass 2 — bridged nets: flag Power/GndShield mixing. Compute the nets once
// here and reuse them for the model checks below.
std::vector<Net> nets = compute_all_nets(sys);
r.total_nets = (int)nets.size();
for (const Net &n : nets) {
if (n.members.size() < 2)
continue;
++r.bridged_nets;
SignalType dom;
if (net_type_consistent(n, dom))
continue;
NetInconsistency ni;
for (const auto &mp : n.members)
ni.members.push_back({mp.first->name, mp.second->name, mp.second->type});
r.net_inconsistencies.push_back(std::move(ni));
}
// Pass 3 — orphans: pins with no signal and not bridged via a connection.
std::unordered_set<Pin *> bridged_pins;
for (auto &ckv : *sys->connections())
for (auto &wp : ckv.second->pin_map) {
if (wp.first) bridged_pins.insert(wp.first);
if (wp.second) bridged_pins.insert(wp.second);
}
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second)
for (auto &nkv : *pkv.second) {
Pin *pin = nkv.second;
if (pin->signal() || bridged_pins.count(pin))
continue;
bool dropped;
if (pin->nc_origin == NcOrigin::ImportedUnconnected) {
++r.orphan_imported;
dropped = false;
} else if (pin->nc_origin == NcOrigin::DroppedSingleton) {
++r.orphan_dropped;
dropped = true;
} else {
continue;
}
r.orphans.push_back({mkv.first, pkv.first, nkv.first, dropped});
}
// Passes 4-8 — model-driven checks (reuse the nets from pass 2).
r.pin_anomalies = check_pin_specs(sys, &nets);
r.jtag_anomalies = check_jtag_chain(sys, &nets);
r.conflict_anomalies = check_source_conflicts(sys);
r.completeness_anomalies = check_bsdl_completeness(sys);
r.diff_anomalies = check_diff_crossings(sys, &nets);
return r;
}
} // namespace app

71
src/core/app/verify.hpp Normal file
View File

@@ -0,0 +1,71 @@
#ifndef _APP_VERIFY_HPP_
#define _APP_VERIFY_HPP_
#include "core/domain/analysis.hpp" // Anomaly
#include "core/domain/signal_type.hpp" // SignalType
#include <string>
#include <vector>
class System;
namespace app {
// One typed-pin role mismatch: the connector/BSDL expectation disagrees with
// the actual net type.
struct RoleMismatch {
std::string module, part, pin;
std::string signal; ///< signal name, or "(NC)"
SignalType expected = SignalType::Other;
SignalType actual = SignalType::Other;
};
// One bridged net mixing Power and GndShield, with its members for display.
struct NetInconsistency {
struct Member { std::string module, signal; SignalType type; };
std::vector<Member> members;
};
// One orphan pin: no signal and not bridged via a connection. `dropped` is true
// for a dropped singleton (essim detached it), false for an import-time NC.
struct OrphanPin {
std::string module, part, pin;
bool dropped = false;
};
// The full result of `verify`: structured data only — no strings beyond the
// names, no formatting. Frontends (the verify command, the analyze screen, the
// dashboard) render it however they like.
struct VerifyReport {
int typed_pins = 0; ///< pins with a non-Other expectation considered
std::vector<RoleMismatch> role_mismatches;
int total_nets = 0;
int bridged_nets = 0;
std::vector<NetInconsistency> net_inconsistencies;
int orphan_imported = 0;
int orphan_dropped = 0;
std::vector<OrphanPin> orphans; ///< per-pin detail (both origins)
std::vector<Anomaly> pin_anomalies; ///< check_pin_specs
std::vector<Anomaly> jtag_anomalies; ///< check_jtag_chain
std::vector<Anomaly> conflict_anomalies; ///< check_source_conflicts
std::vector<Anomaly> completeness_anomalies; ///< check_bsdl_completeness
std::vector<Anomaly> diff_anomalies; ///< check_diff_crossings
int orphan_total() const { return orphan_imported + orphan_dropped; }
int model_total() const {
return (int)(pin_anomalies.size() + jtag_anomalies.size()
+ conflict_anomalies.size() + completeness_anomalies.size()
+ diff_anomalies.size());
}
};
// Run every verify pass over the system and return the findings. Pure core —
// computes the nets once and feeds them to the net-based checks.
VerifyReport verify(System *sys);
} // namespace app
#endif // _APP_VERIFY_HPP_

View File

@@ -31,15 +31,15 @@ const char *anomaly_kind_name(AnomalyKind k) {
case AnomalyKind::JtagBusUnbridged: return "jtag-bus-unbridged";
case AnomalyKind::SourceConflict: return "source-conflict";
case AnomalyKind::BsdlPinMissing: return "bsdl-pin-missing";
case AnomalyKind::DiffPolaritySwap: return "diff-polarity-swap";
case AnomalyKind::DiffCrossIncomplete: return "diff-cross-incomplete";
}
return "?";
}
namespace {
// Diff-pair suffix detection. Returns true and fills <stem, polarity> if
// `name` ends with one of {_P, _N, _p, _n} preceded by a non-suffix char.
// 'P' / 'N' result is normalised to uppercase.
// 'P' / 'N' result is normalised to uppercase. Shared with diff_check.cpp.
bool diff_suffix(const std::string &name, std::string &stem, char &pol) {
if (name.size() < 3) return false;
char last = name.back();
@@ -52,6 +52,29 @@ bool diff_suffix(const std::string &name, std::string &stem, char &pol) {
return true;
}
// Tool-internal net names we never want to surface to the user (Mentor's
// `$Nxxxx` convention, ODS placeholders, etc.). Cheap prefix check.
bool is_internal_name(const std::string &n) {
return !n.empty() && n[0] == '$';
}
// Trailing-integer split: "MDI0" → ("MDI", 0); "PCIE_TX_3" → ("PCIE_TX_", 3);
// "USB" → false (no trailing digits). Used for diff-bus aggregation only —
// the strict `_` rule from `numeric_suffix` does NOT apply here because the
// caller has already stripped a `_P` / `_N` polarity suffix, so we know the
// remaining digits are an index rather than part of a longer name.
bool split_trailing_index(const std::string &s, std::string &outer, int &idx) {
if (s.empty()) return false;
size_t i = s.size();
while (i > 0 && std::isdigit((unsigned char)s[i - 1])) --i;
if (i == s.size() || i == 0) return false;
idx = std::atoi(s.c_str() + i);
outer = s.substr(0, i);
return true;
}
namespace {
// Bus suffix detection. Two accepted forms:
// - bracketed: NAME[12] → stem "NAME", idx 12
// - underscore: NAME_12 → stem "NAME_", idx 12 (underscore is REQUIRED
@@ -81,27 +104,6 @@ bool numeric_suffix(const std::string &name, std::string &stem, int &idx,
return true;
}
// Tool-internal net names we never want to surface to the user (Mentor's
// `$Nxxxx` convention, ODS placeholders, etc.). Cheap prefix check.
bool is_internal_name(const std::string &n) {
return !n.empty() && n[0] == '$';
}
// Trailing-integer split: "MDI0" → ("MDI", 0); "PCIE_TX_3" → ("PCIE_TX_", 3);
// "USB" → false (no trailing digits). Used for diff-bus aggregation only —
// the strict `_` rule from `numeric_suffix` does NOT apply here because the
// caller has already stripped a `_P` / `_N` polarity suffix, so we know the
// remaining digits are an index rather than part of a longer name.
bool split_trailing_index(const std::string &s, std::string &outer, int &idx) {
if (s.empty()) return false;
size_t i = s.size();
while (i > 0 && std::isdigit((unsigned char)s[i - 1])) --i;
if (i == s.size() || i == 0) return false;
idx = std::atoi(s.c_str() + i);
outer = s.substr(0, i);
return true;
}
void analyse_module(Module *mod, AnalysisReport &out) {
// ---- Pass 1: diff pairs ----
std::unordered_map<std::string, std::pair<Signal *, Signal *>> dp; // stem -> {P, N}
@@ -276,13 +278,21 @@ SignalTypeInferenceStats infer_signal_types(System *sys) {
Module *mod = mkv.second;
for (auto &skv : *mod->signals) {
Signal *s = skv.second;
SignalType named = infer_signal_type(s->name);
if (named == SignalType::GndShield) {
NameClassification ncl = classify_signal_name(s->name);
if (ncl.verdict == NameVerdict::GndShield) {
s->type = SignalType::GndShield;
++st.gnd;
continue;
}
if (named == SignalType::Power) {
if (ncl.verdict == NameVerdict::PowerMgmt) {
// A rail token next to a control token (SENSE, EN, PG, …):
// a signal about a rail, confidently NOT the rail — never
// suspect, whatever the fan-out.
s->type = SignalType::Other;
++st.mgmt;
continue;
}
if (ncl.verdict == NameVerdict::Rail) {
int fanout = (int)s->size();
// Hard rule: a "power" net that touches fewer than three
// pins cannot physically be a rail (a real rail goes to

View File

@@ -39,6 +39,8 @@ enum class AnomalyKind {
JtagBusUnbridged, ///< TMS or TCK is not common to all TAP devices.
SourceConflict, ///< A model contradicts the netlist (e.g. BSDL power pin left NC).
BsdlPinMissing, ///< A BSDL power/ground port has no pin on the netlist part.
DiffPolaritySwap, ///< A diff pair crosses a connection with P and N swapped.
DiffCrossIncomplete, ///< A diff pair/bus only partially crosses a connection.
};
struct Anomaly {
@@ -62,6 +64,8 @@ struct SignalTypeInferenceStats {
int power = 0; ///< Signals promoted to Power (name + structural).
int gnd = 0; ///< Signals promoted to GndShield (name only).
int kept_other = 0; ///< Name said Power but structural evidence too weak.
int mgmt = 0; ///< Power-management signal (rail + SENSE/EN/PG/…
///< token) → confidently Other, never suspect.
};
// Thresholds used by `infer_signal_types` (re-exposed so the analyze screen
@@ -71,6 +75,15 @@ inline constexpr int POWER_FANOUT_CONFIRM_MIN = 4; ///< ≥ this confirms Powe
bool has_voltage_pattern(const std::string &name);
// Name-parsing helpers shared with the diff-crossing check (diff_check.cpp).
// diff_suffix: true if `name` ends with _P/_N (case-insensitive); fills the
// stem and the polarity normalised to uppercase 'P'/'N'.
// split_trailing_index: "MDI0" → ("MDI", 0); false without trailing digits.
// is_internal_name: tool-internal net names never surfaced ($Nxxxx …).
bool diff_suffix(const std::string &name, std::string &stem, char &pol);
bool split_trailing_index(const std::string &s, std::string &outer, int &idx);
bool is_internal_name(const std::string &n);
// Best-effort signal-type inference. Sets `Signal::type`:
// - GndShield when the name unambiguously matches GND/SHIELD/CHASSIS/EARTH.
// - Power when the name suggests Power AND there is structural evidence

View File

@@ -0,0 +1,194 @@
#include "diff_check.hpp"
#include "modules.hpp"
#include "signals.hpp"
#include "system.hpp"
#include <algorithm>
#include <map>
#include <set>
#include <string>
#include <unordered_map>
#include <utility>
namespace {
// One complete local diff pair with the net ids of its two legs.
struct LocalPair {
Module *mod = nullptr;
std::string stem;
Signal *p = nullptr, *n = nullptr;
int np = -1, nn = -1;
};
std::string pair_label(const LocalPair &lp) {
return lp.mod->name + "/" + lp.stem + "_P/N";
}
} // namespace
std::vector<Anomaly> check_diff_crossings(System *sys,
const std::vector<Net> *nets)
{
std::vector<Anomaly> out;
if (!sys || !nets) return out;
// Signal → net id (compute_all_nets covers every signal, singletons too).
std::unordered_map<Signal *, int> net_of;
for (size_t i = 0; i < nets->size(); ++i)
for (const auto &mp : (*nets)[i].members)
net_of[mp.second] = (int)i;
// Complete local pairs, module by module. Orphan halves (X_P without
// X_N) are analysis's DiffPairOrphan business — skipped here.
std::vector<LocalPair> pairs;
std::unordered_map<Signal *, int> pair_of; // leg signal → index in `pairs`
for (auto &mkv : *sys->modules()) {
Module *mod = mkv.second;
std::map<std::string, LocalPair> by_stem;
for (auto &skv : *mod->signals) {
if (is_internal_name(skv.first)) continue;
std::string stem; char pol;
if (!diff_suffix(skv.first, stem, pol)) continue;
LocalPair &lp = by_stem[stem];
lp.mod = mod; lp.stem = stem;
if (pol == 'P') lp.p = skv.second;
else lp.n = skv.second;
}
for (auto &kv : by_stem) {
LocalPair lp = kv.second;
if (!lp.p || !lp.n) continue;
auto ip = net_of.find(lp.p), in = net_of.find(lp.n);
if (ip == net_of.end() || in == net_of.end()) continue;
lp.np = ip->second;
lp.nn = in->second;
int idx = (int)pairs.size();
pairs.push_back(lp);
pair_of[lp.p] = idx;
pair_of[lp.n] = idx;
}
}
// Pass 1 — pair against pair. Each unordered couple of pairs is judged
// once (dedup set), so A↔B is never also reported as B↔A.
std::set<std::pair<int, int>> seen;
for (int i = 0; i < (int)pairs.size(); ++i) {
const LocalPair &a = pairs[i];
if (a.np == a.nn) {
// Degenerate: both legs land on one net — only the connections
// can do that (two module-local signals are distinct by nature).
Anomaly an;
an.kind = AnomalyKind::DiffPolaritySwap;
an.module = a.mod;
an.message = a.mod->name + ": " + a.stem + "_P and " + a.stem
+ "_N join the same net (through the connections)";
an.involved = {a.p, a.n};
out.push_back(std::move(an));
continue;
}
// Candidate peers: pairs of OTHER modules with a leg on net np or nn.
std::set<int> cands;
for (int net : {a.np, a.nn})
for (const auto &mp : (*nets)[net].members) {
auto it = pair_of.find(mp.second);
if (it == pair_of.end()) continue;
const LocalPair &b = pairs[it->second];
if (b.mod == a.mod) continue; // intra-module: nothing to say
if (b.np == b.nn) continue; // degenerate: own anomaly above
cands.insert(it->second);
}
for (int j : cands) {
std::pair<int, int> key = std::minmax(i, j);
if (!seen.insert(key).second) continue;
const LocalPair &b = pairs[j];
if (a.np == b.np && a.nn == b.nn) continue; // straight: all good
Anomaly an;
an.module = a.mod;
an.involved = {a.p, a.n, b.p, b.n};
if (a.np == b.nn && a.nn == b.np) {
an.kind = AnomalyKind::DiffPolaritySwap;
an.message = pair_label(a) + " <-> " + pair_label(b)
+ ": polarity swapped (P legs meet N legs)";
} else {
an.kind = AnomalyKind::DiffCrossIncomplete;
std::string how;
if (a.np == b.np) how = "only the P legs are bridged";
else if (a.nn == b.nn) how = "only the N legs are bridged";
else if (a.np == b.nn) how = "P leg bridged to N leg; "
"the other legs are not";
else how = "N leg bridged to P leg; "
"the other legs are not";
an.message = pair_label(a) + " <-> " + pair_label(b)
+ ": " + how;
}
out.push_back(std::move(an));
}
}
// Pass 2 — diff buses at a crossing. Lanes grouped by outer stem. A lane
// is "dangling" when it crosses to NO module at all while sibling lanes
// do cross — distributed buses (lanes fanned out to different peers, a
// backplane classic) are legitimate and stay silent. Lanes crossing
// partially are already reported above, so they don't count as dangling.
// One aggregated anomaly per bus, per side (each side names its lanes).
std::map<std::pair<Module *, std::string>, std::map<int, int>> groups;
for (int i = 0; i < (int)pairs.size(); ++i) {
std::string outer; int idx;
if (!split_trailing_index(pairs[i].stem, outer, idx)) continue;
groups[{pairs[i].mod, outer}][idx] = i;
}
for (auto &gkv : groups) {
auto &lanes = gkv.second; // lane index → pair index
if (lanes.size() < 2) continue;
std::set<int> touching_any; // lanes sharing ≥1 net with a peer
std::set<int> complete_any; // lanes fully crossing somewhere
std::set<Module *> reached;
for (auto &lkv : lanes) {
const LocalPair &a = pairs[lkv.second];
if (a.np == a.nn) continue;
for (int net : {a.np, a.nn})
for (const auto &mp : (*nets)[net].members) {
auto it = pair_of.find(mp.second);
if (it == pair_of.end()) continue;
const LocalPair &b = pairs[it->second];
if (b.mod == a.mod) continue;
touching_any.insert(lkv.first);
bool straight = (a.np == b.np && a.nn == b.nn);
bool swapped = (a.np == b.nn && a.nn == b.np);
if (straight || swapped) {
complete_any.insert(lkv.first);
reached.insert(b.mod);
}
}
}
if (complete_any.empty()) continue; // fully local bus: fine
std::vector<int> dangling;
for (auto &lkv : lanes)
if (!touching_any.count(lkv.first))
dangling.push_back(lkv.first);
if (dangling.empty()) continue;
int lo = lanes.begin()->first;
int hi = lanes.rbegin()->first;
Anomaly an;
an.kind = AnomalyKind::DiffCrossIncomplete;
an.module = gkv.first.first;
std::string m = gkv.first.first->name + ": " + gkv.first.second
+ "[" + std::to_string(lo) + ".."
+ std::to_string(hi) + "]_P/N: lane(s)";
for (int ix : dangling) m += " " + std::to_string(ix);
m += " do not cross (others reach";
std::vector<std::string> names;
for (Module *mod : reached) names.push_back(mod->name);
std::sort(names.begin(), names.end());
for (const std::string &nm : names) m += " " + nm;
m += ")";
an.message = std::move(m);
for (auto &lkv : lanes) {
an.involved.push_back(pairs[lkv.second].p);
an.involved.push_back(pairs[lkv.second].n);
}
out.push_back(std::move(an));
}
return out;
}

View File

@@ -0,0 +1,27 @@
#ifndef _DIFF_CHECK_HPP_
#define _DIFF_CHECK_HPP_
#include "analysis.hpp" // Anomaly, diff_suffix, split_trailing_index
#include "nets.hpp" // Net
#include <vector>
class System;
// Differential-pair crossing checks. Every complete local diff pair
// (X_P / X_N, name-based) resolves its two legs to two bridged nets; any
// other module whose own pair sits on those nets must match them leg for
// leg. Findings:
// - DiffPolaritySwap: the peer pair is wired P→N / N→P, or a pair's two
// legs end up joined onto one single net through the connections.
// - DiffCrossIncomplete: the two pairs share only one leg (the other does
// not cross), or some lanes of a diff bus do not reach a module the
// other lanes reach.
// Name-based on BOTH sides: a peer whose signals carry no _P/_N suffix is
// not judged (silent). Polarity swaps are sometimes intentional (routing
// compensation, SerDes with configurable polarity) — these are findings to
// review, not hard errors. `nets` must come from compute_all_nets(sys).
std::vector<Anomaly> check_diff_crossings(System *sys,
const std::vector<Net> *nets);
#endif // _DIFF_CHECK_HPP_

View File

@@ -5,8 +5,25 @@
enum class SignalType { Power, GndShield, Other };
// Name-level verdict, richer than SignalType. `PowerMgmt` is the key
// addition: a name holding BOTH a rail token (VCC/VDD/PWR/…) and a control
// token (SENSE/EN/PG/FB/…) is a power-management signal — measurement,
// enable, power-good — not the rail itself. Its non-Power classification is
// therefore confident, where a bare rail name without structural evidence
// stays suspect.
enum class NameVerdict { Rail, GndShield, PowerMgmt, Other };
struct NameClassification {
NameVerdict verdict = NameVerdict::Other;
std::string token; ///< PowerMgmt only: the control token that decided it.
};
NameClassification classify_signal_name(const std::string &signal_name);
const char *signal_type_name(SignalType t);
bool signal_type_from_name(const std::string &s, SignalType &out);
// Thin wrapper over classify_signal_name: Rail → Power, GndShield → GndShield,
// PowerMgmt/Other → Other.
SignalType infer_signal_type(const std::string &signal_name);
SignalType next_signal_type(SignalType t); // Power → GndShield → Other → Power

View File

@@ -4,6 +4,7 @@
#include <algorithm>
#include <cctype>
#include <cstring>
#include <vector>
const char *signal_type_name(SignalType t) {
@@ -36,11 +37,74 @@ bool signal_type_from_name(const std::string &s, SignalType &out) {
return false;
}
// Heuristic. Names like GND, GNDA, SHIELD, CHASSIS → GndShield.
// Names containing PWR/POWER/VCC/VDD/VEE/VSS, or matching V±N or +N.NV
// patterns, or starting with VS_/VS3_ → Power. Else Other.
SignalType infer_signal_type(const std::string &name) {
if (name.empty()) return SignalType::Other;
namespace {
// Control/monitoring vocabulary: a name holding both a rail token and one of
// these is a signal ABOUT a rail (feedback, enable, power-good, fault, …) —
// not the rail itself. Matched against whole separator-delimited tokens
// (uppercase, trailing digits stripped so EN1/PG0 still hit). Entries of
// length ≥ 4 also match as a token *suffix*, catching fused (VSENSE, PWRGOOD)
// and active-low (NFAULT) forms; short entries match exactly, so GREEN or
// SENSOR never trip on EN / SENSE.
const char *const kPowerControlTokens[] = {
"SENSE", "SNS", "KELVIN", // remote / Kelvin sense
"FB", "FBK", "FDB", "FDBK", "VFB", // regulator feedback
"FEEDBACK",
"EN", "ENA", "ENABLE", "INH", "INHIBIT", // enable / inhibit
"PG", "PGOOD", "PWRGD", "PWROK", // power-good
"GOOD", "OK", "FAIL", "FAULT", "FLT", // status / fault
"ALERT", "ALRT", "WARN",
"MON", "IMON", "VMON", "PMON", // monitoring
"DET", "DETECT", "PRSNT", "PRESENT", // presence detection
"OC", "OCP", "OV", "OVP", "UV", "UVP", // protection trips
"TRIP", "SHDN", "SHUTDOWN",
"ADJ", "ADJUST", "VADJ", "TRIM", // regulator adjust / trim
"MARG", "MARGIN", // voltage margining
"SET", "VSET", "ISET", // set-point pins
"SEQ", "CTRL", "CTL", "CMD", // sequencing / control / command
"STAT", "STATUS",
"ON", "OFF", "BTN", // on/off request
"REF", "VREF", // voltage reference
"LED", // indicator drive
"CS", "IRQ",
};
bool is_power_control_token(std::string tok) {
while (!tok.empty() && std::isdigit((unsigned char)tok.back()))
tok.pop_back(); // EN1, PG0, FB2 …
if (tok.empty()) return false;
for (const char *lex : kPowerControlTokens) {
size_t n = std::strlen(lex);
if (tok == lex) return true;
if (n >= 4 && tok.size() > n
&& tok.compare(tok.size() - n, n, lex) == 0)
return true; // VSENSE, PWRGOOD, NFAULT …
}
return false;
}
// Split on every non-alphanumeric character. `u` is already uppercase.
std::vector<std::string> alnum_tokens(const std::string &u) {
std::vector<std::string> out;
std::string cur;
for (char c : u) {
if (std::isalnum((unsigned char)c)) { cur += c; continue; }
if (!cur.empty()) { out.push_back(std::move(cur)); cur.clear(); }
}
if (!cur.empty()) out.push_back(std::move(cur));
return out;
}
} // namespace
// Heuristic. Names like GND, GNDA, SHIELD, CHASSIS → GndShield (name alone is
// reliable there — left out of the control-token logic on purpose). Names
// containing PWR/POWER/VCC/VDD/VEE/VSS, or starting with VS_/VBAT/+/ → rail
// candidates; a rail candidate whose tokens include a control word (SENSE,
// EN, PG, …) is downgraded to PowerMgmt. Else Other.
NameClassification classify_signal_name(const std::string &name) {
NameClassification out;
if (name.empty()) return out;
std::string u = name;
std::transform(u.begin(), u.end(), u.begin(),
[](unsigned char c) { return std::toupper(c); });
@@ -52,14 +116,14 @@ SignalType infer_signal_type(const std::string &name) {
return u.rfind(needle, 0) == 0;
};
if (u == "GND" || u == "GROUND") return SignalType::GndShield;
if (starts_with("GND_")
if (u == "GND" || u == "GROUND"
|| starts_with("GND_")
|| (starts_with("GND") && u.size() >= 4
&& std::isalpha((unsigned char)u[3]))) {
return SignalType::GndShield;
&& std::isalpha((unsigned char)u[3]))
|| contains("SHIELD") || contains("CHASSIS") || contains("EARTH")) {
out.verdict = NameVerdict::GndShield;
return out;
}
if (contains("SHIELD") || contains("CHASSIS") || contains("EARTH"))
return SignalType::GndShield;
if (contains("PWR") || contains("POWER")
|| contains("VCC") || contains("VDD") || contains("VEE") || contains("VSS")
@@ -67,7 +131,25 @@ SignalType infer_signal_type(const std::string &name) {
|| starts_with("VS3_") || starts_with("VS4_")
|| starts_with("VBAT") || starts_with("VBUS")
|| starts_with("+") || starts_with("-")) {
return SignalType::Power;
for (const std::string &tok : alnum_tokens(u)) {
if (is_power_control_token(tok)) {
out.verdict = NameVerdict::PowerMgmt;
out.token = tok;
return out;
}
}
out.verdict = NameVerdict::Rail;
return out;
}
return out;
}
SignalType infer_signal_type(const std::string &name) {
switch (classify_signal_name(name).verdict) {
case NameVerdict::Rail: return SignalType::Power;
case NameVerdict::GndShield: return SignalType::GndShield;
case NameVerdict::PowerMgmt:
case NameVerdict::Other: break;
}
return SignalType::Other;
}

View File

@@ -21,14 +21,8 @@ System::~System()
void System::Load(std::string module_name, std::string file_name, ImportType type)
{
// Build the importer first, based on the import type.
ImportBase *imp;
Module *mod = nullptr;
Parts *prts = nullptr;
// Creation or retrieval of the module.
mod = mods->merge(module_name);
// Parsing of the file based on the import type.
if (type == ImportType::IMPORT_MENTOR)
{
imp = new ImportMentor(file_name);
@@ -43,7 +37,20 @@ void System::Load(std::string module_name, std::string file_name, ImportType typ
{
throw std::runtime_error("Unknown import type");
}
imp->parse(mod->signals);
prts = imp->parts();
mod->add(prts);
// Fail fast on a missing/unreadable file, before touching the module table,
// so a failed load never leaves behind an empty module.
if (!imp->is_open())
{
delete imp;
throw std::runtime_error("cannot open file: " + file_name);
}
// Creation or retrieval of the module, then parse into it. add() copies the
// Part pointers into the module, which takes ownership; deleting the
// importer then frees the (now drained) Parts container, not the parts.
Module *mod = mods->merge(module_name);
imp->parse(mod->signals);
mod->add(imp->parts());
delete imp;
}

View File

@@ -27,11 +27,22 @@ public:
*
* @param file_name Name of the file to be imported.
*/
ImportBase(std::string file_name) : file_lines(std::fstream(file_name))
ImportBase(std::string file_name) : file_lines(file_name, std::ios::in)
{
prts = new Parts();
};
/**
* @brief Whether the input file was opened successfully.
*
* Opened read-only, so this is false only when the file is genuinely
* missing or unreadable (a read-only but present file still opens).
* System::Load checks it to fail fast instead of producing an empty module.
*
* @return true if the file stream is open.
*/
bool is_open() const { return file_lines.is_open(); }
/**
* @brief Pure virtual method for parsing the file.
*
@@ -53,9 +64,13 @@ public:
/**
* @brief Virtual destructor for ImportBase.
*
* Ensures proper cleanup of derived classes.
* Frees the Parts container object. Only the container is deleted, not the
* Part objects it holds: by the time the importer is destroyed those have
* been transferred to a Module (SystemElementContainer::add copies the
* pointers), which owns and deletes them. The default ~Parts frees the map
* without touching the elements, so there is no double free.
*/
virtual ~ImportBase() = default;
virtual ~ImportBase() { delete prts; }
};
#endif // _IMPORT_BASE_HPP_

View File

@@ -43,16 +43,6 @@ enum class State
*/
ImportMentor::ImportMentor(string filename) : ImportBase(filename) {}
/**
* @brief Destructor for ImportMentor.
*
* Ensures proper cleanup by calling the base class destructor.
*/
ImportMentor::~ImportMentor()
{
ImportBase::~ImportBase();
}
/**
* @brief Parses the file to extract parts, pins, and signals.
*

View File

@@ -10,7 +10,6 @@ class ImportMentor : public ImportBase
public:
ImportMentor(std::string filename);
void parse(Signals *signals) override;
~ImportMentor();
};
#endif // _IMPORT_MENTOR_HPP_

View File

@@ -0,0 +1,30 @@
#ifndef _FRONTEND_HPP_
#define _FRONTEND_HPP_
#include <iosfwd>
#include <string>
// Abstract entry-point interface every frontend (TUI, GUI, …) implements, so
// one shared launcher (frontend_main) can drive any of them: parse argv, run
// boot commands, optionally dump output (batch / docs) and enter the event
// loop. Lives in the frontends layer — essim_core never depends on it.
class Frontend {
public:
virtual ~Frontend() = default;
// Dispatch one command synchronously, exactly as if the user typed it
// (e.g. "restore foo.essim" or "source bring-up.essim"), before the event
// loop starts — used to seed the system at boot.
virtual void BootDispatch(const std::string &raw) = 0;
// Write the command registry as Markdown (used for doc generation).
virtual void DumpCommandsMd(std::ostream &out) const = 0;
// Write the accumulated console output (batch mode: no event loop).
virtual void DumpOutput(std::ostream &out) const = 0;
// Enter the interactive event loop.
virtual void Run() = 0;
};
#endif // _FRONTEND_HPP_

View File

@@ -0,0 +1,99 @@
#include "frontends/frontend_main.hpp"
#include "frontends/frontend.hpp"
#include <fstream>
#include <iostream>
#include <string>
namespace {
void print_usage(const char *prog) {
std::cerr <<
"usage: " << prog << " [--batch] [--source FILE] [--restore FILE]\n"
" " << prog << " --commands-md [FILE]\n"
" " << prog << " --help\n"
" (no args) launch the interface on an empty system.\n"
" --source FILE after boot, run FILE as an essim script\n"
" (one command per line; same as the `source`\n"
" command). Output goes to the console.\n"
" --restore FILE after boot, restore the system snapshot in\n"
" FILE (same as the `restore` command).\n"
" Combine with --source to layer a script on\n"
" top of a restored snapshot.\n"
" --batch run --restore/--source, print the console\n"
" output to stdout, and exit without launching\n"
" the interface.\n"
" --commands-md [FILE] dump the command registry as Markdown.\n"
" With FILE: write there. Without: stdout.\n"
" (Used by `cmake --build build --target doc`.)\n"
" --help, -h show this help.\n";
}
} // namespace
int frontend_main(int argc, char **argv, Frontend &fe) {
std::string boot_restore;
std::string boot_source;
bool batch = false;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--commands-md") {
if (i + 1 < argc) {
std::ofstream f(argv[++i]);
if (!f) {
std::cerr << "essim: cannot open " << argv[i] << " for writing\n";
return 1;
}
fe.DumpCommandsMd(f);
} else {
fe.DumpCommandsMd(std::cout);
}
return 0;
}
if (a == "--source") {
if (i + 1 >= argc) {
std::cerr << "essim: --source needs a filename\n";
return 2;
}
boot_source = argv[++i];
continue;
}
if (a == "--restore") {
if (i + 1 >= argc) {
std::cerr << "essim: --restore needs a filename\n";
return 2;
}
boot_restore = argv[++i];
continue;
}
if (a == "--batch") {
batch = true;
continue;
}
if (a == "--help" || a == "-h") {
print_usage(argv[0]);
return 0;
}
std::cerr << "essim: unknown option: " << a << "\n";
print_usage(argv[0]);
return 2;
}
// Order matters: a `--restore` brings up a snapshot, then `--source`
// can layer additional commands on top of it (useful e.g. for "load
// snapshot, then re-run a small script that adds a new card").
if (!boot_restore.empty()) fe.BootDispatch("restore " + boot_restore);
if (!boot_source.empty()) fe.BootDispatch("source " + boot_source);
// Batch mode: the boot dispatch already ran synchronously (no event loop
// yet), so the console output is complete. Print it and exit.
if (batch) {
fe.DumpOutput(std::cout);
return 0;
}
fe.Run();
return 0;
}

View File

@@ -0,0 +1,13 @@
#ifndef _FRONTEND_MAIN_HPP_
#define _FRONTEND_MAIN_HPP_
class Frontend;
// Shared process entry point, frontend-agnostic. Parses argv
// (--source / --restore / --batch / --commands-md / --help), drives `fe`
// through the boot commands and then either dumps output (batch) or enters its
// event loop, and returns the process exit code. Each frontend's main() just
// constructs its concrete Frontend and forwards to this.
int frontend_main(int argc, char **argv, Frontend &fe);
#endif // _FRONTEND_MAIN_HPP_

View File

@@ -18,18 +18,5 @@ FetchContent_Declare(ftxui
)
FetchContent_MakeAvailable(ftxui)
# Frontend library = every .cpp here except the entry point.
file(GLOB TUI_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp")
list(REMOVE_ITEM TUI_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/main.cpp")
add_library(essim_tui STATIC ${TUI_SOURCES})
target_link_libraries(essim_tui
PUBLIC
essim_core
ftxui::screen
ftxui::dom
ftxui::component
)
add_executable(essim "${CMAKE_CURRENT_SOURCE_DIR}/main.cpp")
target_link_libraries(essim PRIVATE essim_tui)
# Library essim_tui (sources here minus main.cpp) + the `essim` binary.
essim_add_frontend(tui LIBS ftxui::screen ftxui::dom ftxui::component)

View File

@@ -4,25 +4,23 @@
#include "core/domain/analysis.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/nets.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/persist.hpp"
#include "core/domain/pin_role.hpp"
#include "core/domain/pin_model.hpp"
#include "core/domain/bsdl_model.hpp"
#include "core/domain/bsdl_check.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
#include "core/domain/transform.hpp"
#include "core/domain/transform_vpx.hpp"
#include "core/app/connect.hpp"
#include "core/app/edit.hpp"
#include "core/app/load.hpp"
#include "core/app/verify.hpp"
#include <algorithm>
#include <cctype>
#include <cstdlib>
#include <exception>
#include <fstream>
#include <unordered_set>
#include <utility>
void Tui::RegisterCommands() {
@@ -138,29 +136,25 @@ void Tui::RegisterCommands() {
{"import type [mentor|altium|ods]", Completion::None}},
[this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; }
std::string ls = ToLower(args[2]);
ImportType t;
if (ls == "mentor") t = ImportType::IMPORT_MENTOR;
else if (ls == "altium") t = ImportType::IMPORT_ALTIUM;
else if (ls == "ods") t = ImportType::IMPORT_ODS;
else { Print("unknown import type: " + args[2]); return; }
try {
sys->Load(args[0], args[1], t);
Module *mod = sys->modules()->get(args[0]);
int dropped = drop_singleton_signals(mod->signals);
auto inf = infer_signal_types(sys.get());
Print("loaded '" + args[0] + "' from " + args[1]);
Print(" parts: " + std::to_string(mod->size()));
Print(" signals: " + std::to_string(mod->signals->size())
+ (dropped ? " (dropped " + std::to_string(dropped)
+ " singleton/NC signal(s))" : ""));
Print(" types: " + std::to_string(inf.power) + " power, "
+ std::to_string(inf.gnd) + " gnd, "
+ std::to_string(inf.kept_other)
+ " suspect Power (name only — kept as Other)");
} catch (const std::exception &e) {
Print(std::string("load failed: ") + e.what());
if (!app::import_type_from_name(args[2], t)) {
Print("unknown import type: " + args[2]); return;
}
// Import + drop-singletons + infer-types is one core op; the command
// only parses the type and renders the counts.
app::LoadResult r = app::load_module(sys.get(), args[0], args[1], t);
if (!r.ok) { Print(std::string("load failed: ") + r.error); return; }
Print("loaded '" + args[0] + "' from " + args[1]);
Print(" parts: " + std::to_string(r.parts));
Print(" signals: " + std::to_string(r.signals)
+ (r.dropped ? " (dropped " + std::to_string(r.dropped)
+ " singleton/NC signal(s))" : ""));
Print(" types: " + std::to_string(r.power) + " power, "
+ std::to_string(r.gnd) + " gnd, "
+ std::to_string(r.kept_other)
+ " suspect Power (name only — kept as Other), "
+ std::to_string(r.mgmt)
+ " power-management (control/measure — kept as Other)");
},
/*prompt_for_missing=*/ true,
"load a module from a netlist / pinout file (mentor, altium, ods)",
@@ -228,104 +222,44 @@ void Tui::RegisterCommands() {
commands["verify"] = { {}, [this](auto &) {
if (!sys) { Print("no system: run 'new' first."); return; }
int checked = 0;
int mismatches = 0;
for (auto &mkv : *sys->modules()) {
Module *mod = mkv.second;
for (auto &pkv : *mod) {
Part *prt = pkv.second;
if (prt->connector_type.empty()) continue;
for (auto &nkv : *prt) {
Pin *pin = nkv.second;
++checked;
SignalType expected = pin->expected_signal_type();
if (expected == SignalType::Other) continue;
Signal *s = pin->signal();
SignalType actual = s ? s->type : SignalType::Other;
if (actual == expected) continue;
++mismatches;
std::string sig_label = s ? s->name : std::string("(NC)");
Print(" " + mod->name + "/" + prt->name + "/" + pin->name
+ ": expected " + signal_type_name(expected)
+ ", got " + signal_type_name(actual)
+ " (signal: " + sig_label + ")");
}
}
}
Print("verify: " + std::to_string(mismatches) + " local mismatch(es) over "
+ std::to_string(checked) + " typed pin(s).");
app::VerifyReport r = app::verify(sys.get());
auto nets = compute_all_nets(sys.get());
int bridged = 0, inconsistent = 0;
for (const auto &n : nets) {
if (n.members.size() < 2) continue;
++bridged;
SignalType dom;
if (net_type_consistent(n, dom)) continue;
++inconsistent;
for (const auto &m : r.role_mismatches)
Print(" " + m.module + "/" + m.part + "/" + m.pin
+ ": expected " + signal_type_name(m.expected)
+ ", got " + signal_type_name(m.actual)
+ " (signal: " + m.signal + ")");
Print("verify: " + std::to_string(r.role_mismatches.size())
+ " local mismatch(es) over " + std::to_string(r.typed_pins)
+ " typed pin(s).");
for (const auto &ni : r.net_inconsistencies) {
std::string line = " net mixes Power and GndShield:";
for (const auto &mp : n.members) {
line += " " + mp.first->name + "/" + mp.second->name
+ "(" + signal_type_name(mp.second->type) + ")";
}
for (const auto &mem : ni.members)
line += " " + mem.module + "/" + mem.signal
+ "(" + signal_type_name(mem.type) + ")";
Print(line);
}
Print("verify: " + std::to_string(inconsistent) + " inconsistent net(s) over "
+ std::to_string(bridged) + " bridged net(s) ("
+ std::to_string(nets.size()) + " total).");
Print("verify: " + std::to_string(r.net_inconsistencies.size())
+ " inconsistent net(s) over " + std::to_string(r.bridged_nets)
+ " bridged net(s) (" + std::to_string(r.total_nets) + " total).");
// Orphan pin report. A pin is "orphan" if it came out of import (or
// post-import drop) with no signal, and is still not bridged to a
// real signal via any Connection::pin_map. Use `nc-export` for the
// per-pin list.
std::unordered_set<Pin *> bridged_pins;
for (auto &ckv : *sys->connections())
for (auto &wp : ckv.second->pin_map) {
if (wp.first) bridged_pins.insert(wp.first);
if (wp.second) bridged_pins.insert(wp.second);
}
int orph_imported = 0, orph_dropped = 0;
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second)
for (auto &nkv : *pkv.second) {
Pin *pin = nkv.second;
if (pin->signal() || bridged_pins.count(pin)) continue;
if (pin->nc_origin == NcOrigin::ImportedUnconnected) ++orph_imported;
else if (pin->nc_origin == NcOrigin::DroppedSingleton) ++orph_dropped;
}
Print("verify: " + std::to_string(orph_imported + orph_dropped)
+ " orphan pin(s) at import ("
+ std::to_string(orph_imported) + " imported NC, "
+ std::to_string(orph_dropped) + " dropped singleton).");
Print("verify: " + std::to_string(r.orphan_total())
+ " orphan pin(s) at import (" + std::to_string(r.orphan_imported)
+ " imported NC, " + std::to_string(r.orphan_dropped)
+ " dropped singleton).");
// Model-driven pin checks (drive contention / undriven net / NC-wired)
// from the PinSpec direction/function populated by connector/BSDL models.
auto pin_anoms = check_pin_specs(sys.get(), &nets);
for (const auto &a : pin_anoms)
// Each model-driven group: per-finding lines + a one-line summary.
auto render = [this](const std::vector<Anomaly> &v, const char *tail) {
for (const auto &a : v)
Print(" [" + std::string(anomaly_kind_name(a.kind)) + "] " + a.message);
Print("verify: " + std::to_string(pin_anoms.size())
+ " model-driven pin anomaly(ies).");
// JTAG boundary-scan chain integrity (TAP pins → nets).
auto jtag_anoms = check_jtag_chain(sys.get(), &nets);
for (const auto &a : jtag_anoms)
Print(" [" + std::string(anomaly_kind_name(a.kind)) + "] " + a.message);
Print("verify: " + std::to_string(jtag_anoms.size())
+ " JTAG chain anomaly(ies).");
// Model-vs-netlist conflicts (e.g. a BSDL power pin left unconnected).
auto conflict_anoms = check_source_conflicts(sys.get());
for (const auto &a : conflict_anoms)
Print(" [" + std::string(anomaly_kind_name(a.kind)) + "] " + a.message);
Print("verify: " + std::to_string(conflict_anoms.size())
+ " source-conflict(s).");
// BSDL completeness: device power/ground pins missing from the netlist.
auto missing_anoms = check_bsdl_completeness(sys.get());
for (const auto &a : missing_anoms)
Print(" [" + std::string(anomaly_kind_name(a.kind)) + "] " + a.message);
Print("verify: " + std::to_string(missing_anoms.size())
+ " BSDL completeness issue(s).");
Print("verify: " + std::to_string(v.size()) + tail);
};
render(r.pin_anomalies, " model-driven pin anomaly(ies).");
render(r.jtag_anomalies, " JTAG chain anomaly(ies).");
render(r.conflict_anomalies, " source-conflict(s).");
render(r.completeness_anomalies, " BSDL completeness issue(s).");
render(r.diff_anomalies, " diff-pair crossing anomaly(ies).");
}, true,
"check pin roles, power/gnd net consistency, and (with BSDL) pin and JTAG checks" };
@@ -403,14 +337,10 @@ void Tui::RegisterCommands() {
catch (const std::exception &) {
Print("unknown signal: " + mod->name + "/" + args[1]); return;
}
SignalType t;
if (!signal_type_from_name(args[2], t)) {
Print("type must be one of: power, gnd, other (got: " + args[2] + ")");
return;
}
sig->type = t;
app::SetSignalTypeResult r = app::set_signal_type(sig, args[2]);
if (!r.ok) { Print(r.error); return; }
Print(mod->name + "/" + sig->name + ": signal type = "
+ signal_type_name(t));
+ signal_type_name(r.type));
},
/*prompt_for_missing=*/ true,
"override the auto-detected signal type (power | gnd | other)",
@@ -464,18 +394,15 @@ void Tui::RegisterCommands() {
return;
}
}
std::string err = ValidatePartForKind(prt, args[2]);
if (!err.empty()) {
Print("set-connector-type refused: " + err);
app::SetConnectorTypeResult r = app::set_connector_type(prt, args[2]);
if (!r.ok) {
Print("set-connector-type refused: " + r.error);
return;
}
prt->connector_type = args[2];
ConnectorModel model(args[2]);
ApplyReport rep = apply_model(prt, model);
Print(mod->name + "/" + prt->name + ": connector_type = "
+ (args[2].empty() ? "(none)" : args[2]));
if (rep.materialised > 0)
Print("set-connector-type: added " + std::to_string(rep.materialised)
if (r.materialised > 0)
Print("set-connector-type: added " + std::to_string(r.materialised)
+ " NC pin(s) from the connector layout");
},
/*prompt_for_missing=*/ false,
@@ -516,17 +443,11 @@ void Tui::RegisterCommands() {
}
}
BsdlModel model = BsdlModel::from_file(args[2]);
if (!model.valid()) {
Print("attach-bsdl: cannot parse " + args[2]
+ (model.error().empty() ? "" : (": " + model.error())));
return;
}
BsdlApplyReport r = apply_bsdl(prt, model);
prt->bsdl_path = args[2];
Print(mod->name + "/" + prt->name + ": attached BSDL '" + model.entity()
app::AttachBsdlResult r = app::attach_bsdl(prt, args[2]);
if (!r.ok) { Print("attach-bsdl: " + r.error); return; }
Print(mod->name + "/" + prt->name + ": attached BSDL '" + r.entity
+ "' — " + std::to_string(r.bound) + "/"
+ std::to_string((int)model.ports().size()) + " ports bound"
+ std::to_string(r.ports_total) + " ports bound"
+ (r.unbound ? (", " + std::to_string(r.unbound) + " unbound") : ""));
},
/*prompt_for_missing=*/ false,
@@ -622,47 +543,23 @@ void Tui::RegisterCommands() {
auto [p2, p2_alts] = resolve_part(m2, args[3]);
if (!p2) { report_ambiguous("part in " + m2->name, args[3], p2_alts); return; }
auto &reg = TransformRegistry::get();
Transform *t = reg.lookup(p1->connector_type, p2->connector_type);
bool both_empty = p1->connector_type.empty() && p2->connector_type.empty();
if (t == reg.identity()) {
if (!both_empty) {
Print("connect refused: no transform for types '"
+ (p1->connector_type.empty() ? "(none)" : p1->connector_type)
+ "' ↔ '"
+ (p2->connector_type.empty() ? "(none)" : p2->connector_type)
+ "'. Set matching types via 'set-connector-type' first.");
return;
}
std::string info;
std::string err = CheckIdentityCompatible(p1, p2, &info);
if (!err.empty()) {
Print("connect refused: " + err);
return;
}
if (!info.empty()) {
int added = FillIdentityNCs(p1, p2);
Print("connect: " + info);
if (added > 0)
Print("connect: added " + std::to_string(added)
// Resolution above is arg-parsing (user text → objects); the wiring
// itself — transform lookup, identity NC fill, Connection creation —
// is app::connect_parts.
app::ConnectResult cr = app::connect_parts(sys.get(), m1, p1, m2, p2);
if (cr.refused) { Print("connect refused: " + cr.error); return; }
if (!cr.identity_info.empty()) {
Print("connect: " + cr.identity_info);
if (cr.nc_added > 0)
Print("connect: added " + std::to_string(cr.nc_added)
+ " NC pin(s) so both sides match");
}
}
auto pin_map = t->apply(p1, p2);
std::string conn_name = m1->name + "/" + p1->name
+ " <-> " + m2->name + "/" + p2->name;
try {
Connection *c = new Connection(conn_name, m1, p1, m2, p2);
c->transform_name = t->name;
c->pin_map = std::move(pin_map);
sys->connections()->add(c);
Print("connected: " + conn_name
+ " via " + t->name
+ " (" + std::to_string(c->pin_map.size()) + " wires)");
} catch (const std::exception &e) {
Print(std::string("connect failed: ") + e.what());
}
if (cr.ok)
Print("connected: " + cr.connection_name
+ " via " + cr.transform_name
+ " (" + std::to_string(cr.wires) + " wires)");
else
Print(std::string("connect failed: ") + cr.error);
},
/*prompt_for_missing=*/ false,
"connect a part across two modules (interactive screen if no args)",
@@ -701,51 +598,12 @@ void Tui::RegisterCommands() {
{"new module name", Completion::None}},
[this](const std::vector<std::string> &args) {
if (!sys) { Print("no system: run 'new' first."); return; }
Module *src;
try { src = sys->modules()->get(args[0]); }
catch (const std::exception &) {
Print("unknown module: " + args[0]); return;
}
if (sys->modules()->exists(args[1])) {
Print("duplicate refused: module '" + args[1] + "' already exists.");
return;
}
Module *dst = new Module(args[1]);
// 1. Copy signals (preserve type overrides).
for (auto &skv : *src->signals) {
Signal *ss = skv.second;
Signal *ds = new Signal(ss->name);
ds->type = ss->type;
dst->signals->add(ds);
}
// 2. Copy parts, pins, and re-wire pin→signal.
for (auto &pkv : *src) {
Part *sp = pkv.second;
Part *dp = new Part(sp->name);
dp->connector_type = sp->connector_type;
for (auto &nkv : *sp) {
Pin *sn = nkv.second;
Pin *dn = new Pin(sn->name);
dn->spec = sn->spec;
dn->nc_origin = sn->nc_origin;
dp->add(dn);
if (sn->signal()) {
Signal *ds = dst->signals->get(sn->signal()->name);
ds->add(dn);
dn->connect(ds);
}
}
dst->add(dp);
}
sys->modules()->add(dst);
app::DuplicateResult r =
app::duplicate_module(sys.get(), args[0], args[1]);
if (!r.ok) { Print(r.error); return; }
Print("duplicate: '" + args[0] + "' → '" + args[1] + "'"
+ " (" + std::to_string(dst->size()) + " part(s), "
+ std::to_string(dst->signals->size()) + " signal(s))");
+ " (" + std::to_string(r.parts) + " part(s), "
+ std::to_string(r.signals) + " signal(s))");
},
/*prompt_for_missing=*/ true,
"clone a module under a new name (parts, pins, signals; no connections)",

View File

@@ -1,98 +1,11 @@
#include "frontends/frontend_main.hpp"
#include "frontends/tui/tui.hpp"
#include <fstream>
#include <iostream>
#include <string>
namespace {
void print_usage(const char *prog) {
std::cerr <<
"usage: " << prog << " [--batch] [--source FILE] [--restore FILE]\n"
" " << prog << " --commands-md [FILE]\n"
" " << prog << " --help\n"
" (no args) launch the TUI on an empty system.\n"
" --source FILE after boot, run FILE as an essim script\n"
" (one command per line; same as the `source`\n"
" command). Output is in the console screen.\n"
" --restore FILE after boot, restore the system snapshot in\n"
" FILE (same as the `restore` command).\n"
" Combine with --source to layer a script on\n"
" top of a restored snapshot.\n"
" --batch run --restore/--source, print the console\n"
" output to stdout, and exit without the TUI.\n"
" --commands-md [FILE] dump the command registry as Markdown.\n"
" With FILE: write there. Without: stdout.\n"
" (Used by `cmake --build build --target doc`.)\n"
" --help, -h show this help.\n";
}
} // namespace
// The TUI frontend's entry point: construct the concrete Frontend (Tui) and
// hand off to the shared, frontend-agnostic launcher. All argv parsing and the
// boot/batch/run flow live in frontend_main(); a second frontend's main() looks
// exactly like this with its own Frontend type.
int main(int argc, char **argv) {
std::string boot_restore;
std::string boot_source;
bool batch = false;
for (int i = 1; i < argc; ++i) {
std::string a = argv[i];
if (a == "--commands-md") {
Tui tui;
if (i + 1 < argc) {
std::ofstream f(argv[++i]);
if (!f) {
std::cerr << "essim: cannot open " << argv[i] << " for writing\n";
return 1;
}
tui.DumpCommandsMd(f);
} else {
tui.DumpCommandsMd(std::cout);
}
return 0;
}
if (a == "--source") {
if (i + 1 >= argc) {
std::cerr << "essim: --source needs a filename\n";
return 2;
}
boot_source = argv[++i];
continue;
}
if (a == "--restore") {
if (i + 1 >= argc) {
std::cerr << "essim: --restore needs a filename\n";
return 2;
}
boot_restore = argv[++i];
continue;
}
if (a == "--batch") {
batch = true;
continue;
}
if (a == "--help" || a == "-h") {
print_usage(argv[0]);
return 0;
}
std::cerr << "essim: unknown option: " << a << "\n";
print_usage(argv[0]);
return 2;
}
Tui tui;
// Order matters: a `--restore` brings up a snapshot, then `--source`
// can layer additional commands on top of it (useful e.g. for "load
// snapshot, then re-run a small script that adds a new card").
if (!boot_restore.empty()) tui.BootDispatch("restore " + boot_restore);
if (!boot_source.empty()) tui.BootDispatch("source " + boot_source);
// Batch mode: the boot dispatch already ran synchronously (no screen yet),
// so the console output is complete. Print it and exit without the TUI.
if (batch) {
tui.DumpOutput(std::cout);
return 0;
}
tui.Run();
return 0;
return frontend_main(argc, argv, tui);
}

View File

@@ -1,13 +1,9 @@
#include "frontends/tui/tui.hpp"
#include "frontends/tui/tui_helpers.hpp"
#include "core/app/verify.hpp"
#include "core/domain/analysis.hpp"
#include "core/domain/bsdl_check.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/nets.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
@@ -17,7 +13,6 @@
#include <algorithm>
#include <array>
#include <unordered_set>
using namespace ftxui;
@@ -57,41 +52,23 @@ Component Tui::BuildAnalyzeScreen() {
// connection), then structural anomalies from the analysis pass.
analyze_issues.clear();
int n_role_mismatches = 0, n_typed_pins = 0;
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second) {
Part *prt = pkv.second;
if (prt->connector_type.empty()) continue;
for (auto &nkv : *prt) {
Pin *pin = nkv.second;
++n_typed_pins;
SignalType expected = pin->expected_signal_type();
if (expected == SignalType::Other) continue;
Signal *s = pin->signal();
SignalType actual = s ? s->type : SignalType::Other;
if (actual == expected) continue;
++n_role_mismatches;
std::string sig_label = s ? s->name : std::string("(NC)");
analyze_issues.push_back(
"[pin-role] " + mkv.first + "/" + prt->name + "/"
+ pin->name + ": expected " + signal_type_name(expected)
+ ", got " + signal_type_name(actual)
+ " (signal: " + sig_label + ")");
}
}
// verify + structural anomalies. The verify passes (pin-role, net-mix,
// orphans, model checks) come from the shared core op; the structural
// anomalies (diff-pair/bus) come from analyze_system above.
app::VerifyReport vr = app::verify(sys.get());
auto nets = compute_all_nets(sys.get());
int n_bridged = 0, n_inconsistent = 0;
for (const auto &n : nets) {
if (n.members.size() < 2) continue;
++n_bridged;
SignalType dom;
if (net_type_consistent(n, dom)) continue;
++n_inconsistent;
for (const auto &m : vr.role_mismatches)
analyze_issues.push_back(
"[pin-role] " + m.module + "/" + m.part + "/" + m.pin
+ ": expected " + signal_type_name(m.expected)
+ ", got " + signal_type_name(m.actual)
+ " (signal: " + m.signal + ")");
for (const auto &ni : vr.net_inconsistencies) {
std::string line = "[net-mix] mixes Power and Gnd:";
for (const auto &mp : n.members)
line += " " + mp.first->name + "/" + mp.second->name
+ "(" + signal_type_name(mp.second->type) + ")";
for (const auto &mem : ni.members)
line += " " + mem.module + "/" + mem.signal
+ "(" + signal_type_name(mem.type) + ")";
analyze_issues.push_back(std::move(line));
}
@@ -100,28 +77,26 @@ Component Tui::BuildAnalyzeScreen() {
+ anomaly_kind_name(a.kind) + "] "
+ a.message);
// Model-driven checks (same as `verify`), reusing the nets above.
std::vector<Anomaly> model_anoms;
{
auto a1 = check_pin_specs(sys.get(), &nets);
auto a2 = check_jtag_chain(sys.get(), &nets);
auto a3 = check_source_conflicts(sys.get());
auto a4 = check_bsdl_completeness(sys.get());
model_anoms.insert(model_anoms.end(), a1.begin(), a1.end());
model_anoms.insert(model_anoms.end(), a2.begin(), a2.end());
model_anoms.insert(model_anoms.end(), a3.begin(), a3.end());
model_anoms.insert(model_anoms.end(), a4.begin(), a4.end());
}
for (const auto &a : model_anoms)
// Model-driven checks (pin / JTAG / source-conflict / completeness).
auto push_anoms = [this](const std::vector<Anomaly> &v) {
for (const auto &a : v)
analyze_issues.push_back(std::string("[")
+ anomaly_kind_name(a.kind) + "] "
+ a.message);
int n_model = (int)model_anoms.size();
};
push_anoms(vr.pin_anomalies);
push_anoms(vr.jtag_anomalies);
push_anoms(vr.conflict_anomalies);
push_anoms(vr.completeness_anomalies);
push_anoms(vr.diff_anomalies);
int n_model = vr.model_total();
if (analyze_issues.empty()) analyze_issues.push_back("(no issue found)");
if (analyze_issue_idx >= (int)analyze_issues.size())
analyze_issue_idx = (int)analyze_issues.size() - 1;
int n_role_mismatches = (int)vr.role_mismatches.size();
int n_inconsistent = (int)vr.net_inconsistencies.size();
std::string issues_header = "Issues ("
+ std::to_string(n_role_mismatches + n_inconsistent
+ (int)rep.anomalies.size() + n_model)
@@ -157,29 +132,39 @@ Component Tui::BuildAnalyzeScreen() {
// ============================================================= Types
// Power decisions (confirmed / refuted) and NC orphan breakdown.
analyze_types.clear();
int conf_pwr = 0, ref_pwr = 0, gnd = 0;
struct Row { char kind; std::string mod, sig; int fanout; bool voltage; };
int conf_pwr = 0, ref_pwr = 0, mgmt = 0, gnd = 0;
struct Row { char kind; std::string mod, sig; int fanout; bool voltage;
std::string token; };
std::vector<Row> rows;
for (auto &mkv : *sys->modules()) {
Module *mod = mkv.second;
for (auto &skv : *mod->signals) {
Signal *s = skv.second;
SignalType named = infer_signal_type(s->name);
NameClassification ncl = classify_signal_name(s->name);
char kind = 0;
if (named == SignalType::GndShield && s->type == SignalType::GndShield) {
std::string token;
if (ncl.verdict == NameVerdict::GndShield && s->type == SignalType::GndShield) {
kind = 'G'; ++gnd;
} else if (named == SignalType::Power && s->type == SignalType::Power) {
} else if (ncl.verdict == NameVerdict::Rail && s->type == SignalType::Power) {
kind = 'P'; ++conf_pwr;
} else if (named == SignalType::Power && s->type == SignalType::Other) {
} else if (ncl.verdict == NameVerdict::Rail && s->type == SignalType::Other) {
kind = 'R'; ++ref_pwr;
} else if (ncl.verdict == NameVerdict::PowerMgmt) {
kind = 'M'; ++mgmt; token = ncl.token;
} else continue;
rows.push_back({kind, mod->name, s->name,
(int)s->size(), has_voltage_pattern(s->name)});
(int)s->size(), has_voltage_pattern(s->name),
token});
}
}
// Deliberate display order: confirmed rails, then the suspects (the
// actionable residue), then the power-management signals, gnd last.
auto rank = [](char k) {
return k == 'P' ? 0 : k == 'R' ? 1 : k == 'M' ? 2 : 3;
};
std::sort(rows.begin(), rows.end(),
[](const Row &a, const Row &b) {
if (a.kind != b.kind) return a.kind < b.kind;
[&](const Row &a, const Row &b) {
if (a.kind != b.kind) return rank(a.kind) < rank(b.kind);
if (a.mod != b.mod) return a.mod < b.mod;
return a.sig < b.sig;
});
@@ -207,6 +192,10 @@ Component Tui::BuildAnalyzeScreen() {
else reason = "name only — fan-out "
+ std::to_string(r.fanout)
+ ", no voltage";
} else if (r.kind == 'M') {
tag = "[Power mgmt] ";
reason = "control token '" + r.token
+ "' in name — kept as Other";
} else {
tag = "[Gnd] ";
reason = "name match (fan-out " + std::to_string(r.fanout) + ")";
@@ -215,33 +204,19 @@ Component Tui::BuildAnalyzeScreen() {
std::string(tag) + r.mod + "/" + r.sig + "" + reason);
}
// NC orphan rollup — same filter as the verify pass.
std::unordered_set<Pin *> bridged_pins;
for (auto &ckv : *sys->connections())
for (auto &wp : ckv.second->pin_map) {
if (wp.first) bridged_pins.insert(wp.first);
if (wp.second) bridged_pins.insert(wp.second);
}
int orph_imported = 0, orph_dropped = 0;
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second)
for (auto &nkv : *pkv.second) {
Pin *pin = nkv.second;
if (pin->signal() || bridged_pins.count(pin)) continue;
if (pin->nc_origin == NcOrigin::ImportedUnconnected) ++orph_imported;
else if (pin->nc_origin == NcOrigin::DroppedSingleton) ++orph_dropped;
}
// NC orphan rollup — from the shared verify report.
analyze_types.push_back(
"[NC] orphan pin(s): " + std::to_string(orph_imported + orph_dropped)
+ " (" + std::to_string(orph_imported) + " imported, "
+ std::to_string(orph_dropped) + " dropped)");
"[NC] orphan pin(s): " + std::to_string(vr.orphan_total())
+ " (" + std::to_string(vr.orphan_imported) + " imported, "
+ std::to_string(vr.orphan_dropped) + " dropped)");
if (analyze_type_idx >= (int)analyze_types.size())
analyze_type_idx = (int)analyze_types.size() - 1;
std::string types_header = "Types: " + std::to_string(conf_pwr)
+ " Power, " + std::to_string(ref_pwr)
+ " Suspect, " + std::to_string(gnd)
+ " Suspect, " + std::to_string(mgmt)
+ " Pwr-mgmt, " + std::to_string(gnd)
+ " Gnd";
// Tab bar — horizontal headers, active one inverted.
@@ -281,8 +256,14 @@ Component Tui::BuildAnalyzeScreen() {
"Name suggests Power AND structure agrees: fan-out ≥ 4 pins, "
"or a voltage pattern in the name (e.g. 3V3, 5V, 12V)."),
term("Suspect Power",
"Name suggests Power but the structural check failed — "
"fan-out too low and no voltage in the name."),
"Name suggests Power, no control token explains it, but the "
"structural check failed — fan-out too low and no voltage "
"in the name."),
term("Power mgmt",
"Name holds a rail token AND a control token (SENSE, EN, PG, "
"FB, …): a power-management signal — measurement or control "
"of a rail — not the rail itself. Confidently Other, never "
"suspect."),
term("Hard floor",
"Fan-out below 3 pins forces Other regardless of the name. "
"A real rail physically cannot live on 1-2 pads."),

View File

@@ -1,11 +1,10 @@
#include "frontends/tui/tui.hpp"
#include "frontends/tui/tui_helpers.hpp"
#include "core/domain/connect.hpp"
#include "core/app/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/system.hpp"
#include "core/domain/transform.hpp"
#include <ftxui/component/component.hpp>
#include <ftxui/component/component_options.hpp>
@@ -67,37 +66,24 @@ Component Tui::BuildConnectScreen() {
Part *p1 = m1->get(connect_p1_list[connect_p1_idx]);
Part *p2 = m2->get(connect_p2_list[connect_p2_idx]);
auto &reg = TransformRegistry::get();
Transform *t = reg.lookup(p1->connector_type, p2->connector_type);
bool both_empty = p1->connector_type.empty() && p2->connector_type.empty();
if (t == reg.identity()) {
if (!both_empty) {
Print("connect refused: no transform for types '"
+ (p1->connector_type.empty() ? "(none)" : p1->connector_type)
+ "' ↔ '"
+ (p2->connector_type.empty() ? "(none)" : p2->connector_type)
+ "'. Set matching types via 'set-connector-type' first.");
screen_idx = 0;
return;
// Same wiring op as the `connect` command — see app::connect_parts.
app::ConnectResult cr = app::connect_parts(sys.get(), m1, p1, m2, p2);
if (cr.refused) {
Print("connect refused: " + cr.error);
} else {
if (!cr.identity_info.empty()) {
Print("connect: " + cr.identity_info);
if (cr.nc_added > 0)
Print("connect: added " + std::to_string(cr.nc_added)
+ " NC pin(s) so both sides match");
}
std::string err = CheckIdentityCompatible(p1, p2);
if (!err.empty()) {
Print("connect refused: " + err);
screen_idx = 0;
return;
if (cr.ok)
Print("connected: " + cr.connection_name
+ " via " + cr.transform_name
+ " (" + std::to_string(cr.wires) + " wires)");
else
Print(std::string("connect failed: ") + cr.error);
}
}
auto pin_map = t->apply(p1, p2);
std::string conn_name = m1->name + "/" + p1->name
+ " <-> " + m2->name + "/" + p2->name;
Connection *c = new Connection(conn_name, m1, p1, m2, p2);
c->transform_name = t->name;
c->pin_map = std::move(pin_map);
sys->connections()->add(c);
Print("connected: " + conn_name
+ " via " + t->name
+ " (" + std::to_string(c->pin_map.size()) + " wires)");
} catch (const std::exception &e) {
Print(std::string("connect failed: ") + e.what());
}

View File

@@ -1,13 +1,11 @@
#include "frontends/tui/tui.hpp"
#include "frontends/tui/tui_helpers.hpp"
#include "core/app/verify.hpp"
#include "core/domain/analysis.hpp"
#include "core/domain/bsdl_check.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/nets.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
@@ -16,7 +14,6 @@
#include <algorithm>
#include <map>
#include <unordered_set>
#include <vector>
using namespace ftxui;
@@ -77,58 +74,23 @@ Component Tui::BuildDashboardScreen() {
}
int n_conn = (int)sys->connections()->size();
// ---- verify-style health (recomputed; cheap on realistic sizes) ----
int n_role_mismatches = 0, n_typed_pins = 0;
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second) {
Part *prt = pkv.second;
if (prt->connector_type.empty()) continue;
for (auto &nkv : *prt) {
Pin *pin = nkv.second;
++n_typed_pins;
SignalType expected = pin->expected_signal_type();
if (expected == SignalType::Other) continue;
Signal *s = pin->signal();
SignalType actual = s ? s->type : SignalType::Other;
if (actual != expected) ++n_role_mismatches;
}
}
// ---- verify-style health (shared core op; cheap on realistic sizes) ----
app::VerifyReport vr = app::verify(sys.get());
int n_role_mismatches = (int)vr.role_mismatches.size();
int n_typed_pins = vr.typed_pins;
int n_inconsistent = (int)vr.net_inconsistencies.size();
int n_bridged = vr.bridged_nets;
int orph_imported = vr.orphan_imported;
int orph_dropped = vr.orphan_dropped;
auto nets = compute_all_nets(sys.get());
int n_bridged = 0, n_inconsistent = 0;
for (const auto &n : nets) {
if (n.members.size() < 2) continue;
++n_bridged;
SignalType dom;
if (!net_type_consistent(n, dom)) ++n_inconsistent;
}
// ---- NC orphan summary (matches verify pass 3) ----
std::unordered_set<Pin *> bridged_pins;
for (auto &ckv : *sys->connections())
for (auto &wp : ckv.second->pin_map) {
if (wp.first) bridged_pins.insert(wp.first);
if (wp.second) bridged_pins.insert(wp.second);
}
int orph_imported = 0, orph_dropped = 0;
// Per-module list of dropped-singleton pins, for the detail rows below
// the NC health line. The signal name is gone (the Signal object was
// deleted by `drop_singleton_signals`), but the pin's full path is
// enough to locate it in `explore`.
std::map<std::string, std::vector<std::string>> dropped_by_module;
for (auto &mkv : *sys->modules())
for (auto &pkv : *mkv.second)
for (auto &nkv : *pkv.second) {
Pin *pin = nkv.second;
if (pin->signal() || bridged_pins.count(pin)) continue;
if (pin->nc_origin == NcOrigin::ImportedUnconnected) {
++orph_imported;
} else if (pin->nc_origin == NcOrigin::DroppedSingleton) {
++orph_dropped;
dropped_by_module[mkv.first].push_back(
pkv.first + "/" + nkv.first);
}
}
for (const auto &o : vr.orphans)
if (o.dropped)
dropped_by_module[o.module].push_back(o.part + "/" + o.pin);
auto health_line = [](bool ok, const std::string &s) {
return hbox({
@@ -144,7 +106,7 @@ Component Tui::BuildDashboardScreen() {
+ " typed pin(s)"));
health_rows.push_back(health_line(n_inconsistent == 0,
"nets: " + std::to_string(n_inconsistent) + " inconsistent over "
+ std::to_string(n_bridged) + " bridged (" + std::to_string(nets.size())
+ std::to_string(n_bridged) + " bridged (" + std::to_string(vr.total_nets)
+ " total)"));
int orph_total = orph_imported + orph_dropped;
health_rows.push_back(health_line(orph_total == 0,
@@ -172,12 +134,9 @@ Component Tui::BuildDashboardScreen() {
}
}
// Model-driven checks (BSDL pin specs, JTAG chain, source conflicts),
// reusing the nets computed above.
int n_model = (int)(check_pin_specs(sys.get(), &nets).size()
+ check_jtag_chain(sys.get(), &nets).size()
+ check_source_conflicts(sys.get()).size()
+ check_bsdl_completeness(sys.get()).size());
// Model-driven checks (BSDL pin specs, JTAG chain, source conflicts,
// completeness) — from the shared verify report.
int n_model = vr.model_total();
health_rows.push_back(health_line(n_model == 0,
"model: " + std::to_string(n_model) + " BSDL/JTAG anomaly(ies)"));

View File

@@ -1,13 +1,12 @@
#include "frontends/tui/tui.hpp"
#include "frontends/tui/tui_helpers.hpp"
#include "core/app/edit.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pin_model.hpp"
#include "core/domain/pin_role.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/system.hpp"
#include "core/domain/transform_vpx.hpp"
#include <ftxui/component/component.hpp>
#include <ftxui/component/component_options.hpp>
@@ -63,14 +62,11 @@ Component Tui::BuildSettypeScreen() {
try {
Module *mod = sys->modules()->get(settype_modules[settype_m_idx]);
Part *prt = mod->get(settype_p_list[settype_p_idx]);
std::string err = ValidatePartForKind(prt, settype_type);
if (!err.empty()) {
settype_status = "refused: " + err;
app::SetConnectorTypeResult r = app::set_connector_type(prt, settype_type);
if (!r.ok) {
settype_status = "refused: " + r.error;
return;
}
prt->connector_type = settype_type;
ConnectorModel model(settype_type);
apply_model(prt, model);
std::string msg = mod->name + "/" + prt->name + " = "
+ (settype_type.empty() ? "(none)" : settype_type);
settype_status = "applied: " + msg;

View File

@@ -281,20 +281,29 @@ void Tui::Source(const std::string &filename) {
if (const char *home = std::getenv("HOME"))
expanded = std::string(home) + expanded.substr(1);
}
if (source_stack.size() >= 32) { // same depth guard as the core engine
Print("source: nesting too deep, skipping " + filename);
return;
}
std::ifstream f(expanded);
if (!f) { Print("source failed: cannot open " + filename); return; }
// Slurp the whole file so we can drive line-by-line processing from the
// event loop (one line per posted task). This lets the screen redraw
// between lines and surface the "Computing…" modal.
loading_lines.clear();
SourceFrame fr;
fr.filename = filename;
std::string line;
while (std::getline(f, line)) loading_lines.push_back(line);
while (std::getline(f, line)) fr.lines.push_back(std::move(line));
// Nested source (a sourced line is itself `source …`): just stack the
// frame — the driver already running (ticker thread or headless drain)
// picks it up on the next ProcessNextSourceLine, and the caller's frame
// resumes when it finishes.
bool nested = !source_stack.empty();
source_stack.push_back(std::move(fr));
if (nested) return;
loading_filename = filename;
loading_idx = 0;
loading_executed = 0;
loading_lineno = 0;
loading_prev_in_source = in_source;
source_origin_screen = screen_idx; // a sourced line that leaves this screen aborts
in_source = true;
@@ -332,9 +341,18 @@ void Tui::Source(const std::string &filename) {
void Tui::ProcessNextSourceLine() {
if (!loading.load()) return;
while (loading_idx < loading_lines.size()) {
const std::string &raw = loading_lines[loading_idx++];
++loading_lineno;
while (!source_stack.empty()) {
if (source_stack.back().idx >= source_stack.back().lines.size()) {
// Frame done: summarise it and resume the caller's frame.
const SourceFrame &done = source_stack.back();
Print("source: " + done.filename
+ " (" + std::to_string(done.executed) + " line(s))");
source_stack.pop_back();
continue;
}
SourceFrame &fr = source_stack.back();
const std::string raw = fr.lines[fr.idx++];
++fr.lineno;
size_t start = raw.find_first_not_of(" \t");
if (start == std::string::npos) continue;
if (raw[start] == '#') continue;
@@ -343,28 +361,26 @@ void Tui::ProcessNextSourceLine() {
trimmed.pop_back();
if (trimmed.empty()) continue;
++fr.executed;
int lineno = fr.lineno; // copies: Submit can push a nested frame,
// which may reallocate and invalidate `fr`.
input = trimmed;
cursor_pos = (int)input.size();
Submit();
++loading_executed;
if (screen_idx != source_origin_screen) {
Print("source: line " + std::to_string(loading_lineno)
Print("source: line " + std::to_string(lineno)
+ " is interactive (would open a screen) — aborting.");
screen_idx = source_origin_screen;
loading.store(false);
computing_open = false;
tick_in_flight.store(false);
in_source = loading_prev_in_source;
return;
source_stack.clear(); // an abort cancels the whole chain
break;
}
// One effective line per tick — ack so the ticker can pace the next.
tick_in_flight.store(false);
return;
}
Print("source: " + loading_filename
+ " (" + std::to_string(loading_executed) + " line(s))");
// Stack drained (or aborted): close up.
loading.store(false);
computing_open = false;
tick_in_flight.store(false);

View File

@@ -12,7 +12,6 @@ using namespace ftxui;
Tui::Tui()
: cursor_pos(0), history_idx(-1), scroll_offset(0), quit(false), in_source(false),
loading(false), tick_in_flight(false),
loading_idx(0), loading_executed(0), loading_lineno(0),
loading_prev_in_source(false), screen_ptr(nullptr),
screen_idx(4), // boot to the dashboard; console (screen 0) is now a sub-screen
connect_m1_idx(0), connect_m2_idx(0),
@@ -64,12 +63,17 @@ void Tui::Run() {
// script is opened from the dashboard. The Renderer re-reads the live
// progress every frame.
auto computing_modal = Renderer([this] {
std::string progress = std::to_string(loading_executed) + " / "
+ std::to_string((int)loading_lines.size()) + " lines";
std::string fname, progress;
if (!source_stack.empty()) { // top frame = the file currently running
const SourceFrame &fr = source_stack.back();
fname = fr.filename;
progress = std::to_string(fr.executed) + " / "
+ std::to_string((int)fr.lines.size()) + " lines";
}
return vbox({
text(" Computing… ") | bold | center,
separator(),
text(loading_filename) | center,
text(fname) | center,
text(progress) | center,
}) | borderDouble | size(WIDTH, GREATER_THAN, 40);
});

View File

@@ -13,9 +13,11 @@
#include <ftxui/component/component.hpp>
#include <ftxui/component/screen_interactive.hpp>
#include "frontends/frontend.hpp"
class System;
class Tui {
class Tui : public Frontend {
enum class Completion { None, Path, Command };
struct Prompt {
@@ -97,11 +99,16 @@ class Tui {
// ---- Source-file loading (event-driven, one line per tick) ----
std::atomic<bool> loading; ///< true while a script is being processed; read by tick thread.
std::atomic<bool> tick_in_flight; ///< main thread acks each tick by clearing this; ticker waits.
std::string loading_filename;
std::vector<std::string> loading_lines;
size_t loading_idx;
int loading_executed;
int loading_lineno;
// One script being processed. Nested `source` pushes a frame so the
// caller resumes where it left off — the stack IS the call chain.
struct SourceFrame {
std::string filename;
std::vector<std::string> lines;
size_t idx = 0; ///< next line to process
int executed = 0; ///< effective (non-blank, non-comment) lines run
int lineno = 0; ///< current 1-based line, for messages
};
std::vector<SourceFrame> source_stack;
bool loading_prev_in_source;
int source_origin_screen = 0; ///< screen a `source` started from; a sourced line that navigates away (opens an interactive screen) aborts it.
bool computing_open = false; ///< drives the global "Computing…" progress modal while a script loads.
@@ -198,16 +205,16 @@ private:
public:
Tui();
~Tui();
void Run();
void DumpCommandsMd(std::ostream &out) const;
void Run() override;
void DumpCommandsMd(std::ostream &out) const override;
// Write the accumulated console output to `out`. Used by batch mode to
// surface a script's output without starting the TUI.
void DumpOutput(std::ostream &out) const;
void DumpOutput(std::ostream &out) const override;
// Boot-time hook: dispatch a single command exactly as if the user
// typed it (e.g. `restore foo.essim` or `source bring-up.essim`).
// Call before `Run()` to seed the system before the event loop starts.
void BootDispatch(const std::string &raw);
void BootDispatch(const std::string &raw) override;
private:
// Lifecycle (commands.cpp)

View File

@@ -0,0 +1,16 @@
# wxWidgets GUI frontend. Builds the `essim` executable against essim_core.
#
# Self-contained like every frontend: it pulls its own GUI toolkit (here a
# system wxWidgets via find_package), then defers the target wiring to the
# shared essim_add_frontend() helper. Select it with -DESSIM_FRONTEND=wx.
#
# Needs the wxWidgets development package, e.g. on Debian/Ubuntu:
# sudo apt install libwxgtk3.2-dev
find_package(wxWidgets REQUIRED COMPONENTS core base)
# UsewxWidgets sets the include dirs and compile definitions for targets defined
# afterwards in this directory — so it must come before essim_add_frontend().
include(${wxWidgets_USE_FILE})
essim_add_frontend(wx LIBS ${wxWidgets_LIBRARIES})

10
src/frontends/wx/main.cpp Normal file
View File

@@ -0,0 +1,10 @@
#include "frontends/frontend_main.hpp"
#include "frontends/wx/wx_frontend.hpp"
// The wx frontend's entry point: construct the concrete Frontend (WxFrontend)
// and hand off to the shared, frontend-agnostic launcher. Identical in shape to
// the tui frontend's main — only the Frontend type differs.
int main(int argc, char **argv) {
WxFrontend fe;
return frontend_main(argc, argv, fe);
}

View File

@@ -0,0 +1,680 @@
#include "frontends/wx/wx_frame.hpp"
#include "frontends/wx/wx_frontend.hpp"
#include "core/app/connect.hpp"
#include "core/app/edit.hpp"
#include "core/app/export.hpp"
#include "core/app/load.hpp"
#include "core/app/script.hpp"
#include "core/app/verify.hpp"
#include "core/domain/analysis.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/persist.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signal_type.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
#include <wx/wx.h>
#include <wx/choicdlg.h>
#include <wx/filedlg.h>
#include <wx/filename.h>
#include <wx/textdlg.h>
#include <wx/treectrl.h>
#include <algorithm>
#include <cctype>
#include <sstream>
#include <string>
#include <vector>
namespace {
enum {
ID_LOAD = wxID_HIGHEST + 1,
ID_RESTORE,
ID_RUN_SCRIPT,
ID_SAVE,
ID_EXPORT,
ID_SET_CONNECTOR_TYPE,
ID_ATTACH_BSDL,
ID_SET_SIGNAL_TYPE,
ID_CONNECT,
ID_DUPLICATE,
ID_VERIFY,
ID_QUIT,
ID_ABOUT,
};
// Core (UTF-8 std::string) -> wxString, and back for paths.
inline wxString wx(const std::string &s) { return wxString::FromUTF8(s.c_str()); }
// Natural order ("2" < "10", "A2" < "A10") so pin/part lists read intuitively.
bool natural_less(const std::string &a, const std::string &b) {
size_t i = 0, j = 0;
while (i < a.size() && j < b.size()) {
unsigned char ca = a[i], cb = b[j];
if (std::isdigit(ca) && std::isdigit(cb)) {
size_t i0 = i, j0 = j;
while (i < a.size() && std::isdigit((unsigned char)a[i])) ++i;
while (j < b.size() && std::isdigit((unsigned char)b[j])) ++j;
std::string na = a.substr(i0, i - i0), nb = b.substr(j0, j - j0);
na.erase(0, na.find_first_not_of('0')); // ignore leading zeros
nb.erase(0, nb.find_first_not_of('0'));
if (na.size() != nb.size()) return na.size() < nb.size();
if (na != nb) return na < nb;
} else {
if (ca != cb) return ca < cb;
++i; ++j;
}
}
return a.size() < b.size();
}
// " (Power)" / " (Gnd)" — only for the meaningful types; "" for Other.
wxString type_suffix(SignalType t) {
return t == SignalType::Other ? wxString()
: " (" + wxString(signal_type_name(t)) + ")";
}
// What a tree node stands for, attached to the item so a selection or a
// right-click can drive the edit operations on the right domain object.
struct NodeData : public wxTreeItemData {
enum class Kind { Other, Module, Part, Pin, Signal };
Kind kind;
Module *module = nullptr;
Part *part = nullptr;
Signal *signal = nullptr;
explicit NodeData(Kind k) : kind(k) {}
};
NodeData *node_of(wxTreeCtrl *tree, const wxTreeItemId &id) {
return id.IsOk() ? static_cast<NodeData *>(tree->GetItemData(id)) : nullptr;
}
// The part of the current selection — a Part node, or the Pin's owning part.
Part *selected_part(wxTreeCtrl *tree) {
NodeData *d = node_of(tree, tree->GetSelection());
if (d && (d->kind == NodeData::Kind::Part || d->kind == NodeData::Kind::Pin))
return d->part;
return nullptr;
}
// The signal of the current selection (and, via `mod`, its module).
Signal *selected_signal(wxTreeCtrl *tree, Module **mod) {
NodeData *d = node_of(tree, tree->GetSelection());
if (d && d->kind == NodeData::Kind::Signal) {
if (mod) *mod = d->module;
return d->signal;
}
return nullptr;
}
} // namespace
EssimFrame::EssimFrame(WxFrontend &fe)
: wxFrame(nullptr, wxID_ANY, "essim — system digital twin",
wxDefaultPosition, wxSize(960, 640)),
fe_(fe) {
auto *file = new wxMenu;
file->Append(ID_LOAD, "&Load module…\tCtrl-L");
file->Append(ID_RESTORE, "&Restore snapshot…\tCtrl-R");
file->Append(ID_RUN_SCRIPT, "&Run script…\tCtrl-U");
file->Append(ID_SAVE, "&Save snapshot…\tCtrl-S");
file->AppendSeparator();
file->Append(ID_EXPORT, "&Export connections…\tCtrl-E");
file->AppendSeparator();
file->Append(ID_QUIT, "&Quit\tCtrl-Q");
auto *edit = new wxMenu;
edit->Append(ID_SET_CONNECTOR_TYPE, "Set &connector type…\tCtrl-T");
edit->Append(ID_ATTACH_BSDL, "Attach &BSDL…\tCtrl-B");
edit->Append(ID_SET_SIGNAL_TYPE, "Set &signal type…\tCtrl-G");
edit->AppendSeparator();
edit->Append(ID_CONNECT, "C&onnect parts…\tCtrl-O");
edit->AppendSeparator();
edit->Append(ID_DUPLICATE, "&Duplicate module…\tCtrl-D");
auto *sysm = new wxMenu;
sysm->Append(ID_VERIFY, "&Verify\tCtrl-K");
auto *help = new wxMenu;
help->Append(ID_ABOUT, "&About");
auto *bar = new wxMenuBar;
bar->Append(file, "&File");
bar->Append(edit, "&Edit");
bar->Append(sysm, "&System");
bar->Append(help, "&Help");
SetMenuBar(bar);
CreateStatusBar();
SetStatusText("essim — wx frontend");
auto *panel = new wxPanel(this);
tree_ = new wxTreeCtrl(panel, wxID_ANY);
overview_ = new wxTextCtrl(panel, wxID_ANY, "", wxDefaultPosition,
wxDefaultSize, wxTE_MULTILINE | wxTE_READONLY);
log_ = new wxTextCtrl(panel, wxID_ANY, "", wxDefaultPosition,
wxDefaultSize, wxTE_MULTILINE | wxTE_READONLY);
wxFont mono(wxFontInfo().Family(wxFONTFAMILY_TELETYPE));
overview_->SetFont(mono);
log_->SetFont(mono);
// Cap each control's minimum so its *content* can't inflate the layout's
// minimum size: on GTK a full tree/text reports a large natural size, which
// would otherwise eat all the vertical space and freeze the log at its
// minimum (it stopped resizing once a script populated the tree). With a
// modest min, the sizer proportions govern and content scrolls inside.
tree_->SetMinSize(wxSize(260, 120));
overview_->SetMinSize(wxSize(260, 120));
log_->SetMinSize(wxSize(420, 90));
auto *top = new wxBoxSizer(wxHORIZONTAL);
top->Add(tree_, 1, wxEXPAND | wxALL, 4);
top->Add(overview_, 1, wxEXPAND | wxALL, 4);
auto *root = new wxBoxSizer(wxVERTICAL);
root->Add(top, 2, wxEXPAND);
root->Add(new wxStaticText(panel, wxID_ANY, " Log"), 0, wxLEFT | wxTOP, 6);
root->Add(log_, 1, wxEXPAND | wxALL, 4);
panel->SetSizer(root);
// Drive the panel from a frame sizer so it fills the client area and
// re-lays-out on every resize (the implicit single-child fill is not
// reliable here — without this the log keeps its size when the window grows).
auto *frame_sizer = new wxBoxSizer(wxVERTICAL);
frame_sizer->Add(panel, 1, wxEXPAND);
SetSizer(frame_sizer);
Bind(wxEVT_MENU, &EssimFrame::OnLoad, this, ID_LOAD);
Bind(wxEVT_MENU, &EssimFrame::OnRestore, this, ID_RESTORE);
Bind(wxEVT_MENU, &EssimFrame::OnSave, this, ID_SAVE);
Bind(wxEVT_MENU, &EssimFrame::OnRunScript, this, ID_RUN_SCRIPT);
Bind(wxEVT_MENU, &EssimFrame::OnExport, this, ID_EXPORT);
Bind(wxEVT_MENU, &EssimFrame::OnSetConnectorType, this, ID_SET_CONNECTOR_TYPE);
Bind(wxEVT_MENU, &EssimFrame::OnAttachBsdl, this, ID_ATTACH_BSDL);
Bind(wxEVT_MENU, &EssimFrame::OnSetSignalType, this, ID_SET_SIGNAL_TYPE);
Bind(wxEVT_MENU, &EssimFrame::OnConnect, this, ID_CONNECT);
Bind(wxEVT_MENU, &EssimFrame::OnDuplicateModule, this, ID_DUPLICATE);
Bind(wxEVT_MENU, &EssimFrame::OnVerify, this, ID_VERIFY);
Bind(wxEVT_MENU, &EssimFrame::OnQuit, this, ID_QUIT);
Bind(wxEVT_MENU, &EssimFrame::OnAbout, this, ID_ABOUT);
tree_->Bind(wxEVT_TREE_ITEM_MENU, &EssimFrame::OnTreeContextMenu, this);
RebuildModelView();
}
void EssimFrame::Log(const wxString &line) {
log_->AppendText(line + "\n");
}
void EssimFrame::RebuildModelView() {
System *sys = fe_.system();
tree_->DeleteAllItems();
wxTreeItemId root = tree_->AddRoot("System");
int n_mods = 0, n_parts = 0, n_sigs = 0;
if (sys) {
std::vector<std::string> mods;
for (auto &mkv : *sys->modules()) mods.push_back(mkv.first);
std::sort(mods.begin(), mods.end());
n_mods = (int)mods.size();
for (const auto &mname : mods) {
Module *m = sys->modules()->get(mname);
int mp = (int)m->size();
int ms = (int)m->signals->size();
n_parts += mp;
n_sigs += ms;
wxTreeItemId mid = tree_->AppendItem(
root, wx(mname) + wxString::Format(" — %d part(s), %d signal(s)",
mp, ms));
{
auto *d = new NodeData(NodeData::Kind::Module);
d->module = m;
tree_->SetItemData(mid, d);
}
// Parts → pins (each pin shows the signal it is wired to, or NC).
std::vector<std::string> parts;
for (auto &pkv : *m) parts.push_back(pkv.first);
std::sort(parts.begin(), parts.end(), natural_less);
for (const auto &pname : parts) {
Part *p = m->get(pname);
wxString label = wx(pname)
+ wxString::Format(" (%d pin(s))", (int)p->size());
if (!p->connector_type.empty())
label += " [" + wx(p->connector_type) + "]";
wxTreeItemId pid = tree_->AppendItem(mid, label);
{
auto *d = new NodeData(NodeData::Kind::Part);
d->module = m;
d->part = p;
tree_->SetItemData(pid, d);
}
std::vector<std::string> pins;
for (auto &nkv : *p) pins.push_back(nkv.first);
std::sort(pins.begin(), pins.end(), natural_less);
for (const auto &pinname : pins) {
Pin *pin = p->get(pinname);
wxString pl = wx(pinname) + " -> ";
if (Signal *s = pin->signal()) {
pl += wx(s->name) + type_suffix(s->type);
} else {
pl += "(NC";
if (pin->nc_origin == NcOrigin::ImportedUnconnected)
pl += ", imported";
else if (pin->nc_origin == NcOrigin::DroppedSingleton)
pl += ", dropped";
pl += ")";
}
wxTreeItemId nid = tree_->AppendItem(pid, pl);
auto *d = new NodeData(NodeData::Kind::Pin);
d->module = m;
d->part = p;
tree_->SetItemData(nid, d);
}
}
// Signals branch (the per-module net view: type + fan-out).
if (ms > 0) {
wxTreeItemId sid =
tree_->AppendItem(mid, wxString::Format("Signals (%d)", ms));
std::vector<std::string> sigs;
for (auto &skv : *m->signals) sigs.push_back(skv.first);
std::sort(sigs.begin(), sigs.end(), natural_less);
for (const auto &sname : sigs) {
Signal *s = m->signals->get(sname);
wxTreeItemId nid = tree_->AppendItem(
sid, wx(sname) + type_suffix(s->type)
+ wxString::Format(" — %d pin(s)", (int)s->size()));
auto *d = new NodeData(NodeData::Kind::Signal);
d->module = m;
d->signal = s;
tree_->SetItemData(nid, d);
}
}
tree_->Expand(mid); // parts + Signals visible; pins/nets on demand
}
}
tree_->Expand(root);
int n_conn = sys ? (int)sys->connections()->size() : 0;
wxString ov;
ov << "Modules: " << n_mods << "\n"
<< "Parts: " << n_parts << "\n"
<< "Signals: " << n_sigs << "\n"
<< "Connections: " << n_conn << "\n";
if (sys) {
app::VerifyReport r = app::verify(sys);
ov << "\nHealth (verify):\n"
<< wxString::Format(" pin-role mismatches: %d / %d typed pin(s)\n",
(int)r.role_mismatches.size(), r.typed_pins)
<< wxString::Format(" net inconsistencies: %d / %d bridged net(s)\n",
(int)r.net_inconsistencies.size(), r.bridged_nets)
<< wxString::Format(" orphan pins: %d (%d imported, %d dropped)\n",
r.orphan_total(), r.orphan_imported, r.orphan_dropped)
<< wxString::Format(" model anomalies: %d\n", r.model_total());
}
overview_->SetValue(ov);
}
void EssimFrame::OnLoad(wxCommandEvent &) {
wxFileDialog dlg(this, "Load a netlist / pinout file", "", "",
"All files (*.*)|*.*", wxFD_OPEN | wxFD_FILE_MUST_EXIST);
if (dlg.ShowModal() != wxID_OK) return;
const wxString path = dlg.GetPath();
wxString modname = wxGetTextFromUser("Module name:", "Load module",
wxFileName(path).GetName(), this);
if (modname.empty()) return;
static const wxString kinds[] = {"mentor", "altium", "ods"};
int ki = wxGetSingleChoiceIndex("Import type:", "Load module",
WXSIZEOF(kinds), kinds, this);
if (ki < 0) return;
ImportType type;
app::import_type_from_name(kinds[ki].ToStdString(), type); // choice is valid
app::LoadResult r = app::load_module(
fe_.system(), modname.utf8_string(), path.utf8_string(), type);
if (!r.ok) {
Log("load failed: " + wx(r.error));
wxMessageBox(wx(r.error), "Load failed", wxOK | wxICON_ERROR, this);
return;
}
Log(wxString::Format(
"loaded '%s' from %s — %d part(s), %d signal(s)"
" (dropped %d; types: %d power / %d gnd / %d suspect / %d pwr-mgmt)",
modname, path, r.parts, r.signals, r.dropped, r.power, r.gnd,
r.kept_other, r.mgmt));
RebuildModelView();
}
void EssimFrame::OnRestore(wxCommandEvent &) {
wxFileDialog dlg(this, "Restore a system snapshot", "", "",
"essim snapshots (*.essim)|*.essim|All files (*.*)|*.*",
wxFD_OPEN | wxFD_FILE_MUST_EXIST);
if (dlg.ShowModal() != wxID_OK) return;
std::string err;
System *fresh = restore_system(dlg.GetPath().utf8_string(), err);
if (!fresh) {
Log("restore failed: " + wx(err));
wxMessageBox(wx(err), "Restore failed", wxOK | wxICON_ERROR, this);
return;
}
fe_.set_system(fresh);
Log("restored from " + dlg.GetPath());
RebuildModelView();
}
void EssimFrame::OnRunScript(wxCommandEvent &) {
wxFileDialog dlg(this, "Run an essim script", "", "",
"essim scripts (*.essim)|*.essim|All files (*.*)|*.*",
wxFD_OPEN | wxFD_FILE_MUST_EXIST);
if (dlg.ShowModal() != wxID_OK) return;
fe_.ensure_system();
std::ostringstream out;
app::ScriptResult r =
app::run_script(fe_.system_ptr(), dlg.GetPath().utf8_string(), out);
if (!r.ok) {
Log("run script: " + wx(r.error));
wxMessageBox(wx(r.error), "Run script", wxOK | wxICON_ERROR, this);
return;
}
// Echo each line of the script's output into the log pane.
std::istringstream lines(out.str());
std::string line;
while (std::getline(lines, line)) Log(wx(line));
Log(wxString::Format("source: %s (%d line(s), %d error(s))",
dlg.GetPath(), r.lines, r.errors));
RebuildModelView();
}
void EssimFrame::OnSave(wxCommandEvent &) {
wxFileDialog dlg(this, "Save system snapshot", "", "system.essim",
"essim snapshots (*.essim)|*.essim|All files (*.*)|*.*",
wxFD_SAVE | wxFD_OVERWRITE_PROMPT);
if (dlg.ShowModal() != wxID_OK) return;
std::string err;
if (save_system(fe_.system(), dlg.GetPath().utf8_string(), err)) {
Log("saved to " + dlg.GetPath());
} else {
Log("save failed: " + wx(err));
wxMessageBox(wx(err), "Save failed", wxOK | wxICON_ERROR, this);
}
}
void EssimFrame::OnExport(wxCommandEvent &) {
wxFileDialog dlg(this, "Export connections", "", "connections.csv",
"CSV (*.csv)|*.csv|OpenDocument sheet (*.ods)|*.ods",
wxFD_SAVE | wxFD_OVERWRITE_PROMPT);
if (dlg.ShowModal() != wxID_OK) return;
const std::string path = dlg.GetPath().utf8_string();
app::ExportFormat fmt;
if (!app::export_format_from_path(path, fmt)) {
wxMessageBox("Unknown export extension (use .csv or .ods).",
"Export failed", wxOK | wxICON_ERROR, this);
return;
}
app::ExportResult r = app::export_connections(fe_.system(), path, fmt);
if (r.ok) {
Log(wxString::Format("exported %d row(s) to %s", r.rows, dlg.GetPath()));
} else {
Log("export failed: " + wx(r.error));
wxMessageBox(wx(r.error), "Export failed", wxOK | wxICON_ERROR, this);
}
}
Module *EssimFrame::PickModule(const wxString &caption) {
System *sys = fe_.system();
if (!sys || sys->modules()->size() == 0) {
wxMessageBox("No modules loaded.", caption,
wxOK | wxICON_INFORMATION, this);
return nullptr;
}
std::vector<std::string> mods;
for (auto &mkv : *sys->modules()) mods.push_back(mkv.first);
std::sort(mods.begin(), mods.end());
wxArrayString choices;
for (const auto &m : mods) choices.Add(wx(m));
int mi = wxGetSingleChoiceIndex("Module:", caption, choices, this);
if (mi < 0) return nullptr;
return sys->modules()->get(mods[mi]);
}
Part *EssimFrame::PickPart(const wxString &caption) {
Module *m = PickModule(caption);
if (!m) return nullptr;
if (m->size() == 0) {
wxMessageBox("That module has no parts.", caption,
wxOK | wxICON_INFORMATION, this);
return nullptr;
}
std::vector<std::string> parts;
for (auto &pkv : *m) parts.push_back(pkv.first);
std::sort(parts.begin(), parts.end());
wxArrayString choices;
for (const auto &p : parts) choices.Add(wx(p));
int pi = wxGetSingleChoiceIndex("Part:", caption, choices, this);
if (pi < 0) return nullptr;
return m->get(parts[pi]);
}
void EssimFrame::OnSetConnectorType(wxCommandEvent &) {
Part *p = selected_part(tree_);
if (!p) p = PickPart();
if (!p) return;
wxTextEntryDialog dlg(this, "Connector type (empty = none):",
"Set connector type", wx(p->connector_type));
if (dlg.ShowModal() != wxID_OK) return;
const std::string kind = dlg.GetValue().utf8_string();
app::SetConnectorTypeResult r = app::set_connector_type(p, kind);
if (!r.ok) {
Log("set-connector-type refused: " + wx(r.error));
wxMessageBox(wx(r.error), "Refused", wxOK | wxICON_ERROR, this);
return;
}
wxString who = (p->prnt ? wx(p->prnt->name) + "/" : wxString()) + wx(p->name);
Log(who + ": connector_type = " + (kind.empty() ? wxString("(none)") : wx(kind)));
if (r.materialised > 0)
Log(wxString::Format(" added %d NC pin(s) from the connector layout",
r.materialised));
RebuildModelView();
}
void EssimFrame::OnAttachBsdl(wxCommandEvent &) {
Part *p = selected_part(tree_);
if (!p) p = PickPart();
if (!p) return;
wxFileDialog dlg(this, "Attach a BSDL model", "", "",
"BSDL files (*.bsd)|*.bsd|All files (*.*)|*.*",
wxFD_OPEN | wxFD_FILE_MUST_EXIST);
if (dlg.ShowModal() != wxID_OK) return;
app::AttachBsdlResult r = app::attach_bsdl(p, dlg.GetPath().utf8_string());
if (!r.ok) {
Log("attach-bsdl: " + wx(r.error));
wxMessageBox(wx(r.error), "Attach BSDL failed", wxOK | wxICON_ERROR, this);
return;
}
wxString who = (p->prnt ? wx(p->prnt->name) + "/" : wxString()) + wx(p->name);
wxString tail = r.unbound ? wxString::Format(", %d unbound", r.unbound)
: wxString();
Log(wxString::Format("%s: attached BSDL '%s' — %d/%d ports bound%s",
who, wx(r.entity), r.bound, r.ports_total, tail));
RebuildModelView();
}
void EssimFrame::OnConnect(wxCommandEvent &) {
Part *p1 = selected_part(tree_);
if (!p1) p1 = PickPart("Connect — first part");
if (!p1) return;
Part *p2 = PickPart("Connect — second part");
if (!p2) return;
if (p1 == p2) {
wxMessageBox("Pick two different parts.", "Connect",
wxOK | wxICON_INFORMATION, this);
return;
}
// m1/m2 are the parts' parent modules — connect_parts needs them for the
// Connection name and ownership.
app::ConnectResult r =
app::connect_parts(fe_.system(), p1->prnt, p1, p2->prnt, p2);
if (r.refused) {
Log("connect refused: " + wx(r.error));
wxMessageBox(wx(r.error), "Connect refused", wxOK | wxICON_ERROR, this);
return;
}
if (!r.identity_info.empty()) {
Log("connect: " + wx(r.identity_info));
if (r.nc_added > 0)
Log(wxString::Format("connect: added %d NC pin(s) so both sides match",
r.nc_added));
}
if (r.ok) {
Log(wxString::Format("connected: %s via %s (%d wires)",
wx(r.connection_name), wx(r.transform_name), r.wires));
} else {
Log("connect failed: " + wx(r.error));
wxMessageBox(wx(r.error), "Connect failed", wxOK | wxICON_ERROR, this);
}
RebuildModelView();
}
void EssimFrame::OnSetSignalType(wxCommandEvent &) {
Module *m = nullptr;
Signal *sig = selected_signal(tree_, &m);
if (!sig) {
m = PickModule("Set signal type");
if (!m) return;
if (m->signals->size() == 0) {
wxMessageBox("That module has no signals.", "Set signal type",
wxOK | wxICON_INFORMATION, this);
return;
}
std::vector<std::string> sigs;
for (auto &skv : *m->signals) sigs.push_back(skv.first);
std::sort(sigs.begin(), sigs.end(), natural_less);
wxArrayString schoices;
for (const auto &s : sigs) schoices.Add(wx(s));
int si = wxGetSingleChoiceIndex("Signal:", "Set signal type", schoices, this);
if (si < 0) return;
sig = m->signals->get(sigs[si]);
}
static const wxString types[] = {"power", "gnd", "other"};
int ti = wxGetSingleChoiceIndex("Type:", "Set signal type",
WXSIZEOF(types), types, this);
if (ti < 0) return;
app::SetSignalTypeResult r = app::set_signal_type(sig, types[ti].ToStdString());
if (!r.ok) {
Log(wx(r.error));
wxMessageBox(wx(r.error), "Set signal type", wxOK | wxICON_ERROR, this);
return;
}
Log(wxString::Format("%s/%s: signal type = %s", wx(m->name), wx(sig->name),
wx(signal_type_name(r.type))));
RebuildModelView();
}
void EssimFrame::OnDuplicateModule(wxCommandEvent &) {
Module *m = PickModule("Duplicate module");
if (!m) return;
const std::string src = m->name; // m may move in the table after the add
wxTextEntryDialog dlg(this, "New module name:", "Duplicate module",
wx(src) + "_copy");
if (dlg.ShowModal() != wxID_OK) return;
const std::string dst = dlg.GetValue().utf8_string();
if (dst.empty()) return;
app::DuplicateResult r = app::duplicate_module(fe_.system(), src, dst);
if (!r.ok) {
Log(wx(r.error));
wxMessageBox(wx(r.error), "Duplicate module", wxOK | wxICON_ERROR, this);
return;
}
Log(wx("duplicate: '" + src + "' → '" + dst + "' ("
+ std::to_string(r.parts) + " part(s), "
+ std::to_string(r.signals) + " signal(s))"));
RebuildModelView();
}
void EssimFrame::OnVerify(wxCommandEvent &) {
app::VerifyReport r = app::verify(fe_.system());
Log("verify:");
Log(wxString::Format(" %d pin-role mismatch(es) over %d typed pin(s)",
(int)r.role_mismatches.size(), r.typed_pins));
for (const auto &m : r.role_mismatches)
Log(wx(" " + m.module + "/" + m.part + "/" + m.pin + ": expected "
+ signal_type_name(m.expected) + ", got "
+ signal_type_name(m.actual)));
Log(wxString::Format(" %d inconsistent net(s) over %d bridged net(s)",
(int)r.net_inconsistencies.size(), r.bridged_nets));
Log(wxString::Format(" %d orphan pin(s) (%d imported, %d dropped)",
r.orphan_total(), r.orphan_imported, r.orphan_dropped));
auto log_anoms = [this](const std::vector<Anomaly> &v, const char *tail) {
Log(wxString::Format(" %d %s", (int)v.size(), tail));
for (const auto &a : v)
Log(wx(std::string(" [") + anomaly_kind_name(a.kind) + "] "
+ a.message));
};
log_anoms(r.pin_anomalies, "model-driven pin anomaly(ies)");
log_anoms(r.jtag_anomalies, "JTAG chain anomaly(ies)");
log_anoms(r.conflict_anomalies, "source-conflict(s)");
log_anoms(r.completeness_anomalies, "BSDL completeness issue(s)");
log_anoms(r.diff_anomalies, "diff-pair crossing anomaly(ies)");
RebuildModelView();
}
void EssimFrame::OnQuit(wxCommandEvent &) { Close(true); }
void EssimFrame::OnAbout(wxCommandEvent &) {
wxMessageBox("essim — system digital twin\n\n"
"wxWidgets frontend over essim_core.",
"About essim", wxOK | wxICON_INFORMATION, this);
}
void EssimFrame::OnTreeContextMenu(wxTreeEvent &ev) {
wxTreeItemId id = ev.GetItem();
if (id.IsOk()) tree_->SelectItem(id); // the edit handlers read the selection
NodeData *d = node_of(tree_, id);
if (!d) return;
// Reuse the menu IDs so these route to the same handlers, which now act on
// the (just-selected) tree item.
wxMenu menu;
if (d->kind == NodeData::Kind::Part || d->kind == NodeData::Kind::Pin) {
menu.Append(ID_SET_CONNECTOR_TYPE, "Set connector type…");
menu.Append(ID_ATTACH_BSDL, "Attach BSDL…");
menu.Append(ID_CONNECT, "Connect to…");
} else if (d->kind == NodeData::Kind::Signal) {
menu.Append(ID_SET_SIGNAL_TYPE, "Set signal type…");
}
if (menu.GetMenuItemCount() > 0) PopupMenu(&menu);
}

View File

@@ -0,0 +1,56 @@
#ifndef _WX_FRAME_HPP_
#define _WX_FRAME_HPP_
#include <wx/frame.h>
class WxFrontend;
class wxTreeCtrl;
class wxTextCtrl;
class wxCommandEvent;
class wxTreeEvent;
// The essim main window. Holds no domain state of its own: it reads and mutates
// the System owned by the WxFrontend, calling the core/app operations directly
// (load, verify, export, save, restore) and rendering their results into a
// model tree, an overview panel and a log.
class EssimFrame : public wxFrame {
public:
explicit EssimFrame(WxFrontend &fe);
private:
// Menu handlers — each is a thin wrapper over a core/app operation.
void OnLoad(wxCommandEvent &);
void OnRestore(wxCommandEvent &);
void OnRunScript(wxCommandEvent &);
void OnSave(wxCommandEvent &);
void OnExport(wxCommandEvent &);
void OnSetConnectorType(wxCommandEvent &);
void OnAttachBsdl(wxCommandEvent &);
void OnConnect(wxCommandEvent &);
void OnSetSignalType(wxCommandEvent &);
void OnDuplicateModule(wxCommandEvent &);
void OnVerify(wxCommandEvent &);
void OnQuit(wxCommandEvent &);
void OnAbout(wxCommandEvent &);
// Right-click on a tree item → context menu of the edit actions valid for
// that node (part / signal). The actions reuse the menu IDs, so they run
// the same handlers — which read the tree selection.
void OnTreeContextMenu(wxTreeEvent &);
// Modal pickers over the current System. `caption` titles the dialogs (e.g.
// to distinguish two picks). Each returns nullptr if there is nothing to
// pick or the user cancels.
class Module *PickModule(const wxString &caption);
class Part *PickPart(const wxString &caption = "Select part");
void RebuildModelView(); ///< refresh tree + overview from the System
void Log(const wxString &line); ///< append a line to the log pane
WxFrontend &fe_;
wxTreeCtrl *tree_ = nullptr;
wxTextCtrl *overview_ = nullptr;
wxTextCtrl *log_ = nullptr;
};
#endif // _WX_FRAME_HPP_

View File

@@ -0,0 +1,113 @@
#include "frontends/wx/wx_frontend.hpp"
#include "frontends/wx/wx_frame.hpp"
#include "core/app/script.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/persist.hpp"
#include "core/domain/system.hpp"
#include <wx/app.h>
#include <wx/init.h>
#include <clocale>
#include <ostream>
#include <sstream>
#include <string>
namespace {
// Minimal wxApp: on init it shows the main window bound to the frontend.
class EssimApp : public wxApp {
public:
explicit EssimApp(WxFrontend &fe) : fe_(fe) {}
bool OnInit() override {
// Decode the UTF-8 in our narrow string literals (em dash, ellipsis…)
// correctly: wxString converts const char* via the C locale, which is
// "C" (ASCII) at startup. Set only LC_CTYPE — leave LC_NUMERIC as "C"
// so number formatting stays dot-decimal.
std::setlocale(LC_CTYPE, "");
(new EssimFrame(fe_))->Show(true);
return true;
}
private:
WxFrontend &fe_;
};
} // namespace
WxFrontend::WxFrontend() = default;
WxFrontend::~WxFrontend() = default;
void WxFrontend::ensure_system() {
if (!sys_) sys_.reset(new System());
}
void WxFrontend::set_system(System *fresh) {
sys_.reset(fresh);
}
void WxFrontend::BootDispatch(const std::string &raw) {
// The GUI has no command shell. Honour the boot commands that make sense
// headlessly: `restore <file>` seeds a snapshot; anything else is noted.
std::istringstream iss(raw);
std::string cmd;
iss >> cmd;
std::string arg;
std::getline(iss, arg);
if (std::size_t b = arg.find_first_not_of(" \t"); b != std::string::npos)
arg = arg.substr(b);
else
arg.clear();
if (cmd == "restore") {
std::string err;
System *fresh = restore_system(arg, err);
if (!fresh) {
output_ += "restore failed: " + err + "\n";
return;
}
sys_.reset(fresh);
output_ += "restored from " + arg + " ("
+ std::to_string(sys_->modules()->size()) + " module(s), "
+ std::to_string(sys_->connections()->size())
+ " connection(s))\n";
} else if (cmd == "source") {
ensure_system();
std::ostringstream out;
app::ScriptResult r = app::run_script(sys_, arg, out);
output_ += out.str();
if (!r.ok)
output_ += "source: " + r.error + "\n";
else
output_ += "source: " + arg + " (" + std::to_string(r.lines)
+ " line(s), " + std::to_string(r.errors) + " error(s))\n";
} else if (!cmd.empty()) {
output_ += "boot: ignored '" + raw + "'.\n";
}
}
void WxFrontend::DumpCommandsMd(std::ostream &out) const {
out << "# essim — wx frontend\n\n"
<< "The wx frontend is menu-driven and exposes no textual command "
<< "registry. Generate the command reference from the tui frontend "
<< "(`-DESSIM_FRONTEND=tui`, then `essim --commands-md`).\n";
}
void WxFrontend::DumpOutput(std::ostream &out) const {
out << output_;
}
void WxFrontend::Run() {
ensure_system();
wxApp::SetInstance(new EssimApp(*this));
int argc = 0;
wxEntryStart(argc, static_cast<char **>(nullptr));
if (wxTheApp->CallOnInit())
wxTheApp->OnRun();
wxTheApp->OnExit();
wxEntryCleanup();
}

View File

@@ -0,0 +1,38 @@
#ifndef _WX_FRONTEND_HPP_
#define _WX_FRONTEND_HPP_
#include "frontends/frontend.hpp"
#include <memory>
#include <string>
class System;
// wxWidgets GUI frontend. Implements the shared Frontend interface so the same
// launcher (frontend_main) drives it: it owns the System and a console buffer,
// handles boot commands headlessly (for --restore/--batch), and Run() opens the
// wxWidgets window. The window itself (EssimFrame) drives essim_core / app::*
// operations directly — no command shell, no TUI reuse.
class WxFrontend : public Frontend {
public:
WxFrontend();
~WxFrontend() override;
// --- Frontend interface ---
void BootDispatch(const std::string &raw) override;
void DumpCommandsMd(std::ostream &out) const override;
void DumpOutput(std::ostream &out) const override;
void Run() override;
// --- used by the window (EssimFrame) ---
System *system() const { return sys_.get(); }
std::unique_ptr<System> &system_ptr() { return sys_; } ///< for run_script (new/restore replace it)
void set_system(System *fresh); ///< take ownership (used by Restore)
void ensure_system(); ///< create an empty System if none yet
private:
std::unique_ptr<System> sys_;
std::string output_; ///< console buffer surfaced by DumpOutput (batch)
};
#endif // _WX_FRONTEND_HPP_

View File

@@ -235,7 +235,7 @@ TEST_CASE("infer_signal_types: Power requires name+structural agreement") {
Signal *p_3v3 = m->signals->merge("PWR_3V3"); fan_out(p_3v3, 3); // voltage + ≥ floor → Power
Signal *vcc = m->signals->merge("VCC"); fan_out(vcc, 5); // fan-out ≥ 4 → Power
Signal *pwr_ok = m->signals->merge("PWR_OK"); fan_out(pwr_ok, 1); // < 3 → hard floor → Other
Signal *pwr_ok = m->signals->merge("PWR_OK"); fan_out(pwr_ok, 1); // control token → pwr-mgmt
Signal *pwr_2 = m->signals->merge("PWR_2"); fan_out(pwr_2, 2); // < 3 → hard floor → Other
Signal *gnd = m->signals->merge("GND"); fan_out(gnd, 1); // gnd: name alone
Signal *clk = m->signals->merge("CLK_50MHZ"); fan_out(clk, 3); // not power-ish → Other
@@ -243,7 +243,8 @@ TEST_CASE("infer_signal_types: Power requires name+structural agreement") {
auto st = infer_signal_types(sys.get());
CHECK(st.power == 2); // PWR_3V3, VCC
CHECK(st.gnd == 1); // GND (name alone)
CHECK(st.kept_other == 2); // PWR_OK, PWR_2 below the hard floor
CHECK(st.kept_other == 1); // PWR_2 below the hard floor
CHECK(st.mgmt == 1); // PWR_OK: power-good control, not suspect
CHECK(p_3v3->type == SignalType::Power);
CHECK(vcc->type == SignalType::Power);
@@ -253,6 +254,27 @@ TEST_CASE("infer_signal_types: Power requires name+structural agreement") {
CHECK(clk->type == SignalType::Other);
}
TEST_CASE("infer_signal_types: power-management beats fan-out — a big sense net is still Other") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");
Part *p = new Part("U1"); m->add(p);
// VDD_CORE_SENSE with fan-out 5: structure alone would confirm Power,
// but the control token settles it as a measurement net → Other,
// counted mgmt (not suspect, not power).
Signal *s = m->signals->merge("VDD_CORE_SENSE");
for (int i = 0; i < 5; ++i) {
Pin *pin = new Pin("p" + std::to_string(i));
p->add(pin); s->add(pin); pin->connect(s);
}
auto st = infer_signal_types(sys.get());
CHECK(st.power == 0);
CHECK(st.kept_other == 0);
CHECK(st.mgmt == 1);
CHECK(s->type == SignalType::Other);
}
TEST_CASE("infer_signal_types: fan-out hard floor overrides voltage in name") {
auto sys = std::make_unique<System>();
Module *m = sys->modules()->merge("M");

79
tests/test_connect.cpp Normal file
View File

@@ -0,0 +1,79 @@
#include <doctest/doctest.h>
#include "core/app/connect.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/system.hpp"
// app::connect_parts is pure core: given two already-resolved parts it looks up
// the transform, fills identity NC pins, creates the Connection and returns a
// ConnectResult. No Print/dialog/FTXUI. These tests drive it directly.
namespace {
// A part with the given pin names, attached to a fresh module.
Part *make_part(System &sys, const std::string &mod, const std::string &part,
std::initializer_list<const char *> pins,
const std::string &type = "")
{
Module *m = sys.modules()->merge(mod);
Part *p = new Part(part);
p->connector_type = type;
m->add(p);
for (const char *pn : pins) p->add(new Pin(pn));
return p;
}
} // namespace
TEST_CASE("connect_parts wires an identity-compatible pair") {
System sys;
Module *a = sys.modules()->merge("A");
Module *b = sys.modules()->merge("B");
Part *p1 = make_part(sys, "A", "J1", {"1", "2"});
Part *p2 = make_part(sys, "B", "P1", {"1", "2"});
app::ConnectResult r = app::connect_parts(&sys, a, p1, b, p2);
CHECK(r.ok);
CHECK_FALSE(r.refused);
CHECK(r.transform_name == "identity");
CHECK(r.wires == 2);
CHECK(r.identity_info.empty()); // identical sets → no NC fill, no warning
CHECK(r.nc_added == 0);
CHECK(r.connection_name == "A/J1 <-> B/P1");
CHECK(sys.connections()->size() == 1);
}
TEST_CASE("connect_parts refuses an unknown connector-type pairing") {
System sys;
Module *a = sys.modules()->merge("A");
Module *b = sys.modules()->merge("B");
Part *p1 = make_part(sys, "A", "J1", {"1"}, "foo");
Part *p2 = make_part(sys, "B", "P1", {"1"}, "bar");
app::ConnectResult r = app::connect_parts(&sys, a, p1, b, p2);
CHECK_FALSE(r.ok);
CHECK(r.refused);
CHECK(r.error.find("no transform") != std::string::npos);
CHECK(sys.connections()->size() == 0); // nothing created
}
TEST_CASE("connect_parts fills NC pins on the subset side and reports it") {
System sys;
Module *a = sys.modules()->merge("A");
Module *b = sys.modules()->merge("B");
Part *p1 = make_part(sys, "A", "J1", {"1", "2", "3"}); // larger side
Part *p2 = make_part(sys, "B", "P1", {"1", "2"}); // missing "3"
app::ConnectResult r = app::connect_parts(&sys, a, p1, b, p2);
CHECK(r.ok);
CHECK_FALSE(r.identity_info.empty()); // subset path surfaces a warning
CHECK(r.nc_added == 1); // pin "3" materialised on B
CHECK(r.wires == 3); // all three now wired
CHECK(p2->size() == 3); // the NC pin really got added
}

184
tests/test_diff_check.cpp Normal file
View File

@@ -0,0 +1,184 @@
#include <doctest/doctest.h>
#include "core/app/verify.hpp"
#include "core/domain/analysis.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
// check_diff_crossings: a complete local diff pair (X_P / X_N) must cross a
// connection leg for leg. Both sides are judged by name only.
namespace {
// New pin on `part`, wired to (or creating) module signal `sig_name`.
Pin *wire(Module *m, Part *p, const std::string &pin_name,
const std::string &sig_name)
{
Pin *pin = new Pin(pin_name);
p->add(pin);
Signal *s = m->signals->merge(sig_name);
s->add(pin);
pin->connect(s);
return pin;
}
struct Rig {
System sys;
Module *a, *b;
Part *ja, *jb;
Rig() {
a = sys.modules()->merge("A");
b = sys.modules()->merge("B");
ja = new Part("J1"); a->add(ja);
jb = new Part("P1"); b->add(jb);
}
void bridge(std::initializer_list<std::pair<Pin *, Pin *>> wires) {
Connection *c = new Connection("A/J1 <-> B/P1", a, ja, b, jb);
c->transform_name = "identity";
for (const auto &w : wires) c->pin_map.push_back(w);
sys.connections()->add(c);
}
};
} // namespace
TEST_CASE("diff crossing: straight P↔P / N↔N is silent") {
Rig r;
Pin *ap = wire(r.a, r.ja, "1", "TX_P");
Pin *an = wire(r.a, r.ja, "2", "TX_N");
Pin *bp = wire(r.b, r.jb, "1", "RX_P");
Pin *bn = wire(r.b, r.jb, "2", "RX_N");
r.bridge({{ap, bp}, {an, bn}});
app::VerifyReport vr = app::verify(&r.sys);
CHECK(vr.diff_anomalies.empty());
}
TEST_CASE("diff crossing: swapped legs report ONE polarity-swap anomaly") {
Rig r;
Pin *ap = wire(r.a, r.ja, "1", "TX_P");
Pin *an = wire(r.a, r.ja, "2", "TX_N");
Pin *bp = wire(r.b, r.jb, "1", "RX_P");
Pin *bn = wire(r.b, r.jb, "2", "RX_N");
r.bridge({{ap, bn}, {an, bp}}); // crossed on purpose
app::VerifyReport vr = app::verify(&r.sys);
REQUIRE(vr.diff_anomalies.size() == 1); // deduped: not once per side
CHECK(vr.diff_anomalies[0].kind == AnomalyKind::DiffPolaritySwap);
CHECK(vr.diff_anomalies[0].message.find("polarity swapped")
!= std::string::npos);
}
TEST_CASE("diff crossing: a single bridged leg reports incomplete") {
Rig r;
Pin *ap = wire(r.a, r.ja, "1", "TX_P");
wire(r.a, r.ja, "2", "TX_N"); // N leg stays local
Pin *bp = wire(r.b, r.jb, "1", "RX_P");
wire(r.b, r.jb, "2", "RX_N"); // peer N leg exists, unbridged
r.bridge({{ap, bp}});
app::VerifyReport vr = app::verify(&r.sys);
REQUIRE(vr.diff_anomalies.size() == 1);
CHECK(vr.diff_anomalies[0].kind == AnomalyKind::DiffCrossIncomplete);
CHECK(vr.diff_anomalies[0].message.find("only the P legs")
!= std::string::npos);
}
TEST_CASE("diff crossing: an unsuffixed peer is not judged") {
Rig r;
Pin *ap = wire(r.a, r.ja, "1", "TX_P");
Pin *an = wire(r.a, r.ja, "2", "TX_N");
Pin *b1 = wire(r.b, r.jb, "1", "RXP"); // no _P/_N suffix
Pin *b2 = wire(r.b, r.jb, "2", "RXN");
r.bridge({{ap, b2}, {an, b1}}); // even crossed: silent
app::VerifyReport vr = app::verify(&r.sys);
CHECK(vr.diff_anomalies.empty());
}
TEST_CASE("diff crossing: P and N joined onto one net is flagged") {
Rig r;
Pin *ap = wire(r.a, r.ja, "1", "TX_P");
Pin *an = wire(r.a, r.ja, "2", "TX_N");
Pin *b1 = wire(r.b, r.jb, "1", "X");
Pin *b2 = wire(r.b, r.jb, "2", "X"); // same peer signal
r.bridge({{ap, b1}, {an, b2}});
app::VerifyReport vr = app::verify(&r.sys);
REQUIRE(vr.diff_anomalies.size() == 1);
CHECK(vr.diff_anomalies[0].kind == AnomalyKind::DiffPolaritySwap);
CHECK(vr.diff_anomalies[0].message.find("join the same net")
!= std::string::npos);
}
TEST_CASE("diff bus crossing: a dangling lane is listed, once per side") {
Rig r;
// Two lanes on each side; only lane 0 is bridged — lane 1 crosses nowhere.
Pin *a0p = wire(r.a, r.ja, "1", "TX0_P");
Pin *a0n = wire(r.a, r.ja, "2", "TX0_N");
wire(r.a, r.ja, "3", "TX1_P");
wire(r.a, r.ja, "4", "TX1_N");
Pin *b0p = wire(r.b, r.jb, "1", "RX0_P");
Pin *b0n = wire(r.b, r.jb, "2", "RX0_N");
wire(r.b, r.jb, "3", "RX1_P");
wire(r.b, r.jb, "4", "RX1_N");
r.bridge({{a0p, b0p}, {a0n, b0n}});
app::VerifyReport vr = app::verify(&r.sys);
// One aggregated anomaly per side (each names its own lane signals).
REQUIRE(vr.diff_anomalies.size() == 2);
for (const auto &an : vr.diff_anomalies) {
CHECK(an.kind == AnomalyKind::DiffCrossIncomplete);
CHECK(an.message.find("lane(s) 1 do not cross") != std::string::npos);
}
}
TEST_CASE("diff bus crossing: a distributed bus (lanes fanned out) is silent") {
// A's two lanes go to two DIFFERENT modules — legitimate backplane fan-out.
System sys;
Module *a = sys.modules()->merge("A");
Module *b = sys.modules()->merge("B");
Module *c = sys.modules()->merge("C");
Part *ja = new Part("J1"); a->add(ja);
Part *jb = new Part("P1"); b->add(jb);
Part *jc = new Part("P1"); c->add(jc);
Pin *a0p = wire(a, ja, "1", "TX0_P");
Pin *a0n = wire(a, ja, "2", "TX0_N");
Pin *a1p = wire(a, ja, "3", "TX1_P");
Pin *a1n = wire(a, ja, "4", "TX1_N");
Pin *b0p = wire(b, jb, "1", "RX0_P");
Pin *b0n = wire(b, jb, "2", "RX0_N");
Pin *c0p = wire(c, jc, "1", "RX0_P");
Pin *c0n = wire(c, jc, "2", "RX0_N");
Connection *cb = new Connection("A/J1 <-> B/P1", a, ja, b, jb);
cb->transform_name = "identity";
cb->pin_map.emplace_back(a0p, b0p);
cb->pin_map.emplace_back(a0n, b0n);
sys.connections()->add(cb);
Connection *cc = new Connection("A/J1 <-> C/P1", a, ja, c, jc);
cc->transform_name = "identity";
cc->pin_map.emplace_back(a1p, c0p);
cc->pin_map.emplace_back(a1n, c0n);
sys.connections()->add(cc);
app::VerifyReport vr = app::verify(&sys);
CHECK(vr.diff_anomalies.empty()); // every lane crosses somewhere
}
TEST_CASE("diff crossing: empty / unconnected systems are silent") {
System sys;
app::VerifyReport vr = app::verify(&sys);
CHECK(vr.diff_anomalies.empty());
// A pair that never crosses anything: silent (local pairs are fine).
Rig r;
wire(r.a, r.ja, "1", "TX_P");
wire(r.a, r.ja, "2", "TX_N");
vr = app::verify(&r.sys);
CHECK(vr.diff_anomalies.empty());
}

113
tests/test_edit.cpp Normal file
View File

@@ -0,0 +1,113 @@
#include <doctest/doctest.h>
#include "core/app/edit.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signal_type.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
// app::set_connector_type is pure core: validate the kind, tag the part and
// apply the connector model. No Print/dialog/FTXUI.
TEST_CASE("set_connector_type tags a part with a free-form kind") {
Part p("J1");
p.add(new Pin("1"));
p.add(new Pin("2"));
app::SetConnectorTypeResult r = app::set_connector_type(&p, "myconn");
CHECK(r.ok);
CHECK(r.error.empty());
CHECK(p.connector_type == "myconn");
}
TEST_CASE("set_connector_type refuses a kind the part doesn't fit — no mutation") {
Part p("J1");
p.add(new Pin("1"));
p.add(new Pin("2"));
p.add(new Pin("3")); // numeric pins don't fit the VPX single-letter columns
app::SetConnectorTypeResult r = app::set_connector_type(&p, "vpx-3u-bkp-p0");
CHECK_FALSE(r.ok);
CHECK_FALSE(r.error.empty());
CHECK(p.connector_type.empty()); // refused before any change
}
TEST_CASE("set_connector_type on a null part fails cleanly") {
app::SetConnectorTypeResult r = app::set_connector_type(nullptr, "x");
CHECK_FALSE(r.ok);
CHECK_FALSE(r.error.empty());
}
TEST_CASE("attach_bsdl reports a parse failure without mutating the part") {
Part p("J1");
p.add(new Pin("1"));
app::AttachBsdlResult r = app::attach_bsdl(&p, "/nonexistent-xyz/none.bsd");
CHECK_FALSE(r.ok);
CHECK(r.error.find("cannot parse") != std::string::npos);
CHECK(p.bsdl_path.empty()); // failure leaves the part untouched
}
TEST_CASE("attach_bsdl on a null part fails cleanly") {
app::AttachBsdlResult r = app::attach_bsdl(nullptr, "x.bsd");
CHECK_FALSE(r.ok);
CHECK_FALSE(r.error.empty());
}
TEST_CASE("set_signal_type parses the name and sets the type") {
Signal s("NET");
app::SetSignalTypeResult r = app::set_signal_type(&s, "power");
CHECK(r.ok);
CHECK(r.type == SignalType::Power);
CHECK(s.type == SignalType::Power);
}
TEST_CASE("set_signal_type rejects an unknown name without mutating") {
Signal s("NET");
s.type = SignalType::Other;
app::SetSignalTypeResult r = app::set_signal_type(&s, "bogus");
CHECK_FALSE(r.ok);
CHECK(r.error.find("power, gnd, other") != std::string::npos);
CHECK(s.type == SignalType::Other); // unchanged
}
TEST_CASE("duplicate_module deep-clones parts, pins and signals") {
System sys;
Module *a = sys.modules()->merge("A");
Part *p = new Part("J1"); a->add(p);
Pin *pin = new Pin("1"); p->add(pin);
Signal *s = a->signals->merge("NET");
s->type = SignalType::Power;
s->add(pin); pin->connect(s);
app::DuplicateResult r = app::duplicate_module(&sys, "A", "B");
CHECK(r.ok);
CHECK(r.parts == 1);
CHECK(r.signals == 1);
REQUIRE(sys.modules()->exists("B"));
Module *b = sys.modules()->get("B");
CHECK(b->signals->get("NET")->type == SignalType::Power); // type preserved
Pin *bpin = b->get("J1")->get("1");
REQUIRE(bpin->signal() != nullptr);
CHECK(bpin->signal() == b->signals->get("NET")); // wired to the clone's own signal
CHECK(bpin->signal() != s); // not aliasing the source
}
TEST_CASE("duplicate_module refuses an unknown source or an existing destination") {
System sys;
sys.modules()->merge("A");
app::DuplicateResult dst = app::duplicate_module(&sys, "A", "A");
CHECK_FALSE(dst.ok);
CHECK(dst.error.find("already exists") != std::string::npos);
app::DuplicateResult unk = app::duplicate_module(&sys, "NOPE", "X");
CHECK_FALSE(unk.ok);
CHECK(unk.error.find("unknown module") != std::string::npos);
}

63
tests/test_load.cpp Normal file
View File

@@ -0,0 +1,63 @@
#include <doctest/doctest.h>
#include "core/app/load.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/system.hpp"
#include <cstdio>
#include <fstream>
#include <string>
// app::load_module is pure core: import a module, drop singleton signals, infer
// signal types, return counts or an error — no Print/dialog/FTXUI. The parse
// helper import_type_from_name is likewise UI-free.
TEST_CASE("import_type_from_name maps names case-insensitively") {
ImportType t;
CHECK(app::import_type_from_name("mentor", t));
CHECK(t == ImportType::IMPORT_MENTOR);
CHECK(app::import_type_from_name("ALTIUM", t));
CHECK(t == ImportType::IMPORT_ALTIUM);
CHECK(app::import_type_from_name("Ods", t));
CHECK(t == ImportType::IMPORT_ODS);
CHECK_FALSE(app::import_type_from_name("kicad", t));
CHECK_FALSE(app::import_type_from_name("", t));
}
TEST_CASE("load_module imports, drops singletons and reports counts") {
// Minimal Mentor netlist: two parts; NETA/NETB span both parts (2 pins
// each, kept), LONELY sits on one pin only (dropped as a singleton).
const char *path = "test_load_in.net";
{
std::ofstream f(path);
f << "COMP: 'C1' 'J1'\n"
" Explicit Pin: '1' 'x' 'NETA'\n"
" Explicit Pin: '2' 'x' 'NETB'\n"
" Explicit Pin: '3' 'x' 'LONELY'\n"
"COMP: 'C2' 'J2'\n"
" Explicit Pin: '1' 'x' 'NETA'\n"
" Explicit Pin: '2' 'x' 'NETB'\n";
}
System sys;
app::LoadResult r = app::load_module(&sys, "M", path, ImportType::IMPORT_MENTOR);
CHECK(r.ok);
CHECK(r.error.empty());
CHECK(r.parts == 2);
CHECK(r.signals == 2); // NETA, NETB — LONELY dropped
CHECK(r.dropped == 1); // LONELY
std::remove(path);
}
TEST_CASE("load_module fails cleanly on a missing file") {
// ImportBase opens read-only and System::Load checks is_open(), so a missing
// file is a clean error — and no empty module is left in the system.
System sys;
app::LoadResult r = app::load_module(
&sys, "M", "/nonexistent-dir-xyz/nope.net", ImportType::IMPORT_MENTOR);
CHECK_FALSE(r.ok);
CHECK(r.error.find("cannot open") != std::string::npos);
CHECK(sys.modules()->size() == 0);
}

82
tests/test_script.cpp Normal file
View File

@@ -0,0 +1,82 @@
#include <doctest/doctest.h>
#include "core/app/script.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/system.hpp"
#include <cstdio>
#include <fstream>
#include <memory>
#include <sstream>
#include <string>
// app::run_script is pure core: it drives the app::* operations from a command
// script and writes their output to a stream. No UI.
TEST_CASE("run_script builds a system from a command script") {
const char *net = "test_script_in.net";
{
std::ofstream f(net);
f << "COMP: 'C1' 'J1'\n"
" Explicit Pin: '1' 'x' 'NETA'\n"
" Explicit Pin: '2' 'x' 'NETB'\n"
"COMP: 'C2' 'J2'\n"
" Explicit Pin: '1' 'x' 'NETA'\n"
" Explicit Pin: '2' 'x' 'NETB'\n";
}
const char *scr = "test_script_in.essim";
{
std::ofstream f(scr);
f << "# a comment\n"
"\n"
"set NET " << net << "\n"
"new\n"
"load M $NET mentor\n"
"set-signal-type M NETA power\n"
"verify\n";
}
std::unique_ptr<System> sys;
std::ostringstream out;
app::ScriptResult r = app::run_script(sys, scr, out);
CHECK(r.ok);
CHECK(r.errors == 0);
REQUIRE(sys != nullptr);
CHECK(sys->modules()->size() == 1);
const std::string log = out.str();
CHECK(log.find("loaded 'M'") != std::string::npos); // load ran
CHECK(log.find("signal type = power") != std::string::npos); // $NET expanded + set
CHECK(log.find("verify:") != std::string::npos); // verify ran
std::remove(net);
std::remove(scr);
}
TEST_CASE("run_script reports a missing top-level file") {
std::unique_ptr<System> sys;
std::ostringstream out;
app::ScriptResult r =
app::run_script(sys, "/nonexistent-xyz/none.essim", out);
CHECK_FALSE(r.ok);
CHECK(r.error.find("cannot open") != std::string::npos);
}
TEST_CASE("run_script flags an unsupported command and keeps going") {
const char *scr = "test_script_unsup.essim";
{
std::ofstream f(scr);
f << "new\nfrobnicate widgets\nnew\n";
}
std::unique_ptr<System> sys;
std::ostringstream out;
app::ScriptResult r = app::run_script(sys, scr, out);
CHECK(r.ok);
CHECK(r.errors == 1);
CHECK(out.str().find("unsupported command 'frobnicate'") != std::string::npos);
REQUIRE(sys != nullptr); // the two `new` lines still ran
std::remove(scr);
}

View File

@@ -51,7 +51,52 @@ TEST_CASE("infer_signal_type: power family") {
CHECK(infer_signal_type("VS3_5V0") == SignalType::Power);
CHECK(infer_signal_type("+5V") == SignalType::Power);
CHECK(infer_signal_type("-12V") == SignalType::Power);
CHECK(infer_signal_type("VBAT_SENSE") == SignalType::Power);
// Rail token + control token → power-management, mapped to Other by the
// wrapper (it is a sense line ABOUT VBAT, not the rail).
CHECK(infer_signal_type("VBAT_SENSE") == SignalType::Other);
}
TEST_CASE("classify_signal_name: rail vs power-management signals") {
CHECK(classify_signal_name("VCC").verdict == NameVerdict::Rail);
CHECK(classify_signal_name("VDD_3V3").verdict == NameVerdict::Rail);
CHECK(classify_signal_name("+5V").verdict == NameVerdict::Rail);
NameClassification c = classify_signal_name("VDD_CORE_SENSE");
CHECK(c.verdict == NameVerdict::PowerMgmt);
CHECK(c.token == "SENSE");
CHECK(classify_signal_name("VBAT_SENSE").verdict == NameVerdict::PowerMgmt);
CHECK(classify_signal_name("VCC_EN").verdict == NameVerdict::PowerMgmt);
CHECK(classify_signal_name("VCC_EN1").verdict == NameVerdict::PowerMgmt); // trailing digit
CHECK(classify_signal_name("VDD_FB").verdict == NameVerdict::PowerMgmt);
CHECK(classify_signal_name("PWR_GOOD").verdict == NameVerdict::PowerMgmt);
CHECK(classify_signal_name("PWR_OK").verdict == NameVerdict::PowerMgmt);
CHECK(classify_signal_name("VBUS_DET").verdict == NameVerdict::PowerMgmt);
CHECK(classify_signal_name("POWER_FAIL").verdict == NameVerdict::PowerMgmt);
CHECK(classify_signal_name("VDD_VSENSE").verdict == NameVerdict::PowerMgmt); // fused suffix
CHECK(classify_signal_name("PWR_NFAULT").verdict == NameVerdict::PowerMgmt); // active-low
CHECK(classify_signal_name("VDD_ADJ").verdict == NameVerdict::PowerMgmt); // regulator adjust
CHECK(classify_signal_name("VCC_TRIM").verdict == NameVerdict::PowerMgmt);
CHECK(classify_signal_name("VDD_VTRIM").verdict == NameVerdict::PowerMgmt); // fused suffix
CHECK(classify_signal_name("VCC_VSET").verdict == NameVerdict::PowerMgmt);
CHECK(classify_signal_name("VCC_FBK").verdict == NameVerdict::PowerMgmt); // feedback variants
CHECK(classify_signal_name("VDD_FDB").verdict == NameVerdict::PowerMgmt);
CHECK(classify_signal_name("PWR_CMD").verdict == NameVerdict::PowerMgmt); // command
CHECK(classify_signal_name("PWR_LED").verdict == NameVerdict::PowerMgmt); // indicator
CHECK(classify_signal_name("VDD_REF").verdict == NameVerdict::PowerMgmt); // reference
CHECK(classify_signal_name("VCC_VREF").verdict == NameVerdict::PowerMgmt);
// Whole-token matching: SENSOR is not SENSE, GREEN is not EN —
// these stay genuine rails.
CHECK(classify_signal_name("VDD_SENSOR").verdict == NameVerdict::Rail);
CHECK(classify_signal_name("VCC_GREEN").verdict == NameVerdict::Rail);
// No rail token at all → Other, even with a control word.
CHECK(classify_signal_name("SPI_CS").verdict == NameVerdict::Other);
CHECK(classify_signal_name("FAN_SENSE").verdict == NameVerdict::Other);
// GND family is deliberately left out of the control-token logic.
CHECK(classify_signal_name("GND_RET").verdict == NameVerdict::GndShield);
}
TEST_CASE("infer_signal_type: other (data signals)") {

95
tests/test_verify.cpp Normal file
View File

@@ -0,0 +1,95 @@
#include <doctest/doctest.h>
#include "core/app/verify.hpp"
#include "core/domain/connect.hpp"
#include "core/domain/modules.hpp"
#include "core/domain/parts.hpp"
#include "core/domain/pins.hpp"
#include "core/domain/signals.hpp"
#include "core/domain/system.hpp"
// app::verify is pure core: it takes a System* and returns a VerifyReport of
// structured findings, with no Print/dialog/FTXUI. These tests build small
// systems by hand and assert the report — no UI involved.
TEST_CASE("verify on a null or empty system reports nothing") {
app::VerifyReport none = app::verify(nullptr);
CHECK(none.typed_pins == 0);
CHECK(none.total_nets == 0);
CHECK(none.role_mismatches.empty());
System sys;
app::VerifyReport r = app::verify(&sys);
CHECK(r.typed_pins == 0);
CHECK(r.total_nets == 0);
CHECK(r.bridged_nets == 0);
CHECK(r.net_inconsistencies.empty());
CHECK(r.orphan_total() == 0);
CHECK(r.model_total() == 0);
}
TEST_CASE("verify flags a bridged net that mixes Power and GndShield") {
// Two cards, one wired pin pair: A.NETA (Power) <-> B.NETB (GndShield).
System sys;
Module *a = sys.modules()->merge("A");
Module *b = sys.modules()->merge("B");
Part *ja = new Part("J1"); a->add(ja);
Part *jb = new Part("P1"); b->add(jb);
Pin *pa = new Pin("1"); ja->add(pa);
Pin *pb = new Pin("1"); jb->add(pb);
Signal *sa = a->signals->merge("NETA"); sa->type = SignalType::Power;
Signal *sb = b->signals->merge("NETB"); sb->type = SignalType::GndShield;
sa->add(pa); pa->connect(sa);
sb->add(pb); pb->connect(sb);
Connection *c = new Connection("A.J1<->B.P1", a, ja, b, jb);
c->transform_name = "identity";
c->pin_map.emplace_back(pa, pb);
sys.connections()->add(c);
app::VerifyReport r = app::verify(&sys);
CHECK(r.total_nets == 1);
CHECK(r.bridged_nets == 1);
REQUIRE(r.net_inconsistencies.size() == 1);
CHECK(r.net_inconsistencies[0].members.size() == 2);
// Both endpoints are present with their declared types.
bool seen_power = false, seen_gnd = false;
for (const auto &m : r.net_inconsistencies[0].members) {
if (m.type == SignalType::Power) seen_power = true;
if (m.type == SignalType::GndShield) seen_gnd = true;
}
CHECK(seen_power);
CHECK(seen_gnd);
}
TEST_CASE("verify counts orphan pins by their import origin") {
System sys;
Module *m = sys.modules()->merge("M");
Part *p = new Part("J1"); m->add(p);
Pin *imp = new Pin("1"); imp->nc_origin = NcOrigin::ImportedUnconnected; p->add(imp);
Pin *drp = new Pin("2"); drp->nc_origin = NcOrigin::DroppedSingleton; p->add(drp);
Pin *wired = new Pin("3"); p->add(wired);
Signal *s = m->signals->merge("NET"); s->add(wired); wired->connect(s);
app::VerifyReport r = app::verify(&sys);
CHECK(r.orphan_imported == 1);
CHECK(r.orphan_dropped == 1);
CHECK(r.orphan_total() == 2);
// Per-pin detail carries the path and origin (the dashboard lists the
// dropped ones under the NC health row).
REQUIRE(r.orphans.size() == 2);
int n_dropped = 0;
bool dropped_path_ok = false;
for (const auto &o : r.orphans) {
if (o.dropped) {
++n_dropped;
if (o.module == "M" && o.part == "J1" && o.pin == "2")
dropped_path_ok = true;
}
}
CHECK(n_dropped == 1);
CHECK(dropped_path_ok);
}

71
tests/tui/test_source.cpp Normal file
View File

@@ -0,0 +1,71 @@
#include <doctest/doctest.h>
#include "frontends/tui/tui.hpp"
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>
// Tui::Source nesting — regression for the bug where a nested `source`
// overwrote the single loading state, so the CALLING script's remaining
// lines never ran. Headless path (no screen): BootDispatch drains
// synchronously, exactly like `essim --batch --source`.
namespace {
std::string run_boot(const std::string &cmd) {
Tui t;
t.BootDispatch(cmd);
std::ostringstream oss;
t.DumpOutput(oss);
return oss.str();
}
} // namespace
TEST_CASE("source: lines after a nested source still run") {
const char *inner = "test_src_inner.essim";
const char *outer = "test_src_outer.essim";
{
std::ofstream f(inner);
f << "new\n";
}
{
std::ofstream f(outer);
f << "source " << inner << "\n"
"verify\n";
}
std::string out = run_boot(std::string("source ") + outer);
// The inner script ran and was summarised…
CHECK(out.find("system created.") != std::string::npos);
CHECK(out.find(std::string("source: ") + inner) != std::string::npos);
// …and the OUTER script kept going after it: verify executed…
CHECK(out.find("verify: 0 local mismatch(es)") != std::string::npos);
// …and the outer summary counts its 2 effective lines.
CHECK(out.find(std::string("source: ") + outer + " (2 line(s))")
!= std::string::npos);
std::remove(inner);
std::remove(outer);
}
TEST_CASE("source: self-recursion stops at the depth guard") {
const char *loop = "test_src_loop.essim";
{
std::ofstream f(loop);
f << "source " << loop << "\n";
}
std::string out = run_boot(std::string("source ") + loop);
CHECK(out.find("source: nesting too deep, skipping")
!= std::string::npos);
// Every frame still closes with its own summary.
CHECK(out.find(std::string("source: ") + loop + " (1 line(s))")
!= std::string::npos);
std::remove(loop);
}