Wave v0.2.1-pre-beta

Wave v0.2.1-pre-beta Release Notes

LunaStev

Wave v0.2.1-pre-beta Release Notes

Wave v0.2.1-pre-beta was released on October 5, 2026.

On the language side, we standardized variable declarations and module boundaries, and added payload variants and asynchronous execution. We improved the correctness of numeric conversions, constant expressions, pointers and indexing, strings, and I/O, while expanding the standard library across files, networking, time, memory, and binary data processing.

In the compiler, we reorganized the architecture so that types and conversion information resolved by the frontend are recorded in Typed HIR and consumed by LLVM. We expanded support for RISC-V 64, LoongArch64, FreeBSD, Windows MSVC, and WebAssembly, while strengthening C ABI handling and link-input validation. Diagnostics now preserve original source locations more accurately, and ASTs can be emitted as WSON, JSON, or S-expressions.

For development tooling, we improved the reliability of test selection and execution results, and moved CI and release procedures into shared Python tooling. The final workflows are organized by operating system, architecture, and role, with required validation and packaging performed in parallel before publication after all results have been verified. This release provides nine native compiler packages: four for Linux, two for macOS, two for Windows, and one for FreeBSD.

The sections below cover changes visible when writing Wave programs, changes to the compiler implementation, and changes to the development and release tooling that supports them.


1. Language and Standard Library

Variable declarations and the entry point

Variable declarations are now standardized on var. The previous let and let mut forms have been removed, and for initializers follow the same rule. Declarations throughout the standard library and examples have been updated as well.

var and static declarations must explicitly specify their types. Rather than determining a declaration's type solely from its initializer, the stored type is now directly visible in the source. Module-qualified and generic types can also be used.

fun main() {
    var language: str = "Wave";
    var count: i32 = 1;

    println("Hello from {} #{}", language, count);
}

main is now defined as a private entry point with no parameters. Parameters with default values are not allowed either, and main cannot be exported to other modules with pub fun main(). Default arguments for ordinary functions and methods remain supported.

Conditions and function control flow

Assignments, compound assignments, and increment/decrement expressions inside conditions are now diagnosed. This applies to if, else if, while, and for, including cases where such expressions are nested inside parentheses, function arguments, or indexing expressions.

For example, if (command[0] = 'h') modifies a value instead of comparing it. Accidentally writing such an expression now produces an error at the relevant location. Intentional mutations should be moved into a separate statement before the condition. Ordinary assignment statements and for increment expressions remain supported, and this rule does not require every function called from a condition to be pure.

The compiler also validates function return types against actual return values and checks for missing return paths in functions that must return a value. break and continue outside loops, invalid uses of void, and mismatched call arguments are diagnosed before code generation. Existing -> ! declarations now properly affect control flow to indicate that the function never returns.

Duplicate declaration checking now covers functions, globals, type aliases, parameters, local variables, struct fields, enum and variant cases, and methods. Duplicate declarations in the same scope are distinguished from valid shadowing in nested scopes. We also fixed cases where code generation incorrectly used the type or value of a variable after it had been shadowed.

fun increment(value: i32, step: i32 = 1) -> i32 {
    return value + step;
}

fun main() {
    var count: i32 = 0;

    while (count < 3) {
        count = increment(count);
        println("count: {}", count);
    }

    if (count == 3) {
        var count: i32 = 10;
        println("inner: {}", count);
    }

    println("outer: {}", count);
}

Modules and public APIs

Imports have been expanded from simple source inclusion into a module system with per-file namespaces. Package roots, submodules, relative paths, aliases, selective imports, and public re-exports are supported.

import("add");
import("add::math");
import("./helpers" as helpers);
import("add")::{sum, Point};

add refers to the package's src/lib.wave, while add::math refers to src/math.wave. ./helpers is resolved relative to the file containing the import. Aliases can assign a local name to a module, while selective imports bring only the required public symbols into scope.

Declarations intended for use from other modules must be marked pub. The compiler checks access to private declarations, namespace conflicts, import cycles, and paths that escape the package root. Standard-library imports are also resolved against real paths to ensure they cannot escape the configured std root.

Public APIs can be re-exported from another module with pub import(...)::{...};. Internal declarations with the same name in separate modules are also prevented from conflicting with one another.

pub controls visibility between Wave modules, while export(c) controls exposure through the external C ABI. These are separate concepts, so making a function public to Wave modules does not automatically export it as a C symbol.

// helpers.wave
pub fun sum(a: i32, b: i32) -> i32 {
    return a + b;
}
// main.wave
import("./helpers")::{sum};

fun main() {
    println("{}", sum(20, 22));
}

Generics, type aliases, and methods

Generic functions, structs, and methods now have stronger type-argument substitution and validation. Concrete types are preserved for fields and method receivers, and incorrect generic arity in imported code is reported at the relevant declaration or use site.

Generic struct literals now work through module name resolution and code generation. We fixed cases where comparison expressions were confused with generic argument syntax, and empty struct construction can also be used through modules.

Arrays, pointers, and structs accessed through type aliases now resolve aliases before validating indexing or field access. Struct values loaded from arrays and nested field expressions also retain the correct field type.

Method default arguments are now materialized correctly at call sites, and internally generated method symbols no longer conflict with ordinary user-defined functions. Generic methods and methods imported through modules preserve the proper receiver type and default arguments.

type Score = i32;

struct Box<T> {
    value: T;

    fun get(self: ptr<Box<T>>) -> T {
        return self.value;
    }
}

fun main() {
    var item: Box<Score> = Box<Score> { value: 42 };
    var result: Score = (&item).get();

    println("{}", result);
}
struct Counter {
    value: i32;

    fun add(self: ptr<Counter>, step: i32 = 1) -> i32 {
        self.value += step;
        return self.value;
    }
}

fun main() {
    var counter: Counter = Counter { value: 10 };
    var first: i32 = (&counter).add();
    var second: i32 = (&counter).add(5);

    println("{} {}", first, second);
}

Variants and pattern matching

Payload variants have been added, allowing each case to carry different data rather than representing only a state.

Generic variants, nested variants, recursive structures through pointers, imported variants, and variants referenced through type aliases are supported. Variants can appear inside structs and arrays and can also be used in constant and static initializers.

Pattern matching can destructure nested payloads and checks whether all required cases are covered. The compiler also verifies that variant layouts are finite, so recursive value structures can be represented through pointer indirection. Existing enums remain a separate feature.

Integer match handling has also been improved. Negative patterns are supported, pattern values are checked against the scrutinee's integer range, and duplicates are detected according to their actual numeric value. Different textual representations of the same value are therefore recognized as duplicates, while valid enum aliases remain distinct from duplicate branches. Control flow after matches whose every branch terminates has also been fixed.

A public C representation for payload variants has not yet been defined. They can therefore be used within Wave, but exposing them directly across the C ABI produces a diagnostic.

variant Option<T> {
    Some(T),
    None
}

fun value_or(value: Option<i32>, fallback: i32) -> i32 {
    match value {
        Option::Some(number) => {
            return number;
        }
        Option::None => {
            return fallback;
        }
    }
}

fun main() {
    var present: Option<i32> = Option::Some(42);
    var missing: Option<i32> = Option::None;

    println("{} {}", value_or(present, 0), value_or(missing, 0));
}

Async/Await and asynchronous tasks

Lazy asynchronous functions and await have been added. Calling an async function creates a Future, and the executor progresses it until execution must suspend, then resumes it from that point later. A normal fun main() can run asynchronous work through std::task::block_on.

The cooperative executor is integrated with operating-system readiness notifications. Asynchronous socket operations, IPv6 helpers, exact-length reads, and cancellation completion are supported, while Windows uses IOCP-based completion handling.

We also fixed resource-lifetime problems that could occur when reusing sockets after shutting down an executor, or when cancellation, shutdown, and socket closure occurred repeatedly. Executor lifetime and completion-port association are now separated so resources required by a live socket are not released prematurely.

std::task::sleep_ms returns Future<i32> so callers can observe the result of the wait.

Condition Return value
Successful wait 0
Zero-millisecond wait 0
Negative duration -22
Deadline calculation overflow -22

await and block_on expose the same result. Code written against development builds that explicitly used Future<void> must update its return type.

import("std::task")::{block_on, shutdown, sleep_ms};

async fun pause() -> i32 {
    return await sleep_ms(10);
}

fun main() {
    var status: i32 = block_on<i32>(pause());
    println("{}", status);
    shutdown();
}

Constant expressions and static initialization

Global const and static initializers now support arithmetic, comparisons, bitwise operations, shifts, logical operations, casts, and references to other constants. Existing literals, aggregate values, and variant initialization remain supported.

const N: i32 = 1 + 2;

