Language reference

Data Types & Operators

Primitive types, fixed-size arrays, literals, casts, and operators: the vocabulary for representing values in Livt.

Livt types describe values that will eventually become hardware: single signals, vectors, counters, byte streams, fixed tables, state variables, and test data. The type system is intentionally familiar, but every width and array dimension has hardware consequences.

This chapter introduces the core primitive types, fixed-size arrays, literals, casts, and operators. The goal is not to memorize every rule at once. The goal is to learn which type communicates the intent of a value most clearly.

Primitive Types

Livt provides keyword spellings for the primitive types used most often:

Keyword Meaning Typical use
bool Two-valued condition: true or false Decisions, function results, assertions
logic Hardware logic value Signals, ports, bit vectors
byte Unsigned 8-bit value Protocol data, buffers, encoded text
int Signed 32-bit integer Counters, loop variables, arithmetic
uint Unsigned 32-bit integer Non-negative counts and sizes
string Text value Simulation, constants, encoded byte data
clock Clock signal Sequential process context
reset Reset signal Sequential process context

Prefer the keyword spelling in examples and application code. It is shorter and keeps the source focused on intent.

bool

Use bool for decisions:

livt
var enabled: bool = true
var done: bool = false

if (enabled)
{
    done = true
}

Comparisons produce bool:

livt
var value: int = 7
var small: bool = value < 10

Keep bool separate from logic. A bool answers a language-level question: should this branch run, did this function succeed, did this assertion pass? A logic value represents a hardware signal and can have HDL-style values.

logic

Use logic for hardware signals:

livt
var bit: logic = 0b1
var unknown: logic = 0bX

Common logic literals include:

livt
0b0
0b1
0bU
0bX
0bZ
0bW
0bL
0bH
0b-
0b1010_1100

Binary logic literals use the same signal states as VHDL std_logic: U uninitialized, X unknown, 0, 1, Z high impedance, W weak unknown, L weak zero, H weak one, and - don't care. Underscores are allowed as separators in longer literals.

logic[N] is an N-bit vector:

livt
var nibble: logic[4] = 0b1010
var word: logic[32] = 0x0000002A

logic and logic[1] are intentionally different. logic is one scalar hardware bit. logic[1] is a one-bit vector. Use the scalar form for single ports and flags, and use the vector form when a value should remain part of a uniform vector API. logic[0] is invalid because it would describe a zero-bit hardware value.

Use logic when a value is part of the hardware signal model. Use bool when a value is a condition in Livt control flow.

byte

byte is an unsigned 8-bit value with range 0 through 255:

livt
var zero: byte = 0x00
var letterA: byte = 0x41
var max: byte = 0xFF

Bytes are natural for packet data, UART payloads, memory contents, encoded text, and protocol fields. Hex literals are common because they make byte boundaries obvious.

When you need signed arithmetic, use int. A byte should usually mean raw unsigned data.

int and uint

Use int for signed integer arithmetic and loop counters:

livt
var offset: int = -4
var index: int = 0

Use uint when negative values do not make sense:

livt
var length: uint = 1500

Both types are fixed-width Livt hardware types. int is signed 32-bit and uint is unsigned 32-bit. Their behavior does not depend on whether a VHDL simulator happens to implement integer internally as 32 or 64 bits.

In hardware-oriented code, prefer named constants for important limits and widths instead of repeating numeric literals.

clock and reset

clock and reset identify timing signals used by sequential processes:

livt
component TimedCounter
{
    public count: int

    new(clk: clock, rst: reset)
    {
        this.context.clk = clk
        this.context.rst = rst
    }

    process Count()
    {
        this.count = this.count + 1
    }
}

Combinational functions and clockless processes do not need a clock or reset. Sequential processes do.

string

Strings are text values. They are especially useful in tests, simulation reports, and fixed text that should become byte data:

livt
Simulation.Report("starting test")

A string can be encoded into bytes:

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

    fn GetGreetingLength() int
    {
        return GREETING.Length()
    }
}

Treat strings carefully in synthesizable code. Text is most often used at compile time, in constants, or in simulation-only APIs. When hardware needs to transmit text, encode it into byte data.

Fixed-Size Arrays

Hardware resources are statically allocated, so arrays usually have fixed sizes. The size is part of the type:

livt
var payload: byte[64]
var table: int[16]
var matrix: byte[2, 3]

Array literals initialize fixed-size arrays:

