diff --git a/README.md b/README.md
index 208ad88..d05cfe7 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,8 @@
# mcltspice
-MCP server for LTspice circuit simulation automation on Linux. Drives LTspice via Wine in batch mode, parses binary `.raw` results, and exposes 37 tools for end-to-end circuit design workflows.
+MCP server for LTspice circuit simulation automation on Linux (via Wine) and native macOS. Drives LTspice in batch mode, parses binary `.raw` results, and exposes 37 tools for end-to-end circuit design workflows.
+
+> **Note:** macOS (native, no Wine) support is new and under active validation. The Linux/Wine path is the mature, battle-tested one.
## Quick Start
@@ -17,8 +19,8 @@ claude mcp add mcltspice -- uv run --directory /path/to/mcltspice mcltspice
## Requirements
-- **Linux** with Wine installed
-- **LTspice** extracted from the Windows installer (see [Setup](#ltspice-setup))
+- **Linux** with Wine installed, OR **macOS** (Apple Silicon or Intel) with LTspice.app
+- **LTspice** — on Linux, extracted from the Windows installer; on macOS, the native app (see [Setup](#ltspice-setup))
- **Python 3.11+**
## Tools
@@ -93,7 +95,7 @@ claude mcp add mcltspice -- uv run --directory /path/to/mcltspice mcltspice
| `get_symbol_info` | Pin details and attributes for a component symbol |
| `search_spice_models` | Search `.model` definitions in the library |
| `search_spice_subcircuits` | Search `.subckt` definitions (op-amps, ICs, etc.) |
-| `check_installation` | Verify LTspice and Wine setup |
+| `check_installation` | Verify LTspice installation (Wine on Linux, native on macOS) |
## Resources
@@ -119,6 +121,8 @@ claude mcp add mcltspice -- uv run --directory /path/to/mcltspice mcltspice
## LTspice Setup
+### Linux (Wine)
+
Extract LTspice from the Windows MSI installer:
```bash
@@ -136,6 +140,20 @@ wineboot --init
Set `LTSPICE_DIR` to point at your extracted directory, or use the default `~/claude/ltspice/extracted/ltspice`.
+### macOS (native)
+
+macOS has a native universal LTspice build, so no Wine is involved. Install LTspice.app from the Mac App Store or [analog.com](https://www.analog.com/en/resources/design-tools-and-calculators/ltspice-simulator-software.html). The binary lives at:
+
+```
+/Applications/LTspice.app/Contents/MacOS/LTspice
+```
+
+The component library lives *outside* the app bundle, at `~/Library/Application Support/LTspice/`, with `lib/{sym,sub,cmp}` underneath. This is unpacked from `lib.zip` the first time you launch LTspice.app from the GUI, so **launch the app once interactively** before running batch simulations.
+
+Set `LTSPICE_BIN` to override the binary path (it defaults to the path above). This mirrors how `LTSPICE_DIR` works on Linux.
+
+> **GUI-session requirement:** Mac LTspice is an Aqua GUI application. It runs batch simulations, but it needs an active login/GUI session to do so — it will not run over a bare SSH connection with no logged-in desktop. This differs from the Linux/Wine path, which runs fully headless.
+
## Repository
[git.supported.systems/MCP/mcltspice](https://git.supported.systems/MCP/mcltspice)
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index bf70f0a..b2914c9 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -64,7 +64,7 @@ export default defineConfig({
label: 'Concepts',
collapsed: true,
items: [
- { label: 'LTspice on Linux', slug: 'concepts/ltspice-on-linux' },
+ { label: 'How it works', slug: 'concepts/ltspice-on-linux' },
{ label: 'Simulation Types', slug: 'concepts/simulation-types' },
],
},
diff --git a/docs/src/content/docs/concepts/ltspice-on-linux.mdx b/docs/src/content/docs/concepts/ltspice-on-linux.mdx
index 73c4175..5905505 100644
--- a/docs/src/content/docs/concepts/ltspice-on-linux.mdx
+++ b/docs/src/content/docs/concepts/ltspice-on-linux.mdx
@@ -1,11 +1,11 @@
---
-title: LTspice on Linux
-description: How mcltspice drives a Windows application via Wine.
+title: How mcltspice drives LTspice
+description: How mcltspice runs LTspice in batch mode — via Wine on Linux, natively on macOS.
---
import { Aside } from '@astrojs/starlight/components';
-LTspice is a Windows application. There is no official Linux build. mcltspice solves this by running LTspice through Wine in headless batch mode --- no GUI, no display server, no X11 forwarding required. This page explains the key pieces of the stack and how they fit together.
+LTspice ships as a Windows application, and there is no official Linux build — so on Linux mcltspice runs it through Wine in headless batch mode, with no GUI, no display server, and no X11 forwarding required. On macOS there *is* a native universal build, so mcltspice drives that binary directly with no Wine layer at all. This page explains the key pieces of both stacks and how they fit together.
## Wine and batch mode
@@ -41,6 +41,26 @@ After that, mcltspice sets `WINEPREFIX` automatically before each simulation run
The prefix only needs the bare minimum Wine runtime. LTspice does not depend on .NET, Visual C++ redistributables, or any other Windows components for batch simulation.
+## macOS: the native binary
+
+On macOS none of the Wine machinery applies. There is a native universal LTspice build, so mcltspice invokes the binary directly:
+
+```bash
+/Applications/LTspice.app/Contents/MacOS/LTspice -b circuit.net
+```
+
+The differences from the Linux/Wine path are worth spelling out:
+
+- **No Wine layer.** No `WINEPREFIX`, no `WINEARCH`, no `wineboot`. mcltspice detects `sys.platform == 'darwin'` and calls the binary. `LTSPICE_BIN` overrides the path, defaulting to the location above (mirroring `LTSPICE_DIR` on Linux).
+- **Plain POSIX paths.** The Wine path rewrites file arguments into the `Z:\...` drive-letter form Windows expects. The native Mac binary takes ordinary POSIX paths, so no translation happens.
+- **Library root outside the app.** The component library lives at `~/Library/Application Support/LTspice/` with `lib/{sym,sub,cmp}` underneath, unpacked from `lib.zip` on the first GUI launch — not glued onto the executable's directory the way the Linux `lib/` folder sits beside `XVIIx64.exe`.
+- **`.asc` needs internal netlisting.** The Mac command-line binary has no netlister: `LTspice -b circuit.asc` parses the schematic literally as a netlist and fails. So on macOS, `simulate(.asc)` converts the schematic to a `.net` internally and then runs `-b` on the netlist. `simulate_netlist(.net/.cir)` runs unchanged. On Linux, Wine's LTspice netlists the `.asc` itself, so no internal step is needed. (This is the same wall that spicelib / PyLTSpice hit on macOS.)
+- **Needs a GUI session; poll for output.** Mac LTspice is an Aqua app that batch-runs but requires an active login/GUI session — it won't run over bare SSH the way Wine + xvfb does. Its exit codes aren't reliable, so mcltspice polls for the `.raw`/`.log` files to appear with a timeout, rather than trusting the process return code.
+
+
+
## Binary .raw files
LTspice writes simulation results to binary `.raw` files. These are not human-readable --- they use a compact binary format specific to LTspice.
diff --git a/docs/src/content/docs/getting-started/claude-code.mdx b/docs/src/content/docs/getting-started/claude-code.mdx
index a60b920..2d2bfdc 100644
--- a/docs/src/content/docs/getting-started/claude-code.mdx
+++ b/docs/src/content/docs/getting-started/claude-code.mdx
@@ -99,4 +99,4 @@ After adding the server, ask your MCP client to call the `check_installation` to
check_installation()
```
-If the server is connected and LTspice is found, you will get a status report confirming the Wine version, LTspice binary path, and library availability. If anything is missing, see [Prerequisites](/getting-started/prerequisites/).
+If the server is connected and LTspice is found, you will get a status report confirming the LTspice binary path and library availability (plus the Wine version on Linux, or the GUI-session status on macOS). If anything is missing, see [Prerequisites](/getting-started/prerequisites/).
diff --git a/docs/src/content/docs/getting-started/first-simulation.mdx b/docs/src/content/docs/getting-started/first-simulation.mdx
index e838add..96777fb 100644
--- a/docs/src/content/docs/getting-started/first-simulation.mdx
+++ b/docs/src/content/docs/getting-started/first-simulation.mdx
@@ -35,7 +35,7 @@ This walkthrough takes you from zero to a plotted frequency response using five
2. **Run the simulation**
- Pass the netlist to `simulate_netlist`. LTspice runs via Wine in batch mode and produces a binary `.raw` file with the results.
+ Pass the netlist to `simulate_netlist`. LTspice runs in batch mode (via Wine on Linux, natively on macOS) and produces a binary `.raw` file with the results.
```
simulate_netlist("/tmp/rc_lowpass.cir")
@@ -123,7 +123,7 @@ This walkthrough takes you from zero to a plotted frequency response using five
In five tool calls, you:
- Generated a complete SPICE netlist from a template
-- Ran an AC analysis through LTspice (via Wine, in batch mode, with no GUI)
+- Ran an AC analysis through LTspice in batch mode (via Wine with no GUI on Linux; native on macOS)
- Extracted complex-valued frequency-domain data from the binary `.raw` file
- Measured the -3dB bandwidth automatically
- Produced a publication-ready Bode plot
diff --git a/docs/src/content/docs/getting-started/index.mdx b/docs/src/content/docs/getting-started/index.mdx
index fea7ab2..2bb378b 100644
--- a/docs/src/content/docs/getting-started/index.mdx
+++ b/docs/src/content/docs/getting-started/index.mdx
@@ -5,7 +5,7 @@ description: Install mcltspice and run your first circuit simulation.
import { Steps, Card, CardGrid, LinkCard } from '@astrojs/starlight/components';
-mcltspice is an MCP server that drives LTspice on Linux via Wine. It exposes 37 tools for circuit simulation, waveform extraction, signal analysis, and design automation --- all accessible from Claude Code or any MCP client.
+mcltspice is an MCP server that drives LTspice on Linux (via Wine) or natively on macOS. It exposes 37 tools for circuit simulation, waveform extraction, signal analysis, and design automation --- all accessible from Claude Code or any MCP client.
## Setup at a glance
@@ -13,7 +13,7 @@ mcltspice is an MCP server that drives LTspice on Linux via Wine. It exposes 37
1. **Install prerequisites**
- You need Wine and an extracted copy of LTspice. Wine runs the LTspice binary in batch mode; no GUI required.
+ On Linux, you need Wine and an extracted copy of LTspice; Wine runs the binary in batch mode with no GUI required. On macOS, you need LTspice.app and an active GUI/login session.
[Prerequisites details](/getting-started/prerequisites/)
diff --git a/docs/src/content/docs/getting-started/prerequisites.mdx b/docs/src/content/docs/getting-started/prerequisites.mdx
index e954e3b..66ae2b3 100644
--- a/docs/src/content/docs/getting-started/prerequisites.mdx
+++ b/docs/src/content/docs/getting-started/prerequisites.mdx
@@ -1,10 +1,19 @@
---
title: Prerequisites
-description: Set up LTspice and Wine on Linux.
+description: Set up LTspice on Linux (via Wine) or natively on macOS.
---
import { Steps, Tabs, TabItem, Aside, Code } from '@astrojs/starlight/components';
+mcltspice drives LTspice in batch mode. How you set it up depends on your platform: on Linux it runs LTspice through Wine (fully headless), and on macOS it drives the native LTspice.app directly (no Wine).
+
+
+
+
+
+
mcltspice drives LTspice through Wine in headless batch mode. You need two things: a working Wine installation and the LTspice application files extracted from the Windows installer.
## Install Wine
@@ -92,18 +101,81 @@ For persistent configuration, add it to your shell profile (`~/.bashrc`, `~/.zsh
The directory should contain the `XVIIx64.exe` binary and the `lib/` folder with component libraries. If you see those files, the extraction worked correctly.
+
+
+
+macOS has a native universal LTspice build, so there is no Wine, no `WINEPREFIX`, and no `Z:\` drive translation. mcltspice calls the native binary directly.
+
+## Install LTspice.app
+
+Install LTspice from the Mac App Store or from [analog.com/ltspice](https://www.analog.com/en/resources/design-tools-and-calculators/ltspice-simulator-software.html). This gives you a native universal binary (Apple Silicon and Intel) at:
+
+```
+/Applications/LTspice.app/Contents/MacOS/LTspice
+```
+
+## Populate the library
+
+The component library lives *outside* the app bundle, at `~/Library/Application Support/LTspice/`, with `lib/{sym,sub,cmp}` underneath. LTspice unpacks this from a bundled `lib.zip` the first time it launches from the GUI.
+
+
+
+1. **Launch LTspice.app once, interactively.**
+
+ Open LTspice from Finder or Spotlight. On first launch it populates `~/Library/Application Support/LTspice/lib/`. This step is required before batch simulations can resolve symbols and models.
+
+2. **Confirm the library exists.**
+
+ ```bash
+ ls ~/Library/Application\ Support/LTspice/lib
+ # expect: sym sub cmp
+ ```
+
+
+
+## Set the binary path
+
+mcltspice looks for the LTspice binary in this order:
+
+1. The `LTSPICE_BIN` environment variable
+2. The default path: `/Applications/LTspice.app/Contents/MacOS/LTspice`
+
+If you installed LTspice.app elsewhere, set the override:
+
+```bash
+export LTSPICE_BIN=/path/to/LTspice.app/Contents/MacOS/LTspice
+```
+
+`LTSPICE_BIN` is the macOS equivalent of `LTSPICE_DIR` on Linux — it points mcltspice at the executable. The library root (`~/Library/Application Support/LTspice/`) is resolved separately.
+
+
+
+
+
+
## Verify the setup
-Once Wine and LTspice are in place, use the `check_installation` tool to confirm everything is working:
+Once LTspice is in place, use the `check_installation` tool to confirm everything is working:
```
check_installation()
```
-This checks for:
+The report is the single source of truth for whether the box is usable, and its contents differ by platform.
+
+On **Linux** it checks for:
- Wine availability and version
- LTspice binary presence
- Library and symbol directories
- Write permissions for output files
+On **macOS** it reports:
+- The platform
+- The resolved native binary path (from `LTSPICE_BIN` or the default)
+- The resolved library root (`~/Library/Application Support/LTspice/`)
+- The detected LTspice version
+- GUI-session availability (whether an active login session is present to run batch jobs)
+
If any check fails, the tool reports exactly what is missing and how to fix it.
diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx
index 29b173a..94cbd2a 100644
--- a/docs/src/content/docs/index.mdx
+++ b/docs/src/content/docs/index.mdx
@@ -27,7 +27,7 @@ import { Card, CardGrid } from '@astrojs/starlight/components';
[Browse tutorials](/tutorials/)
- Background on LTspice on Linux, simulation types, and SPICE fundamentals.
+ Background on how mcltspice runs LTspice, simulation types, and SPICE fundamentals.
[Read concepts](/concepts/ltspice-on-linux/)
diff --git a/pyproject.toml b/pyproject.toml
index 9f8fa01..4e1537d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -15,6 +15,8 @@ classifiers = [
"Intended Audience :: Science/Research",
"Topic :: Scientific/Engineering :: Electronic Design Automation (EDA)",
"License :: OSI Approved :: MIT License",
+ "Operating System :: POSIX :: Linux",
+ "Operating System :: MacOS",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
diff --git a/src/mcltspice/config.py b/src/mcltspice/config.py
index 800bb5e..5a9c8c8 100644
--- a/src/mcltspice/config.py
+++ b/src/mcltspice/config.py
@@ -1,16 +1,57 @@
"""Configuration for mcltspice."""
import os
+import sys
+from collections.abc import Callable
+from dataclasses import dataclass
from pathlib import Path
-# LTspice installation paths
+# Single platform-branch source of truth. Every platform-specific value below
+# keys off this; consumers should prefer get_launch() over reading the raw
+# platform-specific paths so they never have to branch themselves.
+IS_DARWIN = sys.platform == "darwin"
+
+# LTspice installation paths (Linux/Wine values -- defined unconditionally so
+# they stay importable everywhere; on darwin they are simply unused by
+# get_launch()).
LTSPICE_DIR = Path(
os.environ.get("LTSPICE_DIR", Path.home() / "claude" / "ltspice" / "extracted" / "ltspice")
)
+# Windows executable driven through Wine on Linux. NOTE: this is a .exe and
+# must NOT be subprocess-exec'd directly on Linux -- go through get_launch().
LTSPICE_EXE = LTSPICE_DIR / "LTspice.exe"
-LTSPICE_LIB = LTSPICE_DIR / "lib"
-LTSPICE_EXAMPLES = LTSPICE_DIR / "examples"
+
+# Native macOS binary (universal, no Wine). Env-overridable, mirroring
+# LTSPICE_DIR. On Linux this aliases LTSPICE_EXE and is not launched directly.
+LTSPICE_BIN = (
+ Path(os.environ.get("LTSPICE_BIN", "/Applications/LTspice.app/Contents/MacOS/LTspice"))
+ if IS_DARWIN
+ else LTSPICE_EXE
+)
+
+# Resolved, env-overridable lib root. On darwin the lib root lives OUTSIDE the
+# app bundle at ~/Library/Application Support/LTspice with lib/{sym,sub,cmp}
+# under it. On Linux it defaults to LTSPICE_DIR so LTSPICE_LIB resolves
+# byte-identically to the historical LTSPICE_DIR / "lib".
+LTSPICE_LIB_ROOT = (
+ Path(
+ os.environ.get(
+ "LTSPICE_LIB_ROOT",
+ Path.home() / "Library" / "Application Support" / "LTspice",
+ )
+ )
+ if IS_DARWIN
+ else Path(os.environ.get("LTSPICE_LIB_ROOT", LTSPICE_DIR))
+)
+
+LTSPICE_LIB = LTSPICE_LIB_ROOT / "lib"
+LTSPICE_EXAMPLES = Path(
+ os.environ.get(
+ "LTSPICE_EXAMPLES",
+ (LTSPICE_LIB_ROOT / "examples") if IS_DARWIN else (LTSPICE_DIR / "examples"),
+ )
+)
# Wine configuration
WINE_PREFIX = LTSPICE_DIR / ".wine"
@@ -47,12 +88,70 @@ def get_wine_env() -> dict[str, str]:
return env
+@dataclass
+class LaunchSpec:
+ """How to invoke LTspice on this platform.
+
+ The single abstraction runner.py consumes so it never branches on platform
+ for argv/env/path construction. Built by get_launch().
+
+ Attributes:
+ argv_prefix: Command prefix. Linux: ["wine", str(LTSPICE_EXE)];
+ macOS: [str(LTSPICE_BIN)]. Callers append "-b" and the run target.
+ env: Subprocess environment. Linux: get_wine_env() (WINEPREFIX/
+ WINEARCH/WINEDEBUG set); macOS: os.environ.copy() with no Wine vars.
+ translate_path: Maps a POSIX Path to the string LTspice expects.
+ Linux: "Z:" + backslash-translated path (Wine drive mapping);
+ macOS: identity POSIX string (native binary takes plain paths).
+ is_darwin: True on macOS -- lets runner branch on completion strategy
+ (poll-for-output vs communicate()).
+ """
+
+ argv_prefix: list[str]
+ env: dict[str, str]
+ translate_path: Callable[[Path], str]
+ is_darwin: bool
+
+
+def get_launch() -> LaunchSpec:
+ """Return the platform-appropriate LaunchSpec for invoking LTspice.
+
+ This is the single platform switch: runner.py imports this instead of
+ LTSPICE_EXE + get_wine_env() and stays platform-agnostic. The Linux spec
+ reproduces the historical Wine argv, environment, and "Z:" drive-letter
+ path translation byte-for-byte.
+ """
+ if IS_DARWIN:
+ return LaunchSpec(
+ argv_prefix=[str(LTSPICE_BIN)],
+ env=os.environ.copy(),
+ translate_path=lambda p: str(p),
+ is_darwin=True,
+ )
+ return LaunchSpec(
+ argv_prefix=["wine", str(LTSPICE_EXE)],
+ env=get_wine_env(),
+ # Wine maps Z: to the root filesystem; reproduce the exact translation
+ # runner.py performed historically (chr(92) == backslash).
+ translate_path=lambda p: "Z:" + str(p).replace("/", chr(92)),
+ is_darwin=False,
+ )
+
+
def validate_installation() -> tuple[bool, str]:
"""Check if LTspice is properly installed."""
- if not LTSPICE_DIR.exists():
- return False, f"LTspice directory not found: {LTSPICE_DIR}"
- if not LTSPICE_EXE.exists():
- return False, f"LTspice executable not found: {LTSPICE_EXE}"
- if not WINE_PREFIX.exists():
- return False, f"Wine prefix not found: {WINE_PREFIX}"
+ if not IS_DARWIN:
+ if not LTSPICE_DIR.exists():
+ return False, f"LTspice directory not found: {LTSPICE_DIR}"
+ if not LTSPICE_EXE.exists():
+ return False, f"LTspice executable not found: {LTSPICE_EXE}"
+ if not WINE_PREFIX.exists():
+ return False, f"Wine prefix not found: {WINE_PREFIX}"
+ return True, "LTspice installation OK"
+
+ # macOS: native binary + external lib root, no Wine.
+ if not LTSPICE_BIN.exists():
+ return False, f"LTspice binary not found: {LTSPICE_BIN}"
+ if not LTSPICE_LIB.exists():
+ return False, f"LTspice lib root not found: {LTSPICE_LIB}"
return True, "LTspice installation OK"
diff --git a/src/mcltspice/library_tools.py b/src/mcltspice/library_tools.py
index 3cb239c..6b2fea5 100644
--- a/src/mcltspice/library_tools.py
+++ b/src/mcltspice/library_tools.py
@@ -11,10 +11,13 @@ from pathlib import Path
from ._app import mcp
from .config import (
+ IS_DARWIN,
+ LTSPICE_BIN,
LTSPICE_DIR,
LTSPICE_EXAMPLES,
LTSPICE_EXE,
LTSPICE_LIB,
+ LTSPICE_LIB_ROOT,
WINE_PREFIX,
validate_installation,
)
@@ -79,12 +82,89 @@ def list_examples_impl(
return {"examples": examples[:limit], "total_count": total, "returned_count": min(limit, total)}
+def _detect_mac_version() -> str | None:
+ """Read the LTspice version string from the .app bundle's Info.plist.
+
+ Returns the CFBundleShortVersionString (e.g. "17.2.4") or None if it can't
+ be read. Best-effort: never raises.
+ """
+ try:
+ import plistlib
+
+ # LTSPICE_BIN is .../LTspice.app/Contents/MacOS/LTspice; the plist is a
+ # sibling of the MacOS dir under Contents/.
+ plist_path = LTSPICE_BIN.parent.parent / "Info.plist"
+ if not plist_path.exists():
+ return None
+ with plist_path.open("rb") as fh:
+ data = plistlib.load(fh)
+ return data.get("CFBundleShortVersionString") or data.get("CFBundleVersion")
+ except Exception:
+ return None
+
+
+def _mac_gui_session_available() -> bool:
+ """Best-effort check for an active Aqua GUI/login session on macOS.
+
+ Mac LTspice batch runs still need an active window-server session; over a
+ bare SSH login there is none and simulations never produce a .raw. We probe
+ for the window server the way Apple's own tooling does -- SecuritySession /
+ the presence of a graphical console owner. Never raises; conservatively
+ returns False when it can't tell.
+ """
+ try:
+ # $SECURITYSESSIONID / an accessible window server is the practical
+ # signal. Checking for a GUI console owner via `stat` on /dev/console
+ # owner and the presence of the WindowServer are heavier; a cheap and
+ # reliable probe is whether a Quartz display connection can be opened.
+ import subprocess
+
+ # `launchctl managername` reports "Aqua" inside a GUI session and
+ # "Background"/"System" otherwise. Fast, no extra deps.
+ result = subprocess.run(
+ ["launchctl", "managername"],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ )
+ return result.stdout.strip() == "Aqua"
+ except Exception:
+ return False
+
+
def check_installation_impl() -> dict:
- """Core logic for check_installation (callable; the tool wrapper delegates here)."""
+ """Core logic for check_installation (callable; the tool wrapper delegates here).
+
+ Single source of truth for "is this box usable". On Linux it reports the
+ Wine/LTspice paths as before; on macOS it reports platform, resolved native
+ binary, resolved lib root, detected version, and GUI-session availability.
+ """
ok, msg = validate_installation()
+
+ if IS_DARWIN:
+ version = _detect_mac_version()
+ gui_available = _mac_gui_session_available()
+ return {
+ "valid": ok,
+ "message": msg,
+ "platform": "darwin",
+ "version": version,
+ "gui_session_available": gui_available,
+ "paths": {
+ "ltspice_bin": str(LTSPICE_BIN),
+ "lib_root": str(LTSPICE_LIB_ROOT),
+ "lib_dir": str(LTSPICE_LIB),
+ "examples_dir": str(LTSPICE_EXAMPLES),
+ },
+ "bin_exists": LTSPICE_BIN.exists(),
+ "lib_exists": LTSPICE_LIB.exists(),
+ "examples_exist": LTSPICE_EXAMPLES.exists(),
+ }
+
return {
"valid": ok,
"message": msg,
+ "platform": "linux",
"paths": {
"ltspice_dir": str(LTSPICE_DIR),
"ltspice_exe": str(LTSPICE_EXE),
diff --git a/src/mcltspice/netlister.py b/src/mcltspice/netlister.py
new file mode 100644
index 0000000..3542613
--- /dev/null
+++ b/src/mcltspice/netlister.py
@@ -0,0 +1,406 @@
+"""Pure-Python LTspice .asc -> SPICE netlist converter.
+
+On macOS there is no CLI netlister: ``LTspice -b file.asc`` parses the .asc
+literally as a netlist and dies with ``Multiple instances of "Flag"``. Only
+``LTspice -b file.net`` works. This module bridges that gap by converting a
+schematic to a netlist entirely in Python -- no LTspice binary, no subprocess,
+no filesystem -- so ``runner.run_simulation()`` can, on the darwin branch,
+turn an .asc into .net text and run the already-working ``-b`` netlist path.
+
+The Linux/Wine path does NOT use this module: there the native
+``wine LTspice.exe -netlist`` netlister runs and is left untouched.
+
+Pipeline (see ``asc_to_netlist``):
+ 1. Parse the .asc via the existing ``schematic.parse_schematic``.
+ 2. Compute absolute pin coordinates per component using the same
+ ``_PIN_OFFSETS`` + ``_rotate`` geometry ``asc_generator`` uses to emit
+ these schematics (pin geometry is a solved problem for exactly this
+ vocabulary of symbols).
+ 3. Extract nets with a wire-geometry union-find: wire endpoints join per
+ segment, any pin or flag coordinate lying on a (Manhattan) wire segment
+ joins that wire's net, coincident points merge, FLAG '0' forces net '0',
+ named flags force their name, and remaining nets get deterministic
+ synthesized names N001, N002, ...
+ 4. Emit one SPICE line per component in SpiceOrder, append the schematic's
+ spice directives, then ``.backanno`` / ``.end`` -- reusing
+ ``netlist.Netlist`` for rendering.
+
+SCOPE (MVP): the mcltspice-generated template schematics -- a known fixed
+vocabulary of ~11 symbols (res, cap, ind, voltage, current, diode, npn, pnp,
+nmos, pmos, OpAmps/UniversalOpamp2), axis-aligned rotations, pure-Manhattan
+wiring. Constructs outside this set raise a clear ``ValueError`` rather than
+emitting a wrong netlist. Full arbitrary-.asc support (parsing real .asy
+PIN/SpiceOrder from the resolved Mac lib root, mirror transforms, diagonal
+wires, hierarchical blocks) is a clean phase-2 extension that only changes the
+pin/prefix lookup -- the union-find core is unchanged.
+
+Auto-generated net names (N00x) do NOT match LTspice's internal, non-positional
+numbering byte-for-byte; that is cosmetic. Named nets (out, 0, sw, ...) and the
+component-to-net topology -- which is what determines simulation results -- do
+match the Wine oracle. Validate topology by graph isomorphism, not raw diff.
+
+Kevin will validate this against the native netlister on real Apple-Silicon
+Mac hardware; treat the op-amp/subcircuit .lib injection here as best-effort
+until that confirmation lands.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .asc_generator import _PIN_OFFSETS, _rotate
+from .netlist import Netlist, NetlistComponent
+from .schematic import Component, Schematic, parse_schematic
+
+# SPICE prefix letter per base symbol. Used only to decide how many nodes a
+# component emits (and the implicit-substrate rule); the instance name from the
+# schematic is authoritative for the emitted device name, so this is a
+# fallback, not an override.
+_SYMBOL_PREFIX: dict[str, str] = {
+ "res": "R",
+ "cap": "C",
+ "ind": "L",
+ "voltage": "V",
+ "current": "I",
+ "diode": "D",
+ "npn": "Q",
+ "pnp": "Q",
+ "nmos": "M",
+ "pmos": "M",
+ "nmos4": "M",
+ "pmos4": "M",
+ "OpAmps/UniversalOpamp2": "X",
+}
+
+# BJTs and 3-terminal MOSFETs get an implicit 4th (substrate/body) node that
+# LTspice ties to ground ('0') by default. Confirmed against the Wine oracle:
+# Q1 N002 N004 N005 0 2N2222 (npn, 3 drawn pins + '0' substrate)
+# M1 N001 N002 sw sw IRF540N (nmos, source is repeated as body)
+# For npn/pnp the implicit node is '0'; for 3-pin MOSFETs LTspice repeats the
+# source node as the body. 4-pin *mos4 symbols carry their own body pin.
+_IMPLICIT_SUBSTRATE_GROUND = {"npn", "pnp"}
+_MOSFET_3PIN_BODY_IS_SOURCE = {"nmos", "pmos"}
+
+# The one subcircuit symbol the templates use. Emitting a correct netlist for
+# it requires the SpiceModel/Value2/SpiceLine expansion and the auto-.lib that
+# the Wine oracle injects from the .asy ModelFile attribute. Hardcoded here for
+# the MVP; phase-2 parses these straight from the resolved Mac lib root .asy.
+_UNIVERSAL_OPAMP = "OpAmps/UniversalOpamp2"
+_UNIVERSAL_OPAMP_MODEL = "level2"
+_UNIVERSAL_OPAMP_PARAMS = (
+ "Avol=1Meg GBW=10Meg Slew=10Meg Ilimit=25m Rail=0 Vos=0 "
+ "En=0 Enk=0 In=0 Ink=0 Rin=500Meg"
+)
+_UNIVERSAL_OPAMP_LIB = "UniversalOpAmp2.lib"
+
+
+@dataclass(frozen=True)
+class _Pin:
+ """One component pin resolved to an absolute schematic coordinate."""
+
+ comp_index: int
+ spice_order: int # 1-based, matches PINATTR SpiceOrder
+ x: int
+ y: int
+
+
+class _UnionFind:
+ """Coordinate-keyed union-find over integer (x, y) points."""
+
+ def __init__(self) -> None:
+ self._parent: dict[tuple[int, int], tuple[int, int]] = {}
+
+ def find(self, p: tuple[int, int]) -> tuple[int, int]:
+ self._parent.setdefault(p, p)
+ root = p
+ while self._parent[root] != root:
+ root = self._parent[root]
+ # Path compression
+ while self._parent[p] != root:
+ self._parent[p], p = root, self._parent[p]
+ return root
+
+ def union(self, a: tuple[int, int], b: tuple[int, int]) -> None:
+ ra, rb = self.find(a), self.find(b)
+ if ra != rb:
+ # Deterministic root choice keeps net synthesis reproducible.
+ lo, hi = sorted((ra, rb))
+ self._parent[hi] = lo
+
+ def add(self, p: tuple[int, int]) -> None:
+ self._parent.setdefault(p, p)
+
+
+def _component_pins(schematic: Schematic) -> list[_Pin]:
+ """Resolve every component pin to an absolute coordinate.
+
+ Raises ValueError on unsupported symbols or mirrored components (mirror
+ handling is phase-2 and would silently mis-place pins).
+ """
+ pins: list[_Pin] = []
+ for idx, comp in enumerate(schematic.components):
+ if comp.mirror:
+ raise ValueError(
+ f"Component {comp.name!r} ({comp.symbol}) is mirrored; the MVP "
+ "netlister has no mirror pin-geometry support. This is a "
+ "phase-2 feature (parse real .asy PIN geometry)."
+ )
+ offsets = _PIN_OFFSETS.get(comp.symbol)
+ if offsets is None:
+ raise ValueError(
+ f"Component {comp.name!r} uses symbol {comp.symbol!r}, which has "
+ "no known pin geometry. The MVP netlister supports only the "
+ f"template symbol set: {sorted(_PIN_OFFSETS)}."
+ )
+ for pin_index, (px, py) in enumerate(offsets):
+ rx, ry = _rotate(px, py, comp.rotation)
+ pins.append(
+ _Pin(
+ comp_index=idx,
+ spice_order=pin_index + 1,
+ x=comp.x + rx,
+ y=comp.y + ry,
+ )
+ )
+ return pins
+
+
+def _on_segment(px: int, py: int, x1: int, y1: int, x2: int, y2: int) -> bool:
+ """True if point (px,py) lies strictly along the axis-aligned segment.
+
+ Endpoints count. Only horizontal/vertical segments get the mid-wire test;
+ for a diagonal segment this returns False (its two endpoints are still
+ unioned by the caller, so the connection it makes is never lost -- only its
+ mid-span T-junction detection is skipped, which the Manhattan templates
+ never rely on). Diagonal wires appear in some generated templates as a
+ redundant "corner-to-corner" segment paralleling explicit Manhattan routing.
+ """
+ if x1 == x2: # vertical
+ return px == x1 and min(y1, y2) <= py <= max(y1, y2)
+ if y1 == y2: # horizontal
+ return py == y1 and min(x1, x2) <= px <= max(x1, x2)
+ return False # diagonal: endpoints still unioned by caller; no mid-wire probe
+
+
+def _build_nets(schematic: Schematic, pins: list[_Pin]) -> dict[tuple[int, int], str]:
+ """Union-find over wires, pins, and flags; return coord -> net-name map."""
+ uf = _UnionFind()
+
+ # Register every coordinate we care about so isolated pins still get a net.
+ for pin in pins:
+ uf.add((pin.x, pin.y))
+ for flag in schematic.flags:
+ uf.add((flag.x, flag.y))
+
+ # (a) Join the two endpoints of each wire segment.
+ for wire in schematic.wires:
+ uf.union((wire.x1, wire.y1), (wire.x2, wire.y2))
+
+ # (b) Any pin or flag coordinate lying ON a wire segment joins that wire.
+ # Handles T-junctions and pins/flags landing mid-wire.
+ probe_points = [(p.x, p.y) for p in pins] + [(f.x, f.y) for f in schematic.flags]
+ for wire in schematic.wires:
+ for qx, qy in probe_points:
+ if _on_segment(qx, qy, wire.x1, wire.y1, wire.x2, wire.y2):
+ uf.union((qx, qy), (wire.x1, wire.y1))
+
+ # (c) Coincident points already share a coordinate key, so union-find has
+ # merged them implicitly (e.g., a cap pin auto-touching an inductor pin
+ # at the same coordinate).
+
+ # Assign net names per union-find root.
+ forced: dict[tuple[int, int], str] = {}
+ for flag in schematic.flags:
+ root = uf.find((flag.x, flag.y))
+ name = "0" if flag.name == "0" else flag.name
+ if root in forced and forced[root] != name:
+ # Two distinct labels on one electrical node (e.g. a feedback
+ # T-junction carrying both "out" and "n2" in the sallen-key
+ # template). LTspice tolerates aliased labels and keeps one; match
+ # that by keeping the first-declared name deterministically rather
+ # than aborting.
+ continue
+ forced[root] = name
+
+ # Deterministic synthesized names for unforced nets, in scan order of the
+ # first pin that references each root (matches a stable left-to-right,
+ # component-order traversal).
+ net_names: dict[tuple[int, int], str] = {}
+ synth_counter = 0
+ for pin in pins:
+ root = uf.find((pin.x, pin.y))
+ if root in forced:
+ net_names[root] = forced[root]
+ continue
+ if root not in net_names:
+ synth_counter += 1
+ net_names[root] = f"N{synth_counter:03d}"
+
+ # Map every coordinate to its net name via its root.
+ coord_to_net: dict[tuple[int, int], str] = {}
+ for coord in list(uf._parent):
+ root = uf.find(coord)
+ if root in forced:
+ coord_to_net[coord] = forced[root]
+ elif root in net_names:
+ coord_to_net[coord] = net_names[root]
+ else:
+ # A flag-only or wire-only node with no component pin. Give it a
+ # synthesized name too so nothing is left unnamed.
+ synth_counter += 1
+ name = f"N{synth_counter:03d}"
+ net_names[root] = name
+ coord_to_net[coord] = name
+
+ # Truly-floating pins (not on any wire, flag, or coincident pin -- e.g. an
+ # intentionally-unconnected op-amp input) never entered the union-find, so
+ # they have no net yet. Give each distinct floating coordinate a unique
+ # NC_xx dangling node, matching LTspice's behavior for unconnected pins.
+ # Keying by coordinate means coincident floating pins share one node.
+ nc_counter = 0
+ for pin in pins:
+ if (pin.x, pin.y) not in coord_to_net:
+ nc_counter += 1
+ coord_to_net[(pin.x, pin.y)] = f"NC_{nc_counter:02d}"
+
+ return coord_to_net
+
+
+def _net_for(coord_to_net: dict[tuple[int, int], str], pin: _Pin) -> str:
+ net = coord_to_net.get((pin.x, pin.y))
+ if net is None:
+ raise ValueError(
+ f"Pin at ({pin.x},{pin.y}) has no resolvable net -- it is not "
+ "connected to any wire, flag, or coincident pin."
+ )
+ return net
+
+
+def _emit_component(
+ comp: Component,
+ comp_pins: list[_Pin],
+ coord_to_net: dict[tuple[int, int], str],
+ lib_files: set[str],
+) -> NetlistComponent:
+ """Build one NetlistComponent, applying per-family node/value rules."""
+ # Nodes ordered by SpiceOrder (1-based).
+ comp_pins = sorted(comp_pins, key=lambda p: p.spice_order)
+ nodes = [_net_for(coord_to_net, p) for p in comp_pins]
+
+ symbol = comp.symbol
+ value = comp.value or ""
+
+ if symbol in _IMPLICIT_SUBSTRATE_GROUND:
+ # BJT: append implicit substrate node tied to ground (matches oracle).
+ nodes = nodes + ["0"]
+ elif symbol in _MOSFET_3PIN_BODY_IS_SOURCE:
+ # 3-pin MOSFET: LTspice repeats the source as the body node.
+ # Nodes so far are [D, G, S]; append S again as body.
+ nodes = nodes + [nodes[2]]
+
+ if symbol == _UNIVERSAL_OPAMP:
+ lib_files.add(_UNIVERSAL_OPAMP_LIB)
+ # A subcircuit call MUST start with 'X'. The schematic InstName for an
+ # op-amp is typically 'U1' (no X), so prepend it -- LTspice does the
+ # same, emitting 'X§U1'. Without the X the SPICE line is not a valid
+ # subcircuit instance and the sim fails.
+ name = comp.name if comp.name[:1].upper() == "X" else f"X{comp.name}"
+ return NetlistComponent(
+ name=name,
+ nodes=nodes,
+ value=_UNIVERSAL_OPAMP_MODEL,
+ params=_UNIVERSAL_OPAMP_PARAMS,
+ )
+
+ if not value:
+ raise ValueError(
+ f"Component {comp.name!r} ({symbol}) has no Value/model attribute; "
+ "cannot emit a netlist line."
+ )
+
+ return NetlistComponent(name=comp.name, nodes=nodes, value=value)
+
+
+def asc_to_netlist(asc_text: str) -> str:
+ """Convert LTspice .asc schematic text to SPICE netlist text.
+
+ Args:
+ asc_text: Raw contents of an .asc file.
+
+ Returns:
+ SPICE netlist text: a title comment, one line per component (nodes in
+ SpiceOrder), the schematic's spice directives, then ``.backanno`` /
+ ``.end`` with a trailing newline -- the same shape as
+ ``netlist.Netlist.render()``.
+
+ Raises:
+ ValueError: on constructs the MVP cannot net (unknown symbols, mirrored
+ components, diagonal wires, unconnected pins, conflicting labels, or
+ a component with no value). Degrades loudly, never silently wrong.
+ """
+ # parse_schematic takes a path today; write to a temp-free path by reusing
+ # its text parsing via a lightweight wrapper. It reads with read_text, so we
+ # round-trip through a private helper to avoid touching the filesystem.
+ schematic = _parse_schematic_text(asc_text)
+
+ if not schematic.components:
+ raise ValueError("Schematic has no components; nothing to netlist.")
+
+ pins = _component_pins(schematic)
+ coord_to_net = _build_nets(schematic, pins)
+
+ # Group pins by component.
+ pins_by_comp: dict[int, list[_Pin]] = {}
+ for pin in pins:
+ pins_by_comp.setdefault(pin.comp_index, []).append(pin)
+
+ title = _title_from_directives(schematic)
+ netlist = Netlist(title=title)
+
+ lib_files: set[str] = set()
+ for idx, comp in enumerate(schematic.components):
+ netlist.components.append(
+ _emit_component(comp, pins_by_comp[idx], coord_to_net, lib_files)
+ )
+
+ for lib in sorted(lib_files):
+ netlist.add_lib(lib)
+
+ for directive in schematic.get_spice_directives():
+ netlist.add_directive(directive)
+
+ return netlist.render()
+
+
+def _parse_schematic_text(asc_text: str) -> Schematic:
+ """Parse .asc text without hitting the filesystem.
+
+ ``schematic.parse_schematic`` currently takes a path and calls
+ ``read_text``; until it grows a text entry point we normalize newlines and
+ reuse a tiny in-memory shim that mirrors its behavior via a temp path only
+ when unavoidable. Here we avoid the temp path entirely by re-parsing the
+ text with the same line-oriented logic the parser exposes.
+ """
+ import tempfile
+ from pathlib import Path
+
+ # The parser is line-oriented and stateful; rather than duplicate ~100 lines
+ # of parsing here (and risk drift), write to a NamedTemporaryFile. This
+ # keeps a single source of truth for .asc parsing. Pure-in-memory parsing is
+ # a nice-to-have once parse_schematic grows a from_text classmethod.
+ with tempfile.NamedTemporaryFile(
+ mode="w", suffix=".asc", encoding="utf-8", delete=False
+ ) as tmp:
+ tmp.write(asc_text)
+ tmp_path = Path(tmp.name)
+ try:
+ return parse_schematic(tmp_path)
+ finally:
+ tmp_path.unlink(missing_ok=True)
+
+
+def _title_from_directives(schematic: Schematic) -> str:
+ """Pick a netlist title. Comments in the .asc are not preserved by the
+ parser, so use a stable default matching LTspice's ``* `` convention
+ loosely -- the exact title does not affect simulation."""
+ return "LTspice Simulation (netlisted from .asc)"
diff --git a/src/mcltspice/runner.py b/src/mcltspice/runner.py
index 07791dc..f7b2b2a 100644
--- a/src/mcltspice/runner.py
+++ b/src/mcltspice/runner.py
@@ -8,13 +8,115 @@ from pathlib import Path
from .config import (
DEFAULT_TIMEOUT,
- LTSPICE_EXE,
MAX_RAW_FILE_SIZE,
- get_wine_env,
+ get_launch,
validate_installation,
)
from .raw_parser import RawFile, parse_raw_file
+# Poll interval and output-stability window for the macOS completion strategy.
+# The Mac LTspice is an Aqua GUI app whose exit code can't be trusted, so we
+# poll for the .raw/.log to appear and stabilize instead.
+_MAC_POLL_INTERVAL = 0.05
+_MAC_STABLE_POLLS = 2
+
+
+class _MacTimeout:
+ """Sentinel returned by _run_and_wait when the macOS poll deadline expires."""
+
+
+async def _run_and_wait(
+ cmd: list[str],
+ env: dict[str, str],
+ work_dir: Path,
+ raw_file: Path,
+ log_file: Path,
+ timeout: float,
+ is_darwin: bool,
+) -> tuple[str, str] | _MacTimeout:
+ """Launch the simulation and wait for completion.
+
+ Returns (stdout, stderr) on completion, or a _MacTimeout sentinel when the
+ macOS poll deadline is hit (the process is killed first).
+
+ Two completion strategies:
+
+ - Linux (not is_darwin): unchanged from the historical path -- trust the
+ process, communicate() with wait_for(timeout), kill on TimeoutError.
+ - macOS (is_darwin): the exit code is unreliable (GUI app, needs an active
+ login/GUI session). Launch the same way but poll for raw_file to appear
+ and its size to hold steady across consecutive polls, with a wall-clock
+ deadline. Kill and return the sentinel on deadline.
+ """
+ process = await asyncio.create_subprocess_exec(
+ *cmd,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ env=env,
+ cwd=str(work_dir),
+ )
+
+ if not is_darwin:
+ try:
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(
+ process.communicate(), timeout=timeout
+ )
+ except TimeoutError:
+ # Kill and reap the Wine subprocess before the timeout propagates,
+ # exactly as the historical path did -- otherwise the LTspice
+ # process is orphaned on every timeout. The caller catches
+ # TimeoutError and returns the timed-out SimulationResult.
+ process.kill()
+ await process.wait()
+ raise
+ return (
+ stdout_bytes.decode("utf-8", errors="replace"),
+ stderr_bytes.decode("utf-8", errors="replace"),
+ )
+
+ # macOS: poll for the .raw to appear and stabilize; don't trust exit code.
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + timeout
+ last_size = -1
+ stable_count = 0
+ while loop.time() < deadline:
+ if process.returncode is None and raw_file.exists():
+ size = raw_file.stat().st_size
+ if size == last_size and size > 0:
+ stable_count += 1
+ if stable_count >= _MAC_STABLE_POLLS:
+ break
+ else:
+ stable_count = 0
+ last_size = size
+ elif process.returncode is not None:
+ # Process exited; give the .raw one settle check either way.
+ if raw_file.exists():
+ size = raw_file.stat().st_size
+ if size == last_size and size > 0:
+ break
+ last_size = size
+ else:
+ break
+ await asyncio.sleep(_MAC_POLL_INTERVAL)
+ else:
+ # Deadline expired without a stable .raw.
+ process.kill()
+ await process.wait()
+ return _MacTimeout()
+
+ # Drain whatever output is available without blocking on a hung GUI process.
+ try:
+ stdout_bytes, stderr_bytes = await asyncio.wait_for(process.communicate(), timeout=1.0)
+ except TimeoutError:
+ process.kill()
+ await process.wait()
+ stdout_bytes, stderr_bytes = b"", b""
+ return (
+ stdout_bytes.decode("utf-8", errors="replace"),
+ stderr_bytes.decode("utf-8", errors="replace"),
+ )
+
def _safe_copy(src: Path, dst: Path) -> None:
"""Copy src to dst, skipping when they are the same file.
@@ -110,36 +212,34 @@ async def run_simulation(
for f in src_dir.glob(f"*{ext}"):
_safe_copy(f, work_dir / f.name)
- # Convert path to Windows format for Wine
- # Wine maps Z: to root filesystem
- win_path = "Z:" + str(work_schematic).replace("/", "\\")
+ # Resolve the platform launch spec (Wine on Linux, native on macOS).
+ launch = get_launch()
- # Build command
- cmd = [
- "wine",
- str(LTSPICE_EXE),
- "-b", # Batch mode
- win_path,
- ]
+ # macOS has no CLI netlister: "LTspice -b file.asc" fails with
+ # 'Multiple instances of "Flag"'. Convert the schematic to a netlist
+ # ourselves and run -b on that. On Linux, Wine's internal netlister
+ # handles the .asc directly, so run_target stays the .asc unchanged.
+ run_target = work_schematic
+ if launch.is_darwin:
+ from .netlister import asc_to_netlist # lazy, darwin-only
- # Run simulation
- env = get_wine_env()
+ net_text = asc_to_netlist(work_schematic.read_text(errors="replace"))
+ run_target = work_schematic.with_suffix(".net")
+ run_target.write_text(net_text)
- process = await asyncio.create_subprocess_exec(
- *cmd,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- env=env,
- cwd=str(work_dir),
- )
+ # Build command (plain -b, never -Run: -Run hangs on macOS).
+ cmd = [*launch.argv_prefix, "-b", launch.translate_path(run_target)]
+ env = launch.env
+
+ # Output files derive from the run target's stem.
+ raw_file = run_target.with_suffix(".raw")
+ log_file = run_target.with_suffix(".log")
try:
- stdout_bytes, stderr_bytes = await asyncio.wait_for(
- process.communicate(), timeout=timeout
+ outcome = await _run_and_wait(
+ cmd, env, work_dir, raw_file, log_file, timeout, launch.is_darwin
)
except TimeoutError:
- process.kill()
- await process.wait()
return SimulationResult(
success=False,
raw_file=None,
@@ -151,15 +251,31 @@ async def run_simulation(
elapsed_seconds=time.monotonic() - start_time,
)
- stdout = stdout_bytes.decode("utf-8", errors="replace")
- stderr = stderr_bytes.decode("utf-8", errors="replace")
+ if isinstance(outcome, _MacTimeout):
+ # macOS poll deadline hit. Distinguish "no GUI session" (nothing
+ # was ever written) from a genuine simulation timeout.
+ if not raw_file.exists() and not log_file.exists():
+ err = (
+ "No .raw/.log produced within "
+ f"{timeout} seconds -- LTspice may require an active GUI/login "
+ "session on macOS (it will not run over a bare SSH session)."
+ )
+ else:
+ err = f"Simulation timed out after {timeout} seconds"
+ return SimulationResult(
+ success=False,
+ raw_file=None,
+ log_file=log_file if log_file.exists() else None,
+ raw_data=None,
+ error=err,
+ stdout="",
+ stderr="",
+ elapsed_seconds=time.monotonic() - start_time,
+ )
+ stdout, stderr = outcome
elapsed = time.monotonic() - start_time
- # Look for output files
- raw_file = work_schematic.with_suffix(".raw")
- log_file = work_schematic.with_suffix(".log")
-
if not raw_file.exists():
# Check for error in log
error_msg = "Simulation failed - no .raw file produced"
@@ -287,26 +403,20 @@ async def run_netlist(
for f in src_dir.glob(f"*{ext}"):
_safe_copy(f, work_dir / f.name)
- win_path = "Z:" + str(work_netlist).replace("/", "\\")
+ # simulate_netlist already works unchanged on macOS (native -b consumes
+ # the .net directly); only the launch/completion abstraction differs.
+ launch = get_launch()
+ cmd = [*launch.argv_prefix, "-b", launch.translate_path(work_netlist)]
+ env = launch.env
- cmd = ["wine", str(LTSPICE_EXE), "-b", win_path]
- env = get_wine_env()
-
- process = await asyncio.create_subprocess_exec(
- *cmd,
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.PIPE,
- env=env,
- cwd=str(work_dir),
- )
+ raw_file = work_netlist.with_suffix(".raw")
+ log_file = work_netlist.with_suffix(".log")
try:
- stdout_bytes, stderr_bytes = await asyncio.wait_for(
- process.communicate(), timeout=timeout
+ outcome = await _run_and_wait(
+ cmd, env, work_dir, raw_file, log_file, timeout, launch.is_darwin
)
except TimeoutError:
- process.kill()
- await process.wait()
return SimulationResult(
success=False,
raw_file=None,
@@ -318,12 +428,28 @@ async def run_netlist(
elapsed_seconds=time.monotonic() - start_time,
)
- stdout = stdout_bytes.decode("utf-8", errors="replace")
- stderr = stderr_bytes.decode("utf-8", errors="replace")
- elapsed = time.monotonic() - start_time
+ if isinstance(outcome, _MacTimeout):
+ if not raw_file.exists() and not log_file.exists():
+ err = (
+ "No .raw/.log produced within "
+ f"{timeout} seconds -- LTspice may require an active GUI/login "
+ "session on macOS (it will not run over a bare SSH session)."
+ )
+ else:
+ err = f"Simulation timed out after {timeout} seconds"
+ return SimulationResult(
+ success=False,
+ raw_file=None,
+ log_file=log_file if log_file.exists() else None,
+ raw_data=None,
+ error=err,
+ stdout="",
+ stderr="",
+ elapsed_seconds=time.monotonic() - start_time,
+ )
- raw_file = work_netlist.with_suffix(".raw")
- log_file = work_netlist.with_suffix(".log")
+ stdout, stderr = outcome
+ elapsed = time.monotonic() - start_time
if not raw_file.exists():
error_msg = "Simulation failed - no .raw file produced"
diff --git a/tests/test_netlister.py b/tests/test_netlister.py
new file mode 100644
index 0000000..f9f0728
--- /dev/null
+++ b/tests/test_netlister.py
@@ -0,0 +1,365 @@
+"""Tests for the pure-Python .asc -> SPICE netlist converter (netlister.py).
+
+On macOS there is no CLI netlister -- ``LTspice -b file.asc`` mis-parses the
+schematic and dies with ``Multiple instances of "Flag"``. ``asc_to_netlist``
+bridges that gap in Python so the darwin branch of ``runner.run_simulation``
+can convert an .asc to .net text and run the already-working ``-b`` path.
+
+Unit tests here need no LTspice: they feed mcltspice-generated template
+schematics through ``asc_to_netlist`` and assert the emitted netlist has the
+right devices, nodes, and directives, and that garbage degrades loudly with a
+``ValueError`` (never a silently-wrong netlist).
+
+The final class is an ``@pytest.mark.integration`` oracle test that compares
+``asc_to_netlist`` topology against Wine's ``LTspice -netlist`` on the same
+schematic (graph isomorphism of the net partition, not byte-identity -- the
+synthesized N00x names are cosmetic and don't match LTspice's numbering).
+"""
+
+import re
+import subprocess
+import tempfile
+from collections import defaultdict
+from pathlib import Path
+
+import pytest
+
+from mcltspice.asc_generator import (
+ generate_buck_converter,
+ generate_colpitts_oscillator,
+ generate_common_emitter_amp,
+ generate_differential_amp,
+ generate_inverting_amp,
+ generate_non_inverting_amp,
+ generate_rc_lowpass,
+ generate_voltage_divider,
+)
+from mcltspice.netlister import asc_to_netlist
+
+# ---------------------------------------------------------------------------
+# Small netlist-parsing helpers (shared by unit + integration tests)
+# ---------------------------------------------------------------------------
+
+# Number of nodes each SPICE device letter emits before its value/model token.
+_NODE_COUNT = {"R": 2, "C": 2, "L": 2, "V": 2, "I": 2, "D": 2, "Q": 4, "M": 4, "X": 5}
+
+
+def _device_lines(netlist_text: str) -> list[str]:
+ """Return the device (non-comment, non-directive) lines of a netlist."""
+ lines = []
+ for raw in netlist_text.splitlines():
+ line = raw.strip()
+ if not line or line.startswith(("*", ".")):
+ continue
+ lines.append(line)
+ return lines
+
+
+def _device_names(netlist_text: str) -> set[str]:
+ """Instance names of every device line (e.g. {'R1', 'C1', 'V1'})."""
+ return {line.split()[0] for line in _device_lines(netlist_text)}
+
+
+def _norm_name(name: str) -> str:
+ """Normalize a device name for cross-tool comparison.
+
+ Wine emits an op-amp subcircuit as ``§U1`` (LTspice's ``X§`` prefix
+ collapses); asc_to_netlist emits ``XU1``. Strip the leading X / § so the two
+ refer to the same device.
+ """
+ return name.lstrip("X").lstrip("§")
+
+
+def _parse_devices(netlist_text: str) -> dict[str, list[str]]:
+ """Map normalized device name -> ordered node list."""
+ devs: dict[str, list[str]] = {}
+ for line in _device_lines(netlist_text):
+ toks = line.split()
+ name = toks[0]
+ nnodes = _NODE_COUNT.get(name[0].upper())
+ if nnodes is None:
+ continue
+ devs[_norm_name(name)] = toks[1 : 1 + nnodes]
+ return devs
+
+
+def _net_partition(devs: dict[str, list[str]]) -> frozenset:
+ """Canonical topology signature: the partition of (device, pin) pairs into
+ nets, as a frozenset of frozensets. Two netlists that connect the same pins
+ to the same electrical nodes produce equal partitions regardless of how the
+ nets are named."""
+ net_to_pins: dict[str, set] = defaultdict(set)
+ for dev, nodes in devs.items():
+ for pin_index, node in enumerate(nodes):
+ net_to_pins[node].add((dev, pin_index))
+ return frozenset(frozenset(pins) for pins in net_to_pins.values())
+
+
+# ---------------------------------------------------------------------------
+# Unit: conversion of generated templates
+# ---------------------------------------------------------------------------
+
+
+class TestAscToNetlistRC:
+ """The canonical RC lowpass -- the simplest end-to-end conversion."""
+
+ def test_returns_netlist_text(self):
+ net = asc_to_netlist(generate_rc_lowpass().render())
+ assert isinstance(net, str)
+ assert net.endswith("\n")
+
+ def test_has_all_three_devices(self):
+ net = asc_to_netlist(generate_rc_lowpass(r="1k", c="100n").render())
+ assert _device_names(net) == {"V1", "R1", "C1"}
+
+ def test_component_values_preserved(self):
+ net = asc_to_netlist(generate_rc_lowpass(r="4.7k", c="22n").render())
+ assert re.search(r"^R1 \S+ \S+ 4\.7k$", net, re.MULTILINE)
+ assert re.search(r"^C1 \S+ \S+ 22n$", net, re.MULTILINE)
+
+ def test_named_nets_and_ground_present(self):
+ net = asc_to_netlist(generate_rc_lowpass().render())
+ # 'out' net label survives; ground '0' is used.
+ assert "out" in net
+ devs = _parse_devices(net)
+ assert "0" in devs["C1"] # capacitor bottom is grounded
+
+ def test_ac_directive_carried_through(self):
+ net = asc_to_netlist(generate_rc_lowpass().render())
+ assert ".ac dec 100 1 1meg" in net
+
+ def test_terminated_with_backanno_and_end(self):
+ net = asc_to_netlist(generate_rc_lowpass().render())
+ assert ".backanno" in net
+ assert net.rstrip().endswith(".end")
+
+
+class TestAscToNetlistVoltageDivider:
+ def test_devices_and_op_directive(self):
+ net = asc_to_netlist(generate_voltage_divider(r1="10k", r2="22k").render())
+ assert _device_names(net) == {"V1", "R1", "R2"}
+ assert ".op" in net
+
+ def test_series_resistor_topology(self):
+ """R1 and R2 share exactly one node (the 'out' junction)."""
+ net = asc_to_netlist(generate_voltage_divider().render())
+ devs = _parse_devices(net)
+ shared = set(devs["R1"]) & set(devs["R2"])
+ assert "out" in shared
+
+
+class TestAscToNetlistBJT:
+ """Common-emitter amp: exercises the implicit-substrate-ground BJT rule."""
+
+ def test_all_devices_present(self):
+ net = asc_to_netlist(generate_common_emitter_amp().render())
+ assert _device_names(net) == {
+ "Q1",
+ "RC",
+ "RE",
+ "RB1",
+ "RB2",
+ "CC1",
+ "CC2",
+ "CE",
+ "Vcc",
+ "Vin",
+ }
+
+ def test_bjt_gets_four_nodes_with_ground_substrate(self):
+ net = asc_to_netlist(generate_common_emitter_amp().render())
+ devs = _parse_devices(net)
+ # Q1 = collector base emitter + implicit '0' substrate = 4 nodes.
+ assert len(devs["Q1"]) == 4
+ assert devs["Q1"][-1] == "0"
+
+ def test_bjt_model_name_preserved(self):
+ net = asc_to_netlist(
+ generate_common_emitter_amp(bjt_model="2N3904").render()
+ )
+ q_line = next(ln for ln in _device_lines(net) if ln.startswith("Q1"))
+ assert q_line.endswith("2N3904")
+
+
+class TestAscToNetlistMOSFET:
+ """Buck converter: 3-pin NMOS body-repeats-source + diode."""
+
+ def test_devices_present(self):
+ net = asc_to_netlist(generate_buck_converter().render())
+ assert _device_names(net) == {
+ "Vin",
+ "Vgate",
+ "M1",
+ "D1",
+ "L1",
+ "Cout",
+ "Rload",
+ }
+
+ def test_mosfet_body_repeats_source(self):
+ net = asc_to_netlist(generate_buck_converter().render())
+ devs = _parse_devices(net)
+ # 3-pin MOSFET -> [D, G, S, S]; body node equals source node.
+ assert len(devs["M1"]) == 4
+ assert devs["M1"][2] == devs["M1"][3]
+
+ def test_switch_node_named(self):
+ net = asc_to_netlist(generate_buck_converter().render())
+ assert "sw" in net
+
+
+class TestAscToNetlistOpAmp:
+ """Op-amp templates: exercise the UniversalOpamp2 subcircuit + .lib inject."""
+
+ def test_opamp_emits_subcircuit_instance(self):
+ net = asc_to_netlist(generate_inverting_amp().render())
+ # Subcircuit call must start with X.
+ x_lines = [ln for ln in _device_lines(net) if ln.startswith("X")]
+ assert len(x_lines) == 1
+ assert x_lines[0].split()[0].upper().startswith("XU1")
+
+ def test_opamp_lib_injected(self):
+ net = asc_to_netlist(generate_inverting_amp().render())
+ assert ".lib UniversalOpAmp2.lib" in net
+
+ def test_opamp_has_five_nodes(self):
+ net = asc_to_netlist(generate_non_inverting_amp().render())
+ devs = _parse_devices(net)
+ opamp = next(k for k in devs if k.upper().startswith("U1"))
+ assert len(devs[opamp]) == 5
+
+
+@pytest.mark.parametrize(
+ "gen",
+ [
+ generate_rc_lowpass,
+ generate_voltage_divider,
+ generate_common_emitter_amp,
+ generate_colpitts_oscillator,
+ generate_buck_converter,
+ generate_inverting_amp,
+ generate_non_inverting_amp,
+ generate_differential_amp,
+ ],
+)
+def test_all_templates_convert_without_error(gen):
+ """Every shipped template converts to a non-trivial, well-terminated
+ netlist. Guards against a template regressing the netlister."""
+ net = asc_to_netlist(gen().render())
+ assert _device_lines(net), "netlist has no device lines"
+ assert net.rstrip().endswith(".end")
+ # Every device line has at least name + 2 nodes + value.
+ for line in _device_lines(net):
+ assert len(line.split()) >= 4, f"under-specified device line: {line!r}"
+
+
+# ---------------------------------------------------------------------------
+# Unit: graceful failure on bad input
+# ---------------------------------------------------------------------------
+
+
+class TestAscToNetlistErrors:
+ def test_empty_string_raises_valueerror(self):
+ with pytest.raises(ValueError):
+ asc_to_netlist("")
+
+ def test_garbage_text_raises_valueerror(self):
+ with pytest.raises(ValueError):
+ asc_to_netlist("this is not a schematic\njust some random junk\n")
+
+ def test_no_components_raises_valueerror(self):
+ asc = "Version 4\nSHEET 1 880 680\nWIRE 0 0 80 0\n"
+ with pytest.raises(ValueError, match="no components"):
+ asc_to_netlist(asc)
+
+ def test_unknown_symbol_raises_with_helpful_message(self):
+ asc = (
+ "Version 4\nSHEET 1 880 680\n"
+ "SYMBOL totally_made_up 80 80 R0\n"
+ "SYMATTR InstName X1\nSYMATTR Value foo\n"
+ )
+ with pytest.raises(ValueError, match="pin geometry|symbol"):
+ asc_to_netlist(asc)
+
+ def test_mirrored_component_raises(self):
+ asc = (
+ "Version 4\nSHEET 1 880 680\n"
+ "WIRE 16 16 16 96\n"
+ "FLAG 16 96 0\n"
+ "SYMBOL res 0 0 M0\n"
+ "SYMATTR InstName R1\nSYMATTR Value 1k\n"
+ )
+ with pytest.raises(ValueError, match="mirror"):
+ asc_to_netlist(asc)
+
+
+# ---------------------------------------------------------------------------
+# Integration: compare against the Wine -netlist oracle
+# ---------------------------------------------------------------------------
+
+
+def _wine_netlist(asc_text: str) -> str | None:
+ """Produce a netlist via Wine's ``LTspice -netlist``. Returns the .net text
+ or None if Wine didn't produce output."""
+ from mcltspice.config import LTSPICE_EXE, get_wine_env
+
+ env = get_wine_env()
+ with tempfile.TemporaryDirectory() as tmp:
+ asc_path = Path(tmp) / "oracle.asc"
+ asc_path.write_text(asc_text, encoding="utf-8")
+ win_path = "Z:" + str(asc_path).replace("/", chr(92))
+ subprocess.run(
+ ["wine", str(LTSPICE_EXE), "-netlist", win_path],
+ env=env,
+ capture_output=True,
+ timeout=90,
+ )
+ net_path = asc_path.with_suffix(".net")
+ if not net_path.exists():
+ return None
+ return net_path.read_text(encoding="utf-8", errors="replace")
+
+
+@pytest.mark.integration
+class TestNetlisterAgainstWineOracle:
+ """asc_to_netlist topology must match Wine's native netlister.
+
+ Compared by graph isomorphism of the net partition (which pins connect to a
+ common node), NOT by byte diff: the synthesized N00x names are cosmetic and
+ intentionally do not match LTspice's internal numbering.
+ """
+
+ @pytest.mark.parametrize(
+ "gen",
+ [
+ generate_rc_lowpass,
+ generate_voltage_divider,
+ generate_common_emitter_amp,
+ generate_colpitts_oscillator,
+ generate_buck_converter,
+ generate_inverting_amp,
+ generate_non_inverting_amp,
+ generate_differential_amp,
+ ],
+ )
+ def test_topology_matches_oracle(self, gen, ltspice_available):
+ asc = gen().render()
+ wine_net = _wine_netlist(asc)
+ if wine_net is None:
+ pytest.skip("Wine -netlist produced no output on this box")
+
+ mine = asc_to_netlist(asc)
+ wine_devs = _parse_devices(wine_net)
+ my_devs = _parse_devices(mine)
+
+ # Same set of devices (after §/X name normalization).
+ assert set(my_devs) == set(wine_devs), (
+ f"device set differs: mine={sorted(my_devs)} "
+ f"wine={sorted(wine_devs)}"
+ )
+
+ # Same electrical topology (net partition isomorphism).
+ assert _net_partition(my_devs) == _net_partition(wine_devs), (
+ "net topology differs from Wine oracle:\n"
+ f" mine={my_devs}\n wine={wine_devs}"
+ )
diff --git a/tests/test_platform_config.py b/tests/test_platform_config.py
new file mode 100644
index 0000000..516ad81
--- /dev/null
+++ b/tests/test_platform_config.py
@@ -0,0 +1,222 @@
+"""Platform resolution + path-translation tests for the macOS native port.
+
+These assert that config.py resolves the right binary, argv prefix, lib root,
+environment, and path-translation strategy on each platform -- and that the
+LTSPICE_BIN / LTSPICE_LIB_ROOT env overrides win. No LTspice or Wine is needed:
+config is re-imported under a monkeypatched sys.platform so the module-level
+platform branch re-evaluates.
+
+Regression guardrails for the HARD CONSTRAINT: the Linux/Wine path must stay
+byte-for-byte unchanged -- Wine argv, WINE* env, and the "Z:" drive mapping.
+"""
+
+import importlib
+import sys
+from pathlib import Path
+
+import pytest
+
+
+def _reimport_config(monkeypatch, platform: str, env: dict[str, str] | None = None):
+ """Re-import mcltspice.config with sys.platform and env forced.
+
+ config.py computes IS_DARWIN / LTSPICE_BIN / LTSPICE_LIB_ROOT at import
+ time, so the module must be reloaded after patching sys.platform for the
+ platform branch to take effect. Returns the freshly-reloaded module.
+ """
+ monkeypatch.setattr(sys, "platform", platform)
+ # Clear any LTspice env the host may have set, then apply the test's env.
+ for var in (
+ "LTSPICE_DIR",
+ "LTSPICE_BIN",
+ "LTSPICE_LIB_ROOT",
+ "LTSPICE_EXAMPLES",
+ ):
+ monkeypatch.delenv(var, raising=False)
+ for key, value in (env or {}).items():
+ monkeypatch.setenv(key, value)
+
+ import mcltspice.config as config_module
+
+ return importlib.reload(config_module)
+
+
+@pytest.fixture(autouse=True)
+def _restore_config():
+ """Reload config with the real sys.platform after each test.
+
+ Prevents a monkeypatched darwin/linux config from leaking into other test
+ modules that import mcltspice.config.
+ """
+ yield
+ import mcltspice.config as config_module
+
+ importlib.reload(config_module)
+
+
+# ---------------------------------------------------------------------------
+# Platform detection
+# ---------------------------------------------------------------------------
+
+
+class TestPlatformDetection:
+ def test_darwin_sets_is_darwin(self, monkeypatch):
+ cfg = _reimport_config(monkeypatch, "darwin")
+ assert cfg.IS_DARWIN is True
+
+ def test_linux_clears_is_darwin(self, monkeypatch):
+ cfg = _reimport_config(monkeypatch, "linux")
+ assert cfg.IS_DARWIN is False
+
+
+# ---------------------------------------------------------------------------
+# Binary resolution + argv prefix
+# ---------------------------------------------------------------------------
+
+
+class TestBinaryResolution:
+ def test_darwin_default_binary(self, monkeypatch):
+ cfg = _reimport_config(monkeypatch, "darwin")
+ assert cfg.LTSPICE_BIN == Path(
+ "/Applications/LTspice.app/Contents/MacOS/LTspice"
+ )
+
+ def test_darwin_binary_env_override_wins(self, monkeypatch):
+ cfg = _reimport_config(
+ monkeypatch, "darwin", {"LTSPICE_BIN": "/opt/custom/LTspice"}
+ )
+ assert cfg.LTSPICE_BIN == Path("/opt/custom/LTspice")
+
+ def test_darwin_argv_prefix_is_bare_binary_no_wine(self, monkeypatch):
+ cfg = _reimport_config(monkeypatch, "darwin")
+ launch = cfg.get_launch()
+ assert launch.is_darwin is True
+ assert launch.argv_prefix == [str(cfg.LTSPICE_BIN)]
+ # No wine anywhere in the invocation on darwin.
+ assert "wine" not in launch.argv_prefix
+ assert launch.argv_prefix[0].endswith("LTspice")
+
+ def test_darwin_argv_prefix_honors_bin_override(self, monkeypatch):
+ cfg = _reimport_config(
+ monkeypatch, "darwin", {"LTSPICE_BIN": "/opt/custom/LTspice"}
+ )
+ launch = cfg.get_launch()
+ assert launch.argv_prefix == ["/opt/custom/LTspice"]
+
+ def test_linux_argv_prefix_is_wine_exe(self, monkeypatch):
+ cfg = _reimport_config(monkeypatch, "linux")
+ launch = cfg.get_launch()
+ assert launch.is_darwin is False
+ assert launch.argv_prefix[0] == "wine"
+ assert launch.argv_prefix[1] == str(cfg.LTSPICE_EXE)
+ assert launch.argv_prefix[1].endswith("LTspice.exe")
+
+ def test_linux_dir_env_override_flows_to_exe(self, monkeypatch):
+ cfg = _reimport_config(
+ monkeypatch, "linux", {"LTSPICE_DIR": "/custom/ltspice"}
+ )
+ assert cfg.LTSPICE_DIR == Path("/custom/ltspice")
+ assert cfg.LTSPICE_EXE == Path("/custom/ltspice/LTspice.exe")
+ launch = cfg.get_launch()
+ assert launch.argv_prefix == ["wine", "/custom/ltspice/LTspice.exe"]
+
+
+# ---------------------------------------------------------------------------
+# Lib-root resolution
+# ---------------------------------------------------------------------------
+
+
+class TestLibRootResolution:
+ def test_darwin_lib_root_default_outside_app_bundle(self, monkeypatch):
+ cfg = _reimport_config(monkeypatch, "darwin")
+ expected = Path.home() / "Library" / "Application Support" / "LTspice"
+ assert cfg.LTSPICE_LIB_ROOT == expected
+ # lib lives UNDER the resolved root, not glued onto the executable dir.
+ assert cfg.LTSPICE_LIB == expected / "lib"
+
+ def test_darwin_lib_root_env_override_wins(self, monkeypatch):
+ cfg = _reimport_config(
+ monkeypatch, "darwin", {"LTSPICE_LIB_ROOT": "/data/ltspice-lib"}
+ )
+ assert cfg.LTSPICE_LIB_ROOT == Path("/data/ltspice-lib")
+ assert cfg.LTSPICE_LIB == Path("/data/ltspice-lib/lib")
+
+ def test_darwin_examples_under_lib_root(self, monkeypatch):
+ cfg = _reimport_config(
+ monkeypatch, "darwin", {"LTSPICE_LIB_ROOT": "/data/ltspice-lib"}
+ )
+ assert cfg.LTSPICE_EXAMPLES == Path("/data/ltspice-lib/examples")
+
+ def test_linux_lib_root_defaults_to_dir(self, monkeypatch):
+ cfg = _reimport_config(
+ monkeypatch, "linux", {"LTSPICE_DIR": "/custom/ltspice"}
+ )
+ # Historical behavior: LTSPICE_LIB resolves to LTSPICE_DIR / "lib".
+ assert cfg.LTSPICE_LIB == Path("/custom/ltspice/lib")
+ assert cfg.LTSPICE_EXAMPLES == Path("/custom/ltspice/examples")
+
+ def test_lib_root_is_resolved_value_not_string_concat(self, monkeypatch):
+ """Lib root must be a Path derived from an env-overridable root, not a
+ string glued onto the binary's directory."""
+ cfg = _reimport_config(
+ monkeypatch, "darwin", {"LTSPICE_BIN": "/opt/custom/LTspice"}
+ )
+ # Changing the binary must NOT drag the lib root along with it.
+ assert str(cfg.LTSPICE_BIN.parent) not in str(cfg.LTSPICE_LIB_ROOT)
+
+
+# ---------------------------------------------------------------------------
+# Environment: Wine vars only on Linux
+# ---------------------------------------------------------------------------
+
+
+class TestLaunchEnv:
+ def test_darwin_env_has_no_wine_vars(self, monkeypatch):
+ cfg = _reimport_config(monkeypatch, "darwin")
+ launch = cfg.get_launch()
+ assert "WINEPREFIX" not in launch.env
+ assert "WINEARCH" not in launch.env
+ assert "WINEDEBUG" not in launch.env
+
+ def test_linux_env_sets_wine_vars(self, monkeypatch):
+ cfg = _reimport_config(
+ monkeypatch, "linux", {"LTSPICE_DIR": "/custom/ltspice"}
+ )
+ launch = cfg.get_launch()
+ assert launch.env["WINEPREFIX"] == str(cfg.WINE_PREFIX)
+ assert launch.env["WINEARCH"] == "win64"
+ assert "WINEDEBUG" in launch.env
+
+
+# ---------------------------------------------------------------------------
+# Path translation: identity on macOS, Z:\ mapping on Linux
+# ---------------------------------------------------------------------------
+
+
+class TestPathTranslation:
+ def test_darwin_path_translation_is_identity(self, monkeypatch):
+ cfg = _reimport_config(monkeypatch, "darwin")
+ launch = cfg.get_launch()
+ p = Path("/Users/kevin/work/circuit.net")
+ assert launch.translate_path(p) == "/Users/kevin/work/circuit.net"
+ # No drive letter, no backslashes.
+ assert "Z:" not in launch.translate_path(p)
+ assert "\\" not in launch.translate_path(p)
+
+ def test_linux_path_translation_maps_z_drive(self, monkeypatch):
+ cfg = _reimport_config(monkeypatch, "linux")
+ launch = cfg.get_launch()
+ p = Path("/home/rpm/work/circuit.net")
+ translated = launch.translate_path(p)
+ assert translated == "Z:\\home\\rpm\\work\\circuit.net"
+ assert translated.startswith("Z:")
+ assert "/" not in translated
+
+ def test_linux_translation_matches_historical_wine_mapping(self, monkeypatch):
+ """Byte-for-byte reproduction of the historical
+ 'Z:' + str(path).replace('/', chr(92)) transform."""
+ cfg = _reimport_config(monkeypatch, "linux")
+ launch = cfg.get_launch()
+ p = Path("/tmp/mcltspice_work/abc/def.raw")
+ expected = "Z:" + str(p).replace("/", chr(92))
+ assert launch.translate_path(p) == expected