pyrope · diff
git:20260923.262a1e8 to git:20261001.2d63ad6
507 added, 140 removed. Audit A to A.
---
name: pyrope
description: Write and check Pyrope (LiveHD's HDL). Use when creating or editing .prp files, translating Verilog to/from Pyrope, or answering Pyrope syntax questions.
---
# Writing Pyrope
Pyrope is a hardware description language: every construct elaborates to wires,
muxes, flops, and memories. This file is the working subset needed to *generate
correct code*. The full spec lives at <https://masc-ucsc.github.io/docs/>
(Pyrope chapters 00–15, e.g. `pyrope/01b-quick_intro/` is the human-oriented
condensation, `pyrope/15-tbd/` the not-implemented list). Always verify
generated code with `lhd` (last section).
## Ground rules
* Comments are `//` only. `;` is the same as a newline. No variable shadowing,
anywhere.
* Every declaration starts with a kind keyword — data: `const` / `mut` /
`wire` / `reg`; lambda: `comb` / `pipe` / `mod` / `fluid`. Prefix modifiers:
`comptime`, `pub`. Assignment prefixes: `wrap`, `sat`. `stage[N]` is a
`mod`-only declaration modifier.
* Every data declaration needs `= value`:
- a concrete value (`0`, `false`, `""`, `(x=1)`) — the normal case
- `nil` — invalid / "no value yet"; *reading* it is an error; the default
for tuples; `reg x = nil` declares a register with **no reset**;
`const x = nil` / `wire x = nil` forward-declare an as-yet-unbound net
(see Wire below)
- `0sb?` / `0ub?` / `0ub10??01` — unknown bits (Verilog `x`); a valid
integer value that x-propagates (`0sb? + 1 == 0sb??`, `0sb? | 1 == 1`)
- There is **no bare `?`**, **no `_` default**, and **no `0b` prefix**
(write `0ub`/`0sb` explicitly).
* Names are **case-sensitive**, with an enforced style: type names are
`Capitalized` (or all-lower ending `_t`); every other identifier is
lowercase (single letter + digit like `a1`/`D0` may be either). `_` and
`_0`, `_1`, … are reserved, not bindable. Backtick-escape any string —
- including keywords — as an identifier: `` `while` ``.
+ including keywords — as an identifier: `` `while` ``. **Reserved-word
+ matching is CASE-SENSITIVE**: only the exact spelling is reserved, so `if`
+ needs backticks but `IF`/`If` are ordinary names, and `Clock`/`Reset` (type
+ words) need backticks as names while `clock`/`reset` do NOT (the minted
+ `clock:Clock`, a tick's `clock`, `acc.reset = clock < 2` are all bare).
+ * Built-in type names are **capitalized and reserved**: `U<N>`, `S<N>` (any
+ digit string: `U1`, `U8`, `S4`, `U1333`), `Unsigned`, `Signed`, `Bool`,
+ `String`, `Clock`, `Reset`. They cannot name a variable, port, parameter,
+ lambda or field (`const U4 = 3`, `x.U33`, `f(Clock=1)` are errors); a
+ backticked one is a plain name (`` `U4` ``), never the type. The OLD
+ lowercase spellings — `u`/`s`/`i` + digits (`u8`, `s4`, `i32`), `bool`,
+ `boolean`, `unsigned`, `signed`, `string` — are **ordinary identifiers**
+ (owner ruling 2026-09-30): legal names with no backticks (`s1`, `u4`, `i0`,
+ `s2`; `S2`/`U8` are the reserved ones). Only when used as a TYPE or CAST
+ (`x:u8`, `u8(x)`) and not a user-declared name is it an error: "`u8` was
+ renamed `U8`".
+ A backticked name uses the string escapes (`` `a\\b` `` for a backslash).
* `comptime` must be written explicitly (`comptime const SIZE = 16`).
Uppercase naming carries no comptime meaning.
- * Integers are unlimited-precision **signed**. `u8`, `i4`, `unsigned`,
- `int(min=0, max=300)` are range constraints on that one type
- (`u<N>` max is 2^N−1). `1K == 1024`, also `M`/`G`/`T`.
- * bool and int never mix: `if x != 0 {}`, casts `int(true)`, `boolean(v#[3])`.
- `and`/`or`/`implies`/`not` are boolean-only (short-circuit); `& | ^ ~ ~& ~|
- ~^` are bitwise integer ops. `%` lowers only when cheap: power-of-two
+ * Integers are unlimited-precision **signed**. `U8`, `S4`, `Unsigned`,
+ `Signed(min=0, max=300)`, `Unsigned(max=300)` are range constraints on that
+ one type (`U<N>` max is 2^N−1). `1K == 1024`, also `M`/`G`/`T`. The `int`
+ type, the `int(...)` cast and the `I<N>`/`iN` types were **removed**: `x:i32` is a
+ compile error ("renamed `S32`"); `I8` is not a type, just an ordinary name (as a type it is an unknown type) — use `S<N>`, `Signed`/`Unsigned`, a sized
+ `U<N>(x)`/`S<N>(x)` cast, or leave the declaration untyped.
+ * A width computed from a comptime value or a generic is spelled
+ `Unsigned(bits=N)` / `Signed(bits=N)` (`U<N>` needs a LITERAL N). It is
+ valid everywhere a type goes: ports, locals, tuple field types, array
+ elements (`[N]Unsigned(bits=N)`), type aliases
+ (`type Row = Unsigned(bits=N)`) and generic arguments. A type word can head
+ a suffix chain: `U8.[max] == 255`, `U8(x)#[0]`; a type has no fields
+ (`U8.x` is an error).
+ * Read a declaration's width/range with attributes: `x.[bits]`, `x.[max]`,
+ `x.[min]` (a typed value reports its declared type; works on inputs and
+ outputs: `for i in 0..<gray.[bits]`). An untyped `const` alias of a typed
+ value has that value's type (`const x = a` with `a:U8`: `x.[bits] == 8`); an
+ arithmetic result stays untyped. An untyped **comptime** integer
+ reports the minimal width of its value (`comptime const N = 13` →
+ `N.[bits] == 4`; `0` → 1; a negative value → its signed width); an untyped
+ runtime value reads `nil` (and driving hardware with that nil is an error). For an index/select width use the built-in
+ `std.clog2(x)` — Verilog `$clog2` (`std.clog2(1) == 0`, `std.clog2(13) == 4`,
+ `std.clog2(16) == 4`, `std.clog2(17) == 5`), comptime only, compile error for
+ `x <= 0` or a runtime `x`. `std` is a built-in namespace (no `import`).
+ * `Bool` and integers never mix — not in expressions, not at port boundaries,
+ not on instance outputs: `if x != 0 {}`; Bool→bit is `U1(flag)` (true == 1;
+ also `U<N>(flag)`/`Unsigned(flag)` — `Signed(true) == -1` is a
+ reinterpretation, not the idiom); bit→Bool is `Bool(v#[3])` or
+ `v#[3] == 1`. `Bool` is legal on **every** port, including top-level IOs (a
+ 1-bit port, true == 1): bind a `U1` to a `Bool` port with `Bool(x)`, use a
+ `Bool` output as a number with `U1(child.flag)`. Use `U1` ports only where
+ the value is arithmetic. The type name is also the cast: `x:Bool` /
+ `Bool(x)`, `x:U8` / `U8(x)`, `String(n)`.
+ `and`/`or`/`implies`/`not`/`!` are boolean-only (short-circuit; `!x` of an
+ integer is a compile error — write `x == 0`); `& | ^ ~` are bitwise integer
+ ops. There are NO binary nand/nor/xnor operators (`a ~& b` etc.) and no
+ negated reductions: write `~(a & b)`, `~(a | b)`, `~(a ^ b)`, `x#&[..] == 0`.
+ `~x` needs a KNOWN type: on an UNSIGNED-typed x of width N (`U<N>`,
+ `Unsigned(bits=N)`, an element of a `[M]U<N>` array, a slice `x#[..]` —
+ `x#[i]` is a `U1` — a `U<N>(...)` cast, an untyped `const` alias of such, `&|^~`
+ of such) it flips those N bits — `(2^N-1) - x`, a `U<N>` (`~flag` of a `U1`
+ toggles) — even into a wider destination; a signed-typed x or a compile-time
+ integer (`~5`, `const k = 5; ~k`) is `-x - 1`; an UNTYPED runtime x
+ (`~(a + 1)`, a `mut` bound without a type, `a & 3` with a literal operand,
+ an `if`/`match` expression) is a compile error — type it (`const t:U9 = a + 1`) or slice it. `%` lowers only when cheap: power-of-two
divisor, `% 3`, or a divisor provably larger than the dividend — anything
else is a compile error. No exponent operator.
* Precedence is shallow — parenthesize: `3 & 4*4` is a compile error; write
`3 & (4*4)`. Comparisons chain in one direction (`a <= b < c`).
## Lambdas (the only functions)
| kind | contract |
|------|----------|
| `comb` | Pure combinational, zero cycles. No `reg` (only `::[debug]` state). `ref` args allowed (acts as an implicit output, still combinational). Inlined when not fully typed. |
| `pipe[N]` | Fixed latency `N > 0`: every output lands exactly N cycles after its inputs; **never** a comb input→output path. A feedback `reg` is state (adds no latency); an unconditionally-written feedforward `reg` is a pipeline stage counted in N. A conditional write ⇒ state register. |
| `pipe[A..=B]` / bare `pipe` | Latency range / fully flexible; the **caller** picks via `stage[N]`. `pipe` calls are only legal inside `mod`. |
- | `mod` | No constraints (Mealy, Moore, orchestrator). **Every output declares its landing cycle at the interface**: `-> (x:u8@[2], y:u8@[0])`. `@[0]` = comb feedthrough (legal in `mod`, forbidden in `pipe`); `@[]` = unconstrained opt-out; omitting `@[...]` is a compile error. |
- | `fluid` | Transactional valid/stop handshakes. (TBD: parses only, no lowering.) |
+ | `mod` | No constraints (Mealy, Moore, orchestrator). **Every output declares its landing cycle at the interface**: `-> (x:U8@[2], y:U8@[0])`. `@[0]` = comb feedthrough (legal in `mod`, forbidden in `pipe`); `@[]` = unconstrained opt-out; omitting `@[...]` is a compile error. |
+ | `fluid` | Transactional valid/retry handshakes. (TBD: parses only, no lowering.) |
```pyrope
- comb add(a:u8, b:u8) -> (r:u9) { r = a + b }
+ comb add(a:U8, b:U8) -> (r:U9) { r = a + b }
pub comb get5() -> (v) { v = 5 } // pub = importable from other files
```
**Outputs** — always declared **by name** in `-> (...)`; the clause is
mandatory (`-> ()` for none); only `self`-methods may omit it. The body
*assigns* the outputs — a trailing bare expression does nothing. **`return` is
a terminator only**: `return X` is a syntax error. Assign first, then `return`
(for a conditional early exit, wrap it: `if cond { return }`). Callers get a named tuple
(`r.next`); a single-output tuple auto-unwraps. Destructuring binds **by
name** — `const (b, c) = f(...)` (order irrelevant); rename with
- `(x=f.b) = f(...)`. Unnamed RHS tuples destructure positionally.
+ `(x=f.b) = f(...)`. Unnamed RHS tuples destructure positionally. An output
+ starts as `nil` (like `mut v:U8 = nil`), so bit/lane writes into it without a
+ prior whole assignment are legal (`out#[i] = ...`, `v[i] = ...`).
**Calls** — parentheses always (`noarg()`); a bare lambda name is a value
(higher-order), never a call. Name every argument: `f(a=1, b=2)`. Unnamed is OK
only when: the lambda has a single argument, the passed variable's name equals
the parameter name, or the types are unambiguous. UFCS `x.f(args)` works only
when `f` declares `self` as first parameter; `self` binds positionally, never
`f(self=...)`; `ref self` needs a `mut` receiver. `ref` must be written at the
declaration **and** the call: `comb inc(ref a) -> () { a += 1 }` … `inc(ref y)`.
- An input may declare a **default**: `comb f(in1:u4, in2:u4=3)` — a call that
- omits `in2` takes the default.
+ An input may declare a **default**: `comb f(in1:U4, in2:U4=3)` — a call that
+ omits `in2` takes the default; a provided argument always overrides it. The
+ default binds like an argument: it must fit the input's type. A `comb` default
+ may read earlier inputs (`b:U8 = a + 1`); a `mod`/`pipe` default is what the
+ caller drives the omitted port with, so it must FOLD to a compile-time
+ constant — a literal, a generic (`c:U8 = N * 2`, per specialization), a
+ `comptime const`, an attribute read (`W.[bits]`), an enum entry,
+ `std.clog2(N)`; `b:U8 = a + 1` on a `mod` is `default-not-comptime`.
+ **Argument binding follows the assignment overflow rule** (`wrap`/`sat`): an
+ argument whose type/range may not fit the parameter's declared type — a `U16`
+ into `a:U4`, or into `a:Unsigned(bits=N)` with `N=4` — is a compile error for
+ `comb` and `mod`, generic or not. The caller slices explicitly:
+ `f(a=x#[0..<4])`. A wider integer into a `Bool` port is an error too. An
+ untyped output of a FULLY TYPED `mod`/`pipe` (`mod cnt(a:U4) -> (o@[0])`)
+ carries the range its body derives (`o = a + 1` is [1, 16]; the port is that
+ wide), so `y:U8 = c.o` is legal and `y:U4 = c.o` is may-not-fit. A value whose
+ range is UNKNOWN — the untyped output of a TEMPLATE `mod` (untyped input or
+ generics), or one its body leaves unbounded — may not fit any typed
+ destination: `wrap y = c.o`, or type the output. (An untyped `comb` output is
+ not unknown: its range is derived.)
+
+ **Nested lambdas capture only compile-time constants** (name lookup is
+ lexical, and the rule is the same at file scope and inside a `comb`, `mod` or
+ `pipe`): a lambda declared inside another — or at file scope — sees the
+ enclosing `comptime const`s, every plain `const` whose value FOLDS to a
+ compile-time constant (`const x = 2`, `const w = N * 4` with N comptime or a
+ generic, `mut m = 3; const s = m + 1` in a mod body, a const accumulated by a
+ loop over constants), the enclosing lambda's generics, a `for` index (and its
+ element over a comptime iterable), imports, types and lambdas, in its body
+ and its signature (`comb f(a:Unsigned(bits=w))`). Reading an enclosing
+ **runtime** value — a `const` whose value depends on an input, a `reg`, a
+ `wire` or a `mod` instance (`const s = a + 1`), a `mut` read directly, or the
+ element of `for x in (a, b)` — is a compile error; pass it as an input. A
+ generic argument is a compile-time value too (`g<N=a>` with `a` an input is
+ an error). Declaring a local, a loop index, a test parameter, or a lambda
+ input/output/generic with the name of a visible enclosing const, type or
+ lambda is shadowing (an error), even for a file-scope `const`.
+
+ **Every read needs an accessible declaration**: reading a name nothing
+ declares (or that is out of scope / declared later) is a compile error at the
+ read (`undefined-read`) in every context — bodies, signatures and port
+ widths, generic defaults and call-site generic args, attributes, tuple fields
+ (`t.zz` on a tuple without `zz` is `unknown-field`), test blocks. It never
+ becomes `nil` or a hidden input.
+
**Overloading** — overload by gathering: `const add = [add1, add2]`;
a call dispatches (comptime) to the FIRST gathered lambda that can accept it
(tuple order, no ambiguity error; no-match = compile error). "Can accept" uses
the SAME argument rules as a direct call.
**Generics** — `<...>` after the lambda name; each name binds any
**compile-time entity**: a type, a comptime constant, or a lambda. Defaults
- allowed (`<T, K=1>`). Bind explicitly (`f<u8>(a=1)`), by name
- (`f<T=u8, K=10>(...)`), or let type-valued generics infer (unify) from the
- actuals' declared types (bare literals leave T as the unbounded int kind).
+ allowed (`<T, K=1>`). Bind explicitly (`f<U8>(a=1)`), by name
+ (`f<T=U8, K=10>(...)`), or let type-valued generics infer (unify) from the
+ actuals' declared types (bare literals leave T as the unbounded integer kind).
+ A binding may be a postfix form unparenthesized — a dotted field or an
+ attribute read (`f<N=x.[bits]>(...)`, `<N=cfg.width>`); an operator
+ expression, a negative literal or a call must be parenthesized (`<N=(W*2)>`,
+ `<N=(-3)>`, `<N=(std.clog2(D))>` — a bare call reads as a type, an error).
Body references work: `a + K`, `T(x)` (cast), `F(v=a)` (call a lambda-valued
- generic). A generic `mod`/`pipe` mints one module per binding
+ generic). A generic default or argument names a type, lambda or comptime const
+ DECLARED EARLIER in the file (`<T=Pixel>` before `type Pixel` is an error). A generic `mod`/`pipe` mints one module per binding
(`madd__u8_K_2_...`). **The old `[...]` comptime-parameter slot is gone** —
- `comb g[n:int=1](x)` / `g[3](x=2)` is now a *syntax error*; write a constant
- generic: `comb g<N=1>(x:u8) -> (r) { r = x + N }` called `g<N=3>(x=a)`.
+ `comb g[n=1](x)` / `g[3](x=2)` is now a *syntax error*; write a constant
+ generic: `comb g<N=1>(x:U8) -> (r) { r = x + N }` called `g<N=3>(x=a)`.
Varargs `(...args)` gather leftovers (`args[i]` / `args.NAME`). There is
**no placeholder lambda sugar** — no `_`/`_0`/`_1`; pass a named comb.
**Constructor** — `init` is the *only* implicit hook: a `comb`, run once at
construction (`mut x:T = v` or explicit `T(v)`); overload via
`const init = [fn1, fn2]`. There are **no getter/setter hooks** — after
construction, all reads/writes are structural. Extension methods can be added
later: `Typ.double = some_comb`.
## Tuples, arrays, types
```pyrope
- mut p = (mut x:u8 = 0, mut y:u8 = 0) // named fields use a kind keyword
+ mut p = (mut x:U8 = 0, mut y:U8 = 0) // named fields use a kind keyword
const iface = (
- ,mut value:u8 = 0
- ,comb read(self) -> (v:u8) { v = self.value }
+ ,mut value:U8 = 0
+ ,comb read(self) -> (v:U8) { v = self.value }
,comb inc(ref self) { wrap self.value += 1 }
)
mut t = (1, 2, 3) // positional entries are bare values
mut arr = [1, 2, 3] // [] = array: all entries same type
```
* Access: `p.x`, `t[0]`, `a['r1']`. Integer indices select *positional*
entries only; named fields are name-access only.
* `(...a, b=2)` splices in place (duplicate name = error).
- * A selector `[...]` takes ONE expression (int, string, range, or a
+ * A selector `[...]` takes ONE expression (integer, string, range, or a
conditional) — `a[0,1]` is not allowed.
* Mutability: outer `const` freezes every field; inner `const` pins one field
of a `mut` tuple.
- * `type Foo = (mut color:string = "", mut value:i33 = nil)` declares a type.
+ * `type Foo = (mut color:String = "", mut value:S33 = nil)` declares a type.
Complicated lambda types must be declared ahead with `type`, never inline.
- * Structural typing operators: `does` (a covers b's structure; `u32 does u16`),
+ * Structural typing operators: `does` (a covers b's structure; `U32 does U16`),
`equals`, `case` (`does` + defined-value match; `nil`/`0sb?` wildcards),
`is` (nominal), `has` (field), `in` (membership). Negate with `not (...)` —
there are no `!has`/`!in`/`!and` forms.
* `:Type` annotations only at declaration sites. Check elsewhere with
- `cassert(x does T)`; convert with constructor calls: `u8(x)`, `int(s)`,
- `string(n)`. Type-shape operands write the bare type (`x does u8`).
+ `cassert(x does T)`; convert with constructor calls: `U8(x)`, `Signed(s)`,
+ `String(n)`. Type-shape operands write the bare type (`x does U8`). A tuple
+ of types is a value like any tuple: `(a=U8, b=S20)`.
* Enums: `enum State = (Idle, Run, Done)` — one-hot encoding by default; any
- explicit value (or a `:signed` type) switches to sequential. **Always compare
+ explicit value (or an integer type) switches to sequential. **Always compare
against names** (`st == State.Idle`), never raw integers. Casts:
- `string(E.a)`, `E("a")`, and `E.a#[..]` for the bits. Hierarchical enums
- (`Animal.bird.eagle`) work. A named constant or expression seeds the
- subsequent automatic ordinals: `const c=1; enum(a=c, b)` gives b=2.
- `signed(E.a)` and `unsigned(E.a)` also expose the integer encoding.
+ `String(E.a)`, `E("a")`, `E.a#[..]` for the bits; `Signed(E.a)` and
+ `Unsigned(E.a)` expose the integer encoding. A named constant or expression
+ seeds the following automatic ordinals: `comptime const c = 1;
+ enum E = (a=c, b)` gives `E.b == 2`.
+ An enum used as a TYPE (`mod m(c:Color)`, `reg st:Color`, `mut d:Dir`) is an
+ unsigned integer as wide as its widest entry value: one-hot N entries → N
+ bits, explicit/sequential values → the width of the largest, hierarchical →
+ one bit per node (`Animal` → 6 bits), `enum Op:U8` → 8 bits. The level type
+ may be any integer type (`enum Op:Unsigned(bits=4)`, `enum Op:Nib` with
+ `type Nib = U4`); it numbers the entries sequentially. There is no
+ expression form: `const Color = enum(...)` is an error (write
+ `enum Color = (...)`). A one-hot enum holds at most 63
+ entries (use an integer level type for more), and an enum with payloads
+ cannot type a port.
* Ranges: `0..=7`, `0..<8`, `2..+3`, optional `step 2`; ascending only.
`step` applies both in loops and to range values: `(0..<30 step 10)` is
- `(0,10,20)`. A step must be a positive integer. Open
- ends in selectors (`a[1..]`); negative = distance from the end
- (`b#[1..=-2]`).
+ `(0,10,20)`; a step is a positive integer. Open ends in selectors
+ (`a[1..]`); negative = distance from the end (`b#[1..=-2]`).
## Bit selection and reduction
```pyrope
v#[3] // bit 3 v#[1..=4] // bit slice (zext result)
v#sext[0..=2] // sign-extended slice
- v#|[..] v#&[..] v#^[..] v#+[..] // or/and/xor-reduce, popcount (lower to int 0/1)
+ v#|[..] v#&[..] v#^[..] v#+[..] // or/and/xor-reduce, popcount (integer 0/1)
trans#[0] = v#[1] // LHS bit assign; every dest bit driven exactly once
const onehot = 1 << (1, 4, 3) // == 0ub01_1010
- const z:u9 = (a, b, c)#[..] // a:u4, b:u3, c:u2 — `a` lands in [3..0]
+ const z:U9 = (a, b, c)#[..] // a:U4, b:U3, c:U2 — `a` lands in [3..0]
```
`#[]` is bits, `[]` is tuple/array elements, `@[N]` is a cycle typecheck —
- never mix them. Runtime bit indices (`a#[i]`) work.
+ never mix them. Runtime bit indices (`a#[i]`) work, also into an array element
+ (`m[i]#[j] = x`) and a memory entry (`mem[a]#[0..<4] = d`).
Pack an ordered tuple or array with `p#[..]`: entry 0 occupies the least
- significant declared window. `#sext`, reductions, and subranges work over the
- same packed word, including a variable holding a tuple or array. Each window
- comes from the lane's declared type, never its current value. Multi-field named
- bundles have no bit order; spell an ordered tuple of their fields explicitly.
- A destination declared for a packing must match the sum of the lane widths.
- Whole reads of a register array require `:[ordering="old"]` because the
- simulator has no same-cycle forwarding model for them.
+ significant declared window (so Verilog `{a, b}` is `(b, a)#[..]`). `#sext`,
+ reductions, and subranges work over the same packed word, including a variable
+ holding a tuple or array. Each window comes from the lane's declared type,
+ never its current value (an untyped or literal lane is rejected). Multi-field
+ named bundles have no bit order; spell an ordered tuple of their fields
+ explicitly. A destination declared for a packing must match the sum of the
+ lane widths. There is no `concat`/`{a,b}` form.
## Statements
* `if c { } elif { } else { }` — also an expression form. `unique if` asserts
mutually exclusive conditions (one-hot mux; replaces tri-state).
* `match` — **parallel-unique** case (implicit `assume` of mutual exclusivity
*and* exhaustiveness), **NOT priority**. The `else` arm is **optional**: omit
it when the arms already cover the whole key space; an omitted `else` lowers
to a don't-care (unreachable). Add an explicit `else` only for a real
catch-all value or a `cassert(false)`. A bare value means `==`. Arms: `== v`,
`in (2,3)`, `case (a=1)`, `< 5`, `else`. If two arms can match the same
value, two `__hotmux` controls are active at once, which breaks the cell's
- one-hot-or-zero obligation: `pass.formal` reports it, and every backend
- resolves it to the FIRST active arm — for priority/overlapping conditions use
- `if/elif/else`. The selector
- can declare locals: `match const t = f(); t { ... }`.
+ one-hot obligation: `pass.formal` reports it, sim/LEC resolve it to the
+ FIRST active arm, and synthesis may assume exclusivity (OR the arms) — for
+ priority/overlapping conditions use `if/elif/else`. The selector can declare
+ locals: `match const t = f(); t { ... }`.
* There are **no `when`/`unless` trailing gates** (removed from the
language). All gating — comptime or runtime — uses an `if` block:
`if DEBUG { assert(x) }`, `if enable { count += 1 }`. A comptime-false
condition folds the block away entirely.
* Loops `for i in 0..<N {}`, `while`, `loop` are fully unrolled — bounds must
be comptime. `break`/`continue` work. Iterate tuples
- (`for (i, v) in t.enumerate()`), mutate via `for x in ref t { x += 1 }`.
+ (`for (i, v) in t` — a pair binding is the enumerate; `for (i, v, key) in t`
+ adds the field name; there is no `.enumerate()`), mutate via `for x in ref t { x += 1 }`.
Build tuples by accumulating: `mut acc:[] = nil` … `acc ++= v` (there are no
- comprehensions).
+ comprehensions). A loop body may READ a `wire`, but WRITING (driving) a
+ `wire` inside a loop body is an error — a body write runs once per
+ iteration and breaks the single-driver rule. A body may write a memory or a
+ `reg` array (`for i in 0..<4 { if we { arr[i] = d } }`).
* Code blocks `{ ... }` are expressions evaluating to their last expression
(`mut a = {mut d=3; d+1} + 100`); they may not have outer side effects.
A block can carry synthesis attributes (`{::[abc='...', color=2] ... }`) to
form its own ABC partition region — semantics-free, synthesis tuning only.
* `wrap` / `sat` prefix every narrowing assignment (`wrap c = a + 1`,
`sat d += 1`); an unannotated narrowing assignment is a compile error.
## Registers and timing
```pyrope
- reg counter:u8 = 0 // '= 0' is the RESET value (nil ⇒ no reset)
+ reg counter:U8 = 0 // '= 0' is the RESET value (nil ⇒ no reset)
const q = counter // bare name reads current q (snapshot first if needed)
- counter += 1 // write with plain =/+=; lands at the cycle boundary
+ wrap counter += 1 // write with =/+= (wrap: U8 narrows); lands at the cycle boundary
const old = past[2](counter) // pipelined: inserts 2 flops, shifts landing cycle by 2
```
* `past[N](x)` (design body only, literal N) is a *pipelining* operator — it
moves the expression's cycle, so `assert(past[1](x) == x)` is ill-typed. The
verification history sample is the (TBD) positional `past(x, n)` — a
different operator.
* No `@[-1]`/`@[1]` register indexing, no `.[defer]` — use a `wire` (below)
for next-state reads and backward edges.
* Register attributes at declaration:
- `reg c:u8:[clock_pin=ref clk2, reset_pin=ref rst2, async=true, posclk=false] = 3`.
- `_pin` attributes connect **wires** → they need `ref` (a comptime constant
- like `reset_pin=false` doesn't). `async` defaults false (async reset =
- `async=true`).
+ `reg c:U8:[clock_pin=clk2, reset_pin=rst2, async=true, posclk=false] = 3`.
+ A `*_pin` attribute (`clock_pin`, `reset_pin`, …) is written WITHOUT `ref`
+ (`reset_pin=my_wire` is a connection, not a read of the value;
+ `clock_pin=ref clk` is a compile error); `reset_pin=false` means no reset.
+ `clock_pin` names a `Clock`: a `Clock` input, a gated clock
+ `Clock(clock_pin=clk, enable=en)` or a child's `Clock` output, never a
+ constant, a `Reset` or `Bool`/`U1` data (`clock-bind-not-clock`);
+ `reset_pin` takes a `Reset` or any `Bool` expression (`reset_pin=rst or soft`).
+ `async` defaults false (`async=true` = asynchronous reset; the importer's
+ `sync=` is accepted with a deprecation warning — never write it). A reset is
+ active-high unless the register says `negreset=true`: the reset's NAME
+ carries no polarity (`rst_n` is active-high without it). The implicit-reset
+ style is the `compile.upass.reset_style` flag (`sync` default | `async`); a
+ per-register `async` overrides it. The reset value must be
+ comptime (a `comptime const` computed by a `comb` call works; a runtime
+ `mut` loop does not).
* `reg x:T:[latch=true]` declares a level-sensitive latch — the grammar has no
`latch` keyword, and the marker is consumed at declaration (not readable back
as `.[latch]`). `enable_high` is an alias of `posclk` accepted on ANY
register: on a flop it is the clock edge, on a latch it is the ENABLE
polarity. An active-low latch enable (`enable_high=false`/`posclk=false`) is
REFUSED — write the inverted condition instead (`if !g { ... }`).
+ * **Clocks and resets bind by TYPE, not by name.** A `mod`/`pipe` input typed
+ `Clock` is a clock and one typed `Reset` is a reset, whatever the name
+ (`core_clk:Clock`, `rst_n:Reset`); an input named `clk`/`clock`/`rst`/`reset`
+ typed `Bool`/`U1` is plain DATA. Implicit clock = the module's single
+ `Clock` input; implicit reset = its single `Reset` input. Two or more
+ `Clock` (`Reset`) inputs leave no implicit one: every register, memory and
+ stage names it (`clock_pin=clk_a`, `reset_pin=r2`), and relying on it is a
+ compile error (`clock-ambiguous`). With none, a `clock:Clock` /
+ `reset:Reset` input is minted (a non-`Clock` input already named `clock`, or
+ non-`Reset` named `reset`, is then a compile error) and every caller wires
+ its own implicit clock/reset to it.
+ * A `Clock` is not data: no arithmetic, no `if clk`, no `U1(clk)`/`Bool(clk)`
+ (`clock-as-data`); it only drives clock pins or another `Clock` port. Its
+ numeric view (a cycle counter) is readable only in debug contexts (`test`
+ blocks, `puts`, `assert(clk < 1000)` in a body). The ONE derived clock is a
+ gated one, the ICG `const gclk = Clock(clock_pin=clk, enable=en)` (named
+ arguments; lowers to a `Clock_cell`, `en` latched while `clk` is low), used as
+ `reg r::[clock_pin=gclk] = 0` or bound to a child's `Clock` input; it chains
+ (`Clock(clock_pin=gclk, enable=e2)`). `clk and en`, `not clk`, a clock mux,
+ a register used as a clock, and a latch enable read from the clock
+ (`if !clk { l = d }`) are not clocks (`Clock(x)` is not a cast). A `Reset` is Bool-like: `if rst`,
+ `rst or soft`, `f = rst` work, and a `Bool` expression binds to it without a
+ cast; `rst != 0` or `rst | x` do not (`U1(rst)`). `Clock` and `Reset` are
+ distinct types (`Clock does Bool` is false): a Clock binds only to a `Clock`
+ input (never to a `Bool` input the child reads, never to a `Reset`), a
+ child's `Clock` input takes only a Clock (no derived clocks: `c=gclk_and_en`
+ is `clock-bind-not-clock`), `clock_pin` never names a `Reset`, and a `Clock`
+ output never takes a constant or data.
+ * At a call, OMIT a `mod`/`pipe` child's `Clock`/`Reset` inputs (declared or
+ minted) — they auto-wire to the caller's single `Clock`/`Reset` (minted in
+ the caller when it has none). The reset crosses as the RAW wire, exactly
+ like an explicit binding: polarity belongs to each register (`negreset=`),
+ so crossing polarities needs an explicit computed binding
+ (`cnt8(rst_n=!rst)`). A caller with two or more `Clock` (`Reset`) inputs
+ must bind every child `Clock` (`Reset`) input explicitly — a child's MINTED
+ one by its name (`child(clock=c1, ...)`). A `comb`
+ holds no state and can NOT declare a `Clock`/`Reset` input
+ (`comb-clock-reset-input`): its `clk`/`rst` are plain `Bool`/`U1` DATA,
+ never auto-wired. Any `comb` input its body never reads may be omitted
+ (nothing is wired); omitting one it reads is `missing required argument` —
+ pass it (`clr(rst=not rst_n, a=a)`).
+ * Binding a constant to a `Clock` is always a compile error
+ (`clock_pin=k` with a constant `k`, `dut(clk=true, ...)` into a `Clock`
+ input: `clock-const-bind`), whether or not a register reads it. A `Bool`
+ input is data whatever its name, so `f(clk=false, ...)` into a `clk:Bool`
+ is fine.
+ * `reg` in a `mod` OUTPUT list declares a persistent register whose q IS the
+ output, `= v` after the cycle is its reset value:
+ `-> (reg count:U8@[0] = 0)`. A conditional or feedback write makes it state
+ (lands at `@[0]`); an unconditional write of a fresh value makes it a stage
+ (lands at `@[1]`). `reg` on an INPUT is an error (inputs are driven at each
+ call). A caller sees each child output at that output's own declared landing
+ cycle; `@[]` opts out.
+ * A **body** `reg` (declared in the body, not the output list) written every
+ cycle from its inputs and read by a `mod` output directly or through a bit
+ slice (`q = r`, `q = r#[0..<4]`) lands one cycle after its inputs
+ (`r = d; q = r` needs `@[1]`, ruling 83), with the implicit clock or an
+ explicit `clock_pin`/`reset_pin`, written whole or through bit slices
+ (`r#[0..<4] = d`). NOT settled (today's behavior): any other read of it
+ (`q = ~r`, `q = r ^ 1`, `q = w` via a wire) is cycle-0 state (`@[0]`); a
+ conditionally written or feedback body reg is state at its home stage
+ (`if en { r = d }; q = r` needs `@[0]`; a feedback reg that adds a
+ stage-3 value, `wrap total = total + tmp@[3]`, homes and lands at `@[3]`),
+ while the same reg on a gated clock (`Clock(clock_pin=clk, enable=en)`)
+ needs `@[1]`.
+ When the cycle matters, declare the register in the output list or use
+ `stage[N]`.
+ * `::[timecheck=false]` (old spelling `hdl`) on a lambda is an escape hatch
+ that turns off ALL timing checks inside it (landing-cycle checks, cycle-mix
+ checks incl. through children's declared latencies, the same-cycle
+ wire-ring/comb-loop check); plain regs become cycle-0 state and an undriven
+ `wire` reads `x`. The Verilog importer and the Pyrope writer set it;
+ hand-written code uses it only as an escape hatch.
* Multi-cycle reset code: assign a lambda **by name** (no parens):
`reg arr:[1024]Tag = my_reset_mod`.
## Wire — single-driver combinational nets
`wire` declares ONE combinational net with **exactly one driver** (a Verilog
continuous-assign). Unlike `mut` (last write in *program order*), a `wire` may
be **read before its driver appears textually**. Use it to close interconnect
rings and feed a register's next-state to a same-cycle consumer.
```pyrope
- reg counter:u32 = 0
- wire nx:u32 = nil // forward-declared, as-yet-undriven net
+ reg counter:U32 = 0
+ wire nx:U32 = nil // forward-declared, as-yet-undriven net
nx = counter + 1 // the ONE driver (may appear later in program order)
counter = nx // registered write
const also = nx + 1 // same-cycle consumer reads the same net
```
* **Exactly one driver.** A mux (an `if`/`match` *expression*, or mutually-
exclusive conditional writes) counts as one driver. A second unconditional
assignment or a never-driven net is a compile error. The driver may be
CONDITIONAL and need not cover every path: a `wire` is defined by its one
assignment, so writing it inside an `if` with no `else` means the same as
writing it unconditionally (an un-taken path is a don't-care, not an `x`).
+ * A `for`/`while` body may read a `wire` but never write one (see Loops).
* `wire` removes *textual* ordering only, **not cyclic dataflow**: a net that
combinationally feeds itself is a real comb loop, rejected (SCC check). A
ring is legal only when a `reg` breaks it.
* `const x [:T] = nil` is the same forward declaration WITHOUT the ordering
exemption: bound exactly once (a rebind is an error), that bind defines the
value on every path exactly as a `wire`'s does, but it must precede every
read. Prefer it when the write already comes first — `wire` is for the case a
read genuinely comes before its driver.
## Pipelining inside `mod`
```pyrope
- pipe mul(a:u16, b:u16) -> (c:u32) { c = a * b }
- pipe add(a:u32, b:u32) -> (c:u32) { wrap c = a + b }
+ pipe mul(a:U16, b:U16) -> (c:U32) { c = a * b }
+ pipe add(a:U32, b:U32) -> (c:U32) { wrap c = a + b }
- mod mac(in1:u16, in2:u16) -> (out:u32@[4]) {
+ mod mac(in1:U16, in2:U16) -> (out:U32@[4]) {
stage[3] tmp = mul(a=in1, b=in2) // RHS delivered 3 cycles later
stage[3] in1_d = in1 // pure 3-cycle delay
stage[1] out@[4] = add(a=tmp@[3], b=in1_d@[3]) // all alignments typechecked
}
```
* `stage[N]` — declaration modifier, `mod`-only, `N > 0` (`stage[0]` is an
error; plain `=` is same-cycle). Also `stage[A..=B]` and `stage[]` (tool
picks). N is the **latency of the RHS**, relative.
* `x@[N]` — pure cycle typecheck (absolute cycle counted from the lambda
inputs), legal on LHS and RHS, in `mod` and `pipe` bodies. It **never
inserts flops**; a mismatch is a compile error. `@[]` opts out.
* A bare/ranged `pipe` call must be consumed by `stage[N]`. There is no
implicit alignment: to mix values from different cycles, delay explicitly
with `stage[N] x = v` or `past[n](v)`. An accumulator over a pipelined unit
- is just `reg total:u32 = 0; wrap total = total + tmp@[3]; out = total`
+ is just `reg total:U32 = 0; wrap total = total + tmp@[3]; out = total`
(state regs read q at their home stage).
## Memories
```pyrope
- reg mem:[256]u32 = 0 // async mem: 0-cycle read, reset to 0
- reg mem2:[16]i8 = nil // no reset
+ reg mem:[256]U32 = 0 // async mem: 0-cycle read, reset to 0
+ reg mem2:[16]S8 = nil // no reset
mut scratch:[] = nil // plain array: no persistence across cycles
- mut m2:[4][8]u8 = 13 // multi-dimensional, row-major
+ mut m2:[4][8]U8 = 13 // multi-dimensional, row-major
```
Read `mem[addr]`; write `if we { mem[addr] = din }`. Indices can be enum- or
- range-constrained (`mut x:[X]u3`, `mut s:[-8..<7]u3`).
+ range-constrained (`mut x:[X]U3`, `mut s:[-8..<7]U3`).
**Same-cycle read/write semantics** — the `ordering` attribute (replaces the
- old `fwd=` attribute): `reg mem:[4]u2:[ordering="fwd"] = 0`.
+ old `fwd=` attribute): `reg mem:[4]U2:[ordering="fwd"] = 0`.
* `"program"` (default): reads/writes resolve in program order, like software
(what `mut` arrays always do).
* `"fwd"`: transparent — a read of an address written this cycle returns the
new data regardless of textual position.
* `"none"`: cheapest, collision is **undefined** — X in formal, random in
simulation.
* `"old"`: a read always returns the *committed* (previous-cycle) value —
defined; what an imported Verilog reg memory means.
A synchronous SRAM is an async mem with flopped address or flopped data, or a
direct `stage[1..<inf] res = __memory(cfg)` RTL instantiation.
## Verification statements
Three statements, distinguished by **who discharges the obligation**
(all parenthesized, optional trailing `"msg"`):
* `cassert(expr)` — elaboration check: must fold to true at compile time, or
the build fails. Never reaches formal or the netlist.
* `assert(expr)` — design obligation: `pass.formal` proves it at compile time;
what it cannot prove stays as a runtime check. `assert_always` also checks
during reset (that is the whole reset story; there is no `always_assert`
family).
* `assume(expr)` — a constraint the tool may rely on, **proven first**: each
assume is proven independently, and only a proven one becomes a hypothesis
for the surrounding asserts. A refuted assume is a build error. Over a
lambda's free inputs it cannot be proven: at the **top** module that fails
the build (`assume-refuted`; downgrade with
`--set compile.formal.on_refute=warn`); in an **instantiated** module it is
deferred with a warning (the parent's drivers discharge it). `lhd formal
verify` applies the same discipline: EVERY assume (formal-block or design)
is checked as an assert before it is used, and a refuted check fails the
run with a hint to spell `assume_nocheck`.
- `assume_nocheck(expr)` (formal blocks) is the explicit free environment
- constraint — assumed WITHOUT check, disclosed in the verdict table;
+ `assume_nocheck(expr)` is the explicit free environment constraint —
+ assumed WITHOUT check, disclosed as UNCHECKED in the verdict;
`assume_nocheck_formal(expr)` is the fcore spelling of the same (it also
warns per encounter); `assume_nocheck_synth(expr)` is a synthesis-only
don't-care, invisible to verification.
**TRAP — guards are NOT inherited**: `if c { assert(x) }` lowers as an
*unconditional* `assert(x)` (known compiler bug, verified 2026-07-31). Always
write the guard into the condition: `assert(c implies x)`.
**TRAP — `lhd sim` does not execute design-body asserts**: only assertions
written inside a `test` block are checked at simulation time. Design-body
asserts are checked by `pass.formal` at compile time (and emitted into the
Verilog netlist); a violated design assert still reports sim PASS.
`requires`/`ensures` were **removed** (they parse only to warn "no obligation
generated") — write `assume` for a precondition, `assert` for a postcondition.
`cover`/`covercase` do not exist. `optimize(...)` is no longer documented —
use `assume` (a proven assume is available to the optimizer as a don't-care).
Prints: `puts("a={a} b={}", b)` (interpolation, queued to end of cycle, legal
- in `comb`), `print`. `cputs("msg")` prints at elaboration — file
- top-scope only for now (inside a lambda it is an undefined call).
+ in `comb`), `print`. `format(...)` was removed. `cputs("msg")` prints at
+ elaboration — file top-scope only for now (inside a lambda it is an undefined
+ call).
## Tests (`lhd sim`)
A `test` is a debug-only block named by a **dotted identifier** — the string
form `test "name"` is a syntax error now. Optional runtime parameters, no
return. The DUT is declared once as an *instance* and driven through field
access; `tick N { }` is the non-unrolling cycle loop with exactly one `step`
- (the clock edge) per iteration; `clock` is the 0-based cycle index:
+ (the clock edge) per iteration. Each `tick` has a minted `clock:Clock` that
+ counts its cycles from 0 (a debug context, so `clock < 2` is legal there) and
+ auto-wires into the DUT's unbound `Clock` input:
```pyrope
- mod counter(enable:bool) -> (value:u8@[0]) {
- reg count:u8 = 0
+ mod counter(enable:Bool) -> (value:U8@[0]) {
+ reg count:U8 = 0
value = count
if enable { wrap count += 1 }
}
test counter.held_high {
mut acc = counter // one persistent instance, reset on declaration
mut v_final = nil
tick 20 { // run up to 20 cycles
acc.enable = true // drive this cycle's input (pre-edge)
step // the clock edge
v_final = acc.value // sample post-edge (outputs and internal regs)
}
assert(v_final == 20)
}
- test counter.run_for(cycles:u8=5) { // runtime param: --arg cycles=9
+ test counter.run_for(cycles:U8=5) { // runtime param: --arg cycles=9
mut acc = counter
tick cycles {
acc.enable = true
- acc.reset = clock < 2 // reset is just an input, driven from the index
+ acc.reset = clock < 2 // the minted Reset takes a Bool, driven from the cycle
step
}
}
```
Assertions inside the test are checked as it runs; the test reports failure at
the end rather than stopping.
* Parameters: `p:T=v` is optional (default used), `p:T` (or `=nil`) is
**required** — the runner must pass `--arg p=v` or the run errors (never a
silent 0). Params are sim-only values; they never reach hardware.
* Waiting has no primitive: `step` then `if not acc.ready { continue }` inside
`tick N` (the bound is the timeout). `waitfor`/`spawn`/`join`/`cancel` were
**removed** — one loop, `if`-block "tasks", no coroutines. Monitors are
inline `if`-blocks; a golden model is a `mut` updated in lockstep.
- * `step [n]` advances n cycles. String-path `peek`/`poke` work; dotted field
- access is the normal form.
+ * `step [n]` advances n cycles. Read any cell with a bare dotted path at any
+ depth (`acc.core0.count`); drive a register through a `regref` handle bound
+ once (`mut h = regref(acc.child.count)` … `h = 7`; the string form
+ `regref("acc/child.count")` also works). `peek`/`poke`/`sigref` were
+ removed.
+ * An instance handle (`mut d = dut`) is not a tuple: `d.x = v` drives input
+ `x`, `d.o` reads the design, anywhere in the test (after a `tick`, after a
+ bare `step`). Strict Bool holds in tests too: drive a `Bool` input with
+ `true`/`false`/`Bool(x)` (`d.en = 1` is an error) and use a `Bool` output
+ as an integer only through `U1(d.f)`. A poke follows the argument rules
+ (`d.a = 300` into a `U8` is an error). Never poke a constant into a
+ `Clock` input; a `Reset` input takes a `Bool` (`d.rst = clock < 2`). File-scope `const`s are visible (a
+ test local or test parameter named like one is shadowing, an error); `lhd sim` evaluates the
+ plain integer and tuple-field ones, not yet one built from `std.clog2`, an
+ enum entry or a nested tuple field ("unsupported dot expression").
* Rejected inside a `test` body: `for` loops, `.[rand]`/`.[crand]`, `past[N]`.
A *top-scope* comptime `for` wrapping many `test` blocks works (test
fan-out; the runner disambiguates by index).
## Formal blocks (`lhd formal verify`)
A `formal name.path { }` block is a declarative overlay: every statement is a
claim that must hold at **every cycle** (nothing procedural — no `step`/
`tick`), it never lowers to hardware or simulation, and only
`lhd formal verify` consumes it. Properties live next to the design or in a
sidecar `.prp` file. Bind the design like a test does, then state properties
over dotted paths (ports, registers through instance names):
```pyrope
// cnt.verify.prp — sidecar (never becomes hardware)
const top = import("cnt.cnt")
formal cnt.bounded {
mut acc = top
- assume(acc.enable == 0) // input-only: environment constraint
+ assume_nocheck(acc.enable == false) // free environment constraint (unchecked)
assert(acc.count != 5, "frozen")
}
```
* **Blocks are independent tests**: each block's assumes constrain only its
own asserts, so two blocks may carry mutually exclusive assumes. Design-body
assumes are the other tier — always in force for every block. A
contradictory assume set is named and fails the run (never silently vacuous).
- * An assume over **primary inputs only** is an environment constraint
- (disclosed in the verdict); one touching **registers or outputs** is
- prove-then-use — a false claim is REFUTED, never a fake proof.
+ * A plain `assume` is CHECKED as an assert before it is used (prove-then-use):
+ one over free primary inputs is REFUTED (nothing forces it), one over
+ registers/outputs is used only once proven. Spell an intended environment
+ constraint `assume_nocheck(...)`.
* `lhd lec` takes no formal-block sidecar (it has a single obligation);
environment constraints for lec belong in the design itself.
## Files, visibility, instantiation
* A file's top scope is setup code, run once. Only `pub` top-scope lambdas,
types, and constants can be imported: `const lib = import("file")` /
`import("file.pub_name")` / `import("proj/file")`. No glob patterns.
`pub mut`, `pub reg` and `pub wire` are compile errors; the SYNTHESIZABLE
cross-hierarchy register attach (`regref` by string path, zero-or-many
matches) is TBD — design code reaches registers through the ordinary
instance hierarchy instead.
* Pin the generated netlist/Verilog module name with the `lg` attribute:
`pub comb my_top::[lg="chip_top"](...)` — pub-only, comptime string; the
`import` key stays `my_top`; the artifact becomes importable as
`import("lg:chip_top")`. Never invent `pub("name")`.
* A fully typed `pipe`/`mod` lowers to a module; an untyped one is a per-call
template (every actual feeding it must have a declared type). Generated
module names are `file.entity` (e.g. `cnt.counter`).
* Do not instantiate conditionally to "save hardware": a lambda called inside
`if`/`match` behaves as if inlined there with valid-gated inputs. Structure
the design with unconditional calls and mux the results.
## Canonical patterns
```pyrope
- // Counter — the registered-output interface form
- // `mod c(...) -> (reg count:u8@[0])` does NOT lower yet; use a body register:
- mod counter1(enable:bool) -> (value:u8@[0]) {
- reg count:u8 = 0
- value = count
- if enable { wrap count += 1 }
+ // Counter — registered-output interface form: q IS the output, `= 0` resets it
+ mod counter2(enable:Bool) -> (reg count:U8@[0] = 0) {
+ if enable { wrap count += 1 } // conditional/feedback write: state, @[0]
}
- pipe[1] counter3(enable:bool) -> (reg count:u8) { // pipe state output: home stage N-1
+ // Counter — body register read by a combinational output
+ mod counter1(enable:Bool) -> (value:U8@[0]) {
+ reg count:U8 = 0
+ value = count
if enable { wrap count += 1 }
}
// FSM (names are case-sensitive: `state` and type `State` coexist)
enum State = (Idle, Run, Done)
- mod fsm(start:bool, fin:bool) -> (busy:bool@[0]) {
+ mod fsm(start:Bool, fin:Bool) -> (busy:Bool@[0]) {
reg state:State = State.Idle
busy = state == State.Run
match state {
== State.Idle { if start { state = State.Run } }
== State.Run { if fin { state = State.Done } }
else { state = State.Idle }
}
}
// 1-cycle dual-port RAM
- pipe[1] dpram(we:bool, waddr:u8, raddr:u8, wdata:u32) -> (rdata:u32) {
- reg mem:[256]u32 = 0
+ pipe[1] dpram(we:Bool, waddr:U8, raddr:U8, wdata:U32) -> (rdata:U32) {
+ reg mem:[256]U32 = 0
if we { mem[waddr] = wdata }
rdata = mem[raddr]
}
+
+ // Generic width + clog2-sized select
+ mod pick<N=8>(v:Unsigned(bits=N), sel:Unsigned(bits=std.clog2(N))) -> (b:Bool@[0]) {
+ b = v#[sel] == 1
+ }
+
+ // Two clock domains: two Clock inputs, so every register names its clock;
+ // the single Reset `rst` stays implicit (active-low only where negreset=true)
+ mod two_clk(clk_a:Clock, clk_b:Clock, rst:Reset, a:U8, b:U8) -> (qa:U8@[0], qb:U8@[0]) {
+ reg ra:U8:[clock_pin=clk_a] = 0
+ reg rb:U8:[clock_pin=clk_b, negreset=true] = 0
+ if a != 0 { ra = a }
+ if b != 0 { rb = b }
+ qa = ra
+ qb = rb
+ }
```
## Verilog ↔ Pyrope quick map
| Verilog | Pyrope |
|---------|--------|
| `module m(...)` | `mod m(...) -> (out:T@[N])` (or `pipe[N]`/`comb`) |
- | `input [7:0] x` / `output [7:0] y` | `x:u8` input / `y:u8@[N]` mod output |
- | `reg [7:0] x` + reset | `reg x:u8 = 0` |
- | `reg [7:0] x` / procedural blocking `=` | `mut x:u8 = 0` |
- | `wire [7:0] x` / continuous `assign` | `wire x:u8 = ...` (single-driver net) |
+ | `input [7:0] x` / `output [7:0] y` | `x:U8` input / `y:U8@[N]` mod output |
+ | `input clk, rst_n` | implicit clock/reset BY TYPE: `clk:Clock, rst_n:Reset` (omit at the call site); an active-low reset needs `negreset=true` on its registers |
+ | `reg [7:0] x` + reset | `reg x:U8 = 0` |
+ | `reg [7:0] x` / procedural blocking `=` | `mut x:U8 = 0` |
+ | `wire [7:0] x` / continuous `assign` | `wire x:U8 = ...` (single-driver net) |
| `x <= y` (non-blocking) | `x = y` on a `reg` (registered write) |
+ | `always @(posedge clk or posedge rst)` | `reg x:U8:[async=true] = 0` |
| `parameter N = 8` | `comptime const N = 8`, or a constant generic `<N=8>` |
+ | `logic [N-1:0] x` / `$clog2(N)` | `x:Unsigned(bits=N)` / `std.clog2(N)` |
| `always @(posedge clk)` / `@(*)` | implicit — `reg` vs `mut` |
| `case (x) ... endcase` | `match x { == v {...} else {...} }` |
| `x[6:3]` | `x#[3..=6]` |
+ | `y8 = ~x1` (context-widened `~`, 8'hFF for x1 == 0) | `y8 = ~U8(x1)` or `~x1#[0..<8]` — a plain `~x1` of a `U1` flips 1 bit |
| `{a, b}` concat | `(b, a)#[..]` — entry 0 lands in the LOW bits, so the argument order REVERSES |
| `4'b10x?` / `x` value | `0ub10??` / `0sb?` |
| one-hot mux / tri-state bus | `unique if` (lowers to `__hotmux`) |
| Verilog reg memory read semantics | `reg mem:[N]T:[ordering="old"]` |
+ | Verilog-style flops, no cycle checks | `mod m::[timecheck=false](...)` (escape hatch; the importer sets it) |
| SVA `$rose(req) \|-> ##[1:10] $rose(ack)` | `assert(rose(req) implies rose(ack, 1..=10))` (temporal lib TBD) |
| testbench `initial` | `test name.leaf { ... tick N { ... step ... } }` |
## Gotchas — check before emitting code
1. `return X` is always wrong — assign the named output, then bare `return`.
2. Outputs must be named in `-> (...)`; no positional returns; clause is
mandatory (except `self` methods).
- 3. `match` is parallel, not priority: overlapping arms ⇒ X output. `else` is
- optional when arms cover the key space; use `if/elif` for priority.
+ 3. `match` is parallel, not priority: overlapping arms break the one-hot
+ obligation. `else` is optional when arms cover the key space; use
+ `if/elif` for priority.
4. `when`/`unless` trailing gates and `.[defer]` no longer exist — `if` blocks
and `wire` nets respectively.
5. `@[N]` never inserts flops (pure check); `stage[N]` inserts them
(`mod`-only). A `mod` output without `@[N]`/`@[]` is a compile error; a
comb path through a `pipe` is illegal.
- 6. No bool/int mixing: `if 5 {}` is a type error → `if 5 != 0 {}`. Reduce ops
- (`x#|[..]`) return int 0/1, not bool.
+ 6. No Bool/integer mixing, also across ports and instance outputs: `if 5 {}` is a
+ type error → `if 5 != 0 {}`; `U1(flag)` for Bool→bit, `Bool(x)` for
+ bit→Bool. Reduce ops (`x#|[..]`) return integer 0/1, not Bool.
7. Narrowing assignments need `wrap`/`sat`; widths come from types, never from
- a `:[max=...]` attribute.
+ a `:[max=...]` attribute. The same rule binds arguments: a `U16` into
+ `a:U4` is a compile error — pass `x#[0..<4]`.
8. Loop bounds must be comptime (loops unroll); `tick` (test-only) is the only
- runtime-count loop. No comprehensions.
+ runtime-count loop. No comprehensions. Never drive a `wire` inside a loop.
9. `0b1010` is invalid — `0ub1010`/`0sb1010`. No bare `?`/`_` initializers;
- use `nil` or `0sb?`.
+ use `nil` or `0sb?`. No `int`, no `x:i8` type and no `I8` type — `S<N>`/`Signed`. Type words
+ are capitalized (`U8`, `Bool`); `u8`/`bool`/`s1` are ordinary names, but as a
+ type or cast (`x:u8`, `u8(x)`) they are a "renamed `U8`" error.
10. `++` is tuple/string concat, never arithmetic. `#[]` bits vs `[]` elements
vs `@[]` cycles.
11. Name your call arguments (`f(a=1, b=2)`); UFCS only on `self` lambdas;
`ref` written at declaration and call.
12. The comptime `[...]` slot is a syntax error — comptime parameters are
constant generics: `comb g<N=1>(x)`, called `g<N=3>(x=a)`.
- 13. `_pin` register attributes need `ref` (`clock_pin=ref clk`); reset value
- is the `= expr` initializer; `async=true` for async reset.
+ 13. `*_pin` takes no `ref` (`clock_pin=clk`); reset value is the `= expr`
+ initializer; `async=true` for async reset (never `sync=`); `negreset=true`
+ for active-low (a `_n` name means nothing).
14. Enum comparisons use names (`State.Idle`), never the underlying integer.
15. `if c { assert(x) }` checks `assert(x)` UNCONDITIONALLY — write
`assert(c implies x)`.
16. Test names are dotted identifiers (`test add.basic`), never strings.
Design-body asserts are not executed by `lhd sim` — put checked asserts in
the `test` block.
+ 17. Clocks/resets are TYPES: a register's clock is the single `Clock` input
+ (`clk:Clock`), never an input merely NAMED `clk` (a `clk:U1` port is data
+ and the module then mints `clock:Clock`). Never bind a constant to a
+ `Clock` (`dut(clk=true, ...)` is an error); omit it and the caller's
+ implicit clock is wired. A `Bool` input named `clk` may be constant.
+ 18. A nested lambda reads only compile-time constants of its enclosing scopes
+ (`comptime const`, a `const` that folds to a constant, generics) — pass
+ runtime values as inputs. Lambda inputs/outputs/generics must not reuse a
+ visible enclosing const/type/lambda name.
+ 19. `~x` flips only an unsigned-typed x's OWN width: `x:U3` gives `7 - x`,
+ also into a `U8` (5 for x == 2, not 253); `const t = w` is a U3 alias too;
+ a signed x or a literal gives `-x - 1`; `~(w + 1)` (untyped runtime) is a
+ compile error. Widen first for a wider flip (`~U8(x)`, `~x#[0..<8]`).
+ ## Known LiveHD bugs (work around; re-test before trusting)
+
+ Re-verified 2026-09-28 (lhd built 2026-09-28) with the OLD lowercase type
+ spellings; the entries below are respelled with the 2026-09-29 capitalized type
+ words and were not re-run after that migration. **Fixed since 2026-09-27**, no
+ workaround needed any more: generic `comb` outputs with generic widths;
+ `Unsigned(bits=16 * N)` ports read in loops; false combinational loop through a
+ child's register (drop `::[timecheck=false]`); bit writes straight into an
+ output (`y#[i] = ...`); `f<N=x.[bits]>`; nested lambdas reading an enclosing
+ `comptime const`; `wire` reads and `reg`-array writes inside `for`; byte-enable
+ memory writes (`mem[a]#[r] = d`); `a#[0..+1]`; single-output auto-unwrap;
+ named tuple `type` ports, `.[bits]` of a tuple field, `U1()` of a `Bool` tuple
+ field, tuple outputs from a typed `comb`; stateful children instantiated inside
+ a `for` loop in `lhd sim`; an absent field of a one-named-field tuple or a
+ one-member import namespace (now `unknown-field`); a call omitting a defaulted
+ `mod`/`pipe` input; test blocks reading a file-scope `const` or an instance
+ output outside a `tick`. **Fixed 2026-09-29:** enum-typed ports/regs (one
+ signed bit before), LEAF values of hierarchical enums in hardware, `mod`/`pipe`
+ defaults that fold to a constant (generics, `comptime const`s, comb calls,
+ if-expressions, attribute reads, `const X = enum(...)` entries), untyped
+ outputs of a fully typed `mod` read into typed destinations (a Bool one stays
+ a Bool), the Pyrope writer on a `comb` default computed through calls; calls
+ into a `mod` imported pre-elaborated from an `ln:` dir (or restored from the
+ compile cache) now get the argument/output fit checks (they truncated
+ silently); `.[bits]` of a typed tuple field after an enum-entry write.
+
+ Still open:
+
+ * `lhd pyrope fmt` (default `--mode ai`: one line per statement, no width limit,
+ sorts all-named call/tuple lists, rewrites `f(x=x)` to `f(x)`) can still
+ change meaning: it sorts `type`/`enum` field declarations (positional
+ initialization then binds differently) and shortens `q=q` even when the callee
+ has no `q` (an error becomes a valid single-argument bind). LEC the formatted
+ file against the unformatted one. (The compiler bugs where a bare same-name
+ argument bound by position, for `inline=false` comb instances and for a
+ same-kind leftover parameter, are fixed in livehd as of 2026-09-28.)
+
+ * **Silent:** a `reg` both bit-read and conditionally bit-written inside a
+ rolled `for` loop (`out#[p] = r#[p]; if v#[p] == 1 { r#[p] = d#[p] }`) loses
+ the write. Build the next value in a `mut` inside the loop and assign the
+ register once after it, or `--set compile.unroll=true`.
+ * **Silent:** `reg mem:[N]T:[ordering="old"]` with several partial writes
+ (`mem[a]#[r] = d`) to one entry in the same cycle keeps only the last one.
+ Write the merged whole word.
+ * **Regression:** a memory element passed to a typed `comb` input
+ (`f(x=mem[i])` with `x:U16`) → "(unbounded range) may not fit". Bind
+ `const v:U16 = mem[i]` first.
+ * A PARENT value of a hierarchical enum (`Animal.bird`, not a leaf such as
+ `Animal.bird.parrot`) does not lower to hardware yet: storing it in a typed
+ local, comparing a port with it, or passing it to a port fails
+ (assign-type-mismatch or an unresolved reference). Use leaf values.
+ * A registered output declared `@[1]` makes any caller that mixes it with
+ same-cycle values fail ("mixes values at different cycles"), including the
+ generated lhdtrack harnesses; on such tops declare it `@[]` with a comment.
+ * In a generic lambda that has any `mut`, `U1(<Bool input>)` failed
+ ("undefined function 'u1'" before the type-word migration); cast a local
+ const instead (`const b = in; U1(b)`).
+ * An import const named like its own file (`const pc = import(...)` in
+ `pc.prp`) breaks generic calls through it; rename the const or the file.
+ * `lhd sim` may report a false dependency cycle ("occurrence-wide color
+ scheduler") for one rolled loop computing both a request and the ready that
+ comes back through another module; split the loop.
+ * Arrays with a generic element width: `reg r:[N]Unsigned(bits=N)` ("memory
+ 'r' element type must be a sized integer or bool") and array ports of a
+ generic (`v:[N]Unsigned(bits=N)`, "has no declared type"). Pack into
+ `Unsigned(bits=N*W)` and slice `#[(i*W)..+W]`.
+ * Bit-assign into an array element inside nested loops
+ (`m[i]#[j] = ...`) → "array index is negative (range [-4, 3])". Build the
+ row in a scalar local and assign `m[i] = row`.
+ * `std.clog2(x)` and value-derived `K.[bits]` (13.[bits] == 4) are documented
+ but not in the compiler (`std` undefined; `.[bits]` of a comptime value is
+ not its width). Pass widths as extra generics, or use
+ `Unsigned(max=DEPTH - 1)` as an index type.
+ * An unparenthesized expression as a generic argument (`m<W=2*N - 1>`) does
+ not parse; write `m<W=(2*N - 1)>`.
+ * Unverified since the fix pass: `lhd sim` reading a `wire` back-edge one
+ cycle late inside a `for` loop (silent, LEC still proved it). Back loop
+ refactors with a full-length simulation.
+ * An instance handle named like a `reg` in the body of a non-`pub` child
+ (`const c = cnt8(...)` when `cnt8` declares `reg c`) fails ("call to
+ undefined function 'cnt8'"); rename the handle.
+ * `U1()`/`Bool()` on a DESTRUCTURED or auto-unwrapped `Bool` output
+ (`const (f, g) = child(...); U1(f)`) or on a `Bool` tuple field failed
+ ("call to undefined function 'u1'" before the migration): read it through
+ the instance, `const c = child(...); U1(c.f)`.
+ * A named tuple `type` as a `mod` port type, `.[bits]` on a tuple field, and
+ a tuple output from a typed `comb` fail; use inline tuple port types and
+ `mod`.
+ * The documented `pipe` state-output idiom `pipe[1] c(en:Bool) -> (reg
+ count:U8) { if en { wrap count += 1 } }` fails ("state register 'count' …
+ homes at stage 0 but its declared landing cycle 1 requires home 1"); use the
+ `mod` form `-> (reg count:U8@[0] = 0)` (`counter2` pattern).
+
## Not yet implemented (avoid emitting)
Valid spec Pyrope that LiveHD does not lower yet (the `pyrope/15-tbd/` doc
- chapter is the authoritative list; status below re-verified against a fresh
- build
- 2026-07-31). Do not generate these unless explicitly asked:
+ chapter is the authoritative list; status below last re-checked 2026-09-27).
+ Do not generate these unless explicitly asked:
- * `fluid` lambdas / valid-stop handshakes (parses only).
+ * `fluid` lambdas / valid-retry handshakes (parses only).
* The verification **temporal library** — `past(x, n)`, `rose(x [, w])`,
`fell`, `stable`, `changed`, `eventually(x, w)`, `always(x, w)`. Cycle
arguments are **positional** (there is no `f[N](x)` bracket form in this
library) and windows are bounded ranges (`1..=10`). No
`.[rising]`/`.[falling]`/`.[changed]` attributes. `lhd formal verify`
rejects these with an explicit not-implemented diagnostic. (The *pipelining*
`past[N](x)` DOES work — design body only.)
* Testbench extras: `force`/`release`, `cpp("model")` external models,
unbounded `tick`; the SYNTHESIZABLE string-path `regref`; `assert.[failed]`.
- The `test`-block `regref` (dotted or single-cell string) WORKS and is the
- only way to drive a cell; reads are bare dotted `dut.x` at any depth.
- `sigref` was REMOVED 2026-09-06 — it was exactly a bare dotted read.
- Nested registers can be driven with `regref(dut.child.count)` or
- `regref("dut/child.count")`; child instance names follow their source bindings.
+ (The `test`-block `regref` works — see Tests.)
* `cover`/`covercase`; in-language `lec()`; `.[rand]`/`.[crand]` (rejected in
test blocks and design bodies; survive only where they constant-fold).
- * `macro=` memory-compiler binding; `import("prp")` stdlib.
- * **Registered-output interface form** `mod f(...) -> (reg count:u8@[0])` →
- `reg-output-cycle` error. Use a body register (`counter1` pattern above).
+ * `macro=` memory-compiler binding; `import("prp")` stdlib (the built-in
+ `std` namespace with `std.clog2` needs no import); the `retime` register
+ attribute (parses, warns, not lowered).
+ * The landing-cycle rule for a BODY `reg` driving a `mod` output is unsettled
+ (see Registers and timing).
* `format(...)`, operator-overload hooks (`eq`/`lt`/`to_string`/`to_bool`),
- strings as char tuples, `:Param_type(string)`, `u(W)`, recursive-enum ADTs
- and the tuple-LHS `in` subset test were all REMOVED 2026-09-06 — see
- `fixes_pyrope.md`. (Nested/hierarchical enums such as `Animal.bird.eagle`
- DO work; the old "use a flat enum" note here was wrong.)
+ strings as char tuples, `:Param_type(String)`, `u(W)`, recursive-enum ADTs,
+ `pub wire`, `sigref`/`peek`/`poke`, the tuple-LHS `in` subset test and the
+ binary `a ~& b` / `a ~| b` / `a ~^ b` spellings were all REMOVED (or never
+ existed) — do not emit them; write `~(a & b)` / `~(a | b)` / `~(a ^ b)`.
Note: generic constant/lambda bindings, generic defaults, named `<T=…>`
- bindings, body references of a generic, and input default values **all work**
- (re-verified 2026-09-06 — every tracker compiles green); the stale `15-tbd`
- rows claiming otherwise were removed. Prefer trusting `lhd` over any TBD table.
+ bindings, body references of a generic, and input default values **all work**;
+ prefer trusting `lhd` over any TBD table.
Checking a comptime-only `.prp` (no `pub mod`/`pub comb` hardware entity) needs
`--set upass.tolg=false --set upass.verifier=true`. Bare `lhd compile` forces
lowering and invents `tolg-error: unresolved reference '%self_0'` /
`tuple-store-unsupported` on programs that contain no hardware.
## Checking code with `lhd`
`lhd` is the LiveHD CLI (built in a [LiveHD](https://github.com/masc-ucsc/livehd)
checkout with `bazel build //lhd:lhd` → `bazel-bin/lhd/lhd`; prefer that
checkout binary over a copied one, which may be stale).
Stateless and deterministic; exit code 0 = pass, and the final line
is one JSON result object whose `error.class` says why it failed
(`syntax`, `equiv_fail`, `unsupported`, `internal`, …).
**Interactive-use tips** (these two save the most friction):
* Every command self-describes: `lhd help <cmd>` (== `lhd <cmd> --help`)
prints the exact accepted args as JSON — check it before guessing flags.
`lhd list options|steps|emit-kinds|error-classes` and `lhd describe <name>`
enumerate the vocabulary.
* Diagnostics format: `--diag-fmt auto|jsonl|pretty` (auto = pretty on a
terminal, JSONL when piped). **When piping through grep/head, pass
`--diag-fmt pretty`** for clang-style text instead of JSONL; use
`--emit diagnostics:PATH` / `--result-json PATH` when a tool consumes them.
```sh
lhd compile foo.prp # parse + lower + diagnostics (quick check)
lhd compile foo.prp --top NAME --emit verilog:foo.v --workdir tmp # netlist
lhd compile foo.prp --emit-dir ln:foo_lns/ # emit IR; ln:/lg: dirs are also
lhd compile ln:foo_lns/ --emit net.v # valid INPUTS (compile/sim/lec/synth)
- lhd synth foo.prp --top foo --workdir W --stats # compile -> color synth -> abc -> opentimer
lhd sim foo.prp # run every test block in the file
lhd sim foo.prp add.basic --arg n=4 # one test (dotted selector), runtime args
lhd formal verify foo.prp props.verify.prp --top foo --set formal.bound=12
lhd lec --impl foo.prp --ref gold.v --top gold --set formal.solver=cvc5
+ lhd synth foo.prp --top foo --workdir W --stats # compile -> color synth -> map -> STA
lhd pyrope fmt -i foo.prp # formatter; `lhd pyrope lsp` = LSP server
lhd scan foo.prp # list the file's imports
lhd tool cat|grep|diff|tree ... # inspect ln:/lg: artifacts
```
- * **`lhd synth`** is the one-shot synthesis flow (compile -> `pass color synth`
- -> `pass abc` tech-map -> `pass opentimer` STA) over one in-memory design.
- `--top` takes the bare entity; one Liberty (`--set synth.liberty=…`, default
- `$HAGENT_TECH_DIR/sky130_fd_sc_hd__tt_025C_1v80.lib`) feeds abc and
- opentimer. With `--workdir W` the compiled design, mapped netlist, `qor.json`
- and `timing.json` land in `W/synth/` and a re-run reuses everything unchanged
- (`--set lhd.incremental=false` = honest cold run, same netlist). Outputs:
- `--emit-dir lg:` / `--emit verilog:` (mapped netlist), `--emit-dir report:`
- (the two JSON reports); `--stats` adds per-color rows; pass knobs ride their
- pass namespace (`--set abc.adder=cla`, `--set color.synth.max_gate=50000`). The
- coloring is always `synth` — use the manual `lhd pass color <alg>` + `lhd pass
- abc` steps for anything else.
* **`lhd sim`** builds a C++ simulation of the `test` blocks. It needs the sim
runtime headers — if a copied binary reports "could not locate the sim
runtime headers (slop.hpp)", run the `bazel-bin/lhd/lhd` binary from a
LiveHD checkout.
Useful: `--list-tests`, `--seed N`, `--set sim.vcd=true`, `--probe SIG`,
`--break-when 'SIG OP VALUE'`, `--vcd-on-fail`.
* **`lhd formal verify`** proves design assert/assume plus `formal` blocks by
BMC from reset (per-obligation verdicts: PROVEN inductive vs bounded,
REFUTED with a replay trace under `--workdir`: `formal_report.json`,
- `formalfail.prp`, VCD). Knobs: `--set formal.bound/timeout/engine/...`;
+ `simfail_*.prp`, VCD). Knobs: `--set formal.bound/timeout/engine/...`;
`--formal '<glob>'` selects blocks. `formal.strict` defaults **true**: an
inconclusive run exits nonzero (it proved nothing); a refuted obligation is
`equiv_fail`.
* **`lhd lec`** sides are `verilog:`/`pyrope:`/`ln:`/`lg:` (bare paths: kind
inferred). Solver: `--set formal.solver=cvc5` (default) | `bitwuzla` |
`lgyosys`. It is sequential-aware (flop-cut induction + BMC). Vacuous
passes are closed: an absent top or empty module is a hard `equiv_fail`.
The backends **can disagree**: cvc5/bitwuzla reason on the LGraph, lgyosys
on the cgen-emitted Verilog — a cgen bug or induction weakness makes one
pass while the other fails. When a verdict surprises you, get an
independent oracle: `--emit verilog` the netlist and simulate against the
golden with iverilog/verilator over an exhaustive or random sweep.
+ * LEC verdicts: `proven`/`refuted` are answers; `unknown` (timeout or
+ inconclusive) is neither — it has hidden real miscompiles, so back it with
+ simulation. For refactors, LEC each small step against the *previous*
+ version (compile each side with `--set compile.upass.inline=false
+ --emit-dir lg:DIR`), not against the original: small deltas prove in
+ seconds. Renamed instances break flop pairing (`const arb = child(...)`
+ names the instance `arb`); pair them with
+ `--set formal.lec.match='ref.inst.q=impl.inst.q'` (equal widths only).
+ * **`lhd synth`** is the one-shot synthesis flow (compile -> `pass color
+ synth` -> mapper -> OpenTimer STA) over one in-memory design. `--top` takes
+ the bare entity; `--set synth.mapper=abc` (default) | `usyn`; one Liberty
+ (`--set synth.liberty=…`, default
+ `$HAGENT_TECH_DIR/sky130_fd_sc_hd__tt_025C_1v80.lib`) feeds mapping and STA.
+ With `--workdir W` the compiled design, mapped netlist and reports land in
+ `W/synth/` and a re-run reuses everything unchanged
+ (`--set lhd.incremental=false` = honest cold run). Outputs: `--emit
+ verilog:PATH` / `--emit-dir lg:` (netlist), `--emit-dir report:`; `--stats`
+ adds per-region rows; pass knobs ride their namespace (`--set
+ abc.adder=cla`). Check `lhd help synth` for the current knobs.
+ * Cleaning machine-emitted Pyrope (e.g. `lhd compile ... --emit-dir pyrope:`)
+ into idiomatic code: follow `livehd/docs/sample_prompt_cleanup_pyrope.md`.
+ * `lhd pyrope style` suggests loops/bundles for repeated code in
+ machine-emitted Pyrope; `lhd pyrope fmt -i` formats (re-check long
+ if-expressions and argument lists it joins onto one line).
Triage diagnostics by `category`: `syntax`/`name`/`type`/`bitwidth` — the
source is wrong, fix it; `unsupported` — valid Pyrope LiveHD cannot lower yet
(see the TBD list), rewrite around it, do not "fix" correct source;
- `internal` — a LiveHD bug: reduce to a repro, do not change the source. The
+ `internal` — a LiveHD bug: reduce to a repro, do not change the source.
+ EXCEPTION: timing/landing-cycle ("lands at cycle(s) … but … is declared"),
+ combinational-loop and clock/reset-ambiguity errors currently print as
+ `error[internal]` although they are SOURCE errors — fix the source. The
frontend is more permissive than the spec (stale forms may still parse), so
passing `lhd compile` is necessary but not sufficient — follow this skill's
rules for style/semantics.