fun main() {
    println("{}", N);
}

Expressions like this previously could pass semantic checking and fail later during code generation. Supported constant expressions are now evaluated during compilation, while unsupported function calls and memory accesses are diagnosed by wavec check.

Cycles between constants are detected across imported declarations, while non-cyclic forward references remain valid. Short-circuit semantics for && and || are respected so errors are not produced from operands that would never be evaluated.

Division and remainder by zero, as well as dividing the minimum signed integer by -1, are diagnosed. Addition, subtraction, and multiplication retain wrapping semantics according to the fixed integer width. Constant projections from struct and array fields, nested aggregates, and variant payloads have also been expanded.

Integer computation and conversions

Integer expressions containing literals now preserve the type context of their destination or peer operands. This fixes cases where expressions intended for a wide integer were first evaluated as i32 and lost information.

For example, 4294967296 - 32 in a u64 context evaluates to 4294967264. It is no longer first truncated into a narrow type and then widened from -32. Literals that do not fit the type determined by context are rejected, while context-free integer expressions continue to use the default i32 rule.

Sequences of casts also preserve their ordering. In i32 → u8 → i32, the truncation performed by the intermediate u8 conversion remains observable. Globals, static initializers, and runtime expressions use the same conversion rules, and unsigned widening no longer accidentally uses sign extension.

When integer division is followed by a floating-point conversion, integer division happens first. A floating-point destination does not retroactively change the original operation into floating-point division. Parenthesized expressions, nested casts, and conversions within struct or array initialization follow the same ordering.

isz and usz are resolved according to the pointer width of the compilation target. Numeric literals with explicit radix notation and numeric default arguments have also been corrected.

fun main() {
    var original: i32 = 300;
    var narrowed: u8 = original as u8;
    var widened: i32 = narrowed as i32;
    var limit: u64 = 4294967296 - 32;
    var integer_quotient: f64 = (7 / 2) as f64;

    println("narrowed then widened: {}", widened);
    println("u64 limit: {}", limit);
    println("integer division first: {}", integer_quotient);
}

Shifts and bool/floating-point conversions

Shift expressions preserve the type of their left operand. A wider shift-count type does not widen the value being shifted.

Item Behaviour
Left shift Bits shifted beyond the type width are discarded.
Signed right shift The sign bit is extended.
Unsigned right shift Vacated bits are filled with zero.
Shift count Must satisfy 0 ≤ count < bit width of the left operand.

The shift count must be an integer and is validated before narrowing. This prevents negative or excessively large values from becoming apparently valid after truncation. Invalid constants produce compile errors, while invalid runtime values trap.

Integer-to-bool conversion treats only zero as false. Floating-point conversion treats only +0.0 and -0.0 as false; all other values, including NaN and infinity, are true. Direct pointer-to-bool conversion is not supported.

Floating-point to integer conversion truncates toward zero and then checks whether the result fits the destination type. NaN, infinity, and out-of-range values are diagnosed for constants and trap at runtime.

Floating-point != behaviour with NaN has also been corrected, and unary floating-point negation now preserves signed zero. Constant and runtime conversion between different floating-point widths now follow the same semantics.

fun main() {
    var signed_value: i8 = -8;
    var unsigned_value: u8 = 248;
    var high_bit: u8 = 128;
    var count: i64 = 1;

    var arithmetic: i8 = signed_value >> count;
    var logical: u8 = unsigned_value >> count;
    var discarded: u8 = high_bit << count;

    println("{} {} {}", arithmetic, logical, discarded);
}
fun main() {
    var number: i32 = 2;
    var zero: f64 = -0.0;
    var real: f64 = -12.75;
    var enabled: bool = number as bool;
    var zero_flag: bool = zero as bool;
    var integer: i32 = real as i32;

    println("{} {} {}", enabled, zero_flag, integer);
}

Pointers, indexing, and value storage

Pointer depth and pointee types are now preserved. For example, taking the address of a str variable produces ptr<str> and is not implicitly treated as ptr<i8>. Explicit casts are required when converting between pointers whose actual pointee types differ.

Array and pointer indices can use function calls returning integers and arithmetic expressions. Computed indices such as values[next()] and values[(i * 2) - 1] now behave consistently for loads, stores, address taking, and input destinations.

Indices and destination addresses are no longer evaluated unnecessarily multiple times. Signed and unsigned indices are widened differently to the target pointer width. Negative pointer offsets within valid allocations and storing through pointers returned from function calls have also been fixed.

Array literals and addressed array literals preserve their destination element widths in assignments, function calls, and nested arrays. Assignments and input operations targeting non-modifiable values are rejected, as are invalid mutations or address-taking operations on constants. The lifetime and actual allocation bounds referenced by raw pointers remain the caller's responsibility.

static calls: i32 = 0;

fun next_index() -> i32 {
    calls += 1;
    return 1;
}

fun main() {
    var values: array<i32, 3> = [10, 20, 30];
    var selected: i32 = values[next_index()];
    var target: ptr<i32> = &values[1];
    deref target = 99;

    println("selected: {}", selected);
    println("index calls: {}", calls);
    println("stored: {}", values[1]);
}

String literals, line endings, and formatted output

String \xNN escapes now represent exactly one byte. Ordinary source characters remain UTF-8, while bytes specified by hexadecimal escapes are preserved directly rather than reinterpreted as Unicode characters. Embedded NUL bytes in string literals are rejected, and text-only contexts such as import paths and assembly strings apply the same validation as appropriate.

LF, CRLF, and standalone CR are treated as the same logical newline. The same behaviour applies to comments, target selection, and diagnostic source positions. Nested block comments following target attributes are handled correctly, and text that merely looks like a target attribute inside an ordinary comment is no longer interpreted as configuration.

Format strings are now interpreted by the same rules during validation and code generation. Ordinary percent characters and unmatched braces are preserved as literal text. Completed placeholders that are unsupported or incompatible with the argument type produce compile errors. Aggregates such as structs and arrays are also rejected when used with formatting without a defined aggregate formatting contract.

import("std::string::len")::{len};

fun main() {
    var name: str = "\x57ave";
    var raw: str = "가\xFF";
    var last: u8 = raw[3];

    println("{}", name);
    println("byte length: {}", len(raw));
    println("last byte: {}", last);
    println("progress: 100%");
    println("open brace: {");
}

Integer I/O up to 1024 bits

Decimal input/output and hexadecimal output now work correctly across the existing fixed-width integer types up to 1024 bits. Values such as u128 are no longer truncated through 64-bit paths, and signed/unsigned formatting is preserved.

Input is range-checked before being stored. Long numeric input and values with many leading zeroes are handled correctly, while invalid integers fail with exit status 1 before modifying the destination. Bool input accepts only numeric 0 and 1, and no longer risks overwriting adjacent storage.

Integer literals printed without type context still follow the default integer-type rules. Wide values must therefore be given an explicit type. This work improves I/O for fixed-width integers and does not introduce arbitrary-precision integers.

fun main() {
    var maximum: u128 = 340282366920938463463374607431768211455;
    var wider: u1024 = 340282366920938463463374607431768211456;

    println("{}", maximum);
    println("{x}", maximum);
    println("{}", wider);
}
fun main() {
    var value: u128 = 0;
    var enabled: bool = false;

    input("{}", value);
    input("{}", enabled);
    println("{} {}", value, enabled);
}

Standard-library structure and compatibility

The standard library has been reorganized into common APIs and operating-system/architecture-specific implementations. Higher-level facilities such as files and networking use std::sys, while syscall numbers and structure-layout differences are handled in lower-level platform implementations.

File, memory, process, networking, time, environment, and terminal support has been expanded across Linux, macOS, Windows, and FreeBSD. On Windows, common Win32 and Winsock implementations are shared between amd64 and ARM64, while standard input/output descriptors are mapped to actual Windows standard handles.

General C bindings remain under std::libc. Portable modules avoid depending directly on libc where possible, while a small number of OS features that cannot be replaced by stable direct system-call contracts continue to use approved C ABI bindings.

The std compatibility revision for this release is 5. Incompatible standard-library revisions are diagnosed before compilation, so the standard library shipped with the compiler should be used.

Filesystem and I/O

std::fs and std::io have been expanded across opening, creating, reading, writing, appending, copying, renaming, metadata, directories, and removal. Path-based operations are separated from operations on already-open descriptors, with both fixed-buffer and growable-buffer reading APIs.

fs_read_all reads into caller-owned byte storage. It does not allocate and return a new string, so callers can inspect both the returned byte count and error result.

Invalid operations now avoid modifying existing files before validation. fs_write_all and fs_append_all reject a positive length with a null source pointer before opening the file. This fixes cases where a call that would ultimately fail could first truncate an existing file or create a new one. Zero-length operations retain their existing semantics.

