Language reference

Types and Expressions

Choose suitable types and express calculations, conditions, and bit operations in Livt.

A type describes both what a value means and how it is represented. In Livt, choosing a type also establishes hardware properties: the width of a signal, the range of a counter, or the shape of an array. Expressions then describe how those values are calculated, compared, and combined.

This page follows a small packet-header example to explain those choices. Its 16-bit header contains a version, flags, and a payload length. Along the way, we will make widths explicit, extract fields, and check the results in simulation. You can download the complete example project, including its manifest, component, and four runnable tests.

Choose a type

Start with the role of the value. A condition needs a boolean, an external signal needs a hardware logic type, and arithmetic needs a deliberate signed or unsigned interpretation. The following types cover the common cases:

TypeRepresentationUse it for
booltrue or falseConditions, comparison results, and assertions.
logicOne hardware logic signalSingle-bit ports and signal states.
logic[N]An N-bit logic vectorControl words, packed fields, and signals with an explicit width.
byteUnsigned 8-bit value, 0–255Packet bytes, encoded text, and raw data.
intSigned 32-bit valueArithmetic that may include negative values.
uintUnsigned 32-bit valueCounts and arithmetic with a non-negative interpretation.
clock, resetSignals with timing rolesComponent execution contexts.
stringTextReports, compile-time text, and data to encode as bytes.

Use bool to express a decision, such as whether a length is valid. Use logic for a hardware signal that may carry HDL-style states. Alongside zero and one, logic literals support U (uninitialised), X (unknown), Z (high impedance), W (weak unknown), L (weak zero), H (weak one), and - (don’t care). These states are useful in simulation; they are not ordinary numeric values.

livt
let accepted: bool = true
let valid: logic = 0b1
let unknown: logic = 0bX
let flags: logic[4] = 0b0101
let payloadByte: byte = 0x3C

A scalar logic and a one-bit vector logic[1] are different types. Use the scalar for a single signal and the vector when an API expects a vector shape. Similarly, byte communicates unsigned data, while logic[8] communicates a bit pattern. That distinction matters when converting to a signed integer: the same eight bits can represent 255 as a byte or −1 as a signed vector.

int and uint have fixed 32-bit widths, independent of the simulator’s native integer representation. Their ranges are −2,147,483,648 through 2,147,483,647 and zero through 4,294,967,295 respectively. Use an explicit type at public fields, parameters, and return values so callers can see the intended range and shape.

clock and reset identify timing signals rather than general data. They can be observed as scalar logic; converting scalar logic to reset requires as reset. Logic-to-clock and clock/reset interchange are not supported conversion paths. A cast does not create a clock source or synchronise a signal. See Contexts for connecting clocks and resets.

Declare values and constants

Choose a declaration according to whether the value must be known at compile time and whether a runtime binding can change. Type inference is available for local bindings, but it does not remove the need to understand the resulting type.

DeclarationMeaningTypical choice
constA compile-time valueA protocol width, mask, or fixed table.
letA runtime local binding that cannot be reassignedA sampled value or intermediate result that stays fixed during this execution.
varA mutable runtime localA loop counter or accumulator that is updated.
livt
let header: logic[16] = 0xA53C
var version = header[12:16]
var flags = header[8:12]
let payloadLength = header[0:8] as uint

Here the header width is explicit. The slice bounds establish four-bit results for version and flags, while the cast gives payloadLength an unsigned numeric interpretation. Hover over a local in Visual Studio Code to inspect the inferred type and hardware width. The editor may display the core spelling Logic[4]; application code can use logic[4].

VS Code hover showing the inferred Logic[4] type and four-bit hardware width of the version slice.
Inspect the inferred type when width or signedness matters.

Choose literals that fit

Decimal, hexadecimal, and binary notation make different aspects of a value readable. Use decimal for counts, hexadecimal for byte boundaries, and binary for individual flags. Underscores can separate groups in binary literals. A literal must have a supported conversion to its destination and fit that destination’s range.

livt
let count: uint = 60
let marker: byte = 0xFF
let mask: logic[8] = 0b1111_0000
let nibble: logic[4] = 0xF

For example, var value: byte = 256 is rejected because 256 does not fit in eight bits. A negative literal does not fit uint, and a two-bit literal does not fit scalar logic. Logic-state literals such as 0bX belong to logic types and cannot initialise numeric types. An explicit runtime narrowing cast has different behaviour, explained below.

Understand what let protects