livt
var header: byte[4] = [0xDE, 0xAD, 0xBE, 0xEF]
var offsets: int[3] = [0, 14, 34]

The initializer may also supply the size when the declaration uses an unconstrained array type:

livt
var offsets: int[] = [0, 14, 34] // inferred as int[3]

Fields in classes, components, and interfaces may also use an unconstrained declaration. An inline initializer or a statically known assignment supplies the concrete extent before hardware is generated:

livt
component Lookup
{
    values: int[]

    new()
    {
        this.values = [0, 14, 34] // values is realized as int[3]
    }
}

This is especially useful for interface fields, where each implementing component supplies the actual extent. An unconstrained declaration is not a zero-length array. A concrete component must supply a statically resolvable positive extent before hardware is generated.

Every array value must resolve to a positive hardware extent. Zero-length arrays and empty array literals are not supported:

livt
var missingSize: int[] // invalid: storage has no extent
var zeroSize: int[0]   // invalid: array dimensions must be greater than zero
var empty: int[] = []  // invalid: empty array values are not supported

Use at least one physical element when an algorithm needs to represent an empty logical collection, and track its logical length separately.

Multi-line literals are also supported and are useful for longer tables:

livt
var mac: byte[6] = [
    0x02, 0xAA, 0xBB,
    0xCC, 0xDD, 0xEE
]

Multi-dimensional arrays use nested literals:

livt
var matrix: byte[2, 3] = [
    [0x01, 0x02, 0x03],
    [0x04, 0x05, 0x06]
]

Indexing is zero-based:

livt
var first = header[0]
header[1] = 0xAA
matrix[1, 2] = 0xFF

Array Parameters

Functions can receive fixed-size or unconstrained arrays. A direction may be omitted or written explicitly to describe how the function uses the array:

Direction Initial contents Function may Caller observes updates
omitted supplied read + write yes
in supplied read only no
out not required write only yes
inout supplied read + write yes

A directionless array parameter is mutable. Indexed writes are visible to the caller after the function completes:

livt
public static inline fn SetByte(values: byte[], index: int, value: byte)
{
    values[index] = value
}

Use in for a read-only array and out when a function produces values without depending on the previous contents. Use inout when the function transforms an existing array. Sorting is a typical example:

livt
public fn BubbleSort(data: inout int[])
{
    for (var pass: int = 0; pass < data.Length() - 1; pass++)
    {
        for (var i: int = 0; i < data.Length() - pass - 1; i++)
        {
            if (data[i] > data[i + 1])
            {
                var temp: int = data[i]
                data[i] = data[i + 1]
                data[i + 1] = temp
            }
        }
    }
}

The directionless form data: int[] is also mutable. Explicit inout is often the clearer choice for a public API because it states that the function reads and updates the supplied array.

An unconstrained parameter such as int[] can receive arrays of different lengths. Length() returns the actual length supplied for the current call. Unconstrained array types are also valid as function return types. In both positions, the concrete extent comes from the value passed or returned rather than declaring new unsized storage.

For a multidimensional array, partially index the array before querying the next dimension:

livt
var rows = values.Length()
var columns = values[0].Length()
var depth = values[0, 0].Length()

Logic Vectors and Array Dimensions

logic[N] is a vector of N bits treated as a single value. Initialize it with a binary or hex literal:

livt
var flags: logic[4] = 0b1010

Use bracket-list literals when the type is an array of elements:

livt
var bytes: byte[3] = [0x10, 0x20, 0x30]
var rows: logic[4, 2] = [0b00, 0b01, 0b10, 0b11]

The distinction matters for how values are laid out in hardware. If you mean one bit vector, use logic[N]. If you mean several separate elements, use an array type such as byte[N] or logic[A, B].

Concat combines one-dimensional logic vectors. When both operand widths are known, the result width is the sum of both widths:

livt
var a: logic[8] = 0xAA
var b: logic[8] = 0x55
var c = a.Concat(b)      // logic[16]

Unconstrained logic[] is also convenient when the initializer gives the compiler a concrete width:

livt
var a: logic[] = 0xAA    // inferred as logic[8]
var b: logic[] = 0x55    // inferred as logic[8]
var c = a.Concat(b)     // inferred as logic[16]

If the compiler cannot prove both operand widths, add explicit widths.

Slicing Logic Vectors and Byte Arrays