File copying compares the identity of opened files before modifying the destination. Different paths that refer to the same file through hard links or symbolic links are therefore prevented from truncating the source.

Bounded reads no longer trust seek-derived file sizes alone and continue checking until EOF. Growable reads check capacity growth for overflow and restore the previous buffer length on failure. Partial reads/writes and actual byte counts are also handled more accurately.

Windows paths are converted from UTF-8 to native wide APIs while preserving the original OS error and portable error classification. TRUNC now works without requiring CREAT, and append handles continue writing at EOF after seeking or duplication. Existing contents are preserved if the append handle cannot be prepared.

Windows fstat now uses real handle metadata and distinguishes unsupported handle types. F_OK checks existence, while R_OK and W_OK use non-destructive opens to verify access. Write access checks for read-only regular files have been strengthened; X_OK returns -95, and unknown mode bits return -22.

import("std::fs::file")::{read_into, write};

fun save_and_read(path: str) -> i64 {
    var written: i64 = write(path, "Wave" as ptr<u8>, 4);
    if (written < 0) {
        return written;
    }

    var bytes: array<u8, 4>;
    var count: i64 = read_into(path, &bytes[0], 4);
    if (count < 0) {
        return count;
    }

    println("written: {}, read: {}", written, count);
    return count;
}

Networking and event handling

std::net has been reorganized around addresses, socket ownership, errors, partial I/O, timeouts, and options. In addition to TCP, UDP, and IPv6, it now exposes APIs for connected UDP, broadcast, IPv4/IPv6 multicast, Unix-domain streams, network-interface enumeration, and scatter/gather I/O. Exact availability depends on the target operating system.

On Linux, address resolution handles numeric IPv4/IPv6 addresses and ports without libc. Caller-provided address tables can map hostnames and service names. Unregistered names return an unsupported result rather than silently implying that automatic DNS resolution exists.

Readiness handling uses epoll on Linux, kqueue on macOS and FreeBSD, and synchronous WSAPoll on Windows. Asynchronous Windows completion uses a separate IOCP path. Errors are classified using each operating system's errno or Winsock values while preserving the original native code.

Timeout-enabled I/O uses per-call nonblocking behaviour on Unix and cancellable overlapped operations on Windows. Shared socket modes are not changed, and we fixed cases where operations could remain blocked beyond their timeout or leave sockets unusable afterwards. Windows paths require sockets that support overlapped I/O and report only the number of bytes whose completion has been confirmed when cancellation occurs.

A zero-capacity stream receive succeeds without consuming data. It does not report EOF merely because no storage was provided, while datagram receives retain datagram-specific behaviour.

Single-descriptor POSIX waits validate negative or out-of-range descriptors before narrowing them. This remains distinct from normal poll-array semantics, where negative entries are inactive, and from full-width Windows socket handles.

Path-based AF_UNIX/SOCK_STREAM is supported on Windows 10 version 1803 and later. UTF-8 path lengths up to 107 bytes excluding NUL are validated before socket creation. Invalid input leaves output address state unchanged, and bind/listen/connect failures preserve the original error while cleaning up resources.

Socket closure and pathname removal are separate operations. Existing paths are not deleted automatically before bind, and paths should be removed explicitly with unix_remove. Abstract addresses are outside the scope of this API.

Windows event waits now correctly handle empty descriptor sets: zero timeout returns immediately, finite timeouts wait for the requested duration, and infinite waits remain infinite. Ready-event delivery rotates when the output buffer is smaller than the ready set, preventing the same leading events from starving later ones. macOS kqueue removal failures are no longer silently ignored.

import("std::net::tcp")::{TcpStream, tcp_read_timeout};
import("std::net::error")::{NetIoResult};

fun read_chunk(stream: TcpStream) {
    var data: array<u8, 256>;
    var result: NetIoResult = tcp_read_timeout(stream, &data[0], 256, 1000);
    if (result.error.kind != 0) {
        println("read error: {}", result.error.native_code);
        return;
    }

    println("received: {}, eof: {}", result.count, result.eof);
}

Memory, buffers, and type sizes

size_of<T>() and align_of<T>() have been added so programs can inspect a type's size and alignment for the compilation target. Type-aware memory copy, move, and initialization helpers have also been added.

Overflow is checked before memory access when sizes are converted to narrower widths or multiplied by element counts. Existing explicit byte-size APIs remain available, while actual allocation bounds and storage lifetime remain the caller's responsibility.

The previous caller-sized TypedBuffer<T> API has been replaced. Rather than allowing callers to provide a size unrelated to the type, memory handling now centers around byte buffers and verifiable operations. Allocation failure, bounds, alignment, overlapping sources, and target page-size queries have also been strengthened.

Self-append operations preserve the original source location even when buffer growth reallocates storage. Conversely, separately allocated memory that happens to sit immediately after a buffer is no longer mistaken for an invalid internal range. Rejected internal-range operations preserve the existing pointer, length, capacity, and contents.

Windows anonymous private mappings distinguish no-access, read-only, and read/write protection. Write-only and unsupported flag/protection combinations are rejected before allocation. wasm64 mappings support the defined combination of null address, positive length, READ|WRITE, PRIVATE|ANONYMOUS, descriptor -1, and offset 0; unsupported requests leave allocator state unchanged.

import("std::mem::layout")::{align_of, size_of};
import("std::mem::ops")::{mem_copy_items_checked};

fun main() -> i32 {
    var source: array<i32, 3> = [10, 20, 30];
    var target: array<i32, 3> = [0, 0, 0];
    if (mem_copy_items_checked<i32>(&target[0], &source[0], 3) < 0) {
        return 1;
    }

    println("size: {}, alignment: {}", size_of<i32>(), align_of<i32>());
    println("{} {} {}", target[0], target[1], target[2]);
    return 0;
}
import("std::buffer::alloc")::{Buffer, buffer_free, buffer_init};
import("std::buffer::write")::{buffer_append, buffer_append_str};

fun main() -> i32 {
    var buffer: Buffer;
    if (buffer_init(&buffer, 4) < 0) {
        return 1;
    }
    if (buffer_append_str(&buffer, "Wave") < 0) {
        buffer_free(&buffer);
        return 2;
    }
    if (buffer_append(&buffer, buffer.data, buffer.len) < 0) {
        buffer_free(&buffer);
        return 3;
    }

    println("length: {}", buffer.len);
    println("first byte of each copy: {} {}", buffer.data[0], buffer.data[4]);
    buffer_free(&buffer);
    return 0;
}

WebAssembly memory allocation

The wasm32 and wasm64 allocators now support reuse, splitting, and merging of freed blocks. Memory64 paths preserve 64-bit pointer values instead of narrowing them to 32 bits.

Fresh allocations no longer scan every live block. Reusable free blocks and the append position for new blocks are tracked separately, reducing unnecessary traversal for workloads with many small live allocations. Invalid-pointer checks and address ordering required for adjacent-block merging remain in place.

import("std::mem::alloc")::{mem_alloc_zeroed, mem_free};

fun main() -> i32 {
    var data: ptr<u8> = mem_alloc_zeroed(16);
    if (data == null) {
        return 1;
    }

    deref data[0] = 87;
    println("{}", data[0]);
    if (mem_free(data, 16) < 0) {
        return 2;
    }

    return 0;
}

Byte views and LEB128

std::bytes has been expanded with unsigned endian handling, checked random-access reads/writes, byte views, and reader/writer cursors.

Subviews and borrowed ByteReader reads can reference portions of existing storage without allocating. Views follow the lifetime of their source storage, and failures leave output values and cursor positions unchanged.

ULEB128 and SLEB128 reading/writing and exact encoded-length helpers have been added. Truncated input, representational overflow, and insufficient output capacity are checked before committing state so failed operations do not leave partially advanced cursors or partially committed results. Existing byte APIs remain available.

import("std::bytes::types")::{Bytes, bytes_subview, bytes_view};
import("std::bytes::errors")::{BYTES_OK};

fun main() -> i32 {
    var data: array<u8, 4> = [10, 20, 30, 40];
    var whole: Bytes = bytes_view(&data[0], 4);
    var middle: Bytes = bytes_view(null, 0);
    if (bytes_subview(whole, 1, 2, &middle) != BYTES_OK) {
        return 1;
    }

    data[1] = 99;
    println("length: {}, first: {}", middle.len, middle.data[0]);
    return 0;
}
import("std::bytes::cursor")::{ByteReader, ByteWriter, bytes_reader, bytes_writer};
import("std::bytes::errors")::{BYTES_OK};
import("std::bytes::leb128")::{bytes_reader_read_uleb128_u64, bytes_writer_write_uleb128_u64};

