Skip to main content

virtuoso_cli/commands/
sim.rs

1use crate::client::bridge::{escape_skill_string, VirtuosoClient};
2use crate::error::{Result, VirtuosoError};
3use crate::ocean;
4use crate::ocean::corner::CornerConfig;
5use crate::spectre::jobs::Job;
6use crate::spectre::runner::SpectreSimulator;
7use serde_json::{json, Value};
8use std::collections::HashMap;
9
10pub fn setup(lib: &str, cell: &str, view: &str, simulator: &str) -> Result<Value> {
11    let client = VirtuosoClient::from_env()?;
12    let skill = ocean::setup_skill(lib, cell, view, simulator);
13    let result = client.execute_skill(&skill, None)?;
14
15    if !result.ok() {
16        return Err(VirtuosoError::Execution(result.errors.join("; ")));
17    }
18
19    Ok(json!({
20        "status": "success",
21        "simulator": simulator,
22        "design": { "lib": lib, "cell": cell, "view": view },
23        "results_dir": result.output.trim().trim_matches('"'),
24    }))
25}
26
27pub fn run(analysis: &str, params: &HashMap<String, String>, timeout: u64) -> Result<Value> {
28    let client = VirtuosoClient::from_env()?;
29
30    // Check if resultsDir is set — do NOT override if it is, as changing
31    // resultsDir while an ADE session is active causes run() to silently
32    // return nil (ADE binds the session to a specific results path).
33    let rdir = client.execute_skill("resultsDir()", None)?;
34    let rdir_val = rdir.output.trim().trim_matches('"');
35    if rdir_val == "nil" || rdir_val.is_empty() {
36        return Err(VirtuosoError::Execution(
37            "resultsDir is not set. Run `virtuoso sim setup` first, or open \
38             ADE L for your testbench and run at least one simulation to \
39             establish the session path."
40                .into(),
41        ));
42    }
43
44    // Send analysis setup
45    let analysis_skill = ocean::analysis_skill_simple(analysis, params);
46    let analysis_result = client.execute_skill(&analysis_skill, None)?;
47    if !analysis_result.ok() {
48        return Err(VirtuosoError::Execution(analysis_result.errors.join("; ")));
49    }
50
51    // Send save
52    let _ = client.execute_skill("save('all)", None);
53
54    // Execute run
55    let result = client.execute_skill("run()", Some(timeout))?;
56    if !result.ok() {
57        return Err(VirtuosoError::Execution(result.errors.join("; ")));
58    }
59
60    // Get actual results dir
61    let rdir = client.execute_skill("resultsDir()", None)?;
62    let results_dir = rdir.output.trim().trim_matches('"').to_string();
63
64    // Validate: run() returning nil usually means simulation didn't execute
65    let run_output = result.output.trim().trim_matches('"');
66    if run_output == "nil" {
67        let check =
68            client.execute_skill(&format!(r#"isFile("{results_dir}/psf/spectre.out")"#), None)?;
69        let has_spectre_out = check.output.trim().trim_matches('"');
70        if has_spectre_out == "nil" || has_spectre_out == "0" {
71            return Err(VirtuosoError::Execution(
72                "Simulation failed: run() returned nil and no spectre.out found. \
73                 The netlist may be missing or stale — regenerate via ADE \
74                 (Simulation → Netlist and Run) or `virtuoso sim netlist`."
75                    .into(),
76            ));
77        }
78    }
79
80    Ok(json!({
81        "status": "success",
82        "analysis": analysis,
83        "params": params,
84        "results_dir": results_dir,
85        "execution_time": result.execution_time,
86    }))
87}
88
89/// Reject SKILL expressions that could cause side effects outside of waveform access.
90/// `measure` is intended for read-only PSF queries; block known destructive/execution APIs.
91fn validate_measure_expr(expr: &str) -> Result<()> {
92    // Case-insensitive prefix patterns that indicate non-measurement operations
93    let blocked: &[&str] = &[
94        "system(",
95        "sh(",
96        "ipcbeginprocess(",
97        "ipcwriteprocess(",
98        "ipckillprocess(",
99        "deletefile(",
100        "deletedir(",
101        "copyfile(",
102        "movefile(",
103        "writefile(",
104        "createdir(",
105        "load(",
106        "evalstring(",
107        "hiloaddmenu(",
108    ];
109    let lower = expr.to_lowercase();
110    for pat in blocked {
111        if lower.contains(pat) {
112            return Err(VirtuosoError::Execution(format!(
113                "measure expression contains blocked function '{pat}': \
114                 only waveform access functions are allowed"
115            )));
116        }
117    }
118    Ok(())
119}
120
121pub fn measure(analysis: &str, exprs: &[String]) -> Result<Value> {
122    for expr in exprs {
123        validate_measure_expr(expr)?;
124    }
125
126    let client = VirtuosoClient::from_env()?;
127
128    // Open results from resultsDir PSF and select result type
129    let rdir = client.execute_skill("resultsDir()", None)?;
130    let rdir_val = rdir.output.trim().trim_matches('"');
131    if rdir_val != "nil" && !rdir_val.is_empty() {
132        let open_skill = format!("openResults(\"{rdir_val}/psf\")");
133        let _ = client.execute_skill(&open_skill, None);
134    }
135    let select_skill = format!("selectResult('{analysis})");
136    let _ = client.execute_skill(&select_skill, None);
137
138    // Execute each measure expression individually for reliability
139    let mut measures = Vec::new();
140    for expr in exprs {
141        let result = client.execute_skill(expr, None)?;
142        let value = if result.ok() {
143            result.output.trim().trim_matches('"').to_string()
144        } else {
145            format!("ERROR: {}", result.errors.join("; "))
146        };
147        measures.push(json!({
148            "expr": expr,
149            "value": value,
150        }));
151    }
152
153    // Detect all-nil results and provide diagnostics
154    let all_nil = !measures.is_empty()
155        && measures.iter().all(|m| {
156            m.get("value")
157                .and_then(|v| v.as_str())
158                .map(|s| s == "nil")
159                .unwrap_or(false)
160        });
161
162    let mut warnings: Vec<String> = Vec::new();
163    if all_nil {
164        let rdir_for_check = rdir_val.to_string();
165        let spectre_exists = client
166            .execute_skill(
167                &format!(r#"isFile("{rdir_for_check}/psf/spectre.out")"#),
168                None,
169            )
170            .map(|r| {
171                let v = r.output.trim().trim_matches('"');
172                v != "nil" && v != "0"
173            })
174            .unwrap_or(false);
175
176        if !spectre_exists {
177            warnings.push(
178                "All measurements returned nil. No spectre.out found — simulation \
179                 may not have run. Check netlist with `virtuoso sim netlist`."
180                    .into(),
181            );
182        } else {
183            warnings.push(
184                "All measurements returned nil. Spectre ran but produced no matching \
185                 data — verify signal names match your schematic and that the correct \
186                 analysis type is selected."
187                    .into(),
188            );
189        }
190    }
191
192    Ok(json!({
193        "status": "success",
194        "measures": measures,
195        "warnings": warnings,
196    }))
197}
198
199pub fn sweep(
200    var: &str,
201    from: f64,
202    to: f64,
203    step: f64,
204    analysis: &str,
205    measure_exprs: &[String],
206    timeout: u64,
207) -> Result<Value> {
208    let client = VirtuosoClient::from_env()?;
209
210    // Generate value list
211    let mut values = Vec::new();
212    let mut v = from;
213    while v <= to + step * 0.01 {
214        values.push(v);
215        v += step;
216    }
217
218    let skill = ocean::sweep_skill(var, &values, analysis, measure_exprs);
219    let result = client.execute_skill(&skill, Some(timeout))?;
220
221    if !result.ok() {
222        return Err(VirtuosoError::Execution(result.errors.join("; ")));
223    }
224
225    let parsed = ocean::parse_skill_list(result.output.trim());
226
227    let mut headers = vec![var.to_string()];
228    headers.extend(measure_exprs.iter().cloned());
229
230    let rows: Vec<Value> = parsed
231        .iter()
232        .map(|row| {
233            let mut obj = serde_json::Map::new();
234            for (i, h) in headers.iter().enumerate() {
235                if let Some(val) = row.get(i) {
236                    obj.insert(h.clone(), json!(val));
237                }
238            }
239            Value::Object(obj)
240        })
241        .collect();
242
243    Ok(json!({
244        "status": "success",
245        "variable": var,
246        "points": values.len(),
247        "headers": headers,
248        "data": rows,
249        "execution_time": result.execution_time,
250    }))
251}
252
253pub fn corner(file: &str, timeout: u64) -> Result<Value> {
254    let content = std::fs::read_to_string(file)
255        .map_err(|e| VirtuosoError::NotFound(format!("corner config not found: {file}: {e}")))?;
256
257    let config: CornerConfig = serde_json::from_str(&content)
258        .map_err(|e| VirtuosoError::Config(format!("invalid corner config: {e}")))?;
259
260    let client = VirtuosoClient::from_env()?;
261    let skill = ocean::corner_skill(&config);
262    let result = client.execute_skill(&skill, Some(timeout))?;
263
264    if !result.ok() {
265        return Err(VirtuosoError::Execution(result.errors.join("; ")));
266    }
267
268    let parsed = ocean::parse_skill_list(result.output.trim());
269
270    let mut headers = vec!["corner".to_string(), "temp".to_string()];
271    headers.extend(config.measures.iter().map(|m| m.name.clone()));
272
273    let rows: Vec<Value> = parsed
274        .iter()
275        .map(|row| {
276            let mut obj = serde_json::Map::new();
277            for (i, h) in headers.iter().enumerate() {
278                if let Some(val) = row.get(i) {
279                    obj.insert(h.clone(), json!(val));
280                }
281            }
282            Value::Object(obj)
283        })
284        .collect();
285
286    Ok(json!({
287        "status": "success",
288        "corners": config.corners.len(),
289        "measures": config.measures.len(),
290        "headers": headers,
291        "data": rows,
292        "execution_time": result.execution_time,
293    }))
294}
295
296pub fn results() -> Result<Value> {
297    let client = VirtuosoClient::from_env()?;
298    let result = client.execute_skill("resultsDir()", None)?;
299
300    if !result.ok() {
301        return Err(VirtuosoError::Execution(result.errors.join("; ")));
302    }
303
304    let dir = result.output.trim().trim_matches('"').to_string();
305
306    // Query available result types
307    let types_result = client.execute_skill(
308        &format!(r#"let((dir files) dir="{dir}" when(isDir(dir) files=getDirFiles(dir)) files)"#),
309        None,
310    )?;
311
312    Ok(json!({
313        "status": "success",
314        "results_dir": dir,
315        "contents": types_result.output.trim(),
316    }))
317}
318
319/// Run createNetlist, auto-recovering from OSSHNL-109 ("modified since last extraction").
320///
321/// When a schematic is edited via SKILL (e.g. `dbSave`) without going through
322/// Check & Save, Cadence marks its extraction timestamp as stale and
323/// `createNetlist` returns nil.  We detect this by retrying after
324/// `schCheck(cv)` + `dbSave(cv)`.
325fn create_netlist_inner(
326    client: &VirtuosoClient,
327    lib: &str,
328    cell: &str,
329    view: &str,
330    recreate: bool,
331) -> Result<String> {
332    let cmd = if recreate {
333        "createNetlist(?recreateAll t ?display nil)"
334    } else {
335        "createNetlist(?display nil)"
336    };
337
338    // First attempt
339    let nr = client.execute_skill(cmd, Some(60))?;
340    let nr_out = nr.output.trim().trim_matches('"').to_string();
341    if nr.skill_ok() {
342        return Ok(nr_out);
343    }
344
345    // Auto-fix OSSHNL-109: run schCheck + dbSave to refresh extraction timestamp.
346    // Try to open the cv in write mode; fall back to the already-open write-mode cv
347    // (dbOpenCellViewByType returns nil if the cv is already held in "a" mode by Ocean).
348    let lib_e = escape_skill_string(lib);
349    let cell_e = escape_skill_string(cell);
350    let view_e = escape_skill_string(view);
351    let fix = format!(
352        r#"let((cv chk) cv=dbOpenCellViewByType("{lib_e}" "{cell_e}" "{view_e}") unless(cv cv=car(setof(ocv dbGetOpenCellViews() and(ocv~>libName=="{lib_e}" ocv~>cellName=="{cell_e}" ocv~>viewName=="{view_e}" ocv~>mode=="a")))) if(cv progn(chk=schCheck(cv) when(car(chk)==0 dbSave(cv)) list(car(chk))) list(-1)))"#
353    );
354    let fix_r = client.execute_skill(&fix, None)?;
355
356    // schCheck returns (errorCount warningCount); we wrapped it in list() → "(N)"
357    let raw = fix_r
358        .output
359        .trim()
360        .trim_start_matches('(')
361        .trim_end_matches(')');
362    let err_count: i64 = raw
363        .split_whitespace()
364        .next()
365        .and_then(|s| s.parse().ok())
366        .unwrap_or(-1);
367
368    if err_count == -1 {
369        // cv not openable — two distinct causes:
370        //   (a) OSSHNL-109: library IS registered, cv held open in "a" mode by Ocean.
371        //       createNetlist may have written the file; return "t" so the caller
372        //       resolves via resultsDir() and verifies the file exists.
373        //   (b) Library not registered in this Virtuoso session (e.g. Virtuoso was
374        //       started from a directory without a cds.lib that includes the library).
375        //       createNetlist silently returned nil; returning "t" would produce a
376        //       confusing "file not found" error downstream.
377        //
378        // Distinguish by checking ddGetLibList() for the library name.
379        let lib_probe = format!(r#"when(car(setof(l ddGetLibList() l~>name=="{lib_e}")) "found")"#);
380        let probe_r = client.execute_skill(&lib_probe, None)?;
381        let lib_found = probe_r.output.trim().trim_matches('"');
382        if lib_found != "found" {
383            let cwd_r = client.execute_skill("getWorkingDir()", None)?;
384            let cwd = cwd_r.output.trim().trim_matches('"');
385            let cwd_note = if !cwd.is_empty() && cwd != "nil" {
386                format!(" Virtuoso was started from '{cwd}'.")
387            } else {
388                String::new()
389            };
390            return Err(VirtuosoError::Execution(format!(
391                "Library '{lib}' is not registered in the current Virtuoso session.{cwd_note} \
392                 Start Virtuoso from the project directory whose cds.lib includes '{lib}', \
393                 or run hiLoadCDSLibDefs() in the CIW to register it at runtime."
394            )));
395        }
396        return Ok("t".into());
397    }
398    if err_count != 0 {
399        return Err(VirtuosoError::Execution(format!(
400            "createNetlist failed; schematic has {err_count} check error(s) (OSSHNL-109). \
401             Fix schematic connectivity errors before netlisting."
402        )));
403    }
404
405    // Retry after Check and Save
406    let retry = client.execute_skill(cmd, Some(60))?;
407    let retry_out = retry.output.trim().trim_matches('"').to_string();
408    if !retry.skill_ok() {
409        let errs = if retry.errors.is_empty() {
410            "none".into()
411        } else {
412            retry.errors.join("; ")
413        };
414        return Err(VirtuosoError::Execution(format!(
415            "createNetlist returned nil after Check and Save. Errors: {errs}. \
416             Ensure the schematic is saved and PDK models are loaded."
417        )));
418    }
419    Ok(retry_out)
420}
421
422/// Standard analysis blocks for standalone Spectre invocation.
423/// Returns `None` for unrecognised kinds so callers can warn.
424fn analysis_block(kind: &str) -> Option<&'static str> {
425    match kind {
426        "dc" => Some(
427            "dcOp dc write=\"spectre.dc\" maxiters=150 maxsteps=10000 annotate=status\n\
428             dcOpInfo info what=oppoint where=rawfile\n",
429        ),
430        "ac" => Some("acSweep ac start=1 stop=10G dec=20 annotate=status\n"),
431        "tran" => Some("tran tran stop=10u annotate=status\n"),
432        _ => None,
433    }
434}
435
436pub fn netlist(
437    lib: &str,
438    cell: &str,
439    view: &str,
440    recreate: bool,
441    analyses: &[String],
442) -> Result<Value> {
443    let client = VirtuosoClient::from_env()?;
444
445    // Step 1: Establish Ocean session (simulator + design) so createNetlist has
446    // a target even on a cold start without a prior ADE session.
447    // setup_skill ends with resultsDir() — may return "nil" if not yet bound;
448    // that's acceptable here since createNetlist returns the path directly.
449    let setup = ocean::setup_skill(lib, cell, view, "spectre");
450    let sr = client.execute_skill(&setup, None)?;
451    if !sr.ok() {
452        return Err(VirtuosoError::Execution(format!(
453            "sim setup failed before netlisting: {}",
454            sr.errors.join("; ")
455        )));
456    }
457
458    // Step 2: createNetlist — auto-recovers from OSSHNL-109 via schCheck+dbSave.
459    let nr_out = create_netlist_inner(&client, lib, cell, view, recreate)?;
460
461    // Step 3: Resolve the actual netlist path.
462    // createNetlist returns either:
463    //   (a) the full path to input.scs  — use directly
464    //   (b) the resultsDir path         — append /netlist/input.scs
465    //   (c) "t"                         — reuse resultsDir from setup (sr.output)
466    let candidate = if nr_out.ends_with(".scs") {
467        nr_out.clone()
468    } else if nr_out != "t" && !nr_out.is_empty() {
469        format!("{nr_out}/netlist/input.scs")
470    } else {
471        // createNetlist returned "t"; reuse the resultsDir captured during setup,
472        // falling back to an extra SKILL call if setup returned nil or a relative path.
473        // If resultsDir is relative, prepend getWorkingDir() to make it absolute.
474        let rdir_val = {
475            let from_setup = sr.output.trim().trim_matches('"');
476            let raw =
477                if from_setup != "nil" && !from_setup.is_empty() && from_setup.starts_with('/') {
478                    from_setup.to_string()
479                } else {
480                    let rdir = client.execute_skill("resultsDir()", None)?;
481                    rdir.output.trim().trim_matches('"').to_string()
482                };
483            // Relative path — prepend Ocean's working directory to make it absolute.
484            if !raw.is_empty() && raw != "nil" && !raw.starts_with('/') {
485                let cwd_r = client.execute_skill("getWorkingDir()", None)?;
486                let cwd = cwd_r.output.trim().trim_matches('"');
487                if cwd != "nil" && !cwd.is_empty() {
488                    format!("{cwd}/{raw}")
489                } else {
490                    raw
491                }
492            } else {
493                raw
494            }
495        };
496        if rdir_val == "nil" || rdir_val.is_empty() {
497            return Err(VirtuosoError::Execution(
498                "createNetlist returned 't' but resultsDir() is nil. \
499                 Run `vcli sim setup` first or open ADE L for this cell."
500                    .into(),
501            ));
502        }
503        format!("{rdir_val}/netlist/input.scs")
504    };
505
506    // Step 4: Verify the file actually exists on disk.
507    let check = client.execute_skill(&format!(r#"isFile("{candidate}")"#), None)?;
508    let v = check.output.trim().trim_matches('"');
509    let file_exists = v != "nil" && v != "0";
510
511    if !file_exists {
512        return Err(VirtuosoError::Execution(format!(
513            "createNetlist ran but file not found at '{candidate}'. \
514             createNetlist output was: '{nr_out}'. \
515             Check resultsDir() and ensure write permissions."
516        )));
517    }
518
519    // Step 5: Post-process the netlist for standalone Spectre invocation.
520    let mut patched = false;
521    let mut unknown_analyses: Vec<&str> = Vec::new();
522
523    if !analyses.is_empty() {
524        let mut content = std::fs::read_to_string(&candidate).map_err(|e| {
525            VirtuosoError::Execution(format!("cannot read netlist '{candidate}': {e}"))
526        })?;
527
528        // Fix ADE OA-relative model path (only resolves with +adespetkn token).
529        // Pattern: /oa/smic13mmrf_1233//../  →  /  (removes the indirection)
530        if content.contains("/oa/smic13mmrf_1233//../") {
531            content = content.replace("/oa/smic13mmrf_1233//../", "/");
532            patched = true;
533        }
534
535        // Append missing analysis blocks (skip if already present).
536        for kind in analyses {
537            match analysis_block(kind) {
538                Some(block) => {
539                    let marker = match kind.as_str() {
540                        "dc" => "dcOp ",
541                        "ac" => "acSweep ",
542                        "tran" => "tran tran",
543                        _ => unreachable!(),
544                    };
545                    if !content.contains(marker) {
546                        content.push('\n');
547                        content.push_str(block);
548                        patched = true;
549                    }
550                }
551                None => unknown_analyses.push(kind),
552            }
553        }
554
555        if patched {
556            std::fs::write(&candidate, &content).map_err(|e| {
557                VirtuosoError::Execution(format!("cannot write patched netlist '{candidate}': {e}"))
558            })?;
559        }
560    }
561
562    let mut out = json!({
563        "status": "success",
564        "netlist_path": candidate,
565    });
566    if patched {
567        out["patched"] = json!(true);
568    }
569    if !unknown_analyses.is_empty() {
570        out["unknown_analyses"] = json!(unknown_analyses);
571    }
572    Ok(out)
573}
574
575// ── Async job commands ──────────────────────────────────────────────
576
577pub fn run_async(netlist_path: &str) -> Result<Value> {
578    let content = std::fs::read_to_string(netlist_path)
579        .map_err(|e| VirtuosoError::Config(format!("Cannot read netlist '{netlist_path}': {e}")))?;
580    let sim = SpectreSimulator::from_env()?;
581    let job = sim.run_async(&content)?;
582    Ok(json!({
583        "status": "launched",
584        "job_id": job.id,
585        "pid": job.pid,
586        "netlist": netlist_path,
587    }))
588}
589
590#[cfg(test)]
591mod tests {
592    use super::validate_measure_expr;
593
594    #[test]
595    fn safe_waveform_exprs_are_allowed() {
596        for expr in &[
597            "VT(\"vout\" \"VGS\")",
598            "bandwidth(getData(\"vout\") 3)",
599            "value(getData(\"vout\") 1e-9)",
600            "getData(\"/vout\")",
601            "ymax(getData(\"id\"))",
602            "delay(getData(\"vout\") 0.5)",
603        ] {
604            assert!(
605                validate_measure_expr(expr).is_ok(),
606                "should be allowed: {expr}"
607            );
608        }
609    }
610
611    #[test]
612    fn dangerous_exprs_are_blocked() {
613        let cases = [
614            ("system(\"id\")", "system("),
615            ("sh(\"ls\")", "sh("),
616            ("ipcBeginProcess(\"cmd\")", "ipcbeginprocess("),
617            ("deleteFile(\"/etc/hosts\")", "deletefile("),
618            ("load(\"/tmp/evil.il\")", "load("),
619            ("evalstring(\"getData(1)\")", "evalstring("),
620            // case-insensitive
621            ("SYSTEM(\"id\")", "system("),
622            ("DeleteFile(\"/tmp/x\")", "deletefile("),
623        ];
624        for (expr, pat) in &cases {
625            let err = validate_measure_expr(expr).unwrap_err();
626            assert!(
627                err.to_string().contains(pat),
628                "error should mention '{pat}': {err}"
629            );
630        }
631    }
632}
633
634pub fn job_status(id: &str) -> Result<Value> {
635    let mut job = Job::load(id)?;
636    job.refresh()?;
637    serde_json::to_value(&job).map_err(|e| VirtuosoError::Execution(e.to_string()))
638}
639
640pub fn job_list() -> Result<Value> {
641    let mut jobs = Job::list_all()?;
642    for job in &mut jobs {
643        let _ = job.refresh();
644    }
645    let jobs_value = serde_json::to_value(&jobs)
646        .map_err(|e| VirtuosoError::Execution(format!("Failed to serialize jobs: {e}")))?;
647    Ok(json!({
648        "count": jobs.len(),
649        "jobs": jobs_value,
650    }))
651}
652
653pub fn job_cancel(id: &str) -> Result<Value> {
654    let mut job = Job::load(id)?;
655    job.cancel()?;
656    Ok(json!({
657        "status": "cancelled",
658        "job_id": id,
659    }))
660}