You can extract a sub-range of a logic[N] or byte[N] array using Python-style slice syntax:

livt
var nibble: logic[4] = word[4:8]     // bits 7..4 — upper nibble of an 8-bit word
var hi: logic[16] = frame[16:32]     // bits 31..16
var tail: byte[3] = payload[3:6]     // bytes at index 3, 4, 5

The form is a[start:stop] where start is inclusive and stop is exclusive, identical to Python list slicing. The result width is stop - start.

When both bounds are integer literals, the result type is inferred automatically:

livt
var version: logic[4] = ipHeader[4:8]    // logic[4] inferred — no annotation needed

When the position is a variable but the width is a constant, supply an explicit type annotation:

livt
var chunk: logic[4] = word[offset:offset+4]    // logic[4] explicit

If the width itself cannot be determined statically, an explicit annotation is always required:

livt
var part: logic[4] = word[lo:hi]    // explicit annotation required

Slicing also works on the left-hand side of an assignment to update a sub-range of a field in place:

livt
this.state[16:32] = newHi          // replace bits 31..16
this.buf[1:4] = threeBytes         // replace byte elements 1, 2, 3

Slicing is allowed on any array type (logic[N], byte[N]) but not on scalars (logic, byte, int, bool). The compiler reports an error if you attempt to slice a scalar. Inverted bounds (stop <= start) are also a compile-time error.

Omitted Bounds

Either bound can be omitted. The compiler fills in 0 for a missing start and N for a missing stop:

livt
var low: logic[4] = word[:4]     // same as word[0:4] — bits 3..0
var high: logic[4] = word[4:]    // same as word[4:8] — bits 7..4
var all: logic[8] = word[:]      // same as word[0:8] — full copy

Omitted bounds are resolved at compile time — no runtime cost.

Negative Indices

Negative indices count from the end of the array. -k resolves to N - k at compile time:

livt
var last: logic = word[-1]       // same as word[N-1] — last bit
var tail: logic[4] = word[-4:]   // same as word[N-4:N] — last 4 bits
var head: logic[6] = word[:-2]   // same as word[0:N-2] — all but last 2
var mid: logic[4] = word[-6:-2]  // same as word[N-6:N-2] — middle 4
var mix: logic[4] = word[2:-2]   // same as word[2:N-2] — skip first/last 2

Negative indices work on both sides of a slice and with single-element access. They can be combined with omitted bounds and positive bounds freely.

Unsupported Forms

The following forms are not currently supported:

  • Step or reverse: word[::2], word[::-1]
  • Negative variable bounds: word[-offset:] (only negative integer literals are resolved)

Type Inference

Livt can infer many local variable types from their initializer:

livt
var value = 42
var ok = true
var marker = 0xFF

Use an explicit type when the width, signedness, or hardware shape matters:

livt
var marker: byte = 0xFF
var flags: logic[8] = 0b00001111

For public fields, function parameters, constants, and interfaces, prefer explicit types. They are part of the component contract.

Literal Convertibility

Literals are target-typed. A literal is accepted when it has a supported conversion path and can be represented by the target type:

livt
var b: byte = 255
var bit: logic = 0b1
var vector: logic[4] = 0xF
var i: int = -2147483648
var u: uint = 0xffffffff

The compiler rejects literals that do not fit or do not make sense for the target:

livt
var b: byte = 256          // invalid: byte is 8-bit
var bit: logic = 0b10      // invalid: scalar logic is one bit
var vector: logic[4] = 0x10 // invalid: needs 5 bits
var u: uint = -1           // invalid: uint cannot be negative

State-bearing logic literals such as 0bU, 0bX, 0bZ, 0bW, 0bL, 0bH, and 0b- are valid for logic and logic[N] targets. They are not numeric values and cannot be assigned to int, uint, or byte.

Implicit Conversions

Some conversions happen automatically when the target type is wider than the source. These are called widening conversions:

  • A byte value is accepted where int or uint is expected.
  • A byte value is accepted where logic[8] is expected.
  • A hex or binary literal like 0x0A is accepted where byte is expected.
livt
var n: int = 0xFF        // byte literal accepted as int
var b: byte = 0x41      // hex literal accepted as byte without as

Conversions that may lose information — narrowing a int to byte, or reinterpreting a logic[N] value — always require an explicit as cast. The compiler will report an error if you try a lossy conversion without one.

Casts