fun main() -> i32 {
    var data: array<u8, 10>;
    var writer: ByteWriter = bytes_writer(&data[0], 10);
    if (bytes_writer_write_uleb128_u64(&writer, 300) != BYTES_OK) {
        return 1;
    }

    var reader: ByteReader = bytes_reader(&data[0], writer.position);
    var value: u64 = 0;
    if (bytes_reader_read_uleb128_u64(&reader, &value) != BYTES_OK) {
        return 2;
    }

    println("value: {}, bytes: {}", value, writer.position);
    return 0;
}

Expanded math functions and numerical accuracy

std::math has been expanded with checked integer operations, bit operations, IEEE-754 helpers, square roots, and trigonometric functions. Ordinary arithmetic and APIs that explicitly report arithmetic errors are kept distinct.

Rounding operations preserve values that are already exact integers and preserve signed zero. Approximate comparisons now handle invalid inputs more carefully. We also fixed unnecessary intermediate overflow in interpolation between finite endpoints and loss of representable subnormal results for negative integer exponents.

Trigonometric range reduction has been improved so very large finite inputs preserve the correct phase and quadrant. Signed zero and values near poles are treated explicitly. Validation uses checked-in high-precision reference vectors for both f32 and f64; the tools used to generate those references are maintenance utilities rather than runtime dependencies of Wave programs.

import("std::math::num")::{pow_i32_checked};
import("std::math::result")::{MathResult};
import("std::math::trig")::{radians_f64, sin_f64};

fun main() -> i32 {
    var power: MathResult<i32> = pow_i32_checked(3, 5);
    if (power.error != 0) {
        return 1;
    }

    println("3^5: {}", power.value);
    println("sin(30 degrees): {}", sin_f64(radians_f64(30.0)));
    return 0;
}

String comparison, search, and hashing

std::string::cmp::eq_ignore_ascii_case compares ASCII letters without regard to case. Other bytes are compared unchanged, and the function does not allocate or mutate its inputs. It is not full Unicode case folding.

std::string::find::rfind returns the byte offset of the final substring occurrence. Overlapping matches are considered; -1 is returned when no match exists, and an empty needle matches at the end of the source string.

import("std::string::cmp")::{eq_ignore_ascii_case};
import("std::string::find")::{rfind};

fun main() {
    var same: bool = eq_ignore_ascii_case("Wave", "wAVE");
    var last: i32 = rfind("ababa", "aba");

    println("{} {}", same, last);
}

In the example above, same is true and last is 2. Both functions operate on NUL-terminated strings, and the behaviour of existing eq, find, count, and rfind_char APIs is unchanged. Search offsets in UTF-8 strings remain byte offsets rather than Unicode character counts.

ASCII whitespace classification now includes all six whitespace bytes, including vertical tab and form feed. Trimming of empty and all-whitespace strings has also been fixed so ranges do not become reversed or out of bounds.

FNV-1a now uses the correct 64-bit offset basis and wrapping arithmetic over unsigned bytes. The return type still represents a 64-bit bit pattern, but hashes produced by the previous incorrect implementation may differ.

Time and dates

std::time has been expanded with normalized durations, UTC DateTime, calendar conversions, portable clocks and sleeping, and ISO 8601 parsing/formatting.

Duration arithmetic applies nanosecond carry and borrow before testing second overflow. This fixes cases where an intermediate second value appeared to overflow even though normalization produced a representable result, and strengthens date/time range handling.

UTC ISO 8601 parsing now accepts one through nine fractional-second digits. Millisecond and microsecond forms are right-padded to nanoseconds. Whole-second input and the fixed nine-digit formatter remain supported. This covers the supported UTC subset and does not add every possible ISO 8601 time-zone representation.

Windows now preserves timestamps before 1970. WASI clocks split raw nanosecond values into seconds and nanoseconds so the full u64 range can be represented. Host errors are preserved without modifying caller-provided output storage.

WASI sleep retries only the remaining interval after interruption, based on a fixed monotonic deadline. Permanent errors and interruptions retain their platform-specific error codes. Remaining-time output from raw nanosleep and relative timeout submission to Node WASI have also been fixed.

import("std::time::datetime")::{DateTimeResult};
import("std::time::format")::{time_datetime_parse_iso8601};

fun main() -> i32 {
    var parsed: DateTimeResult = time_datetime_parse_iso8601("2026-10-05T12:34:56.123Z");
    if (!parsed.ok) {
        return 1;
    }

    println("nanoseconds: {}", parsed.value.nanosecond);
    return 0;
}
import("std::time::duration")::{DurationResult, time_duration_checked_new};

fun main() -> i32 {
    var duration: DurationResult = time_duration_checked_new(1, -1);
    if (!duration.ok) {
        return 1;
    }

    println("{}s {}ns", duration.value.seconds, duration.value.nanoseconds);
    return 0;
}

Environment variables and paths

Linux environment loading now reads until EOF and retries interrupted reads. Internal lookup storage can grow beyond 32 KiB, and truncated environment data is no longer returned as if it were complete. Missing keys, caller-capacity failures, source-read failures, incomplete source data, and allocation failure are distinguished.

Windows environment blocks are converted from UTF-16 to UTF-8 while preserving entry separators, separators inside values, the final double NUL, and special drive entries. USERPROFILE can also be used as a fallback for standard-library path discovery when HOME is unavailable on Windows.

Absolute-path detection follows the target operating system, not the OS on which the compiler happens to be running. Windows fully qualified drive paths and UNC paths containing a server and share are distinguished from drive-relative and root-relative forms. Other targets continue to use POSIX-style path rules.

dirname preserves room for terminators when producing drive roots or the . fallback. basename handles trailing separators while preserving root semantics. Paths ending in . or .. are no longer incorrectly treated as having extensions, while existing handling of hidden files and trailing dots remains intact.

Path copying and joining validate null destinations, non-positive capacities, insufficient storage, and length-calculation overflow before writing. Failed operations leave the destination unchanged. FreeBSD getcwd no longer misinterprets a successful return as failure.

import("std::env::environ")::{env_get};

fun main() {
    var value: array<u8, 32768>;
    var length: i64 = env_get("PATH", &value[0], 32768);
    if (length < 0) {
        println("environment error: {}", length);
        return;
    }
    println("{}", &value[0] as str);
}
import("std::path::copy")::{path_join2};

fun main() -> i32 {
    var path: array<u8, 128>;
    if (path_join2(&path[0], 128, "project", "main.wave") < 0) {
        return 1;
    }

    println("{}", &path[0] as str);
    return 0;
}

Processes and terminals

Standard stream remapping for child processes has been improved. Cases where stdout and stderr share a source, two descriptors are swapped, or three-way cycles occur now preserve the original descriptors before applying destination changes.

Closed standard-descriptor slots and invalid source descriptors are validated first so a newly allocated temporary descriptor cannot accidentally occupy the invalid descriptor number and hide the error. Capture readers and temporary descriptors not needed by the child are closed while preserving resources owned by the parent.

Linux process termination now uses exit_group, fixing cases where only the current thread terminated. Unix children terminated by signals return 128 + signal, while ordinary exit codes and existing Windows behaviour are preserved.

Darwin pipe wrappers now publish both returned descriptors, and fork follows the correct parent/child return convention. Secondary return registers on amd64 syscalls are declared so optimized code cannot accidentally reuse a clobbered argument. Darwin timeout-enabled sends also apply the nonblocking behaviour required while waiting for buffer space.

Linux dup2 validates the descriptor even when source and destination are identical. Terminal configuration on Linux, macOS, and FreeBSD rejects invalid actions before making the system call, and termios layouts and return handling have been corrected. macOS additionally exposes MAP_ANONYMOUS alongside the existing MAP_ANON name.

import("std::process::core")::{proc_exit, proc_getpid};

fun main() {
    println("process id: {}", proc_getpid());
    proc_exit(0);
}

Randomness and debugging

Operating-system entropy support in std::random has been expanded. Windows uses BCryptGenRandom and automatically links the required system library, while FreeBSD amd64 uses the raw getrandom syscall. Partial progress and failures are preserved.

std::debug now includes labeled logging, value tracing, assertions, and fatal diagnostic helpers. These are runtime development aids for Wave programs and remain distinct from compiler diagnostics.

import("std::random::fill")::{RandomFillResult, random_fill};
import("std::debug::core")::{debug_log, debug_value};

fun main() {
    var data: array<u8, 16>;
    var result: RandomFillResult = random_fill(&data[0], 16);
    if (!result.ok) {
        println("random error: {}, written: {}", result.error, result.written);
        return;
    }

    debug_log("random buffer filled");
    debug_value<i64>("bytes", result.written);
}

WASI files and system facilities