let requires an initializer and prevents whole-binding assignment, compound updates, and increment or decrement. The initializer executes when control reaches the declaration; it can include a scheduled call, so let is not a compile-time constant. It does not make the contents of an array or component deeply immutable.

livt
var payload: byte[3] = [0x10, 0x20, 0x30]
let snapshot = payload
snapshot[0] = 0xAA
assert payload[0] == 0x10
assert snapshot[0] == 0xAA

A primitive-array local captures an array value. Updating the snapshot does not update the original. By contrast, a local bound to an existing component refers to that same hardware instance, so calling its functions can change shared component state. These value/reference rules apply to both let and var. A let adds a write restriction; it does not promise smaller hardware or lower latency.

A whole let binding cannot be passed where a call may write back to it, including out, inout, or a directionless primitive-array parameter. Use in for read-only access or a mutable local when updates are intended. Indexed elements remain writable. In foreach (let item in values), each iteration supplies a new binding; a loop counter updated with index++ needs var.

Write expressions

An expression combines values to calculate a result. Choose operators that match the meaning of the operands: comparisons produce conditions, logical operators combine conditions, and bitwise operators manipulate individual bits. Arithmetic requires a defined numeric domain rather than an assumption that the destination will choose one.

OperatorsPurpose
+ - * / %Arithmetic on compatible numeric operands.
== !=Equality and inequality; the result is boolean.
< <= > >=Ordering comparisons for supported types.
! && ||Logical negation, AND, and OR.
& | ^ ~Bitwise AND, OR, XOR, and inversion.
<< >>Bit shifts.
= += -= *= /= %=Assignment and arithmetic updates.
++ --Increment and decrement.
livt
let length: int = 60
let validLength = (length > 0) && (length <= 255)
let reject = !validLength

let flags: byte = 0xA5
let lowBits = flags & 0x0F
let highBits = flags & 0xF0
let inverted: byte = ~flags

&& and || short-circuit: the right-hand expression is evaluated only when needed. Bitwise & and | operate on bits instead. Use a comparison such as valid == 0b1 to express a signal-level condition explicitly. ! negates a boolean or scalar logic value; ~ flips the bits of a supported numeric or vector value and is not boolean negation.

Shifting a logic vector preserves its width, fills vacated positions with zeros, and discards bits shifted beyond the vector boundary. If signed numeric behaviour is required, make that interpretation explicit before the operation. Compound assignments update the destination; for a byte target, arithmetic compound updates retain only the low eight bits.

Use parentheses around casts and when mixing masks, arithmetic, and comparisons. (flags & 0x0F) == 0x05 makes the intended comparison visible without asking a reader to recall operator precedence. The same discipline helps when a slice feeds a conversion or arithmetic expression.

Control width and conversions

Some assignments accept a value without an explicit cast, such as a byte assigned to an integer or an eight-bit logic vector. That does not establish a general promotion rule for expressions. Select the numeric type of arithmetic operands explicitly, especially when combining bytes with integers. A standalone byte addition does not choose its arithmetic domain from the eventual return or assignment type.

livt
let left: byte = 200
let right: byte = 100
let total = (left as int) + (right as int)
assert total == 300

var wrapped: byte = left
wrapped += right
assert wrapped == 0x2C

The first expression retains the full result in signed 32-bit arithmetic. The compound update stores into a byte, so 300 becomes 44. Cast before an operation when you need a wider intermediate result; widening a result after bits have already been discarded cannot recover them.

Make narrowing deliberate

livt
let value: int = 300
let lowByte = value as byte
assert lowByte == 0x2C

let nibble: logic[4] = 0b1010
let extended = nibble as logic[8]
assert extended == 0x0A

A narrowing numeric cast retains the low bits without checking that the original value fits. Widening a logic vector adds zeros on the high side. Check the range before narrowing when losing information would be an error. The Livt core library includes conversion helpers with explicit clamped, wrapping, and truncating behaviour, and Livt.Bits.SignExtend for deliberate sign extension.

Integer 300 narrowed to eight bits retains 00101100, decimal 44; a four-bit vector 1010 widens to 00001010.
Narrowing retains the low bits; vector widening adds zeros. Neither operation validates an application-level range.

Choose signed or unsigned interpretation

livt
let bits: logic[8] = 0xFF
assert (bits as int) == -1
assert (bits as uint) == (255 as uint)

let data: byte = 0xFF
assert (data as int) == 255

Converting a logic vector to int interprets its highest bit as the sign bit. Converting it to uint uses an unsigned interpretation. A byte is already unsigned, so converting 0xFF from byte to int gives 255. Make the choice at the boundary where raw bits become a number.