The as operator converts a value from one type to another. Use it when the conversion is intentional and the type change is part of the design:

livt
var b: byte = 0xC8
var n: int = b as int    // 200

What Can Be Cast

The core cast matrix covers bool, int, uint, byte, logic, and logic[N]. Identity casts are valid but usually unnecessary.

From Supported targets Result
bool int, uint, byte, logic, logic[N] true becomes one; false becomes zero
int bool, uint, byte, logic, logic[N] Nonzero is true; numeric and logic targets preserve or retain the low bits
uint bool, int, byte, logic, logic[N] Nonzero is true; numeric and logic targets preserve or retain the low bits
byte bool, int, uint, logic[N] Nonzero is true; numeric targets widen; vectors resize to N bits
logic bool, int, uint, byte, logic[N] Zero or one is represented in the target type
logic[N] bool, int, uint, byte, logic, logic[M] Nonzero is true; numeric interpretation is explicit; vectors resize to M bits

byte as logic is intentionally unsupported because silently selecting one bit from a byte is easy to overlook. Select a bit explicitly, such as value[0], when that is the intended operation.

Fixed-width vector casts resize the value. A wider target is zero-extended; a narrower target retains the low bits:

livt
var lowNibble: logic[4] = data as logic[4]
var extended: logic[12] = lowNibble as logic[12]

Use Livt.Bits.SignExtend when widening a vector should copy its sign bit instead of adding zeros.

String encoding and formatting use the string and conversion helpers described in the base-library chapter rather than the numeric matrix above.

Value Range and Data Loss

When the source value does not fit in the target type, high bits are silently dropped. There is no overflow check:

livt
var n: int = 300
var b: byte = n as byte    // 44 — 300 = 0x12C, low 8 bits = 0x2C = 44

To avoid unexpected truncation, check range before casting or use the Livt.Convert helpers, which offer explicit clamped, wrapping, and truncating variants.

logic[N] to int

logic[N] as int uses signed interpretation. The highest bit of the vector is the sign bit:

livt
var v: logic[8] = 0xFF
var n: int = v as int    // -1

Use as uint when the same vector should be interpreted as an unsigned value:

livt
var u: uint = v as uint   // 255

Boolean Conversions

Conversions between bool and numeric or logic values are semantic conversions, so they require as:

livt
var enabled: bool = count as bool   // false for zero, true otherwise
var bit: logic = enabled as logic   // 0b0 or 0b1
var value: int = enabled as int     // 0 or 1

Without the explicit cast, Livt reports a type error. This keeps a change in meaning visible at the call site.

Mixed-Type Arithmetic

Arithmetic operators (+, -, *, /, %) require both operands to be the same type. There is no implicit promotion. Writing sum + item where sum is int and item is byte is a type error:

livt
// WRONG — type mismatch: int + byte
sum = sum + item

// CORRECT — explicit cast makes the intent clear
sum = sum + (item as int)

This is intentional. Livt targets hardware, where every bit matters and widening has a concrete cost. An implicit promotion rule would have to silently pick a signedness — unsigned for byte, but what for logic[N]? Making the cast explicit keeps the semantics visible and keeps the rule uniform:

  • Use as int to widen a byte or logic[N] for arithmetic with an int.
  • Use as uint to widen for arithmetic with a uint.
  • Use as byte when narrowing back to store a low-8-bit result.

Note that the rule applies at the arithmetic operator, not just at assignment. Even if the result will eventually be stored in an int field, each sub-expression must already have a defined arithmetic domain.

byte does not define a standalone arithmetic domain for +, -, *, /, or %. Cast before arithmetic even when both operands are bytes:

livt
// WRONG - the result domain is not selected by the return type
return left + right

// Signed arithmetic
return (left as int) + (right as int)

// Unsigned arithmetic
return (left as uint) + (right as uint)

Byte comparison, masking, and shifts remain byte operations. Compound updates such as value += amount are also valid: the byte target supplies the result width, and the stored value retains the low eight bits.

Choosing Between byte and logic[N]

byte and logic[8] represent the same 8-bit quantity in hardware, but they communicate different intent:

  • Use byte when the value is data: a character, a protocol field, a checksum, a memory word. It reads as unsigned 0–255 when cast to int.
  • Use logic[N] when the value is a signal or a register: a flag vector, a control word, a port value with a specific hardware width. Prefer uint or byte as the intermediate when converting to int.

Parentheses Around Casts