The WASI standard library provides descriptor I/O, paths beneath preopened directories, clocks and sleeping, environment variables, process termination, and linear-memory allocation.

F_OK access checks test for existence. R_OK and W_OK on regular files perform non-destructive opens using only the requested rights, and combined requests require all requested rights. Access checking does not create, truncate, or write files.

Execution rights and file kinds for which WASI cannot provide an equivalent check return ENOTSUP(-58), while unknown mode bits return EINVAL(-28). Real host errors retain their original WASI errno values.

The implementation remains limited to facilities provided by WASI Preview 1. Native sockets and terminal implementations are not exposed, and unsupported facilities such as POSIX process trees remain explicitly separate.

import("std::io::fd")::{io_write_all};
import("std::time::sleep")::{time_sleep_ms};

fun main() -> i32 {
    var message: str = "Wave on WASI\n";
    var written: i64 = io_write_all(1, message as ptr<u8>, 13);
    if (written != 13) {
        return 1;
    }
    if (time_sleep_ms(1) < 0) {
        return 2;
    }
    return 0;
}

Standard-library license

The standard library has been relicensed under the Apache License 2.0. Source headers under std/, license files, the manifest, and distribution/contribution documentation now consistently reflect this boundary.

The compiler and repository components outside the standard library remain under MPL-2.0. Products that include, modify, or redistribute the standard library can therefore apply the terms of the Apache License 2.0 to that portion.


2. Compiler

Semantic analysis and diagnostics

We reduced the number of invalid programs that reached LLVM code generation and failed as internal errors. Before validating function bodies, the compiler now collects program-level functions, globals, types, methods, and generic declarations and resolves expressions against those declarations.

wavec check and wavec build now apply the same semantic rules to return values, argument types, mutable storage targets, generic arity, and type validity. Unknown types, invalid void uses, unsupported casts, and incorrect array initializers are reported earlier.

The parser no longer silently skips unsupported tokens or crashes on certain malformed numeric forms and declarations. Errors in function signatures, imports, struct fields, and local storage now point at the offending token. Nested boolean syntax, generic-versus-comparison parsing, and imports inside inactive target branches have also been strengthened.

Instead of reconstructing locations by searching diagnostic strings after failure, source positions are carried with semantic errors. Repeated identifiers or return statements therefore point to the actual failing occurrence.

Imported files and generic expansion preserve their original source text and locations. Errors originating in other modules no longer default to the entry file's first line, and generated code no longer hides the user-authored location. Compiler consumers can use a parsing path that preserves spans while the existing spanless path remains available.

Compiler architecture centered on Typed HIR

Language types and conversion semantics are now resolved in the frontend, and the backend consumes those decisions. Typed HIR is the intermediate representation that preserves this semantic information.

We removed paths where LLVM independently inferred an expression's type or reconciled it against a separate table. Expression and pattern identifiers now carry resolved types, original source positions, ordered conversions, and computation widths so the same expression is not reinterpreted differently in separate lowering paths.

Typed HIR has been separated into its own compiler component. It contains information for generics, variants, numeric conversions, constant evaluation, and async suspension/resumption, reducing the amount of internal inference shared between the parser and backend.

For example, i32 → u8 → i32 remains represented as a conversion chain containing an observable intermediate truncation, while integer division followed by floating conversion remains two ordered operations. Missing semantic information is treated as an internal invariant failure before backend code generation instead of being guessed.

Constants and runtime values consume the same conversion decisions. Exact built-in integer arithmetic supports wide constant evaluation, casts, shifts, division validation, and short-circuiting without introducing an external numeric library dependency.

LLVM remains the code-generation backend in this release. Typed HIR establishes a backend-neutral semantic boundary that can also be consumed by future backends such as Whale.

Code generation preserving values and control flow

We fixed cases where pointer fields were loaded as small integers or nested field expressions lost their actual stored types. Struct values loaded from arrays, generic method receivers, shadowed variables, and dereferences of pointer-returning functions now use their resolved storage types.

Array and pointer indices are evaluated as general integer expressions and then converted to the target pointer width. Loads, stores, address-taking, and input destinations share the same handling while preserving signedness and evaluation count. Addressed array literals also retain destination and element type information.

Global character/string initialization and nested numeric conversions now follow the same rules as runtime values. Projections from constant structs, arrays, and variants avoid mutable-address lowering. Floating-point widening/narrowing, NaN comparisons, and signed-zero negation have also been corrected.

Variants now use a single aligned payload area rather than reserving independent storage for every case. Generic, nested, recursive-pointer, constant/static, and aggregate-contained variants all share the same representation model.

Control flow no longer emits instructions after a basic block has already terminated. Merge blocks after conditionals and matches whose branches all terminate, exits from non-breaking infinite loops, and paths after non-returning functions are represented as unreachable. Short-circuit semantics for && and || are preserved so operands that should not execute are not lowered as unconditional computation.

Integer I/O lowering now respects actual type widths and signedness. Wide integer formatting avoids unnecessary dependencies on external wide-division helpers, and input destination addresses are evaluated only once.

Async suspension and resumption

Async functions are transformed into frames that preserve the next execution state and any values that must live across await. Suspended functions resume from their saved state rather than restarting from the beginning.

This transformation is represented independently of specific LLVM instructions. Generated code integrates with the cooperative executor and OS event backends while distinguishing completion results from cancellation acknowledgement.

On Windows, executor shutdown now completes outstanding operations and callbacks before releasing executor ownership. Completion-port associations owned by live sockets remain until those sockets are actually closed, supporting executor restart and socket reuse.

Unified target configuration

The CLI, code generator, and linker now share the same target specification. Supported triples must match exactly, and CPU, ISA features, ABI, code model, and relocation combinations are validated before code generation.

Unknown CPUs or features, malformed feature syntax, duplicate/conflicting settings, and ABI combinations missing required ISA extensions are reported as usage errors. Invalid configuration therefore reaches neither LLVM warnings nor internal panics, and usage errors return exit status 2.

RISC-V CPU names and AArch64 feature names have been aligned with actual backend contracts. RISC-V exposes generic, generic-rv64, rocket-rv64, and sifive-u74, while the AArch64 floating-point feature is exposed as fp-armv8. Duplicate target-attribute keys are rejected, and OS/architecture aliases are normalized consistently.

Hosted status, object format, and valid target configuration can be queried directly:

wavec print supported-targets
wavec print target-spec --target riscv64-unknown-linux-gnu --format=json

Selected ISA and ABI information is preserved in LLVM IR and bitcode. The same configuration validation applies to check, build, object emission, and --dry-run, while JSON usage errors are sent to stderr instead of contaminating normal stdout output.

RISC-V 64

RISC-V 64 support now extends beyond code generation to C interoperability, linking, the standard library, and execution.

Target Default ISA Default ABI
riscv64-unknown-linux-gnu RV64GC LP64D
riscv64-unknown-none-elf RV64IMAC LP64

Hosted Linux programs and freestanding programs therefore use separate defaults, and the selected ISA/ABI is reflected in both code generation and output metadata.

C psABI handling has been expanded for LP64, LP64F, and LP64D. This includes narrow integer extension, integer and floating-point argument registers, aggregate parameters and returns, variadic arguments, and stack arguments. Aggregates that cannot use the floating-point convention fall back to the integer convention, with register consumption and indirect passing handled correctly.

Public C functions receiving or returning large aggregates use wrappers that connect the external calling convention to Wave's internal representation. Calls to the same function from Wave preserve the same language-level value semantics.

Inline assembly now validates register aliases, reserved registers, clobbers, stack usage, and non-returning behaviour. Branch and return instructions must agree with the declared control-flow contract. Atomic lowering depends on the A extension, with fallback library paths when unavailable, and compressed instructions plus PIC/static relocation are handled explicitly.

Linking uses ABI-appropriate startup code, libc/libm, and the dynamic loader. Static links do not incorrectly omit startup objects merely because no dynamic loader is present, and static PIE is distinguished from normal executables and shared libraries. Input ELF files and libraries are validated for ABI compatibility.

RISC-V Linux sysroots are discovered from cross-toolchain-reported paths and standard prefixes. Only candidates containing a complete libc, libm, and loader configuration are selected, while an explicit --sysroot takes precedence. We also fixed host libraries leaking into cross-links and duplicate sysroot prefixing in GNU linker scripts.

C ABI and platform interoperability

C ABI classification is now split by architecture. Language-level conversions and external transport conventions are handled separately so values that are correct internally remain correct across foreign-function boundaries.

On x86-64 SysV, aggregate handling after argument-register exhaustion has been fixed. On AArch64, general aggregates, large structs, indirect returns, and 16-byte alignment of small aggregates have been corrected.

