diff --git a/compiler/frontend/pycircuit/cli.py b/compiler/frontend/pycircuit/cli.py index 2709bcb..20d562f 100644 --- a/compiler/frontend/pycircuit/cli.py +++ b/compiler/frontend/pycircuit/cli.py @@ -1218,18 +1218,24 @@ def _emit_multi_pyc_artifacts(design: Design, *, out_dir: Path) -> tuple[Path, d return (manifest_path, manifest, module_paths, design_pyc_path) +def _find_testbench_fn(mod: object) -> Any | None: + if not hasattr(mod, "tb"): + return None + tb_fn = getattr(mod, "tb") + if not callable(tb_fn): + raise SystemExit("tb must be a callable when present") + if not bool(getattr(tb_fn, "__pycircuit_testbench__", False)): + raise SystemExit("tb(...) must be decorated with `@testbench` when present") + return tb_fn + + def _collect_testbench_payload( - mod: object, + tb_fn: Any, iface: _TopIface, *, trace_plan: TracePlan | None = None, tb_probes: TbProbes | None = None, -) -> tuple[str, str]: - if not hasattr(mod, "tb") or not callable(getattr(mod, "tb")): - raise SystemExit("build requires `@testbench def tb(t: Tb): ...`") - tb_fn = getattr(mod, "tb") - if not bool(getattr(tb_fn, "__pycircuit_testbench__", False)): - raise SystemExit("build requires tb(...) to be decorated with `@testbench`") +) -> tuple[str, str, dict[str, Any]]: t = Tb() try: tb_sig = inspect.signature(tb_fn) @@ -1254,13 +1260,21 @@ def _collect_testbench_payload( if not isinstance(tb_name, str) or not tb_name.strip(): tb_name = f"tb_{iface.sym}" tb_name = _sanitize_id(str(tb_name)) - payload = payload_obj.as_dict() + program_export = payload_obj.as_dict() + program_export["tb_name"] = str(tb_name) + if trace_plan is not None: + program_export["trace_plan"] = trace_plan.as_dict() + payload = dict(program_export) payload["tb_name"] = str(tb_name) if trace_plan is not None: payload["trace_plan"] = trace_plan.as_dict() payload["cpp_text"] = _render_tb_cpp(iface, t, trace_plan=trace_plan) payload["sv_text"] = _render_tb_sv(iface, t, trace_plan=trace_plan) - return (str(tb_name), json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)) + return ( + str(tb_name), + json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False), + program_export, + ) def _emit_testbench_pyc_file( @@ -1329,6 +1343,206 @@ def _save_json(path: Path, data: dict[str, Any]) -> None: _write_text_atomic(path, json.dumps(data, sort_keys=True, indent=2) + "\n") +def _iface_port_rows(iface: _TopIface) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + + def width_of(ty: str) -> int: + if ty == "!pyc.reset": + return 1 + if ty.startswith("i"): + try: + return int(ty[1:]) + except ValueError: + return 0 + return 0 + + for raw, safe, ty in zip(iface.in_raw, iface.in_names, iface.in_tys): + rows.append( + { + "direction": "input", + "raw_name": raw, + "safe_name": safe, + "ty": ty, + "width_bits": width_of(ty), + } + ) + for raw, safe, ty in zip(iface.out_raw, iface.out_names, iface.out_tys): + rows.append( + { + "direction": "output", + "raw_name": raw, + "safe_name": safe, + "ty": ty, + "width_bits": width_of(ty), + } + ) + return rows + + +def _render_cmodel_bridge_hpp(iface: _TopIface) -> str: + top = iface.sym + bridge = f"{top}CModel" + lines: list[str] = [] + lines.append("// Generated by pycircuit: external C++ / TLM bridge scaffold.\n") + lines.append("#pragma once\n\n") + lines.append("#include \n") + lines.append("#include \n\n") + lines.append(f"#include \"{top}.hpp\"\n\n") + lines.append("namespace pyc::cmodel {\n\n") + lines.append("namespace detail {\n\n") + lines.append("template \n") + lines.append("struct has_comb : std::false_type {};\n\n") + lines.append("template \n") + lines.append("struct has_comb().comb())>> : std::true_type {};\n\n") + lines.append("template \n") + lines.append("struct has_eval : std::false_type {};\n\n") + lines.append("template \n") + lines.append("struct has_eval().eval())>> : std::true_type {};\n\n") + lines.append("template \n") + lines.append("struct has_transfer : std::false_type {};\n\n") + lines.append("template \n") + lines.append("struct has_transfer().transfer())>> : std::true_type {};\n\n") + lines.append("template \n") + lines.append("inline void maybe_comb(T &dut) {\n") + lines.append(" if constexpr (has_comb::value) {\n") + lines.append(" dut.comb();\n") + lines.append(" } else if constexpr (has_eval::value) {\n") + lines.append(" dut.eval();\n") + lines.append(" }\n") + lines.append("}\n\n") + lines.append("template \n") + lines.append("inline void maybe_transfer(T &dut) {\n") + lines.append(" if constexpr (has_transfer::value)\n") + lines.append(" dut.transfer();\n") + lines.append("}\n\n") + lines.append("} // namespace detail\n\n") + lines.append(f"class {bridge} {{\n") + lines.append("public:\n") + lines.append(f" using Dut = pyc::gen::{top};\n\n") + lines.append(" Dut dut{};\n\n") + lines.append(" Dut &raw() { return dut; }\n") + lines.append(" const Dut &raw() const { return dut; }\n\n") + lines.append(" void settle() { detail::maybe_comb(dut); }\n") + lines.append(" void tick() { dut.tick(); }\n") + lines.append(" void transfer() { detail::maybe_transfer(dut); }\n\n") + lines.append(" void cycle() {\n") + lines.append(" settle();\n") + lines.append(" tick();\n") + lines.append(" transfer();\n") + lines.append(" settle();\n") + lines.append(" ++cycles_;\n") + lines.append(" }\n\n") + lines.append(" std::uint64_t cycles() const { return cycles_; }\n\n") + lines.append("private:\n") + lines.append(" std::uint64_t cycles_ = 0;\n") + lines.append("};\n\n") + lines.append("} // namespace pyc::cmodel\n") + return "".join(lines) + + +def _render_cmodel_entry_cpp( + iface: _TopIface, + *, + bridge_header_name: str, + tb_program_rel: str | None, +) -> str: + top = iface.sym + bridge = f"{top}CModel" + lines: list[str] = [] + lines.append("// Generated by pycircuit: replace this scaffold with a hand-written C++ TB or TLM.\n") + lines.append("#include \n\n") + lines.append(f"#include \"{bridge_header_name}\"\n\n") + lines.append("int main() {\n") + lines.append(f" pyc::cmodel::{bridge} model;\n") + lines.append(" model.settle();\n") + lines.append(f" std::cout << \"pyCircuit C-model scaffold for {top}\\\\n\";\n") + if tb_program_rel is not None: + lines.append(f" std::cout << \"Stimulus bridge: {tb_program_rel}\\\\n\";\n") + lines.append(" model.cycle();\n") + lines.append(" std::cout << \"Cycles executed: \" << model.cycles() << \"\\\\n\";\n") + lines.append(" return 0;\n") + lines.append("}\n") + return "".join(lines) + + +def _render_cmodel_readme( + iface: _TopIface, + *, + bridge_header_name: str, + entry_cpp_name: str, + tb_program_name: str | None, +) -> str: + lines: list[str] = [] + lines.append(f"# {iface.sym} C-model Bridge\n\n") + lines.append("This directory is generated by `pycircuit build` to support external C++/TLM drivers.\n\n") + lines.append("Files:\n") + lines.append(f"- `{bridge_header_name}`: minimal cycle driver wrapping `pyc::gen::{iface.sym}`\n") + lines.append(f"- `{entry_cpp_name}`: compilable scaffold main that you can replace with a custom TB/TLM\n") + if tb_program_name is not None: + lines.append(f"- `{tb_program_name}`: exported pyCircuit testbench program JSON for external replay\n") + lines.append("\n") + lines.append("Ports:\n") + for row in _iface_port_rows(iface): + lines.append( + f"- `{row['direction']}` `{row['raw_name']}` as `dut.{row['safe_name']}` (`{row['ty']}`, {row['width_bits']} bits)\n" + ) + lines.append("\n") + lines.append("Typical workflow:\n") + lines.append("1. Build the DUT with `python3 -m pycircuit.cli build --target cpp --out-dir `.\n") + lines.append(f"2. Start from `{entry_cpp_name}` and replace the placeholder logic with your custom driver.\n") + if tb_program_name is not None: + lines.append(f"3. If you want pyCircuit-authored stimulus, replay `{tb_program_name}` in your external model.\n") + lines.append("4. Use `cpp_project_manifest.json` or `cmodel_project_manifest.json` to generate a CMake project.\n") + return "".join(lines) + + +def _emit_cmodel_scaffold( + *, + out_dir: Path, + iface: _TopIface, + tb_program: Mapping[str, Any] | None = None, +) -> dict[str, Path]: + cmodel_dir = out_dir / "cmodel" + cmodel_dir.mkdir(parents=True, exist_ok=True) + + bridge_hpp = cmodel_dir / f"{iface.sym}_cmodel.hpp" + entry_cpp = cmodel_dir / f"{iface.sym}_main.cpp" + tb_program_path: Path | None = None + if tb_program is not None: + tb_program_path = cmodel_dir / "tb_program.json" + _save_json(tb_program_path, dict(tb_program)) + + _write_text_atomic(bridge_hpp, _render_cmodel_bridge_hpp(iface)) + _write_text_atomic( + entry_cpp, + _render_cmodel_entry_cpp( + iface, + bridge_header_name=bridge_hpp.name, + tb_program_rel=(tb_program_path.name if tb_program_path is not None else None), + ), + ) + readme = cmodel_dir / "README.md" + _write_text_atomic( + readme, + _render_cmodel_readme( + iface, + bridge_header_name=bridge_hpp.name, + entry_cpp_name=entry_cpp.name, + tb_program_name=(tb_program_path.name if tb_program_path is not None else None), + ), + ) + + out = { + "dir": cmodel_dir, + "bridge_hpp": bridge_hpp, + "entry_cpp": entry_cpp, + "readme": readme, + } + if tb_program_path is not None: + out["tb_program"] = tb_program_path + return out + + def _base_name_of(fn: Any) -> str: override = getattr(fn, "__pycircuit_module_name__", None) if isinstance(override, str) and override.strip(): @@ -1630,17 +1844,30 @@ def _cmd_build(args: argparse.Namespace) -> int: except TraceConfigError as e: raise SystemExit(f"trace config error: {e}") from e - tb_probes = TbProbes.from_probe_manifest(probe_manifest_obj) - tb_name, tb_payload_json = _collect_testbench_payload(mod, iface, trace_plan=trace_plan, tb_probes=tb_probes) - tb_pyc_path = _emit_testbench_pyc_file(out_dir=out_dir, tb_name=tb_name, payload_json=tb_payload_json) - manifest["testbench"] = {"name": tb_name, "pyc": str(tb_pyc_path.relative_to(out_dir))} + tb_fn = _find_testbench_fn(mod) + tb_name: str | None = None + tb_payload_json: str | None = None + tb_program_export: dict[str, Any] | None = None + tb_pyc_path: Path | None = None + if tb_fn is not None: + tb_probes = TbProbes.from_probe_manifest(probe_manifest_obj) + tb_name, tb_payload_json, tb_program_export = _collect_testbench_payload( + tb_fn, + iface, + trace_plan=trace_plan, + tb_probes=tb_probes, + ) + tb_pyc_path = _emit_testbench_pyc_file(out_dir=out_dir, tb_name=tb_name, payload_json=tb_payload_json) + manifest["testbench"] = {"name": tb_name, "pyc": str(tb_pyc_path.relative_to(out_dir))} + else: + manifest["testbench"] = None if trace_plan is not None: trace_path = out_dir / "trace_plan.json" _save_json(trace_path, trace_plan.as_dict()) manifest["trace_plan"] = str(trace_path.relative_to(out_dir)) - tb_cpp_out = out_dir / "tb" / f"{tb_name}.cpp" - tb_sv_out = out_dir / "tb" / f"{tb_name}.sv" + tb_cpp_out = (out_dir / "tb" / f"{tb_name}.cpp") if tb_name is not None else None + tb_sv_out = (out_dir / "tb" / f"{tb_name}.sv") if tb_name is not None else None for sym in sorted(module_paths.keys()): mp = module_paths[sym] h = _module_hash(mp) @@ -1688,7 +1915,7 @@ def _cmd_build(args: argparse.Namespace) -> int: ) ) - if do_cpp: + if do_cpp and tb_name is not None and tb_pyc_path is not None and tb_cpp_out is not None: tb_key = f"tb:{tb_name}" tb_hash = _module_hash(tb_pyc_path) module_hashes[tb_key] = tb_hash @@ -1700,7 +1927,7 @@ def _cmd_build(args: argparse.Namespace) -> int: [str(pycc), str(tb_pyc_path), *pycc_hard_hierarchy_flags, "-cpp", str(tb_cpp_out)], ) ) - if do_v: + if do_v and tb_name is not None and tb_pyc_path is not None and tb_sv_out is not None: tb_key = f"tb:{tb_name}" tb_hash = module_hashes.get(tb_key) or _module_hash(tb_pyc_path) module_hashes[tb_key] = tb_hash @@ -1723,8 +1950,6 @@ def _cmd_build(args: argparse.Namespace) -> int: cpp_sources = _gather_cpp_sources(device_cpp_root) if not cpp_sources: raise SystemExit("build(cpp): no generated C++ sources found") - if not tb_cpp_out.is_file(): - raise SystemExit(f"build(cpp): missing generated TB C++ source: {tb_cpp_out}") cpp_headers = _gather_cpp_headers(device_cpp_root) include_dirs: list[str] = [] include_dirs.append(str(device_cpp_root)) @@ -1734,19 +1959,62 @@ def _cmd_build(args: argparse.Namespace) -> int: include_dirs.append(parent) runtime = _runtime_manifest_for_toolchain(_detect_toolchain_root(pycc)) - - build_manifest = { - "version": 3, + cmodel_paths = _emit_cmodel_scaffold(out_dir=out_dir, iface=iface, tb_program=tb_program_export) + cmodel_include_dirs = list(include_dirs) + cmodel_dir_str = str(cmodel_paths["dir"]) + if cmodel_dir_str not in cmodel_include_dirs: + cmodel_include_dirs.append(cmodel_dir_str) + cmodel_headers = [*cpp_headers, cmodel_paths["bridge_hpp"]] + cmodel_manifest = { + "version": 4, "target_name": iface.sym, - "tb_cpp": str(tb_cpp_out), + "entry_kind": "cmodel", + "entry_cpp": str(cmodel_paths["entry_cpp"]), "sources": [str(p) for p in cpp_sources], - "headers": [str(p) for p in cpp_headers], - "include_dirs": include_dirs, + "headers": [str(p) for p in cmodel_headers], + "include_dirs": cmodel_include_dirs, "runtime": runtime, "cxx_standard": "c++17", "profile": str(args.profile), + "executable_name": "pyc_cmodel", + "dut_type": f"pyc::gen::{iface.sym}", + "top_header": f"{iface.sym}.hpp", + "ports": _iface_port_rows(iface), } + cmodel_manifest_path = out_dir / "cmodel_project_manifest.json" + _save_json(cmodel_manifest_path, cmodel_manifest) + cmodel_section = { + "manifest": str(cmodel_manifest_path.relative_to(out_dir)), + "entry_cpp": str(cmodel_paths["entry_cpp"].relative_to(out_dir)), + "bridge_hpp": str(cmodel_paths["bridge_hpp"].relative_to(out_dir)), + "readme": str(cmodel_paths["readme"].relative_to(out_dir)), + } + if "tb_program" in cmodel_paths: + cmodel_section["tb_program"] = str(cmodel_paths["tb_program"].relative_to(out_dir)) + manifest["cmodel"] = cmodel_section + cpp_manifest = out_dir / "cpp_project_manifest.json" + build_manifest = dict(cmodel_manifest) + if tb_name is not None and tb_cpp_out is not None: + if not tb_cpp_out.is_file(): + raise SystemExit(f"build(cpp): missing generated TB C++ source: {tb_cpp_out}") + build_manifest = { + "version": 4, + "target_name": iface.sym, + "entry_kind": "testbench", + "entry_cpp": str(tb_cpp_out), + "tb_cpp": str(tb_cpp_out), + "sources": [str(p) for p in cpp_sources], + "headers": [str(p) for p in cpp_headers], + "include_dirs": include_dirs, + "runtime": runtime, + "cxx_standard": "c++17", + "profile": str(args.profile), + "executable_name": "pyc_tb", + "dut_type": f"pyc::gen::{iface.sym}", + "top_header": f"{iface.sym}.hpp", + "ports": _iface_port_rows(iface), + } _save_json(cpp_manifest, build_manifest) gen_script = _tool_script("gen_cmake_from_manifest.py") @@ -1789,87 +2057,91 @@ def _cmd_build(args: argparse.Namespace) -> int: subprocess.run(cmake_cmd, check=True) subprocess.run(["cmake", "--build", str(cmake_build), "-j", str(jobs)], check=True) - manifest["cpp_executable"] = str(cmake_build / "pyc_tb") + manifest["cpp_executable"] = str(cmake_build / str(build_manifest.get("executable_name", "pyc_tb"))) if do_v: - if not tb_sv_out.is_file(): + if tb_name is None or tb_sv_out is None: + if bool(args.run_verilator): + raise SystemExit("build(verilator): --run-verilator requires `@testbench def tb(t: Tb): ...`") + elif not tb_sv_out.is_file(): raise SystemExit(f"build(verilator): missing generated TB SV source: {tb_sv_out}") - prim_file: Path | None = None - verilog_module_sources: list[str] = [] - for p in sorted(device_v_root.rglob("*.v")): - if not p.is_file(): - continue - if p.name == "pyc_primitives.v": - if prim_file is None: - prim_file = p - continue - verilog_module_sources.append(str(p)) - if not verilog_module_sources: - raise SystemExit("build(verilator): no generated Verilog sources found") - verilog_sources = ([str(prim_file)] if prim_file is not None else []) + verilog_module_sources - verilog_manifest = { - "version": 1, - "top": tb_name, - "tb_sv": str(tb_sv_out), - "sources": verilog_sources, - "include_dirs": [str(device_v_root)], - } - sim_manifest = out_dir / "verilator_manifest.json" - _save_json(sim_manifest, verilog_manifest) - manifest["verilator_manifest"] = str(sim_manifest.relative_to(out_dir)) - if bool(args.run_verilator): - vbuild = out_dir / "verilator_build" - - # On Windows, MSYS2's `verilator` is typically a script (shebang) and - # cannot be launched via CreateProcess directly. Prefer the real exe. - verilator_exe = "verilator" - if os.name == "nt": - verilator_exe = ( - shutil.which("verilator_bin.exe") - or shutil.which("verilator_bin") - or "verilator_bin.exe" - ) - - # Verilator needs a valid VERILATOR_ROOT on Windows; otherwise it may - # form mixed /path\\include\\... strings and fail to locate std SV. - run_env = None - if os.name == "nt": - run_env = os.environ.copy() - vb = shutil.which(str(verilator_exe)) - if vb: - prefix = Path(vb).resolve().parents[1] - run_env["VERILATOR_ROOT"] = str(prefix / "share" / "verilator") + else: + prim_file: Path | None = None + verilog_module_sources: list[str] = [] + for p in sorted(device_v_root.rglob("*.v")): + if not p.is_file(): + continue + if p.name == "pyc_primitives.v": + if prim_file is None: + prim_file = p + continue + verilog_module_sources.append(str(p)) + if not verilog_module_sources: + raise SystemExit("build(verilator): no generated Verilog sources found") + verilog_sources = ([str(prim_file)] if prim_file is not None else []) + verilog_module_sources + verilog_manifest = { + "version": 1, + "top": tb_name, + "tb_sv": str(tb_sv_out), + "sources": verilog_sources, + "include_dirs": [str(device_v_root)], + } + sim_manifest = out_dir / "verilator_manifest.json" + _save_json(sim_manifest, verilog_manifest) + manifest["verilator_manifest"] = str(sim_manifest.relative_to(out_dir)) + if bool(args.run_verilator): + vbuild = out_dir / "verilator_build" + + # On Windows, MSYS2's `verilator` is typically a script (shebang) and + # cannot be launched via CreateProcess directly. Prefer the real exe. + verilator_exe = "verilator" + if os.name == "nt": + verilator_exe = ( + shutil.which("verilator_bin.exe") + or shutil.which("verilator_bin") + or "verilator_bin.exe" + ) - cmd = [ - verilator_exe, - "--binary", - "-Wall", - "-Wno-fatal", - "-Wno-DECLFILENAME", - "-Wno-UNUSEDSIGNAL", - "-Wno-WIDTHEXPAND", - "--quiet", - # MSYS2/Windows Verilator wrapper does not support --quiet-build. - "--timing", - "--trace", - "--top-module", - tb_name, - "--Mdir", - str(vbuild), - str(tb_sv_out), - *verilog_sources, - ] - subprocess.run(cmd, check=True, env=run_env) - vbin = vbuild / f"V{tb_name}" - if os.name == "nt" and not vbin.is_file(): - vbin_exe = vbin.with_suffix(".exe") - if vbin_exe.is_file(): - vbin = vbin_exe - manifest["verilator_binary"] = str(vbin) - if not vbin.is_file(): - raise SystemExit(f"build(verilator): expected binary not found: {vbin}") - run_args = list(getattr(args, "run_arg", []) or []) - subprocess.run([str(vbin), *run_args], cwd=str(out_dir), check=True) + # Verilator needs a valid VERILATOR_ROOT on Windows; otherwise it may + # form mixed /path\\include\\... strings and fail to locate std SV. + run_env = None + if os.name == "nt": + run_env = os.environ.copy() + vb = shutil.which(str(verilator_exe)) + if vb: + prefix = Path(vb).resolve().parents[1] + run_env["VERILATOR_ROOT"] = str(prefix / "share" / "verilator") + + cmd = [ + verilator_exe, + "--binary", + "-Wall", + "-Wno-fatal", + "-Wno-DECLFILENAME", + "-Wno-UNUSEDSIGNAL", + "-Wno-WIDTHEXPAND", + "--quiet", + # MSYS2/Windows Verilator wrapper does not support --quiet-build. + "--timing", + "--trace", + "--top-module", + tb_name, + "--Mdir", + str(vbuild), + str(tb_sv_out), + *verilog_sources, + ] + subprocess.run(cmd, check=True, env=run_env) + vbin = vbuild / f"V{tb_name}" + if os.name == "nt" and not vbin.is_file(): + vbin_exe = vbin.with_suffix(".exe") + if vbin_exe.is_file(): + vbin = vbin_exe + manifest["verilator_binary"] = str(vbin) + if not vbin.is_file(): + raise SystemExit(f"build(verilator): expected binary not found: {vbin}") + run_args = list(getattr(args, "run_arg", []) or []) + subprocess.run([str(vbin), *run_args], cwd=str(out_dir), check=True) cache_out = dict(cache) cache_out.update( @@ -1956,7 +2228,10 @@ def main(argv: list[str] | None = None) -> int: emit.set_defaults(fn=_cmd_emit) build = sub.add_parser("build", help="Canonical flow: multi-.pyc emit + parallel pycc + CMake/Verilator.") - build.add_argument("python_file", help="Python source defining `@module build(...)` and `@testbench tb(...)`") + build.add_argument( + "python_file", + help="Python source defining `@module build(...)`; `@testbench tb(...)` is optional", + ) build.add_argument("--out-dir", required=True, help="Output directory for project artifacts") build.add_argument( "--param", diff --git a/compiler/frontend/pycircuit/tests/__init__.py b/compiler/frontend/pycircuit/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/compiler/frontend/pycircuit/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/compiler/frontend/pycircuit/tests/test_cli_cmodel.py b/compiler/frontend/pycircuit/tests/test_cli_cmodel.py new file mode 100644 index 0000000..ea99d11 --- /dev/null +++ b/compiler/frontend/pycircuit/tests/test_cli_cmodel.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from pycircuit.cli import ( + _TopIface, + _collect_testbench_payload, + _emit_cmodel_scaffold, + _find_testbench_fn, +) +from pycircuit.design import testbench + + +def test_find_testbench_fn_is_optional() -> None: + assert _find_testbench_fn(SimpleNamespace()) is None + + +def test_find_testbench_fn_rejects_undecorated_tb() -> None: + with pytest.raises(SystemExit, match="decorated with `@testbench`"): + _find_testbench_fn(SimpleNamespace(tb=lambda t: None)) + + +def test_collect_testbench_payload_exports_program_without_backend_text() -> None: + iface = _TopIface(sym="DemoTop", in_raw=["clk", "a"], in_tys=["i1", "i8"], out_raw=["y"], out_tys=["i8"]) + + @testbench + def tb(t) -> None: + t.clock("clk") + t.timeout(4) + t.finish(at=1) + + tb_name, payload_json, program = _collect_testbench_payload(tb, iface) + + payload = json.loads(payload_json) + assert tb_name == "tb_DemoTop" + assert program["tb_name"] == tb_name + assert "cpp_text" in payload + assert "sv_text" in payload + assert "cpp_text" not in program + assert "sv_text" not in program + + +def test_emit_cmodel_scaffold_writes_bridge_and_program(tmp_path: Path) -> None: + iface = _TopIface(sym="DemoTop", in_raw=["clk", "a"], in_tys=["i1", "i8"], out_raw=["y"], out_tys=["i8"]) + tb_program = {"tb_name": "tb_DemoTop", "timeout_cycles": 4, "ports": {"inputs": [], "outputs": []}} + + paths = _emit_cmodel_scaffold(out_dir=tmp_path, iface=iface, tb_program=tb_program) + + bridge_text = paths["bridge_hpp"].read_text(encoding="utf-8") + main_text = paths["entry_cpp"].read_text(encoding="utf-8") + readme_text = paths["readme"].read_text(encoding="utf-8") + tb_program_text = paths["tb_program"].read_text(encoding="utf-8") + + assert "pyc::gen::DemoTop" in bridge_text + assert "Stimulus bridge: tb_program.json" in main_text + assert "dut.a" in readme_text + assert json.loads(tb_program_text)["tb_name"] == "tb_DemoTop" + + +def test_gen_cmake_from_manifest_supports_entry_cpp(tmp_path: Path) -> None: + src = tmp_path / "dut.cpp" + entry = tmp_path / "main.cpp" + runtime_src = tmp_path / "pyc_runtime.cpp" + src.write_text("int dut_helper() { return 0; }\n", encoding="utf-8") + entry.write_text("int main() { return 0; }\n", encoding="utf-8") + runtime_src.write_text("void pyc_runtime_stub() {}\n", encoding="utf-8") + + manifest = tmp_path / "cpp_project_manifest.json" + manifest.write_text( + json.dumps( + { + "sources": [str(src)], + "entry_cpp": str(entry), + "include_dirs": [str(tmp_path)], + "runtime_sources": [str(runtime_src)], + "cxx_standard": "c++17", + "executable_name": "pyc_cmodel", + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + + out_dir = tmp_path / "cmake" + script = Path(__file__).resolve().parents[4] / "flows" / "tools" / "gen_cmake_from_manifest.py" + subprocess.run( + [sys.executable, str(script), "--manifest", str(manifest), "--out-dir", str(out_dir)], + check=True, + ) + + cmake_text = (out_dir / "CMakeLists.txt").read_text(encoding="utf-8") + assert "add_executable(pyc_cmodel ${PYC_TB_SOURCES})" in cmake_text + assert "\"main.cpp\"" in cmake_text diff --git a/docs/FRONTEND_API.md b/docs/FRONTEND_API.md index 3480624..2934ad7 100644 --- a/docs/FRONTEND_API.md +++ b/docs/FRONTEND_API.md @@ -7,7 +7,9 @@ - `value_params` are runtime module IO values (not specialization params) - `@function`: inline helper (inlined into the caller) - `@const`: compile-time helper (pure; may not emit IR or mutate the module) -- `@testbench`: host-side cycle test program lowered via a `.pyc` payload +- `@testbench`: optional host-side cycle test program lowered via a `.pyc` payload + +When `@testbench` is omitted, `pycircuit build --target cpp` still emits the DUT plus a generated `cmodel/` bridge for external C++ / TLM drivers. ## Top-level imports (recommended) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 7640e76..ae95f03 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -13,7 +13,8 @@ Frontend responsibilities: - materialize `@module(value_params=...)` as runtime boundary input ports - emit one `.pyc` per specialized module - emit a deterministic `project_manifest.json` -- emit a testbench `.pyc` payload from `@testbench` +- emit a testbench `.pyc` payload from `@testbench` when present +- emit `cmodel/` bridge artifacts for external C++ / TLM flows when building C++ All emitted modules are stamped with: - `pyc.frontend.contract = "pycircuit"` @@ -49,6 +50,15 @@ Build a project (multi-module + testbench): python3 -m pycircuit.cli build --out-dir --target cpp|verilator|both --jobs ``` +Build a DUT without `@testbench` and get an external C++ driver scaffold: + +```bash +python3 -m pycircuit.cli build --out-dir --target cpp --jobs +``` + +In DUT-only mode, `cpp_project_manifest.json` points at the generated `cmodel/_main.cpp`, and `cmodel_project_manifest.json` +describes the reusable external-driver interface. + Simulation (Verilator): ```bash diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md index 237a3f3..dab1aa7 100644 --- a/docs/QUICKSTART.md +++ b/docs/QUICKSTART.md @@ -45,3 +45,17 @@ python3 -m pycircuit.cli build \ --target both \ --jobs 8 ``` + +Build the DUT only and generate an external C++ / TLM scaffold: + +```bash +PYTHONPATH=/Users/zhoubot/pyCircuit/compiler/frontend \ +PYC_TOOLCHAIN_ROOT=/Users/zhoubot/pyCircuit/.pycircuit_out/toolchain/install \ +python3 -m pycircuit.cli build \ + /Users/zhoubot/pyCircuit/designs/examples/counter/counter.py \ + --out-dir /tmp/counter_model \ + --target cpp \ + --jobs 8 +``` + +The output includes `cmodel/README.md`, `cmodel/_cmodel.hpp`, and `cmodel_project_manifest.json`. diff --git a/docs/TESTBENCH.md b/docs/TESTBENCH.md index 6dd477d..8a0263d 100644 --- a/docs/TESTBENCH.md +++ b/docs/TESTBENCH.md @@ -4,6 +4,10 @@ - frontend emits a TB `.pyc` payload (JSON encoded in module attrs) - backend (`pycc`) lowers that payload to C++ or SystemVerilog testbench text +`@testbench` is optional for `pycircuit build`. +- If `tb(...)` is present and decorated, pyCircuit emits C++/SV testbench artifacts as before. +- If `tb(...)` is absent, pyCircuit still builds the DUT and emits a `cmodel/` scaffold for external C++/TLM drivers. + Observation points (pyc4.0): - `phase="pre"` samples at **TICK-OBS** (after combinational settle, before state commit). @@ -30,7 +34,7 @@ def tb(t: Tb): t.finish(at=10) ``` -`pycircuit build` expects `tb` to be decorated with `@testbench`. +If `tb` is present, it must be decorated with `@testbench`. ## Tb API (selected) diff --git a/docs/simulation.md b/docs/simulation.md index ff99efb..6fe0fff 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -9,6 +9,10 @@ pyCircuit 的 C++ 仿真引擎采用 **静态编译-直接执行 (Compiled-Code 寄存器实例(`pyc_reg`)以及组合逻辑求值函数(`eval()`/`tick()`)。 仿真通过反复调用这些方法来推进时钟周期,在主机 CPU 上直接执行原生 C++ 代码。 +从当前版本开始,`pycircuit build --target cpp` 还会生成 `cmodel/` 外部驱动脚手架: +- 有 `@testbench` 时,保留 pyCircuit 生成的 TB,并额外导出 `cmodel/tb_program.json` +- 无 `@testbench` 时,生成可编译的 `cmodel/_main.cpp` 和 `_cmodel.hpp`,供手写 C++ / TLM 直接接入 + ``` ┌─────────────────────────────────────────────────────────┐ │ Python 测试驱动 (ctypes) │ diff --git a/flows/tools/gen_cmake_from_manifest.py b/flows/tools/gen_cmake_from_manifest.py index 513d917..33a7544 100644 --- a/flows/tools/gen_cmake_from_manifest.py +++ b/flows/tools/gen_cmake_from_manifest.py @@ -41,7 +41,10 @@ def main() -> int: data = _load(manifest_path) srcs = [Path(s).resolve() for s in data.get("sources", []) if isinstance(s, str) and s] - tb_cpp = Path(str(data.get("tb_cpp", ""))).resolve() + entry_cpp_raw = str(data.get("entry_cpp", "")).strip() + if not entry_cpp_raw: + entry_cpp_raw = str(data.get("tb_cpp", "")).strip() + entry_cpp = Path(entry_cpp_raw).resolve() if entry_cpp_raw else Path() incs = [Path(s).resolve() for s in data.get("include_dirs", []) if isinstance(s, str) and s] runtime_srcs = [Path(s).resolve() for s in data.get("runtime_sources", []) if isinstance(s, str) and s] runtime_incs = [Path(s).resolve() for s in data.get("runtime_include_dirs", []) if isinstance(s, str) and s] @@ -54,11 +57,12 @@ def main() -> int: runtime_cfg_exists = bool(runtime_cfg) and (Path(runtime_cfg) / "pycircuitConfig.cmake").is_file() runtime_toolchain_root = str(runtime.get("toolchain_root_hint", "")) std = str(data.get("cxx_standard", "c++17")) + exe_name = str(data.get("executable_name", "pyc_tb")).strip() or "pyc_tb" if not srcs: raise SystemExit("manifest missing `sources`") - if not tb_cpp.is_file(): - raise SystemExit(f"missing tb cpp: {tb_cpp}") + if not entry_cpp_raw or not entry_cpp.is_file(): + raise SystemExit(f"missing entry cpp: {entry_cpp_raw or ''}") for s in srcs: if not s.is_file(): raise SystemExit(f"missing source: {s}") @@ -73,12 +77,12 @@ def main() -> int: lines.append("set(PYC_TB_SOURCES\n") for s in srcs: lines.append(f" \"{_rel(s, out_dir)}\"\n") - lines.append(f" \"{_rel(tb_cpp, out_dir)}\"\n") + lines.append(f" \"{_rel(entry_cpp, out_dir)}\"\n") lines.append(")\n\n") - lines.append("add_executable(pyc_tb ${PYC_TB_SOURCES})\n") + lines.append(f"add_executable({exe_name} ${{PYC_TB_SOURCES}})\n") if incs: - lines.append("target_include_directories(pyc_tb PRIVATE\n") + lines.append(f"target_include_directories({exe_name} PRIVATE\n") for i in incs: lines.append(f" \"{_rel(i, out_dir)}\"\n") lines.append(")\n") @@ -88,7 +92,7 @@ def main() -> int: lines.append( f"find_package({runtime_pkg} CONFIG REQUIRED PATHS \"{_cmake_str(runtime_cfg)}\" NO_DEFAULT_PATH)\n" ) - lines.append(f"target_link_libraries(pyc_tb PRIVATE {runtime_target})\n") + lines.append(f"target_link_libraries({exe_name} PRIVATE {runtime_target})\n") elif runtime_lib_files: lines.append("add_library(pyc4_runtime_prebuilt STATIC IMPORTED GLOBAL)\n") lines.append("set_target_properties(pyc4_runtime_prebuilt PROPERTIES\n") @@ -96,7 +100,7 @@ def main() -> int: if runtime_incs: lines.append(f" INTERFACE_INCLUDE_DIRECTORIES \"{_cmake_list(runtime_incs)}\"\n") lines.append(")\n") - lines.append("target_link_libraries(pyc_tb PRIVATE pyc4_runtime_prebuilt)\n") + lines.append(f"target_link_libraries({exe_name} PRIVATE pyc4_runtime_prebuilt)\n") elif runtime_srcs: lines.append("set(PYC_RUNTIME_SOURCES\n") for s in runtime_srcs: @@ -108,7 +112,7 @@ def main() -> int: for i in runtime_incs: lines.append(f" \"{_rel(i, out_dir)}\"\n") lines.append(")\n") - lines.append("target_link_libraries(pyc_tb PRIVATE pyc4_runtime)\n") + lines.append(f"target_link_libraries({exe_name} PRIVATE pyc4_runtime)\n") lines.append("\n") out = out_dir / "CMakeLists.txt"