Parentheses make cast expressions unambiguous, especially when combined with slicing or arithmetic:

livt
var word: logic[32] = this.GetWord()
var high: byte = (word[24:32]) as byte
var value: int = high as int

Without parentheses, operator precedence can produce surprising results. When in doubt, add them.

Arithmetic Operators

Arithmetic operators work on numeric values:

livt
var a: int = 10
var b: int = 3

var sum = a + b
var difference = a - b
var product = a * b
var quotient = a / b
var remainder = a % b

In hardware, arithmetic is not free. Multiplication, division, and wide additions can affect resource use and timing. Use them when they express the design, but remember that the compiler must lower them into hardware.

Arithmetic is for numeric scalar values. Arrays and logic vectors should be indexed, sliced, concatenated, masked, or explicitly cast before arithmetic is applied.

Comparison and Equality

Comparison operators return bool:

livt
var isDigit = value >= 0x30 && value <= 0x39
var isEmpty = count == 0
var changed = previous != current

Use comparisons to turn signal or numeric values into control-flow conditions. When checking logic, compare it explicitly:

livt
if (this.valid == 0b1)
{
    this.Accept()
}

Logical Operators

Logical operators work on bool values:

livt
var validLength: bool = length > 0
var validChecksum: bool = checksum == 0
var accept: bool = validLength && validChecksum
var reject: bool = !accept

! inverts a single bool or logic (single-bit) value. && and || short-circuit. The right-hand side is evaluated only when needed.

livt
if (index < length && payload[index] == 0x00)
{
    return true
}

Bitwise Operators

Bitwise operators work on integer, byte, and logic-style values:

livt
var flags: byte = 0xF0
var lowNibble = flags & 0x0F
var highNibble = flags & 0xF0

Common bitwise operators are:

Operator Meaning
& bitwise AND
` ` bitwise OR
^ bitwise XOR
~ bitwise NOT (flips all bits)
<< shift left
>> shift right

~ inverts every bit of a byte or logic[N] value:

livt
var mask: byte = 0xF0
var inverted: byte = ~mask     // result: 0x0F

Note that ~ operates on byte and logic[N], not on bool. To invert a single boolean use ! instead.

Shifts are valid for logic[N] values and preserve the vector width. Vacated bits are filled with zero, and bits shifted beyond the vector width are discarded:

livt
var shifted: logic[8] = flags << 2

Cast to int or uint first when signed or unsigned numeric interpretation is part of the operation.

Use bitwise operators for masks, flags, protocol fields, and compact status values.

Assignment Operators

Assignment updates a variable, field, or output parameter:

livt
count = count + 1
this.acceptedCount = this.acceptedCount + 1

Compound assignments are shorthand:

livt
count += 1
flags &= 0x0F

For a byte target, arithmetic compound assignments (+=, -=, *=, /=, and %=) store the result back at eight-bit width. Use an explicit as int or as uint cast when the full arithmetic result must be retained instead.

Increment and decrement are useful for counters and loops:

livt
index++
remaining--

Precedence and Parentheses

Operators have precedence rules, but readable Livt code should not require the reader to remember all of them. Use parentheses when expressions combine several kinds of operators:

livt
var inRange = (value >= 0x30) && (value <= 0x39)
var masked = (flags & 0x0F) == 0x05

Parentheses are especially helpful around casts, slices, bit masks, and combined conditions.

Hardware Meaning

Type choices affect generated hardware. Wider values generally need more storage and wider arithmetic. Fixed-size arrays describe statically sized collections, not dynamically growing containers. Casts can extend, reinterpret, or truncate values, so they are also design decisions about width and range.

Common Mistakes

  • Mixing byte, int, and logic[N] without making the result width explicit.
  • Assuming a cast preserves every value when the destination is narrower.
  • Forgetting that negative indices resolve against a fixed array boundary.
  • Using a literal that is not convertible to the destination type.

Summary

Choose types for intent:

  • bool for decisions.
  • logic for hardware signals and vectors.
  • byte for unsigned 8-bit data.
  • int and uint for arithmetic and counts.
  • string for simulation text and encoded byte constants.
  • clock and reset for sequential process context.
  • Fixed-size arrays for statically allocated collections.

Operators let you calculate, compare, mask, shift, and assign values. The syntax is familiar, but every expression still becomes hardware or simulation behavior. When in doubt, make width, signedness, and intent explicit.