Windows ARM64 now classifies homogeneous floating-point aggregates before applying generic size-based indirect passing. Named aggregate arguments in Windows variadic functions use the platform's composite transport rules, distinct from Linux and Darwin HFA handling.

Both directions of interop—C calling Wave and Wave calling C—are tested independently. Coverage includes narrow integers, oddly sized and strongly aligned structs, register exhaustion, indirect returns, variadic promotion, both macOS architectures, and Windows DLL boundaries.

LoongArch64

The loongarch64-unknown-linux-gnu target has been added. LP64D is the default ABI, with LP64S and LP64F object ABI support as well.

CPU/features, register rules, inline assembly, aggregate C ABI handling, ELF validation, Wave-provided startup code, sysroot discovery, and system support are connected end to end. ABI-specific startup objects and loaders are selected, and objects inside archives are also checked for ABI compatibility.

Hosted LP64S and LP64D links require matching system runtimes. LP64F is available for object generation, while hosted linking is rejected before the linker when no compatible glibc runtime configuration exists. Independent LP64S C-interoperability execution is also provided in a freestanding environment.

Disabling the D floating-point extension also disables implicitly enabled LSX, explicitly conflicting SIMD configurations are rejected, and the loong64 alias is normalized consistently.

Windows MSVC transition

The default Windows targets and distribution toolchain now use MSVC. The amd64 and ARM64 targets are:

x86_64-pc-windows-msvc
aarch64-pc-windows-msvc

Previous Windows GNU/MinGW triples produce migration diagnostics pointing to the new targets. Windows host defaults now use the MSVC ABI, while Linux GNU targets are unaffected.

Windows SDK and Visual C++ libraries are discovered through explicit search paths, LIB, installation metadata, and Visual Studio discovery. UM, UCRT, and VC libraries are verified as complete and architecture-correct. The compiler can discover installed toolchains from ordinary shells, while Developer Prompts and explicit library paths remain supported.

Before invoking the linker, the compiler validates ordinary COFF objects, bigobj files, import objects, and archive indexes/members. Extensionless object files are also inspected. Corrupt or wrong-machine inputs are rejected before existing outputs can be replaced. Valid Microsoft SDK padding, neutral objects, and hybrid archives are accepted while actually selected wrong-architecture members remain rejected.

Microsoft LINK and lld-link receive the appropriate entry-point syntax, and UTF-16 response files support long command lines and Unicode paths. Import libraries and PDB files produced alongside executables or DLLs are published under final basenames, with previous outputs restored if multi-file publication fails.

A Windows ARM64 compiler crash caused by freeing LLVM-allocated strings with a different allocator has also been fixed. CRT and allocator configuration between the compiler and LLVM dependencies is now aligned, with host build-script configuration separated from target-program configuration. The fix preserves correct allocation ownership rather than leaking memory by suppressing frees.

Discovery of arithmetic support libraries required by MSVC hosted programs has been improved, with clear diagnostics when missing, while explicit /NODEFAULTLIB usage remains respected. This compiler-host configuration does not force every Wave user program to use one fixed CRT link strategy.

FreeBSD

FreeBSD support now covers aarch64-unknown-freebsd and riscv64-unknown-freebsd in addition to amd64. Target selection, C ABI handling, ELF emission, and link planning are connected, with LP64D required for FreeBSD RISC-V.

Common 64-bit implementations have been expanded for filesystems, memory, processes, time, networking, environment variables, and terminals. Raw syscall return registers, kqueue event layout and errors, scatter/gather I/O, page-size queries, and environment handling have all been strengthened.

Compiler target support and prebuilt compiler distribution remain separate. This release ships a FreeBSD 14.4 amd64 compiler package, while AArch64 and RISC-V provide target implementations and separate compile/VM validation paths.

WebAssembly and WASI

WebAssembly support now covers code generation, linking, and execution for three targets:

Target Purpose
wasm32-unknown-unknown General WebAssembly modules using 32-bit pointers.
wasm32-wasip1 WASI Preview 1 commands.
wasm64-unknown-unknown Modules using 64-bit pointers and Memory64.

Branches, loops, structs, arrays, enums, payload variants, generics, and C-style host-function boundaries are supported. wasm-ld linking and WASI/linear-memory standard-library implementations are connected.

Bare modules use explicitly supplied host functions, while WASI commands use _start and the WASI Preview 1 interface. This release does not introduce a 64-bit WASI target.

wavec --target=wasm32-unknown-unknown build module.wave
wavec --target=wasm32-wasip1 run command.wave
wavec --target=wasm64-unknown-unknown run memory64-module.wave

wavec run uses Node.js, and WASI receives the current directory preopened as .. Memory64 execution first probes engine capabilities and retries once with legacy flags only when default support is unavailable. Removed experimental flags are no longer unconditionally passed to newer Node versions.

The built-in host now implements the printf and puts contracts emitted by Wave. It handles wasm32/wasm64 pointer widths, variadic alignment, memory growth, raw bytes, and floating-point output with six decimal places. Negative zero and rounding are preserved, while invalid memory accesses, unsupported external formats, and output failures are reported as errors.

If a required host import is absent, the runner reports the missing import before instantiation and explains that an explicit JavaScript host is required. Both human-readable and JSON diagnostics carry the same information.

Explicit WASI proc_exit and normal command completion propagate to the host process exit status. Commands no longer remain alive merely because Node retains unrelated timers or handles, and host startup, malformed modules, and traps now follow clearer failure paths.

Compiler-owned 128-bit arithmetic

The compiler now provides helper code for required 128-bit arithmetic. This fixes native and wasm64 programs that previously failed to link when division, remainder, multiplication, variable shifts, or integer/float conversions required missing external runtime symbols.

Only helpers needed by the generated module are emitted, and they remain private. Operations that can be lowered directly—such as native multiplication, shifts, or division by powers of two—are not unnecessarily converted into helper calls.

The arithmetic runtime itself is freestanding and does not require libc, libgcc, or compiler-rt for those operations. Other hosted dependencies such as I/O or the Windows CRT still follow platform requirements. This is separate from the 1024-bit integer I/O work: the compiler-owned arithmetic helpers specifically cover 128-bit arithmetic.

fun quotient(value: u128, divisor: u128) -> u128 {
    return value / divisor;
}

fun remainder(value: u128, divisor: u128) -> u128 {
    return value % divisor;
}

fun main() {
    var value: u128 = 18446744073709551617;
    var divisor: u128 = 3;

    println("quotient: {}", quotient(value, divisor));
    println("remainder: {}", remainder(value, divisor));
}

ELF validation now follows GNU thin-archive references. Relative paths, long filenames, and references into other archives are resolved, while missing files, malformed metadata, and reference cycles are diagnosed. Packaging an object inside an archive therefore no longer bypasses ABI validation.

Output paths are prevented from overwriting command-line inputs or imported source files. Path aliases such as symbolic links are resolved to actual file identity. Pass-through output modes that do not need to rewrite the original input retain that behaviour.

Invalid UTF-8 command-line arguments now produce usage errors instead of compiler panics or lossy filename substitution. Default output-name generation and backend path conversion follow the same rule, and JSON errors plus invalid option-value diagnostics have been strengthened.

std installation and path selection

install std and update std now default to the standard-library revision recorded for the compiler rather than unconditionally fetching the latest branch. An explicit --ref can still select another revision, but compatibility validation remains active.

Installation downloads and validates the full candidate std tree in a staging directory before replacing the active installation. Download, validation, or replacement failures preserve or restore the previous installation, and source provenance is recorded.

When the compiler is built from a source archive without Git metadata, WAVE_STD_REVISION can provide the intended std revision. If neither source metadata nor this override is available, installation requires an explicit --ref instead of silently selecting an arbitrary latest version.

A per-invocation std root can be selected with --std-root:

wavec --std-root ./std check main.wave

An explicit path takes precedence over the installed std. Invalid explicit roots do not fall back to another installation, even for programs that do not import std. The root is validated once and passed consistently through module resolution so different standard-library trees cannot be mixed.

Source rendering and deeply nested expressions

Terminal diagnostics now align highlights according to display width instead of UTF-8 byte count or scalar count. Tabs use four-column stops, wide characters occupy two columns, combining characters occupy zero, and pinned Unicode 17.0.0 width data is shared through Utils.

JSON diagnostics retain existing UTF-8 byte spans and Unicode-scalar column numbers. Improving terminal layout therefore does not change coordinates consumed by editors or external tooling.

Expression nesting is limited to 128 levels. Parentheses, unary expressions, call arguments, arrays, structs, and generated expression trees are checked, and excess depth produces a source-located error from check, build, and AST emission.

Deep-tree processing has been made more iterative and parser/semantic/generic stack usage reduced. Depth tracking also resets correctly after errors. This protection is explicit rather than relying on artificially enlarged operating-system stack limits.