Boolean conversions also change meaning and require as: numeric zero becomes false and a nonzero numeric value becomes true; true converts to one and false to zero. Use numeric conversions on defined zero/one logic patterns rather than treating simulation states such as unknown or high impedance as numbers.

Conversion reference

SourceCommon supported targets
boolint, uint, byte, logic, logic[N]
int or uintThe other integer type, bool, byte, logic, logic[N]
bytebool, int, uint, logic[N]
logicbool, int, uint, byte, logic[N]
logic[N]bool, int, uint, byte, logic, logic[M]

Casting a byte directly to scalar logic is not supported. To select a particular bit, first convert to a vector, then index that vector: let bits = value as logic[8], followed by bits[0]. A scalar byte itself cannot be indexed. Identity casts are valid but usually unnecessary; strings and named types have separate rules described below.

Work with arrays and bit vectors

An array has a statically resolved shape. byte[3] is three byte elements, whereas logic[16] is one sixteen-bit vector. A multidimensional declaration such as logic[4, 2] describes four two-bit vectors. Use a binary or hex literal for a vector and bracket-list literals for arrays of elements.

livt
var payload: byte[3] = [0x10, 0x20, 0x30]
var patterns: logic[4, 2] = [0b00, 0b01, 0b10, 0b11]
var matrix: byte[2, 3] = [
    [0x01, 0x02, 0x03],
    [0x04, 0x05, 0x06]
]
payload[0] = 0x55
matrix[1, 2] = 0xFF

Indices start at zero. An initializer can establish an omitted extent, as in var values: int[] = [0, 14, 34]. An unconstrained field can also receive its concrete shape through statically known construction. The final hardware still needs a positive extent: zero-length arrays, empty array values, and unsized storage without a resolvable extent are not supported. Represent an empty logical collection with a separate length or valid flag rather than zero physical storage.

Extract fields with slices

A slice uses value[start:stop], including start and excluding stop. The width is the difference between the bounds. For the packet header below, bit zero is the least-significant bit; selecting [12:16] extracts the four version bits.

The 16-bit header A53C has version A in bits 15–12, flags 5 in bits 11–8, and length 3C in bits 7–0.
The slice bounds refer to bit indices, even though the diagram displays the most-significant bit on the left.
livt
let header: logic[16] = 0xA53C
var version = header[12:16]
var flags = header[8:12]
let length = header[0:8] as uint
assert version == 0b1010
assert flags == 0b0101
assert length == (60 as uint)

Literal bounds give the compiler a concrete result width. For variable positions, keep the intended width fixed and explicit, such as logic[4] for a four-bit window, and ensure the chosen bounds stay within the source. A type annotation does not turn hardware storage into a dynamically growing value or make arbitrary bounds safe. Slices can also select a writable subrange of a mutable vector.

An omitted start means zero, and an omitted stop means the source extent. A negative integer-literal index counts from that extent: for this 16-bit header, header[-4:] is the same as header[12:16], and header[-1] selects bit 15. Step/reverse forms such as [::2] and [::-1], and negative variable bounds such as [-offset:], are not supported. Empty or reversed constant ranges are invalid. Scalars such as byte, int, and logic cannot be sliced.

Concat combines one-dimensional logic vectors. The receiver occupies the low bits and the argument supplies the high bits. In our example, the following expression therefore reconstructs the upper byte of the header:

livt
var flags: logic[4] = 0b0101
var version: logic[4] = 0b1010
var combined = flags.Concat(version)
assert combined == 0xA5

When both widths are known, the result width is their sum. The mutable locals also satisfy the method’s array-parameter contract. For reusable width parameters, see Execution models. Array shape alone does not establish whether storage becomes registers or a particular RAM resource; see Hardware integration for checking memory placement.

Pass arrays to functions

An array parameter’s direction tells callers whether the function reads existing contents and whether updates are copied back. Use an explicit direction on public APIs so those effects are visible.

DirectionReads supplied contentsMay writeCaller receives updates
OmittedYesYesYes
inYesNoNo
outNot requiredYesYes
inoutYesYesYes
livt
component PacketFields
{
    public fn ReplaceFirst(values: inout byte[], value: byte)
    {
        values[0] = value
    }

    public fn First(values: in byte[]) byte
    {
        return values[0]
    }
}

After ReplaceFirst completes, the caller observes the updated first element. First reads without changing the array and can accept an immutable binding. A directionless primitive-array parameter is mutable too; omitting the direction does not make it read-only.