WSON and AST output

AST output has moved from Rust debug strings to an explicit schema-based representation. WSON, JSON, and S-expression formats are available.

wavec build main.wave --emit=ast --ast-format=wson
wavec build main.wave --emit=ast --ast-format=json
wavec build main.wave --emit=ast --ast-format=sexpr

The default extension is .ast.wson; JSON uses .ast.json, and S-expressions use .ast. Output carries a schema version so external tools can identify the representation.

AST-only output preserves parsed source structure without expanding imports or running type checking. Import declarations, original numeric/string spellings, and literal bytes remain available for source-analysis or transformation tools. This is distinct from typed HIR output.

Serialization now uses a shared WSON implementation. Exact integer values and decimal spellings are preserved while existing diagnostic JSON/JSONL and CLI JSON fields remain stable. Duplicate object keys are rejected, and strict JSON mode does not silently stringify WSON-only values.

UTF-8 text, Unicode escapes, and surrogate pairs are handled explicitly, while raw control characters and malformed numeric input are rejected. General parsing/writing uses a nesting limit of 256, and AST output applies a separately bounded writer budget after expression-depth validation to prevent compiler stack exhaustion.


3. Development and Release Tooling

Test organization and executable examples

Tests are now divided into common functionality, architecture-common functionality, and OS/architecture-specific functionality. A single manifest controls support and execution selection, and common tests are automatically included for selected platforms.

We fixed cases where shared tests were missing from actual runtime selection. Empty or failed mandatory selection no longer counts as success, and explicit run/exclusion entries are checked against the correct platform suites. FreeBSD discovery handles both standalone files and directories containing main.wave.

Planned platforms and currently supported targets are represented separately. Unsupported OS combinations and unjustified target entries have been cleaned up so the existence of a test directory does not imply compiler support. FreeBSD and freestanding validation are split into separate jobs while continuing to use the same manifest.

Test programs now exercise realistic workloads such as stack VMs, alignment, graph traversal, hash tables, packet encoding, multiprecision arithmetic, arenas, scheduling, convolution, and recursive trees. OS-specific cases cover pipes/event handling, streaming search, parsing, and memory operations.

Examples have also been expanded for file I/O, aggregate constants, computed indices, WebAssembly/WASI, and standard-library usage. Existing networking, struct, and Doom examples have been updated for the new declaration and conversion rules. Incorrect expected byte counts, boundary conditions, and test-only reserved-word conflicts have also been fixed.

Distinguishing compilation success from execution success

Expected program exit codes are now distinct from compiler failures. Compilation completes as a separate phase, a valid executable is confirmed, and only then is the program launched. A compiler failure therefore cannot pass merely because its exit code happens to match the expected nonzero program exit code.

Stale object files from earlier runs cannot hide a new compiler failure. Windows ARM64 smoke testing likewise checks the current command status independently of an already existing valid-looking artifact. Programs that intentionally write error-like text to stderr are not classified as compile failures solely based on output contents.

Cross-target tests that are not executed validate the actual emitted object or assembly. ELF class, architecture, ABI flags, and required instructions are checked, and missing or incorrect artifacts fail the test. These compile-only checks can run from hosts of other architectures.

QEMU and WebAssembly produce separate compile and runtime reports. Program stdin, expected exit status, and the actual phase of failure are recorded. WebAssembly tests reuse the same host execution plan as the compiler, and QEMU compilation/execution uses the same target sysroot.

C ABI comparisons distinguish semantically equivalent outputs even when C compilers render them differently, and account for platform-specific archive-generation differences. Assembly checks use target-specific comment syntax so AArch64 immediates are not accidentally treated as comments.

Error reporting, timeouts, and process cleanup

Independent test results are collected even if one source check fails. Once compiler/std preparation has succeeded, unrelated runtime checks can still run after another pre-check fails, while any mandatory failure continues to mark the overall workflow as failed.

Reports include platform, selected test, compile/execution phase, exact exit status, and stdout/stderr. Completion, failure, timeout, cancellation, user interruption, and not-started states are distinguished. User interruption preserves exit status 130.

JSON reports are written completely to temporary files and atomically replaced. Temporary files are cleaned up after write/replace failure, and configuration failures that occur before source execution—such as invalid manifests or compiler paths—still produce readable reports.

Non-UTF-8 program output no longer aborts an entire test run. Normal reports use replacement decoding, while source and manifest input remain strict UTF-8. Byte-oriented tests distinguish original bytes from platform newline translation rather than reducing everything to Unicode text comparisons.

Timeouts preserve captured output and diagnostic information. POSIX process groups and Windows Job Objects ensure child processes are also cleaned up. We also fixed cases where already-cleaned process groups were terminated again and caused diagnostics to be lost. Limited retries are used for Windows sharing violations immediately after server shutdown.

Server regressions no longer trust responses from a fixed port. Test servers announce an ephemeral port, readiness is checked within a bounded interval, and per-request challenge responses verify that the test is communicating with the server it actually launched. TCP timeout tests verify real backpressure, post-timeout socket recovery, and bytes received by the peer. UDP checks wait for actual arrival/readiness before validating results.

Validation-tool input and file protection

An explicit compiler chosen through --wavec or WAVEC is authoritative. If that path is invalid, tools no longer silently fall back to another local build, avoiding accidental validation of an outdated compiler. Automatic discovery remains available only when no explicit choice is provided.

Before testing source files, tools verify that the compiler path exists and refers to a regular file, with executable permission checked on POSIX. This prevents a single invalid path from generating the same process-launch error for every source. Help output is available before compiler discovery so tools can still be inspected before the compiler is built.

Timeout arguments must be finite positive values. Zero, negative values, NaN, infinity, and malformed strings are rejected as usage errors before starting processes, while valid fractional durations remain supported.

Test source and manifest files are read as UTF-8 independently of the system locale. Decode failures include the source path and underlying reason, while manifest failures use consistent usage-error reporting. Legacy metadata checks inspect only real test directives so ordinary strings and comments are not mistaken for metadata.

Windows/Unix path separators, JSON-escaped paths, and equivalent macOS path aliases are normalized. MSVC independence checks inspect actual linker options and dependencies rather than rejecting any installation path containing the word mingw.

Report destinations are prevented from aliasing source files, the compiler, std, validation tools, VM images, kernels, or firmware. Symbolic links and hard links pointing at the same underlying file are also recognized.

x.py clean operates relative to the repository and removes only recognized Wave outputs. Unrelated archives and .tmp/ remain untouched. Unknown x.py commands now return exit status 1 instead of printing an error while reporting success.

Python-based CI and platform-parallel execution

Build, validation, and release procedures previously embedded across workflows have been moved into shared Python execution plans under tools/ci. GitHub Actions now primarily describes execution environments and job dependencies, while the shared tooling performs the actual procedures and failure handling.

Local and CI execution can use the same procedures, making remote failures easier to reproduce. Environment provisioning and publication remain explicit operations, while success, failure, timeout, cancellation, and not-run states share a common model.

Build and test workflows are centered around ci.yml. CI, Release, and Nightly jobs are grouped by operating system and named using an OS / architecture / role convention. Internal evidence/report names and the files consumed by publication have also been normalized.

Release workflows first validate branch, version, and tag state, then run platform validation and all nine package builds in parallel. Packaging no longer waits unnecessarily for long-running validation of unrelated platforms.

Publication occurs only after every required validation and package job succeeds. Parallelization changes execution order, not the validation requirements, and per-stage logs and summaries expose progress, results, and elapsed time.

The workflows now consume existing prebuilt LLVM SDKs, and the unused optional SDK-build path plus its dispatch options have been removed. Release dispatch accepts the version to publish, while CI, versioned release, and Nightly remain separate workflows.

Feature-specific builds and real platform validation

Build coverage has been expanded for independently selected x86, AArch64, RISC-V, LoongArch, and WebAssembly backends. This catches dependencies hidden by default configurations and ensures disabled backends do not leak their tests into unrelated builds. Multi-backend 64-bit configurations are also validated separately.

Common checks include formatting, locked dependency builds/tests, static analysis and documentation with warnings treated as errors, standard-library policy, Python tooling, source validation, and executable examples. Standard-library policy permits approved whitespace/comment forms but does not treat a missing or failed search tool as successful validation.

Linux and macOS amd64/arm64 and Windows amd64/ARM64 run natively. RISC-V and LoongArch use QEMU execution plus ABI validation, FreeBSD uses a real guest OS for system and package execution, and all three WebAssembly targets execute under Node.

Windows validation covers bidirectional Wave/C calls, DLL boundaries, large stack frames, stack walking/unwind information, allocator ownership, and LINK/lld-link with long paths. Both macOS architectures run C ABI validation natively. Compile-only results, emulated execution, and native OS execution remain distinct in reports.