An unconstrained parameter such as byte[] can receive different statically known lengths. Length() reports the supplied extent; unconstrained return types obtain their extent from the returned value. For multiple dimensions, query values.Length() for the first dimension and values[0].Length() for the next. These forms do not allocate new unsized storage.

Give values a meaning

A type can express what a value means as well as how it is represented. Enums give names to a fixed set of choices, such as the states of a packet processor. Distinct types separate values that share a representation but serve different purposes, such as a payload length and an address. Both make APIs easier to understand and help the compiler detect unintended combinations.

Use enums for named choices

livt
namespace Examples.Packets

enum PacketState
{
    Idle
    Ready
    Done
}

Use an enum when a value represents one of a finite set of choices. Refer to members through the type, such as PacketState.Ready, and compare values of the same enum with == or !=. Members are immutable values, not functions or components to construct. Declare at least one member and give each member a unique name.

Enums can appear in fields, locals, constants, arrays, parameters, and return values. Where a default is supplied, it is the first declared member; explicitly initialise important state. Enums do not support numeric arithmetic, ordering, or numeric encoding through as. The VHDL backend emits an enumeration, and synthesis chooses its representation. Declaration order is not a promised wire encoding; use explicit bit values for a protocol contract.

Use distinct types for different meanings

A payload length and an address might both use unsigned integers, but they should not be interchangeable in an API. A namespace-level distinct type preserves that difference while retaining an underlying representation.

livt
namespace Examples.Packets

type PayloadLength : UnsignedInteger {}
type Address : UnsignedInteger {}

The colon names the representation, not a parent class. The supported representations are Integer, UnsignedInteger, Byte, Boolean, and Logic. Declare an empty body. A distinct type is neither an alias nor a component, and new PayloadLength(...) is not a construction form.

livt
let length = (60 as uint) as PayloadLength
let raw = length as uint
let copy: PayloadLength = length
assert copy == length

Wrapping and unwrapping require as and the exact representation type. Convert numerically first, then wrap. To change from one distinct meaning to another, explicitly unwrap and rewrap. Values of the same distinct type can be assigned and checked for equality; arithmetic, ordering, and logical operations require unwrapping. The declaration adds no range check, and its default follows the representation’s zero value.

Distinct values can be fields, constants, parameters, return values, and generic type arguments. Arrays retain their element identity and can be explicitly converted to or from representation arrays of the same shape. Such a conversion does not resize the array or numerically convert its elements. The VHDL backend uses the representation without an extra type tag or wrapper storage, while ordinary function calls retain their usual scheduling.

Use text and encoded data

Use strings for simulation reports, fixed messages, and text that will become encoded data. When hardware must transmit text, encode it into bytes so its storage and interface are explicit. For example, this component holds a fixed greeting:

livt
component TextConstants
{
    const GREETING: byte[] = "Hello".Encode()

    public fn GetLength() int
    {
        return GREETING.Length()
    }
}

Triple-quoted strings keep multiline content readable. Livt removes common indentation introduced by the surrounding source layout and preserves relative indentation within the content. They are useful for templates and fixed data; ordinary strings remain clearer for short messages.

livt
const MESSAGE: byte[] = """
    Packet ready
        Waiting for transfer
    """.Encode()

Text used in Simulation.Report supports observation during simulation. It does not by itself define a synthesizable text-processing system. The Livt core library provides encoding, formatting, and conversion helpers.

Check your assumptions

Use editor diagnostics to catch incompatible types and out-of-range literals before simulation. The following screenshot shows a byte initialised with 256. Select the diagnostic in Problems to open its source and correct either the value or the chosen type.

VS Code showing the invalid byte literal 256 and the diagnostic that it cannot be converted to Byte.
A literal that does not fit is rejected. This is different from deliberately narrowing a runtime value with a cast.

Then verify the cases where code is valid but the result could be surprising. The complete TypesDemo project checks header slices and concatenation, signed and unsigned conversion, truncation and byte updates, array copying and parameter updates, and distinct types and enums. Extract it, open its folder in Visual Studio Code, and run PacketFieldsTest from Livt Tests or Test Explorer. All four tests should pass.

Four PacketFieldsTest cases passing in VS Code beside the verified packet-header expressions and test output.
Small assertions make width, value, and shape assumptions explicit and repeatable.

Type choices also affect the hardware cost of an expression. Wider arithmetic can require more resources, and division or multiplication can affect timing. A passing functional test establishes the checked values, not routed timing or a particular physical memory mapping. Continue with Control Flow to use these values in decisions and sequences, and Testing to extend the verification.