Platform provisioning has also been strengthened. Windows LLVM/libxml2 is checked for the correct architecture and CRT, Linux ARM64 downloads use retryable IPv4 paths, and macOS bundles versioned LLD together with transitive dylib dependencies.

Windows library inspection distinguishes delayed imports and ARM64X hybrid metadata so valid packages are not rejected while malformed imports and wrong-machine files remain errors. Temporary Windows user profiles are separated from uploaded evidence to prevent protected profile directories from breaking artifact collection.

Nine native distribution packages

This release provides the following compiler packages:

Operating system Architecture Format
Linux amd64 .tar.gz
Linux arm64 .tar.gz
Linux riscv64 .tar.gz
Linux loongarch64 .tar.gz
macOS amd64 .tar.gz
macOS arm64 .tar.gz
Windows MSVC amd64 .zip
Windows MSVC arm64 .zip
FreeBSD amd64 .tar.gz

Packages include the matching std, required LLVM/LLD tools, redistributable dependencies, and license notices. Archives are extracted into isolated locations and validated by running the compiler, producing objects, linking, and executing programs.

Libraries bundled with the package are distinguished from system prerequisites. Windows requires architecture-matching Visual Studio Build Tools C++ libraries, the Windows SDK, and the Visual C++ redistributable; SDK and Visual Studio libraries themselves are not included in the archive.

WebAssembly remains an output target produced by these native compilers and is not distributed as a separate WebAssembly-hosted compiler archive.

RISC-V/LoongArch SDKs and package builds

RISC-V64 and LoongArch64 distributions use separately prepared LLVM 21.1.8 SDKs. Files downloaded from the official distribution location are checked for size and SHA-256, and validation failure does not silently fall back to an arbitrary SDK or source build.

SDKs are relocatable and their target tools are executed under QEMU. Execution of the final compiler packages is validated separately from validation of the SDK itself.

The RISC-V compiler is cross-built on amd64 and then executed under QEMU. This avoids repeatedly building the entire compiler inside an emulated RISC-V userspace while retaining execution validation of the actual resulting RISC-V compiler.

LoongArch LLD tool setup and dependency discovery have been strengthened. Both platforms now pass explicit target sysroots to package smoke tests, distinguishing QEMU's loader path from the compiler linker's library-search path. Unnecessary unconditional libffi linkage requested by unused execution-engine features has been removed while retaining dependencies actually required by the selected LLVM build.

FreeBSD distribution and build reuse

The FreeBSD amd64 package is built inside a pinned FreeBSD 14.4-RELEASE VM. Compiler validation, building, packaging, and execution of the extracted package all happen inside the guest, together with provisioning LLVM/LLD and required non-system dependencies.

VM automation applies timeouts to boot, commands, and tests while preserving errors and logs. Bootloader input uses observed character echo rather than only fixed delays, long bootstrap commands are loaded from the disk image to avoid serial-buffer truncation, SSH public keys are consumed only after the full record has been received, and disk expansion/native linker selection have been hardened.

Repeated runs can restore a validated VM cache. Cache identity includes the OS image, tool versions, dependencies, and build configuration, while each job applies the requested source checkout to a disposable overlay. Reused build state never skips tests or extracted-package execution.

Only guests that complete all validation and clean shutdown are flattened into standalone snapshots. Temporary access credentials and release archives are removed, and restore checks verify integrity plus independence from an external backing disk. Invalid caches fall back to fresh provisioning.

Release validation and changelogs

Immediately before publication, the workflow verifies that the source that was validated is still the exact source intended for release. Publication is stopped if the release branch moves during the build or source lookup results are invalid. Development -dev versions are also prevented from being published as normal releases.

All nine packages must be present, with checksums and metadata validated for missing files, duplicates, corruption, or mismatch. Archive and verification files are staged and replaced safely, preserving or restoring previous outputs when replacement fails.

The versioned release download list exposes only the nine installable archives. Checksums and metadata remain part of internal validation but are not listed as separate public download assets. Installer verification is aligned with GitHub's asset digest.

Generated changelogs collect changes from the previous release to the exact release source and credit pull-request authors, commit authors, and co-authors. Public Full Changelog links compare the previous and current version tags, while Nightly remains commit-based and contributor collection still uses the verified source range.

Nightly builds from validated development sources

When master changes and shared CI succeeds, the same validated source is used to build nine packages and publish a Nightly prerelease. The same source is not rebuilt every day merely because the calendar date changed, and failed validation or packaging never results in a new Nightly publication.

Nightly is kept separate from the latest versioned release. The standard installer excludes Nightly from normal automatic version selection; Nightly artifacts are selected explicitly. The publication process also verifies that the deployed installer contains this selection behaviour, with improved HTTP identification and network error messages.

New artifacts are first uploaded to a private draft and fully validated. Once ready, the previous public Nightly is replaced so only one public Nightly prerelease remains. The stable download entry point is retained while the release object and publication time are refreshed.

Nightly names use the following format:

Wave YYYY-MM-DD-NN-nightly

The date is the build date in Korea Standard Time. NN is a cumulative publication number rather than a per-day counter, so it does not reset when the date changes and can grow beyond two digits. Earlier unnumbered Nightlies are incorporated into the sequence. Retries of the same source or stale candidates do not increment the number.

Failed upload or validation preserves the existing public release. If replacement is interrupted, the draft candidate and assigned sequence number can be reused on retry. A short gap can exist between deleting the previous public release and publishing the prepared replacement.

Publication is serialized with limited write permissions so competing builds cannot overwrite one another. Stale or unrelated sources, duplicate executions, and interrupted publications are distinguished, and successful CI runs can be retried manually. We also corrected workflow paths where CI-skip markers inherited through merge messages could prevent validation and Nightly generation.

Contribution environment and documentation

Contribution documentation has been aligned with actual build and validation commands, and DCO Signed-off-by validation now covers the complete patch series. Verification starts from a clean repository state and restores it after failure or interruption. Tests are isolated from personal Git configuration, and compatibility with the default shell on older macOS runners has been improved.

Maintainer lookup now resolves from the repository itself rather than the current working directory. Absolute and relative paths, Windows separators, and ./.. components are normalized so the same file consistently maps to the same maintainers.

Bug, feature, and performance templates are now Markdown templates organized around the problem, evidence/reproduction, scope, and completion criteria. Unnecessary title prefixes were removed, author guidance is hidden in comments, existing categories and issue selection remain available, and a pull-request template has been added.

Project documentation has been updated to reflect current syntax, modules, low-level facilities, targets, and CLI usage. Wave is explicitly described as an independent general-purpose language rather than a source-compatible C extension, and development, installation, email patch submission, and licensing guidance has been refreshed.

Outdated website/logo paths, build badges, sponsorship configuration, and OpenCollective supporter information have been updated. Unused imagery, example assets, unnecessary documentation, and dedicated checker files introduced during development were cleaned up while preserving the actual compiler fixes and general regressions. Source comments, copyright/contributor notices, and repository language-statistics handling for test sources were also improved.


Upgrade Notes

When upgrading an existing project, update the standard library together with the compiler and review the following changes.

Area Required change
Variable declarations Replace let/let mut with var, and explicitly type var and static declarations.
Entry point and conditions Remove parameters and pub from main; move assignments/increments out of conditions into separate statements.
Modules Update cross-file access to use pub, module names, selective imports, and re-export rules.
Numbers and pointers Review narrowing conversions, pointer pointee types, shift counts, and floating-point-to-integer ranges.
Strings and input Apply byte semantics for \xNN and embedded-NUL restrictions; bool input accepts numeric 0 and 1.
Async sleep Treat sleep_ms as Future<i32> and inspect the result where required.
Memory and hashing Migrate old TypedBuffer<T> usage and verify compatibility of stored FNV-1a hashes.
Standard library Use compatibility revision 5. Invalid --std-root values do not fall back automatically.
Windows Use MSVC targets and matching SDK/C++ tooling, and rebuild external objects/libraries for the same ABI.
AST tooling Replace Rust Debug parsing with WSON, JSON, or S-expression output using the explicit AST schema.
Automation Update integrations that depend on renamed CI jobs/internal artifacts or the previous release-checksum publication model.
Nightly Treat Nightly separately from normal version installation and use the Nightly artifact path explicitly.

Closing

Wave v0.2.1-pre-beta expands how programs can be expressed while fixing cases where numeric, memory, filesystem, and networking operations could previously produce incorrect results. It also establishes Typed HIR and platform-specific ABI handling more clearly and broadens the environments in which both the compiler and generated programs are executed and validated.

Thank you to everyone who contributed code, tests, documentation, issue reports, and sponsorship to this release.