Back to the README · Implementation status

Assembly with a portable body

An ordinary procedure can supply architecture-specific assembly and a required Ziran implementation. C output selects assembly when built by GCC or Clang for that architecture. Go can opt into native assembly through cgo as described below. Other compilers and .zx use the checked Ziran body. Define ZIRAN_NO_ASM when compiling generated C to force the portable body.

Increment :: (value: $T) -> T #asm("x86_64", "add $1, %[out]", "=r", "0", "cc", "") #asm("aarch64", "add %[out], %[value], #1", "=r", "r", "", "") {
    return value + 1
}

Each #asm takes four to six arguments: architecture, assembly template, output constraints, input constraints, and optionally clobbers and options (both default to ""). The architecture and options are literal strings. Templates, constraints and clobbers can also name compile-time string constants; options are "" or "volatile". Use semicolons to separate constraints and clobbers; commas remain available for the assembler's constraint alternatives. Templates use GNU extended assembly syntax, including named operands, register modifiers, multiple instructions, escaped newlines, and assembler directives. Templates are quoted as data in generated source. The native compiler and assembler validate instruction syntax and machine-specific constraints.

A template constant can be generated in the same file with TEMPLATE :: #run MakeTemplate(); and used as #asm("x86_64", TEMPLATE, ...). The generator can use builders, loops, pointers and slices. Put its helpers under #scope_file and restore #scope_export before the public kernel; tests/spec/asm_dct8.zi generates three architectures from one transform description this way.

Generated C uses positional operands internally, so source operand aliases may also be C keywords. Named matching inputs retain the same binding.

Input operands follow parameter order and use the parameter names. Parameters must be scalars, pointers or slices of scalars; a slice operand is its data pointer, so pass the count separately. Pointer and slice parameters imply a "memory" clobber. A scalar or pointer result has operand name out; a record result has one operand named out_FIELD per declared field, in field order. Result fields must be named scalars or pointers. Void results have no output constraints. Outputs require = constraints, including early-clobber =&r; use an input constraint such as "0" to initialize a tied read/write output. Output and input operand names must be distinct. The limit is 30 combined input/output operands.

Generic procedures retain their variants through specialization, including from separately saved libraries. Declarations check syntax, input counts and known operand types; every concrete instance checks all scalar/pointer inputs, result fields, local bindings and its portable body. A type-dependent result must match the declared output constraints at each call. Aggregate inputs are rejected even when the current target would execute the portable body. Generic local blocks follow the same rules. tests/generic_assembly.sh checks integer widths, floating-point copies, tied outputs, memory aliases, caller-owned record types and multiple results across native and portable targets.

For example, a two-field record can use output constraints "=a;=d" to capture two registers. A pointer parameter can give assembly access to memory; declare "memory" in the clobbers when accessing memory beyond named operands, and use "volatile" when the operation must survive dead-result elimination. Follow the GNU extended assembly contract for register lifetimes, early clobbers, matching constraints, flags and memory.

Architecture names are x86_64, x86, aarch64, arm, wasm32, riscv64, riscv32, powerpc64, powerpc, s390x, and loongarch64. Selection uses the native compiler's target macros, so cross-compilation selects the destination architecture. Append required CPU features with +, for example "x86_64+popcnt", "x86_64+avx2+bmi2", "aarch64+crc32", "arm+neon", or "wasm32+simd128".

Count :: (value: u64) -> s64 #asm("x86_64+popcnt", "popcnt %[value], %[out]", "=r", "r", "cc", "") {
    bits := value
    total: s64
    while bits != 0 { total += cast(s64)(bits & 1); bits >>= 1 }
    return total
}

A kernel can list one variant per line between its header and its portable body, long templates as #string text. The body is required: it runs on every other target (Go, WebAssembly, .zx) and is the reference the variants are tested against.

// dst[i] = saturate(dst[i] + src[i]), eight lanes per block.
AddSaturate :: (dst: []s16, src: []s16, blocks: s64)
#asm("x86_64", #string END
    xorl %%eax, %%eax
1:  cmpq %[blocks], %%rax
    jge 2f
    movq %%rax, %%rcx
    shlq $4, %%rcx
    movdqu (%[dst],%%rcx), %%xmm0
    movdqu (%[src],%%rcx), %%xmm1
    paddsw %%xmm1, %%xmm0
    movdqu %%xmm0, (%[dst],%%rcx)
    incq %%rax
    jmp 1b
2:
END, "", "r;r;r", "rax;rcx;xmm0..xmm1;cc")
#asm("aarch64", #string END
    cbz %[blocks], 2f
    mov x9, %[dst]
    mov x10, %[src]
    mov x11, %[blocks]
1:  ld1 {v0.8h}, [x9]
    ld1 {v1.8h}, [x10], #16
    sqadd v0.8h, v0.8h, v1.8h
    st1 {v0.8h}, [x9], #16
    subs x11, x11, #1
    b.ne 1b
2:
END, "", "r;r;r", "x9..x11;v0..v1;cc") {
    for i: 0..blocks * 8 - 1 {
        sum := cast(s32)dst[i] + src[i]
        if sum > 32767 { sum = 32767 }
        if sum < -32768 { sum = -32768 }
        dst[i] = cast(s16)sum
    }
}

Clobbers name registers or numbered ranges: xmm0..xmm7, x9..x11, v0..v3, and on ARMv7 q0..q1, which stands for d0..d3. The checker requires every register a template names to be a clobber or pinned by a constraint ("a" holds rax), every %[name] and %N to be an operand, and no clobber to overlap a pinned register. Templates may read, but never clobber, the stack, frame and platform registers: rbp/rsp on x86-64, x18/x29/sp on AArch64, r7/r11/sp/pc on ARMv7.

Build generated C with -DZIRAN_ASM_CHECK to test the variants: each call of such a procedure runs its portable body, then every variant the CPU supports on copies of the same inputs, compares results and the memory its slice and pointer parameters reach, and aborts with #asm check failed: AddSaturate variant "x86_64" differs from its portable body in dst on a difference. Callers receive the portable results. Run it over random and edge-case inputs. Local #asm blocks are not checked this way. tests/spec/asm_dct8.zi is a complete kernel: AV1's 8x8 inverse DCT with SSSE3, AArch64 and ARMv7 NEON variants, about ten times faster than its portable body.

TinyCC compiles x86_64 variants whose features are among sse2, ssse3, sse4.1, sse4.2 and popcnt, whose constraints are general registers, memory or immediates, and whose clobbers are general or xmm0-xmm15 registers; it detects the features with cpuid. It skips other variants. ziran run FILE.zi compiles the program with TinyCC and runs these variants. A .zx bundle keeps them, but a bundle file, wherever it came from, and the portable VM always run the portable body, so a downloaded bundle cannot run instructions of its own; run sh tests/zx_native_asm.sh build/bin/ziran. Under ziran run the DCT kernel above takes 28 ns a block against 1.8 µs for its portable body.

Feature variants use isolated functions compiled for the requested ISA. The ordinary caller keeps the build's baseline ISA, checks CPU availability once, and caches its selection with atomic accesses. x86 GCC/Clang GNU-ABI drivers use the compiler's CPU detection; Linux/Android AArch64 and ARMv7 C use the kernel's hardware-capability information. Other platforms or unavailable capabilities use the next eligible variant or the checked Ziran body. ZIRAN_NO_ASM forces the portable body. Build the caller for the oldest CPU you intend to support; globally enabling -march=native or extra ISA options can also affect its ordinary generated code.

x86 and x86_64 features are popcnt, sse2, ssse3, sse4.1, sse4.2, avx, avx2, avx512f, bmi, bmi2, lzcnt, fma, aes, and pclmul. AArch64 features are aes, crc32, pmull, sha2, atomics, and simd. ARMv7 (arm) has neon, and wasm32 has simd128. Other architecture/feature combinations are rejected. Feature combinations require every listed feature. Variants are considered in source order; put more demanding variants first and an architecture-only variant last. Duplicate or shadowed variants are rejected. Baseline variants must use instructions guaranteed by the selected build target. See GCC CPU detection, function target attributes, and ARM64 hardware capabilities.

arm+neon variants need ARMv7-A or later with a hard-float ABI (Linux armhf, Android armeabi-v7a from API 18). Their helpers run in ARM state, so Thumb-2 callers work, and the caller keeps the build's FPU option. Name the NEON registers you use in the clobbers (q0 or d0;d1). Write vld1/vst1 without :64/:128 alignment qualifiers unless the address is known to be aligned: a qualified access to a misaligned address faults.

A WebAssembly engine rejects a whole module that holds SIMD instructions it does not support, so wasm32+simd128 is a build option rather than a run-time check. Its variants are compiled in, inline, only when the generated C is built with -msimd128; otherwise the next variant or the Ziran body runs. Build the module twice, with and without -msimd128, and load the SIMD module only where WebAssembly.validate accepts a SIMD module. Operands are locals (local.get %[value], local.set %[out]), and inputs must not be written. v128 values cannot be operands: keep them on the operand stack or in memory passed by pointer. From Clang 19 (current Emscripten qualifies) a template can carry them through block (v128) -> (v128) and loop (v128) -> (v128); Clang 18 crashes on any typed block in inline assembly. Use v128.load 0:p2align=0 for unaligned data.

Assembly and the fallback must implement the same behavior; checking the fallback does not prove their equivalence, so test with ZIRAN_ASM_CHECK. These variants do not yet provide asm goto, naked functions, standalone assembler files, or direct Go ABI assembler output. Saved IR preserves the variants; .zx executes only portable code. Rebuild older IR/bundles with the current compiler.

Generated Go packages use their checked Go implementation by default. Enable the native bridge with CGO_ENABLED=1 go build -tags=ziran_asm . in the generated package. Build packages rather than explicit filename lists so Go applies companion-file build constraints. A working C cross-compiler is required when cross-compiling with cgo. The bridge reuses the C assembly emitter, including GNU constraints, labels, architecture selection and CPU feature guards; it does not translate templates into Go assembler syntax. CGO_ENABLED=0 always uses the checked body. To force that body while testing the native build configuration, set CGO_CFLAGS="-O2 -g -DZIRAN_NO_ASM" together with the build tag.

Go bridge signatures carry scalars and pointers to storage containing no Go pointers. Flat record results are reconstructed from primitive result fields. Pointer-bearing objects, pointer-valued local-block outputs or memory inputs, and local-block pointers to arrays retain the checked body. Standard cgo pointer checks remain enabled. Assembly must not retain Go pointers after the call or create pointers that violate Go's pointer rules. Unavailable variants leave their Go dispatch unset, including before Ziran startup code executes.

Each bridge invocation pays a cgo transition. A whole-loop assembly kernel can amortize that cost; calling the bridge for every small operation can be much slower than the Go body. Measure the intended call granularity before enabling the tag. Default builds retain a compile-time-disabled native branch.

Run tests/assembly.sh build/bin/ziran for source/saved IR execution and rejection checks, and tests/assembly_features.sh for CPU selection, concurrent calls, unavailable features and isolated helpers. It runs ARMv7 NEON and its masked fallback under qemu-arm with GCC and Clang (ZIRAN_ARM_CC, ZIRAN_ARM_CLANG, ZIRAN_QEMU_ARM; ARM_REQUIRED=1 fails instead of skipping). tests/zx_wasm.sh builds SIMD128 and baseline modules with ZIRAN_WASM_CC (default clang, WASM_REQUIRED=1 to require it). tests/go_assembly.sh checks native and default Go builds, forced fallback, strict cgo pointer checking, startup dispatch, memory aliases, concurrency and source/saved IR. bench/go_assembly.py compares whole-loop and per-operation bridges with checked Go bodies, a preserved compiler and native C controls. bench/assembly.py measures baseline assembly against its forced fallback and handwritten C/Go; bench/cpu_features.py measures CPU-selected loop kernels against an optimized Ziran body and handwritten C, with independent result validation. Use assembly where measurements or access to a specific instruction justify it; compilers already recognize many byte swaps and arithmetic patterns.

Local assembly blocks

A block can read and write scalar or pointer locals and parameters inside an ordinary procedure. Its six strings use the same architecture, template, clobber and option rules. Each output/input item is constraint:binding, or constraint:alias=binding when the assembly operand needs a different name. The template refers to the alias, while the binding names existing lexical storage. Every variant must bind the same operands in the same order.

Answer :: () -> s64 {
    value: s64 = 41
    result: s64
    #asm("x86_64", "leaq 1(%[value]), %[result]", "=r:result", "r:value", "", "") {
        result = value + 1
    }
    return result
}

For a read/write value, use output "=r:total" with an input such as "0:old=total;r:value"; the first input initializes the output register and value supplies another operand. Aliases must be distinct across all inputs and outputs. Multiple outputs and zero-operand blocks are supported. Bindings cannot be global names, constants, members, indexed expressions or aggregates; pass a pointer when assembly needs to access other storage, and declare the memory and register clobbers required by the GNU contract.

Architecture-only blocks emit inline assembly. CPU-feature blocks use isolated helpers. C helpers receive input values and return a single =r/=&r output directly; other or multiple outputs use each binding's actual address. Selection is cached atomically per block. Their portable body is always checked, runs on unsupported targets or with ZIRAN_NO_ASM, and must fall through to the following statement. It may contain local loops and their loop control, but cannot return or break out of the assembly block. Native instructions must preserve that fallthrough, stack discipline and the declared side effects as well as result values; checking cannot prove this equivalence. Run tests/assembly_blocks.sh for source/saved IR, native/portable, concurrent selection, typed operands and malformed-metadata checks. Inside a native #parallel loop, operand bindings from the enclosing scope are captured even when only the assembly template uses them; the portable body retains the same loop bindings. Assembly outputs must remain iteration-local, and GPU regions reject pointer operands. Native parallel regions currently support up to 16 captured bindings.

Build and test

Run make to build the current toolchain and make check for the complete lean language, backend, and portable bundle suite. Pruning removed 263 redundant check scripts and their unused helpers. Local and CI runs execute every retained check. Explicit CHECK_FILTER runs include every matching check. Workers follow the build queue's allocation; set CHECK_JOBS=1 to run serially or choose another positive worker count. make sanitize runs the retained suite under AddressSanitizer and UBSan.

A project's GitHub Actions job sets up the Ziran its ziran.lock pins with uses: ziranlang/ziran/.github/actions/setup@COMMIT. It checks out that commit with its compiler built and puts build/bin on PATH; fetch: true also fetches the locked packages. On Linux the build tree is the one the Toolchains workflow published for that commit, so the job builds nothing; on other systems, or for a commit without one, the job builds Ziran once and caches it. Run the Toolchains workflow for an older commit a lock still pins.

Dependency-owned native recipes accept per-build options without editing project settings: ziran pkg artifact provider:native --platform linux-x86_64-gnu --option provider:mode=release. Repeat --option Alias:key=value for declared string or boolean keys, including group.name.key for named tables. Values are literal argv data; quote paths containing spaces. Overrides reach prerequisite recipes and contribute to cache identity. See package artifacts for validation, alias mapping and the programmatic entry points.

Compiler commands

The compiler commands are zi2zir (including --check-only), zi2c, zi2go, and zi2zx. Run a program with ziran run hello.zi or zi2zx hello.zi; Ziran checks the file and executes main in one step. make install-user installs both commands in ~/.local/bin. zi-fmt formats source. ziran guide prints a short reference from the installed compiler, including current safety and target limits. ziran capabilities --target=go --json reports a versioned machine-readable target summary; see the capability schema. ziran api --json --root src src/main.zi checks an entry and its imports, then reports their public types and procedure signatures; see the API query schema. ziran check --lint also warns about casts a program no longer needs: a cast to the value's own type, and one that only widens a whole typed initializer, assignment, argument, or result, which the compiler now widens itself. It also warns when a byte is compared with a printable character's code written as a number, as C ports do: text[i] == 65 reads better as text[i] == #char "A". And it warns about an integer local that only ever holds 0 or 1 and is only compared with them, which reads better as a bool. Warnings do not fail the check ("severity":"warning" in JSON). ziran check|ir|api|inspect|build|bundle|run|fmt remains a convenience dispatcher for the same tools. Cross-target benchmarks measure the compiler, downstream toolchains, and validated runtime cases separately.

Portable bundles run with ziran run app.zx (or --vm app.zx). Graphical bundles use installed package-provided platform hosts through the same command; see runtime providers.

Generate native code

Use ziran build --target=c for C99 output. Add --exe --entry module:function to also compile it into DIR/module, a program that runs that procedure and exits with its integer result; the entry takes nothing or (argc: s32, argv: **u8) (signed byte pointers are also accepted). CC, CFLAGS (default -O3), LDFLAGS, and LDLIBS apply as in make. Add --release for final builds: the default flags become -O3 -flto=auto, which optimizes across modules. Each link then generates code for the whole program again, so one-file edits rebuild many times slower. CFLAGS still wins.

Small public procedures are inlinable across modules in every C build: the module's header carries a static inline copy that other modules' calls use, while the module's own .c keeps the one external definition, so a procedure's address is the same everywhere. Each module still compiles and caches on its own; editing such a procedure's body rebuilds the modules that call it. Procedures that call file-scope code, or that use string literals, constant tables or function values, stay out of line. A C file that defines a module's procedures itself, such as a test mock, puts #define ZIRAN_<MODULE>_H_SOURCE 1 (the header guard plus _SOURCE) before including that module's header, so the header leaves out its copies. -DZIRAN_NO_HEADER_INLINE leaves out every module's copies, so all calls reach the external definitions; a test that interposes procedures at link time, as with ld --wrap, builds with it.

#inline after a procedure's result asks generated C to always inline it: Mix :: (value: s64) -> s64 #inline { ... }. GCC and Clang get always_inline, a public procedure's header copy is kept whatever its size, and a procedure that calls itself is rejected. #cold does the opposite for slow paths such as a refill: the procedure is never inlined, is laid out as rarely run, and other modules call its one definition rather than a header copy. Neither changes what a call does, and Go marks a #cold procedure //go:noinline.

A call that returns an array writes its result straight into the array a declaration creates or a plain assignment names, such as grid := Make(4), row = Next(row) or holder.values = Make(10), with no temporary and copy in C. The callee stores its result only when it returns, so arguments and globals that name the destination still read the old value during the call. Returning an array works the same way: return Make(n) hands the caller's destination on, and return out, return holder.values or a parameter is copied out once. A local array passed by value to a Ziran procedure, as in Sum(values), is copied once, by the procedure as it starts; the caller copies it first only when a later argument could change it.

Indexing stays checked in every build, but generated C leaves out a check it can prove always passes: a loop counter its condition bounds, such as i in for i: 0..7 over a [8]T array or in for i: 0..view.count-1 over a slice or string nothing reassigns, and values bounded by their type or by &, %, >> or << by a constant, such as table[x & 255], bytes[(row << 4) + t] for row in 0..15 and t in 0..3, or bytes[b] for a u8 b and 256-entry arrays. Locals built from such values keep their ranges: after line := row * 16 and at := line + (offset & 15) for row in 0..15, bytes[at] over a 256-entry array needs no check, and a counter declared without a value starts at zero. An index whose variable has its address taken or is changed before the read keeps its check. Run sh tests/proven_index_ranges.sh build/bin/ziran.

Guards count too. After if stride > 64 || input.count < cast(s64)(height + 3) * stride + width + 4 { return }, with width and height bounded the same way, input[row * stride + column] in for row: 0..height + 3 and for column: 0..width + 3 reads without a check. An enclosing if or while condition, earlier && and || operands, and an earlier if that ends in return, break or continue all act as guards until a value they compare changes. A local declared before the guard counts too: after at := row * stride + column and a guard bounding row, column and 4 <= stride <= 36 that returns when cast(s64)at + 4 * cast(s64)stride >= levels.count, levels[at + 1], levels[at + stride] and levels[at + 4 * stride] need no check unless something writes row, column or stride in between. Every step of the guard and of the index must provably not wrap, so if first + 1 + second > view.count { return } does not prove view[first]. Run sh tests/guarded_index_ranges.sh build/bin/ziran.

An if inside a loop only adds to what the loop proves. After if flag > 3 { continue }, if i >= 32 { continue } or if code != 0 { return code }, and inside if i < 32 { ... }, the read view[i * width + j] in for i: 0..height - 1 still uses i <= height - 1. Facts chain, too: inside for j: 0..width - 1 the width is at least 1, so after if stride < width || samples.count < (cast(s64)height - 1) * stride + width { return } the read samples[i * stride + j] needs no check. A local chosen by ifx, such as yy := ifx flip_rows then cast(s64)height - 1 - i else i, is proven for both choices, so samples[yy * stride + xx] needs none either. The guard may also write cast(s64)(height - 1) * stride: inside the loop height is at least 1, so height - 1 did not wrap in s32. A long || guard works as well as a short one: comparisons about values no index reads, such as gain > 255, do not crowd out the ones it needs. A window offset such as (iy + 2 - radius) * stride + 2 - radius + k * stride + j is proven too after if radius < 1 || radius > 2 { return }. An else runs where each branch before it was false: after if i == 0 { ... } and else if j == 0 { ... }, the else reads view[(i - 1) * width + j - 1] without a check, and if i != 8 inside for i: 0..8 leaves i <= 7.

Fields count as well. For frame: *Frame, after if source.count != frame.count || destination.count != frame.count { return } the copy for i: 0..frame.count - 1 { destination[i] = source[i] } needs no checks: the loop reads frame.count once, before its stores. A field read through a pointer keeps what guards showed until a call or a store through any pointer, slice or global could change it, so a read after Grow(frame) or other.count = 0, or in a loop body that stores through a slice, keeps its check. A record parameter's fields, such as shape.width for shape: Shape, change only with the parameter. Run sh tests/field_guard_ranges.sh build/bin/ziran.

A local declared without a value starts at zero, and generated C leaves the fill out when every element is stored before anything could read it. For block: Block with samples: [64]u16, the loop for i: 0..7 { Simd.Store(block.samples[:], i * 8, row) } writes every lane, so C declares Block block;. Stores count at constant positions, in loops with constant bounds, one field at a time or whole; a branch or loop that names nothing of the local is passed over. The fill stays when a store may be missing or late: a loop that stops or skips early, a store or break under a condition not known there, any earlier use of the local such as a read, a view or a using field, an index not known or one that wraps, and records whose fields differ in size. Run sh tests/definite_initialization.sh build/bin/ziran.

Generated C keeps Ziran's integer rules: a zero divisor or a shift count outside 0 to width-1 aborts, and the minimum signed value divided by -1 wraps to itself. Arithmetic compiles to C operators, a constant divisor or shift count needs no check, and other divisions and shifts call a small checked helper of their width. Generated Go shifts by a variable count in the operand's own type after checking the count.

#parallel_gpu for runs a loop on the fastest GPU in native C builds on Linux and Android, and on CPU threads otherwise. Programs need no graphics libraries to build or start. A region qualifies when it uses only s32, u32, float32, u8 and bool locals, parameters and slices, assigns slices only at values[it], and calls plain functions of those types. Use cast(s32)it for arithmetic on the index. The generated C names why any other region stays on CPU threads. Integers, bytes and booleans match the CPU exactly; float32 results may round differently. A region that fails a bounds check or runs a loop past about a million rounds per iteration reruns on the CPU, so failures are reported as usual. ZIRAN_GPU=off keeps regions on the CPU, ZIRAN_GPU=N chooses a device, ZIRAN_GPU_MIN sets the fewest iterations worth sending (default 4096) and ZIRAN_GPU_LOG=1 reports what ran where.

C program entries use a standard int main(int, char **) adapter. The Ziran procedure keeps its declared parameter and result types for ordinary calls and function values. The adapter converts its integer result to a process status; a procedure returning nothing exits successfully. This also applies to a procedure exported under the symbol main through #program_export "main".

Native C executable builds compile source files concurrently on at most half of the available CPUs, with no more workers than free memory holds at 512 MiB each; ZIRAN_JOBS=N lowers the count. Compiler commands run at niceness 10 with low disk priority, which their C compilers inherit; ZIRAN_NICE=N chooses another niceness from 0 to 19. The build links and replaces the executable only after success. The output directory has a writer lock and keeps intermediate objects under .objects; unchanged objects can be reused with supported GCC/Clang invocations. Reuse checks source and header contents (including system headers), compiler executables and their reported subtools, command flags, the environment and header search locations. New headers that shadow an earlier dependency invalidate reuse too. The driver always links again, so changed libraries reach the executable.

Compiler wrappers, unsupported flags, time macros, nonliteral header probes and assembly includes use ordinary compilation. Missing or damaged cache files are rebuilt. Nested generated modules and manually supplied root-level C hosts participate in the link.

Use ziran build --target=go --pkg main --exe --entry module:function -o DIR for Go output. C foreign procedures with scalar or pointer parameters and results route through generated cgo adapters; native Go package imports continue to call those packages directly. C ABI calls need CGO_ENABLED=1 and a C compiler. LDFLAGS and LDLIBS are preserved in the cgo preamble. Slice, record and variadic C ABI parameters are rejected on Go; modules can wrap those APIs with scalar and pointer declarations.

Go can also bind a C header directly with go:C/<relative-header>. The header supplies the function declarations and native type layout; generated adapters do not redeclare its ABI. For example:

c :: #system_library "go:C/example/signature.h";
builtin :: #system_library "go:builtin";
Signature :: #type #foreign c "signature";
New :: () -> *Signature #foreign c "signature_new";
Length :: (value: *Signature) -> usize #go_field #foreign c "(*signature).length";
FreeAtReturn :: (value: *Signature) #go_defer #foreign c "signature_free";
NativeAvailable :: () -> bool #foreign builtin "cgo_enabled";

Header calls accept scalar values and pointers to scalars or declared C types. #go_field supports native fields and header constants; #go_defer preserves cleanup during returns and panic unwinding. Pass include and library paths to Go through CGO_CFLAGS and CGO_LDFLAGS, or preserve linker flags at generation through LDFLAGS and LDLIBS. Calls still obey cgo's pointer ownership rules. Header paths cannot be absolute or contain empty, . or .. segments.

Header bindings generate .cgo.go and .nocgo.go companions. Ordinary Ziran procedures remain available in either build. cgo_enabled reports the native build setting so application code can choose its unavailable path before a C call. With CGO_ENABLED=0, C pointer types are opaque and C calls panic instead of fabricating results. Build the generated Go package with go build . or go run ., which honor the build tags. Reachable header bindings and native availability checks require Go; pruning a portable entry can remove them.

Pass --entry module:function to a C99 build to retain functions, types, globals, and constants reachable from that entry. A native host implementation of a called foreign function is retained when it is among the input modules. Type-only linked modules emit headers without empty C files. Without --entry, all checked declarations and module C files are emitted as before. Runtime branches in reachable functions remain; this pass does not specialize them.

ziran compile-commands DIR writes DIR/compile_commands.json for the C files in DIR, so clangd and other editors can read generated code with the flags it builds with. --compiler "cc -O2" sets the compiler and its extra flags (default $CC, then cc), --include DIR names Ziran's headers (default: the pinned toolchain's with --project, otherwise the checkout beside the launcher), and -o FILE writes elsewhere.

Save and inspect checked modules

ziran ir --entry module:function saves that reachable checked graph as per-module .zir files before native code generation. For a portable library, ziran bundle --entry module:function --retain other:entry also keeps the named function and its dependencies. Repeat --retain for additional entry points consumed by thin --library bundles; unrelated functions remain outside the payload. Every retained entry must exist in the checked input graph. Thin bundles retain new declarations even when their module already occurs in a pruned library. Matching declarations are supplied by that library, and conflicting declarations reject linking. Rebuild hosts, libraries and add-ons together for ZX version 27; see partial module composition. Saved .zir files are binary. ziran inspect path/to/module.zir shows their declarations, statements, and checked expression links as readable text; ziran inspect --hex path/to/module.zir shows bytes and offsets. ziran inspect program.zx shows a portable bundle’s entry, linked modules and assets without executing it; --hex shows its binary bytes. ziran build --target=wasm --entry module:main --root DIR -o program.wasm FILE.zi produces WebAssembly. Its zx.host_i64 dispatch reserves index UINT32_MAX for builtin string output: the i64 argument packs byte length in its upper 32 bits and memory offset in its lower 32 bits; the callback writes those bytes and returns zero. Other reserved indices format signed integers (UINT32_MAX - 1), unsigned integers (- 2), float64 bits (- 3), float32 bits (- 4), and booleans (- 5). Scalar formatted print arguments use these callbacks. Slices support omitted bounds (values[:], values[:end], values[start:]) and empty views at the end of their storage. Both inspection views read the saved file and leave it unchanged.

Imports

For ordinary imports, pass the entry file and a library directory with --module-path DIR (repeat for multiple directories). There is no fixed module-root count limit; storage grows with the inputs, subject to available memory and operating-system argument limits. Search order remains the order of the supplied paths. check, ir, api, build, and bundle load extensionless #import "module" dependencies transitively from .zi or saved .zir. For example:

build/bin/zi2zir --check-only --root app --module-path ../kryon/src/ui app/main.zi

A package-qualified import such as #import "kryon/Widgets" resolves through the package's dependencies in a --project build. Outside a project, name the package's module directory instead: --module-path kryon=../kryon/src/ui serves kryon/NAME imports from that directory (and only those; repeat it for more directories). A package builds the same way in both modes when each export has its module's name. With --project, --module-path DIR adds a folder of generated modules to the project's own package only; see generated modules.

#import, file "../lib/helper.zi"; loads an explicit source file relative to the importing file. Saved .zir builds resolve its module by name from the saved IR directory or a module path. Helper :: #import "helper"; and Helper :: #import, file "../lib/helper.zi"; expose public declarations as Helper.Name without adding them to the unqualified scope. Helper :: #import, dir "../lib/helper"; loads that directory's module.zi. An ordinary #import "helper" also discovers helper/module.zi on a module search path. #load "relative/file.zi"; adds another file to the current module. Loads can nest, and imports inside loaded files resolve relative to those files. #import, string "Value :: () -> s32 { return 42 }"; compiles source text as a module. It also accepts a raw #string body, and Generated :: #import, string "..."; exposes its declarations as Generated.Name.

Foreign functions

Ordinary #import accepts a module identifier. C headers are no longer imported as source modules; foreign procedures use #system_library and #foreign declarations. For a native symbol with an unmangled name, put #program_export on its own line immediately before the function declaration. Foreign procedures use a Jai-style library declaration and #foreign:

libc :: #system_library "libc";
Abs :: (value: s32) -> s32 #foreign libc "abs";

Go packages

For a Go package, use an explicit go: library path. This also supports standard packages whose import paths contain no slash:

strings :: #system_library "go:strings";
TrimSpace :: (value: string) -> string #foreign strings "TrimSpace";

The Go target emits a direct package call. These declarations remain Go imports in saved .zir; they are not portable host capabilities.

An explicit #program_export "NativeName" emits a typed Go function with that exact name. Checked Ziran calls retain their module-qualified implementation name. Reserved names and collisions with functions, types, imports and module startup are rejected; colliding globals and constants keep their generated private names.

Native Go tests use _test.zi files and named exports such as #program_export "TestSigning". Modules using native testing bindings or named test, fuzz and benchmark exports generate _test.go files, including when their module names need disambiguation. Their native adapters expose *testing.T, *testing.F, *testing.B or *testing.M directly for Go's test discovery. Test helper modules may import the testing bindings from another Ziran module. Ordinary Ziran checks without this native boundary retain the _test_ziran.go filename and remain ordinary package code.

To require a package for its native initialization, bind its init symbol as a nullary void procedure:

driver :: #system_library "go:example.org/database/driver";
Initialize :: () #foreign driver "init";

The generated Go import runs the package's initialization once, before the generated package's globals. The binding's call then has no work to repeat. If no types or functions need a named import, the compiler emits a blank import. Initialization bindings accept no receiver, parameters, results or modifiers. Keep a call on the entry's dependency path when generating a pruned executable.

Generated Go executable entries carry the same provenance header and output ownership as other generated files. --prune-stale removes an old entry when the output directory is later generated with --no-main.

Foreign Go types

Declare an opaque Go type alongside the package's foreign procedures:

json :: #system_library "go:encoding/json";
RawMessage :: #type #foreign json "RawMessage";
Envelope :: struct {
    payload: RawMessage #go_tag "json:\"payload\""
}

The Go target emits a type alias, preserving the imported type's identity, methods, interface behavior and zero value. Ziran can store, pass, return and assign these values, including across imports. Fields and layout remain opaque: record literals and size_of are unavailable. These declarations require an explicit go: package and are preserved in checked IR. Other targets reject foreign Go types; an entry build can discard them when they are unused.

A slice can be explicitly converted to or from a foreign Go type whose underlying storage supports the conversion. The Go compiler checks that underlying type. Conversions preserve native length, capacity, nil values and shared backing storage; returned native slices keep that storage alive:

Wrap :: (value: []u8) -> RawMessage { return cast(RawMessage)value }
Unwrap :: (value: RawMessage) -> []u8 { return cast([]u8)value }

Fixed-array casts, slice-to-slice casts and owned vector elements are rejected.

Go receivers, fields and multiple results

A quoted method expression supplies its receiver as the first parameter. The receiver must match an opaque type declared from the same Go package:

sync :: #system_library "go:sync";
Mutex :: #type #foreign sync "Mutex";
Lock :: (value: *Mutex) #foreign sync "(*Mutex).Lock";
Unlock :: (value: *Mutex) #foreign sync "(*Mutex).Unlock";

Use #go_field before #foreign to read a native field through a typed getter. It takes exactly one receiver and returns the field value. The Go compiler verifies that the field exists and has the declared storage type:

http :: #system_library "go:net/http";
Request :: #type #foreign http "Request";
Remote :: (request: *Request) -> string #go_field #foreign http "(*Request).RemoteAddr";

A getter without a receiver or parameters reads a native package variable or constant each time it is called. It preserves native interface identity:

#import "go_types"
sql :: #system_library "go:database/sql";
NoRows :: () -> Error #go_field #foreign sql "ErrNoRows";

Getters cannot return owned vectors or records/slices containing them. A field accessor with a void return is a setter. It takes a pointer receiver and one value, or just one value when assigning a package variable:

time :: #system_library "go:time";
Duration :: #type #foreign time "Duration";
Client :: #type #foreign http "Client";
SetTimeout :: (client: *Client, value: Duration) #go_field #foreign http "(*Client).Timeout";
SetDefault :: (client: *Client) #go_field #foreign http "DefaultClient";

Setters cannot take owned storage or a value receiver. The Go compiler checks the field's type and whether the package value can be assigned. Accessors cannot use variadic parameters, #go_results, or #go_defer. Methods of predeclared Go interfaces use their native receiver spelling, for example #foreign builtin "error.Error" with an Error parameter.

Ordinary procedures can implement native Go interfaces with #go_method "MethodName". The first parameter is a local named record or its pointer; remaining parameters and the result form the native method signature. The generated method calls the ordinary Ziran procedure, preserving pointer, error and panic identity. Method names must be Go identifiers, unique for the receiver record, and different from its generated field names. Foreign types, anonymous record aliases, generic procedures and owned vector signatures are rejected. Go checks interface satisfaction when compiling the generated code.

Message :: struct { text: string }
Describe :: (message: Message) -> string #go_method "Error" {
    return message.text
}

On an ordinary method procedure, #go_results flattens a nonempty concrete result record into native Go multiple results in field order. Its fields must be named without using or owned vectors. Ordinary Ziran calls still receive that record. C and the portable VM keep the same ordinary procedure body; method adapters are Go ABI metadata. Both attributes survive saved IR. Go executable entry selection retains methods of reachable receiver records for native interface dispatch.

Use #go_results on foreign declarations to pack a Go function's multiple results into a concrete record. Record fields correspond to Go results in declaration order, including native error interfaces. It also works with method expressions:

#import "go_types"
net :: #system_library "go:net";
HostPort :: struct { host: string; port: string; error: Error }
Split :: (address: string) -> HostPort #go_results #foreign net "SplitHostPort";

The result record must be nonempty, without owned vectors, using fields or discard fields. Variadic declarations and combinations with #go_field are rejected. Both attributes require explicit Go package targets. Checked IR stores their typed signatures and attributes; generation from saved IR does not infer them from diagnostic source text.

The standard modules sync_go, time_go, random_go, text_go, net_go and http_go expose mutexes, native monotonic timestamps, cryptographic random bytes, copied byte strings, IP primitives, HTTP request fields and headers. atomic_go provides native Uint64 counters with atomic Add and Load. http_go also preserves the native ResponseWriter interface and exposes response headers and SetHeader. time_go provides parsing, UTC conversion and formatting while preserving native time.Time values and parse errors. It also provides native ticker creation, stopping and boxed channel access. context_go preserves native contexts and provides Background and the boxed cancellation channel Done. select_go builds receive, send and default cases over boxed native channels. Select blocks until a case is ready and returns its index, native reflected value and receive-success flag. It uses Go's reflect.Select; channel direction and send-value compatibility are checked at runtime. Nil channels disable their case, closed channels retain their zero-value/false receive result, and multiple ready cases use Go's selection behavior. These operations require the Go target. sql_go exposes native database, transaction, row and result handles, nullable strings, row iteration and transaction cleanup. Declare query, execution and scan bindings with the argument types required by the application; Go checks those native signatures. errors_go provides native error creation, Is, Unwrap and exact messages. SQL and context modules expose their native error sentinels through getters.

Go deferred foreign calls

Declare a void foreign procedure with #go_defer to schedule its native Go call at the end of the calling function, including panic unwinding:

#import "sync_go"
sync :: #system_library "go:sync";
UnlockAtReturn :: (value: *Mutex) #go_defer #foreign sync "(*Mutex).Unlock";
Work :: (mutex: *Mutex) {
    Lock(mutex)
    UnlockAtReturn(mutex)
    // Work while the mutex is locked.
}

Arguments are evaluated and captured at the call statement. Scheduled calls run in reverse order when that function returns or panics; a nested block does not end the schedule. Declare and call the foreign binding in the function that needs cleanup. A Ziran wrapper would schedule cleanup at the wrapper's own return.

The binding requires an explicit Go package target, a void result and no owned vector arguments or variadic parameters. #go_results, #go_field and Go predeclared builtins other than typed call cannot be combined with this attribute. Calls must be standalone statements. Checked IR preserves the attribute, and other targets reject reachable Go bindings. Ordinary Ziran defer keeps its lexical scope cleanup semantics across targets.

Typed go:builtin call declarations invoke their first procedure argument with the remaining arguments. The declaration must match that procedure's parameter and result types and cannot carry owned vector values. A void call can use #go_defer to run application cleanup during native Go panic unwinding:

builtin :: #system_library "go:builtin";
Cleanup :: #type (completed: *bool) -> void;
CleanupAtReturn :: (callback: Cleanup, completed: *bool) #go_defer #foreign builtin "call";

The callback and its arguments are captured when scheduled. Pass a pointer when cleanup needs to observe a value changed later in the calling function. Checked IR retains the typed callback and the schedule independently of the declaration's diagnostic source text.

Typed go:builtin bind declarations capture one or more leading procedure arguments and return a procedure for the remaining arguments. The first parameter is the full procedure type; subsequent parameters match its leading parameters. The returned procedure matches its remaining parameters and result. Captured values are copied when binding, so pointers retain shared storage while later replacement of a value does not alter the capture. Binding never invokes the procedure; native errors, slices and panic values retain their identity. Captured parameters, remaining parameters and results cannot contain owned vectors. C callback types are rejected. These signatures are checked in both source and saved IR.

Ordinary procedure values also resolve each parameter and result in the declaring procedure type's module. Imported records, pointers, slices and native Go interfaces keep their type identity across different import aliases.

builtin :: #system_library "go:builtin";
Full :: #type (count: *s64, amount: s64) -> s64;
Bound :: #type (amount: s64) -> s64;
Bind :: (callback: Full, count: *s64) -> Bound #foreign builtin "bind";

Typed go:builtin retain declarations return their one argument unchanged under Go's managed storage lifetime. The parameter and result types must match and cannot contain owned vectors. This explicit native boundary allows strings, slices and records read through pointers to escape a procedure while preserving their original storage and nil values. It does not copy backing data or keep foreign C allocations alive; such allocations retain their foreign lifetime.

Typed go:builtin spawn declarations start the first procedure argument in a native Go goroutine with the remaining arguments. The callback and binding must return void; their parameter types must match and cannot contain owned vectors. Arguments are captured before starting the goroutine. Native Go pointer, slice, map and channel storage stays alive under Go's collector; shared mutation still requires synchronization. spawn cannot use #go_defer.

Typed go:builtin assert declarations use #go_results and one Any argument. Their result record has exactly two fields: the target value type and a bool indicating whether the native Go type assertion succeeded. A failed assertion returns the target type's zero value and false; a typed nil can succeed and stays distinct from a nil interface. Asserted slices, pointers and interfaces retain native storage and identity.

#import "std/go_types"
builtin :: #system_library "go:builtin";
Worker :: #type (value: s64) -> void;
Start :: (callback: Worker, value: s64) #foreign builtin "spawn";
IntegerAssertion :: struct { value: s64; present: bool }
AsInteger :: (value: Any) -> IntegerAssertion #go_results #foreign builtin "assert";

These operations and the std/channel_go native channel handles are specific to Go. std/context_go.WithCancel and WithTimeout return a native context and its cancellation function; call Cancel to release that context. Schedule cancellation in the owning function with a typed deferred callback when it must also run during native panic unwinding. Other targets reject reachable Go bindings; unused Go-only functions can still be excluded from a portable bundle.

io_go exposes native readers, closable readers, writers and ReadAll. http_go supplies request bodies and contexts, bounded body readers and response status writes. json_go supplies JSON validation and streaming encoders and decoders; url_go supplies decoded query values. Native interface conversions keep the original stream and context objects, and these operations preserve Go error identity. Schedule ReadCloser.Close with #go_defer in the function that owns the stream when cleanup must also run after a panic.

Go predeclared primitives

Typed foreign declarations in go:builtin can allocate Go heap objects and slices/maps, copy bytes into a string, or read a native length:

builtin :: #system_library "go:builtin";
Allocate :: () -> *HostPort #foreign builtin "new";
Bytes :: (count: isize, capacity: isize) -> []u8 #foreign builtin "make";
Text :: (value: []u8) -> string #foreign builtin "string";
Length :: (value: []u8) -> isize #foreign builtin "len";
Append :: (values: []u8, value: u8) -> []u8 #foreign builtin "append";
Panic :: (value: Error) #foreign builtin "panic";

new takes no arguments and derives the allocated type from its pointer result. make derives its slice or map type from the result; slices take a length and optional capacity, and maps take an optional size hint. len returns isize and accepts strings, arrays, slices, maps and declared foreign Go types whose underlying type supports native len. Go compilation checks the underlying operation for opaque types. These calls need no package import and retain Go's allocation, zero-value and byte-copy behavior. append takes a slice and one element, returns the same slice type, and keeps Go's length, capacity and shared backing-storage behavior. Owned vector elements are rejected; variadic slice expansion is not supported by this binding. panic takes one non-owned value and returns void. It preserves the supplied native Go value, including error identity, during panic unwinding.

Go record metadata

Go record fields can carry reflection tags. The checked string is preserved in saved IR and emitted as a Go struct tag; other targets keep the same fields and ignore this Go metadata:

Response :: struct {
    userID: string #go_tag "json:\"user_id\""
    expiresAt: s64 #go_tag "json:\"expires_at,omitempty\""
}

Go can preserve an anonymous native struct's identity with #go_anonymous alone in its record body:

Reply :: struct {
    #go_anonymous
    code: isize #go_tag "json:\"code\""
    message: string #go_tag "json:\"message\""
}

The Go declaration is type Reply = struct { ... }. Native reflection has an empty type name, and JSON decoding retains anonymous struct error details. The annotation preserves ordinary checked Ziran fields and is retained in saved IR. Other targets keep their ordinary record representation.

C header spellings

#c "SPELLING" lets a generated C header match C code that shares the records. On a field it replaces the C type, or an array's element type. Written last on an enum, it prefixes each C constant. Saved IR keeps it; Go and the .zx VM ignore it:

ExprKind :: enum s32 #c "ZIR_EXPR_" { Unknown; Ident }  // ZIR_EXPR_Ident
Expr :: struct {
    name: *u8 #c "const char *"
    op: [8]u8 #c "char"                                    // char op[8]
}

A using field spelled #c "union" (or "struct") also lays its record's fields out as an anonymous C member over the same storage, so C reads stmt->name where Ziran reads stmt.name.

Native scripts

ziran tool.zi ARGS runs ordinary Ziran source as a native script. Use .zi for scripts and compiled programs alike. On Unix, start executable scripts with #!/usr/bin/env ziran, give them an ordinary main procedure, and run chmod +x tool.zi once. Then ./tool.zi ARGS works with ziran on PATH. Every argument after the filename belongs to the script, including --help. The caller's working directory and standard streams are preserved.

The explicit form, ziran run --target=c [--no-cache] tool.zi -- ARGS, also accepts compiler options before the filename. The native runner builds the program once into $XDG_CACHE_HOME/ziran/native, rebuilds it when its sources change, and runs it with the arguments, returning its status. The entry takes nothing or (argc: s32, argv: **u8). In a project, a tool beside the module roots, such as tools/check.zi, still imports the project's modules. The tool reads its source path from ZIRAN_SCRIPT_FILE; see scripts/build_playground.zi for a maintained build tool.

Host capabilities and visibility

Ziran also resolves host_api :: #system_library "host_api"; to its host capability bridge. ziran bundle --bind caller:capability=provider:function can satisfy a portable host capability with an exported Ziran function in the bundle; the provider must have the same signature. --bind-host MODULE binds every remaining capability to MODULE's exported function of the same name, as a C program links a host's symbols by name, so one Ziran host module serves ziran bundle and the Go target alike. The portable host ABI copies returned string bytes into VM-managed storage, including strings inside records, arrays, slices, and mutable slice updates. A host can reuse its result buffers on its next call; saved strings retain their bytes for as long as the program uses them, including across instance runs. Returned fields and elements must remain valid until that next call. Opaque pointer results still refer to host-owned storage. Use #scope_file, #scope_module, and #scope_export to change the visibility of following declarations.

Multiline strings

Raw multiline text uses Jai's #string delimiter form. The body keeps its whitespace and the newline before the closing delimiter:

Greeting :: #string END
Hello, "world"!
END;

Returned views

A returned slice, text view or record of views must borrow storage the caller still owns after the call: a slice or string parameter, a view already stored in a pointed-to record, or static text. A pointer parameter lends its own array storage only for the call, so return a view of it by taking the storage as a slice instead:

Owner :: struct { values: [4]u16; label: string; }
Rest :: (values: []u16) -> []u16 { return values[1:] }    // view of caller storage
Label :: (owner: *Owner) -> string { return owner.label }  // view stored in the record
// Rejected: (owner: *Owner) -> []u16 { return owner.values[:] }

Call it as Rest(owner.values[:]). Inside the call, views of owner.values may be read and written freely.

Shadowing

A declaration in an inner block or a for body may reuse an outer name. Its initializer still reads the outer binding, so x := x + 5 adds to the outer x, and for i: 0..7 { i := i * 10 } keeps counting with the loop's own i. Two bindings of one name in the same block are rejected.

Integer literals

Integer literals are decimal, 0x hex or 0b binary, with _ between digits: 1_000_000, 0xDEAD_BEEF, 0b1010_0101. A u64 literal may use the whole unsigned range, as in 0xFFFF_FFFF_FFFF_FFFF. Locals, globals, :: constants, constant tables, array sizes and range bounds accept the same spellings on every target, and generated C headers spell them in decimal.

Standard library

Import a standard module as #import "std/text". In a project the pinned toolchain supplies it. A standalone command takes it from --module-path std=DIR, a --module-path directory named std, or the standard modules that came with the compiler: std beside an installed toolchain (make install-user copies it), otherwise the checkout the compiler was built from. ZIRAN_STD=DIR names another standard directory. std/text always means the standard module, even beside a library module named text. The short spelling #import "text" also works when no module of your own has that name. Dependencies from other Git repositories import the same way, as #import "NAME/Module"; see Packages.

Vector capacity and initialized length

Import vec to use Vec(T). VecReserve(values, capacity: s64) -> bool reserves at least that total capacity without changing values.count. VecResize(values, count: s64) -> bool changes the initialized length: new entries receive the same default values as uninitialized local declarations (zero scalars, empty strings, nil views/maps, recursively empty owned vectors). Shrinking drops removed owned entries and retains capacity. Reused entries are initialized again on growth; capacity beyond count is never indexable. Neither operation consumes the vector. Pass mutable storage directly; a live borrowed view prevents either operation, just as it prevents VecPush.

#import "vec"
pixels: Vec(u8)
if !VecResize(pixels, 4096) { return false }
pixels[0] = 255

Sizes are s64; negative sizes, size overflow, and failed allocation return false, preserving the old elements, count and capacity. Zero is valid and reserving zero allocates nothing. Native C uses one fallible growth allocation; byte resize initializes its new range in bulk. Other element types use typed default initialization rather than raw byte clearing of managed values. C, Go, and portable .zx support both operations from source and saved .zir. The VM also returns false when its array-storage budget prevents growth. Go catches recoverable allocation range panics, but physical out-of-memory failure can terminate the Go runtime. Capacities and allocation limits depend on the target; a successful capacity request never guarantees future growth. VecGet and VecClone cannot copy owned elements; mutate them in place or move an element with VecPop.

A Vec can hold records that own Vecs. VecPush moves such an element in from a local binding, a call result or a .{...} literal, so the source cannot be used afterwards. Read and change elements in place: groups[i].parts[j], VecPush(groups[i].parts, x), or for * groups { ... }. for groups and x := groups[i] would copy owned storage and are rejected. Clearing, popping, freeing or leaving scope frees the inner Vecs too. A Vec field iterates and slices in place like a local Vec: for h.items, for * h.parts, for it.tags inside for * h.parts, and h.items[1:]. While the loop or slice is live, its elements and the record's other fields stay writable, but pushing to, freeing or moving that Vec, or a record holding it, is rejected. A record field that holds Vecs moves out like a Vec field, as in last := popped.value. Vec(Vec(T)) works the same way. C, Go and portable .zx support this; wasm has no Vec storage. tests/nested_vec_elements.sh checks it, including a leak check.

Group :: struct { name: string; parts: Vec(string); }
groups: Vec(Group)
group: Group
VecPush(group.parts, "a")
VecPush(groups, group)
groups[0].parts[0] = "b"

A global can hold a Vec or Text, alone or in a record. Assigning the global, or a field of it, frees the old value and takes the new one, as in collection = CollectionParse(text) or shelf.items = TextCopy(name). A declaration, assignment or return that moves a global, as in taken := collection, takes its storage and leaves the global empty. A call argument or VecPush element cannot move a global, because that would empty it as a side effect: pass its address or move it into a local first. A global cannot be replaced or moved while a view of it is live. tests/global_owned_storage.sh checks C, saved IR and native and VM bundles.

A field path of a local record, or of a by-value parameter, takes owned values the same way: shelf.label = TextCopy(name) frees the old label, and a field moved out, as in taken := shelf.bag.items, can be assigned again. A whole local still has to be moved before it is replaced. A field cannot be assigned while a view of it is live, after its binding moved, below a moved field or through a pointer. tests/local_owned_fields.sh checks these.

VecResizeBounded(values, count: s64, maximum_capacity: s64) -> bool uses VecResize's initialized-length and owned-element semantics while bounding the resulting backing capacity. It rejects negative sizes, a count above the ceiling, or an existing capacity above the ceiling, including on a shrink/no-op. Rejection or allocation failure leaves storage, length and elements unchanged. Growth clamps the selected capacity to the ceiling; reuse retains capacity. A ceiling is not an allocation request, so a large positive ceiling is allowed when the actual selected allocation fits the target's element/byte limits. An empty Vec accepts count/ceiling zero without allocation. Future operations must supply their own ceilings; the bound is not a stored property of the Vec.

scratch: Vec(u32)
maximum_elements: s64 = 1024
if !VecResizeBounded(scratch, 700, maximum_elements) { return false; }
// scratch.count == 700; 700 <= scratch.capacity <= maximum_elements.

This bounds logical backing element capacity, not allocator size classes, runtime objects, managed-element allocations, VM validity maps or physical RSS. During growth old and new backing allocations may be live together. A caller with a concurrent-live scratch budget must reserve the full possible new backing allocation in addition to already-live old capacity, then reconcile against actual capacity after success. Default staging for managed elements and runtime object overhead require separate accounting. Ordinary Go fatal physical OOM remains outside recoverable allocation failures.

Building text

std/format.zi builds text on an owned Vec(u8) builder. Append(*builder, value) adds a string, integer, bool, or float, and BuilderPrint(*builder, "% of %\n", done, total) appends a formatted piece: each % takes the next argument and %% writes one percent. A file that uses BuilderPrint imports std/format automatically, and std/format makes Vec visible. BuilderFinish(builder) returns the text. Floats keep up to six digits after the point, with trailing zeros dropped.

Owned text

std/vec defines Text :: struct { bytes: Vec(u8); }, UTF-8 text that owns its bytes. A file that names Text or Vec(T) without declaring its own imports std/vec automatically. A Text moves and drops like the Vec it holds: it is freed when its local, record field or Vec element goes away. TextCopy(value) copies a string into a new Text, and BuilderAppend(text.bytes, more) grows it. TextView(text) borrows the Text in place as a string; TextView(it) also accepts the pointer it of a for * loop. A Text place passed or stored where a string is expected lends the same view. A view cannot outlive its Text, so returning a view of a local Text or storing it in a global is rejected. The Text cannot move or change while a view of it is live. TextView of a fresh call result is rejected; bind the Text first.

A procedure can return Text, and BuilderFinish(builder) gives a Text where one is expected, as in return BuilderFinish(out) from a -> Text procedure. A Text result is kept where its owner takes it: a declared, assigned or returned Text, a Text parameter or Vec(Text) element, or a discarded statement, which frees it. Anywhere else, as in line := Render(...) or an argument for a string parameter, the result detaches into a string that keeps its bytes for the rest of the program, as BuilderFinish strings always have, so callers written for a string result keep working. Write line: Text = Render(...) to free it with line. A result that is only read is freed when its block closes instead: an argument to a procedure whose body only reads that string parameter, a comparison, .count, or BuilderFinish passed the same way, as in Count(Render(a)) or Render(a) == Render(b), and a local such as line := Render(...) whose uses only do the same. Reads follow ifx arms, locals, a procedure that returns its argument when that result is only read, and a record literal field, as in Draw(Props.{.text = Render(a)}), whose callee only reads that field of its record parameter. Inside a callee, a parameter may also be copied into a local [N]string whose elements are only read, or into a string local that = later points at other views of parameters or at literals, as in v = s[1:3], and still only be read. A callee or local that stores, returns, aliases or defers the string, or a foreign callee not marked #reads, keeps it. The result is never computed earlier than written, so one after another call in its expression, after a global its procedure may change, in a while condition or for step, or in an ifx arm that gives a string keeps its bytes. Literals such as -1, other globals and a local's address, as in BuilderPrint(*out, "%", Render(a)), may come first. A procedure that only reads through a pointer argument, as in AwardsText(*awards), is freed too; one that writes through it stays in place. Each local is its own binding, so a same-named local in another block does not keep it. An else if condition, the second operand of && or ||, and an ifx arm that gives a number or bool run on their own just before the statement, still only when reached, so a result read there is freed too.

A foreign procedure that only reads its string and byte arguments during the call says so with #reads before #foreign:

MeasureWidth :: (value: string, font: s32) -> s32 #reads #foreign host_api;

Text passed to it is then freed like Text passed to a Ziran procedure that only reads it. Leave the marker off when the host may keep the pointer, such as a binding that stores it; #reads on a procedure with a Ziran body is an error, because that body already shows what it reads. TextDetach(text) detaches explicitly, and TextFromBuilder(bytes) takes a builder's bytes as a Text.

A procedure type makes the same promise for each procedure stored in it with #reads after its result:

SelectionPainter :: #type (x: s32, props: TextProps, label: string) -> s32 #reads;

Each procedure stored in the type, as a slot's value, a record field, an argument or a global's initializer, must only read its string parameters and the string fields of its record parameters, including fields of records they hold by value; one that keeps one is an error naming that parameter or field path, such as wrapped.data.value. A record copied into a field of a local record, as in kept.command = command, is followed to that local's later uses, and = into the field path, or a record on it, later in the same block replaces the string, so storing the record afterwards keeps nothing. A pointer to the record, or a store before the replacement or past it in a loop, keeps the string. Text passed through a value of the type is then freed like Text passed straight to such a procedure.

The standard text producers return Text: TextReplace, TextJoin, TextLower, TextHex, Float64Repr, JsonWrite, JsonString, JsonKey, JsonQuote, JsonNumberText, JoinPath, ResolvePath, WorkingDirectory, CreateTemporaryDirectory, CopyText, ReadStandardInput, DecodeReplace, Lower, CaseFold, Capitalize and Transform. Existing callers keep getting a string; json: Text = JsonWrite(...) frees the text with json.

std/float_text.zi supplies portable binary64 decimal conversion: ParseFloat64(text: string) -> (value: float64, ok: bool) accepts a complete ASCII decimal with optional sign, point and exponent, or inf/Infinity and nan/NaN. It rejects whitespace and trailing bytes. Rounding is nearest, ties to even; overflow succeeds as infinity and underflow as signed zero. Float64Repr(value: float64) -> string returns owned shortest roundtrip text with Python's repr layout: fixed notation for decimal orders -4 through 15, otherwise lowercase e, a signed exponent with at least two digits, and .0 on fixed integral values. It preserves negative zero and writes inf, -inf and nan. Float64ReprInto(value, output: *Vec(u8)) -> bool appends the same text to caller-owned bytes, reserving before writing and reporting allocation rejection. Both conversions use bounded exact integer arithmetic in Ziran, without foreign calls, locale state or a Python runtime. Parsing scans all input bytes, retaining at most 1100 significant digits plus a sticky flag; this exceeds the length of every binary64 rounding boundary. Run build-queue -- sh tests/std_float_text.sh build/bin/ziran for independent edge/random C/Go/VM tests from source and saved IR.

Hash maps

std/hash_map.zi maps string or integer keys to values on every target and the portable runner:

#import "std/hash_map"

ages: HashMap(string, s32)
HashMapSet(*ages, "ada", 36)       // insert or replace
age: s32
if HashMapGet(*ages, "ada", *age) { print("%\n", age) }
HashMapRemove(*ages, "ada")        // returns whether the key was present
HashMapFree(*ages)

HashMapHas tests a key and ages.count is the number of entries. The table doubles when half its slots are in use. Values may be records; keys are string, s64, u64, or u32 (and narrower unsigned integers). The Go target's native Map(K, V) from std/map_go is separate and keeps its MapSet/MapGet operations.

Text and UTF-8

std/text.zi supplies ASCII case folding, prefix matching, and substring matching over immutable UTF-8 strings. Non-ASCII bytes compare unchanged. These functions use only portable Ziran operations, so the same source builds for C, Go, and .zx. Add --module-path std when importing it from an app.

For full Unicode behavior, import std/unicode. CaseFold(text: string), Lower(text: string) and Capitalize(text: string) return strings using Unicode 17.0 full default mappings, including expansions and contextual Greek final sigma. Capitalization titlecases the first scalar and lowercases the rest, as Python's str.capitalize does. IsWord(codepoint: u32) -> bool matches Python Unicode regex \w; IsDecimal(codepoint: u32) -> bool matches \d using only General_Category Nd. IsSpace(codepoint: u32) -> bool and IsWhitespace(text: string) -> bool use Python's Unicode whitespace rules. IsPrintable(codepoint: u32) -> bool accepts Unicode L/M/N/P/S categories and ASCII space, excluding other separators, controls, format characters, private-use and unassigned values under Unicode 17.0. SplitWhitespace(text: string) -> Vec(string) collapses those whitespace runs and omits empty fields, like str.split() without a separator. Its pieces borrow the input. All operations run unchanged on C, Go and the portable VM, without a host Unicode library. See Unicode for exact semantics, the native table generator and conformance tests.

NFCInto(text: string, output: *Vec(u8)) -> bool and LowerInto use reusable caller-owned buffers: they replace previous bytes, reject malformed UTF-8 and empty the output on failure. NFC performs canonical Unicode 17 decomposition, ordering and composition, including Hangul; compatibility normalization is excluded. IsLetter and IsNumber match exact General_Category L and N; IsWhiteSpace matches Unicode White_Space, excluding IsSpace's extra U+001C..U+001F controls. See Unicode for ownership and conformance.

std/string_range.zi provides Substring(source, start, length) over borrowed bytes, using Ziran's checked source[low:high] string range syntax. Choose UTF-8 codepoint boundaries when the result must remain valid text.

std/utf8.zi advances through UTF-8 scalar boundaries and counts codepoints. Invalid bytes advance one byte, which keeps a scanner moving while preserving valid multibyte sequences. Copy(source, destination) writes valid UTF-8 unchanged and replaces each malformed lead byte (following Decode's consumption rule) with U+FFFD. It is bounded and allocation-free, leaves separate source storage unchanged, requires destination bytes not to overlap the source, and stops without splitting a source sequence or replacement when capacity runs out. Its result reports the written byte count and whether all input was consumed; embedded NUL remains data.

DecodeReplace(source: []u8) -> string returns independently stored valid UTF-8 with Python errors='replace' consumption: one replacement for each maximal valid prefix of a malformed sequence. Thus E2 82 becomes one U+FFFD and E1 80 41 becomes U+FFFD + A; forbidden prefixes such as ED A0 80 produce three replacements. It preserves valid UTF-8 and embedded NUL. Decode and Copy keep their documented consume-one-lead-byte behavior.

Generic values

std/option.zi and std/result.zi define generic records. Import a template and create a concrete type with Number :: Option(s32) or Outcome :: Result(s32, string). An Option has has_value and value fields; a Result has is_ok, value, and error fields. Check the status field explicitly before reading the associated value.

std/pair.zi defines a generic record. Import it and write PairNumberText :: Pair(s32, string) to create a concrete record with first: s32 and second: string fields.

A polymorphic procedure binds its type parameter through a direct $T, a slice []$T, a fixed array [N]$T, or a pointer *$T parameter: Sum :: (values: []$T) -> T specializes for whatever element type the caller passes. A generic record parameter binds its arguments too: Add :: (bag: *Bag($K), item: K) called with a *Bag(string) binds K to string.

Copy :: (values: [3]$T) -> [3]T { return values; } infers T while requiring exactly three elements. The capacity may be a compile-time constant or expression from the procedure's module; it is not inferred from the caller. Zero-capacity arrays retain their declared element type. Array parameters keep ordinary value semantics, so changing a parameter does not change the caller's array. Nested-array element types work natively; the portable linker rejects nested-array returns that its runtime cannot represent yet.

Sorting and queues

std/sort.zi sorts any slice whose elements support < in place with Sort(values[:]), and searches a sorted slice with LowerBound and BinarySearch. Sorting allocates nothing and is not stable. SortText(texts[:]) orders strings by unsigned UTF-8 bytes, including embedded NUL and prefixes, independently of the system locale. It also allocates nothing.

std/queue.zi is an allocation-free bounded byte FIFO. Queue state is passed and returned by value, and every operation receives the caller-owned backing storage explicitly. Full pushes and empty pops are rejected without unwinding the queue; partial byte pops report the number removed.

TOML manifests

std/toml_document.zi parses TOML 1.0 directly in Ziran on C, Go and the portable VM. It shares its parser with the package manifest reader. Quoted keys and strings (including escapes, literal and multiline strings), dotted keys, tables, inline tables, arrays and arrays of tables retain their meaning. Duplicate definitions, malformed values, invalid UTF-8 and trailing input reject the whole document, including errors in fields a caller does not query.

#import "std/toml_document"

document: TomlDocument
if TomlRead(*document, text) {
    package := TomlMember(*document, document.root, "package")
    name := TomlString(*document, TomlMember(*document, package, "name"))
    dependencies := TomlMember(*document, document.root, "dependencies")
    dependency := TomlFirst(*document, dependencies)
    while dependency >= 0 {
        alias := TomlKey(*document, dependency)
        dependency = TomlNext(*document, dependency)
    }
}

TomlRead returns bool; failure clears the tree, sets root = -1, and reports error and error_at (a byte offset). Its default byte limit is 262144; pass a third max_bytes argument to change it. Nesting is limited to 128. Keep the input alive while querying the document. Node queries accept -1 for missing values; use TomlKindOf to distinguish an empty string from another type. TomlCount counts members/elements, TomlBool returns a value and a success flag, and TomlScalarText returns the validated spelling of a number, boolean or date/time without converting or rounding it. Run sh tests/toml_document.sh build/bin/ziran for source, saved IR, C, Go and portable execution tests.

JSON and ZIP

std/json_scan.zi supplies allocation-free JSON value skipping, object member and array element lookup, string spans, and decimal number reading. It validates the value shape and escapes while scanning; member names match unescaped ASCII bytes. Callers keep the original input string and use byte offsets returned by the scanner. Its tests compare source and saved-IR portable bundles, and the same module is exercised through native C and Go by a downstream client.

ArrayRangesReuse accepts value ranges from a successful scan and their unchanged backing document. It reuses validation only for complete values whose bytes match at the same array index; other values and the array's separators are checked normally. Keep the preceding document and ranges alive until the call returns, use a separate output slice, and consume offsets only after a nonnegative return. Short output buffers still validate every element. Run sh tests/json_array_reuse.sh build/bin/ziran for source, saved-IR and cross-target transition checks, including malformed replies and numeric prefixes.

ReadStringValue(text, at, max_bytes) decodes a quoted value into owned bytes and reports valid, including for empty strings. Its limit counts decoded bytes, so a long escaped value can fit. Consume the returned value vector with BuilderFinish, or let normal vector cleanup release it. Ordinary UTF-8 runs append directly, and escaped code points use a five-byte temporary buffer instead of a maximum-size string buffer. Invalid input or an exceeded limit returns no decoded bytes.

std/json_document.zi reads a whole document into a tree of numbered nodes that a program queries (JsonMember, JsonElement, JsonFirst/JsonNext, JsonString, JsonInteger), changes (JsonSet keeps a replaced member in place, JsonAppend, JsonRemove, JsonCopy) and writes back. JsonWrite lays the text out as Python's json.dumps does for the same style: indent, separators, ensure_ascii escapes and sort_keys, so tools can rewrite files that Python wrote without changing their bytes. Numbers keep the text they were read with by default. JsonPythonStyle(indent, ascii, sort_keys, true) opts into binary64 float canonicalization (JsonStyle.python_floats for a custom style): 1e3 becomes 1000.0, -0e0 becomes -0.0, and 1e-7 becomes 1e-07. Only tokens containing a point or exponent are converted. Integer tokens, including arbitrary-precision integers and -0, keep their original spelling; the stored node text is never changed. Float overflow writes Infinity or -Infinity, as Python's default JSON encoder does. Run sh tests/std_json_document.sh build/bin/ziran for tree operations and default layout, and sh tests/std_float_text.sh build/bin/ziran for canonical floats.

Documents own decoded escape buffers and generated integer text; normal document cleanup releases them. JsonReset releases those buffers and clears the tree, retaining vector capacity for reuse. Unescaped input, JsonNewString and JsonNewNumber remain borrowed. Consume their views before changing that storage. JsonSetCopy acts like JsonSet but owns a copy of its key; ordinary JsonSet borrows its key. JsonNewStringCopy owns a copy of its value; JsonNewString and JsonNewNumber continue to borrow. JsonCopy deep-copies text and keys into the target, so the source may be reset or dropped first, including when copying within one document. Read member names with JsonKey or JsonKeyInto: a node's raw key field is internal, and JsonCopy and JsonSetCopy leave it empty. JsonString, JsonKey and JsonNumberText still return independent owned strings. JsonStringInto(document, node, output), JsonKeyInto and JsonNumberTextInto instead append to caller-owned Vec(u8) storage and return false on allocation failure or a missing/wrong-kind node. JsonWriteInto and JsonQuoteInto also return false on allocation failure; discard partial output on failure. Their output must not alias the document's input or owned buffers. JsonWrite and JsonQuote detach owned strings.

For private data, pass JsonRead(document, text, erase, context) or register JsonSetEraser(document, erase, context) before constructing a document. The crypto-independent callback (context: *void, bytes: []u8) -> void must overwrite the supplied mutable bytes. It runs before owned decoded/copied/generated text, discarded keys and internal text temporaries are freed, including failed reads. Explicitly defer { JsonReset(*document) } before a private document leaves scope; normal implicit vector destruction cannot call the registered eraser. The caller owns erasure of borrowed input and Into output, and starts output empty/wiped or reserves its full lifetime maximum before storing private bytes. Use Into accessors rather than detached string returns for private data. The parser and writer reserve before copying private bytes, so their own growth does not relocate plaintext into allocations the eraser cannot reach.

std/zip.zi reads classic ZIP directories from caller-owned bytes, verifies stored entries with CRC-32, and writes stored archives into caller-owned output. It rejects split, encrypted, and malformed archives, and duplicate requested entries. ZIP64 archives are unsupported. It runs in portable bundles. std/zip_linux.zi adds raw DEFLATE extraction through the system zlib library for 64-bit Linux native C builds; link those builds with -lz. The caller supplies file I/O, memory limits, and which entry names are meaningful to the application. std/gzip_linux.zi compresses and decompresses a single gzip stream into caller-owned, disjoint byte buffers. GzipDecompress requires the expected uncompressed length and rejects expansion beyond that length, bad checksums, truncation and trailing streams. Native C builds link zlib (-lz); Emscripten builds use -sUSE_ZLIB=1. Run make gzip-test for independent GNU gzip interoperability and malformed-input tests. std/image.zi holds RGBA pictures (Image: width, height and straight-alpha pixels, rows top to bottom) and does what build tools did with Pillow: ResizeNearest, ResizeBox, ResizeBicubic and ResizeLanczos give Pillow's exact pixels (filtered resizing premultiplies alpha as Pillow does), AlphaComposite is Image.alpha_composite, and PasteImage, RotateLeft, DropAlpha and QuantizeImage (median cut with Floyd–Steinberg dithering) complete it. Thumbnail(image: *Image, max_width: s32, max_height: s32, reducing_gap: float64 = 2.0) -> Image returns an owned bicubic thumbnail, preserving aspect ratio with Pillow's floor/ceil choice and never enlarging. ResizeBicubic(image: *Image, width: s32, height: s32, reducing_gap: float64 = 0.0) -> Image also permits enlargement. Both keep the source unchanged and return an empty image for invalid images or nonpositive sizes. Gap 0 means Pillow's None; other gaps must be finite and at least 1 when resizing (an unchanged thumbnail returns before checking the gap). Like Pillow's RGBA path, they resample premultiplied bytes without an integer reduction step, regardless of gap. Unchanged sizes preserve all bytes, including RGB under zero alpha; neither operation applies EXIF orientation. ResizeBicubicInto(image: *Image, width: s32, height: s32, output: *Image) -> bool provides the same default bicubic pixels with checked allocation and dimension arithmetic. Output owns its pixels and can be reused; it must not alias the source or borrow its pixel storage. Failure empties output and releases partial buffers; null output or identical source/output records fail unchanged. The source remains unchanged. Run sh tests/image_resize_fallible.sh build/bin/ziran for portable parity, native allocation faults and sanitizer lifetime tests. Run make -f Makefile -f tests/native_image_thumbnail.mk image-thumbnail-test for 40 frozen Pillow 12.3.0 bitmap oracles through native C source/saved IR, plus Go and portable source/saved-IR assertions (no Python dependency). std/resample.zi makes the same box, bicubic and Lanczos bytes for native programs that resize repeatedly, such as a UI drawing a picture at its on-screen size: ResampleAxisInto computes one axis's weights once, and ResampleRowHorizontal/ResampleRowVertical then write rows without allocating. ResamplePlanInto plans a whole premultiplied picture or a fractional source box as Pillow's Image.resize(box=...) does, and its two passes split into row ranges for worker threads. SSE2 and AVX2 on x86-64 and NEON on AArch64 and ARMv7 run the rows with the scalar loops' exact bytes, chosen once per CPU. A WebAssembly module built with -msimd128 runs SIMD128 ones, so a web host loads it where WebAssembly.validate accepts SIMD and a build without it elsewhere. Other targets, ZIRAN_NO_ASM and .zx use the scalar loops. Filtering is on the stored bytes, as in Pillow, not in linear light. Run sh tests/lanczos_isa_bitexact.sh build/bin/ziran (with ANDROID_NDK_HOME and qemu-user for AArch64 and ARMv7, or ARMV7_ADB_SERIAL for an ARMv7 device; WASM_RUN, a command that runs an Emscripten .js such as node, or WASI_RUN, one that runs a standalone .wasm such as wasmtime run, with EMCC for WebAssembly) to compare every kernel with the scalar loops and those with std/image. std/png_linux.zi reads every standard PNG into an Image and writes RGBA, RGB and palette files; std/jpeg.zi writes baseline JPEGs at an IJG quality; std/zlib_linux.zi compresses and decompresses whole zlib, gzip or raw deflate streams into growing vectors. The PNG and zlib modules link -lz. Run sh tests/std_image_linux.sh build/bin/ziran, which also compares with Pillow when it is installed. std/vorbis.zi reads what an Ogg Vorbis stream in memory declares without decoding it: VorbisProbe(bytes, *audio) checks that the bytes are whole pages of one stream with a Vorbis identification header and gives its channels, rate and length, so a program can bound a stream before any decoder allocates for it. It needs no library. std/vorbis_linux.zi decodes one whole Ogg Vorbis stream held in memory into interleaved signed 16-bit little-endian PCM over the platform libvorbisfile: VorbisDecode(bytes, *pcm, max_frames, *audio) probes first, reports the channels, rate and frame count, and refuses truncated or chained streams, trailing bytes, damaged pages and anything longer than max_frames. Its records mirror the Xiph headers, and a guard after the decoder state is checked on every call. Only programs that import it link -l:libvorbisfile.so.3 (or -lvorbisfile with the development package). tests/vorbis_linux.sh needs ffmpeg with libvorbis; with the Vorbis development headers (or VORBIS_INCLUDE) it also compares the record sizes with the C compiler's.

The reusable audio package, maintained in ziranlang/packages/audio, adds Opus and quality sinc conversion without a GUI or audio-device dependency. audio/opus exposes bounded Ogg decoding and single-owner encoder/decoder states with caller-owned scratch. audio/pipeline adds whole-buffer encoding with automatic sinc resampling. The Ogg decoder applies pre-skip, header gain and final trim; DecoderRewind resets a validated immutable input without rescanning it. audio/resample wraps libsamplerate's best-quality sinc, including 44.1–48 kHz conversion and incremental processing. audio/ogg inspection and audio/wav PCM16/float32 input with PCM16 output also run in portable bundles. Native codec/sinc calls require the C target; portable bundles reject those FFI calls explicitly. Native consumers declare links = ["opus", "samplerate", "m"] in their root package manifest. The package's audio CLI provides encode, decode, inspect, transcode and resample, with bitrate, VBR, constrained VBR and named bandwidth caps. Its README documents ownership, frame/byte limits, supported formats and tested targets. Existing Vorbis modules remain independent.

std/zip_file_linux.zi writes stored ZIP entries directly to a caller-owned file from borrowed byte slices, including mapped files. The caller controls file creation and publication. Its Ziran test checks the output with Info-ZIP's reader as well as Ziran's reader.

std/zstd_go.zi provides single-owner reusable Zstandard encoders and bounded stream decoding for the Go target. The consuming Go module supplies github.com/klauspost/compress (tested with v1.20.3, requiring Go 1.25 or newer). NewEncoder(writer, level, window_bytes, literal_entropy) uses one worker; numeric levels choose the provider's nearest preset. Literal entropy defaults to true so base64-like data can compress even without repeated strings. Close an encoder before Reset attaches its next writer. The module does not pool contexts or retain response buffers itself. DecodeLimited caps returned bytes and the frame's window, rejects malformed/trailing data, and returns no partial bytes on failure. It accepts valid concatenated frames within the same total limit. Native C targets continue to use their transport's negotiated decoder; this Go module does not provide a portable bundle capability. Run sh tests/zstd_go.sh build/bin/ziran for source/saved-IR, race, independent libzstd interoperability and malformed/bounded-input verification.

Binary fields

std/binary.zi reads little-endian numbers from byte text such as file or asset contents: ReadU16LE, ReadU32LE, ReadS32LE and ReadF32LE take the text and a byte offset, and a field that does not fit reads as zero. Float32FromBits and Float64FromBits turn IEEE 754 bit patterns into values with exact arithmetic, including subnormals, signed zeros, infinities and NaN, so native targets and portable bundles agree without reinterpreting memory. Run sh tests/std_binary.sh build/bin/ziran, which also compares the decoding with the C compiler's over a million random patterns. std/binary_linux.zi keeps 64-bit fields for native Linux programs.

Constant tables

A constant array of numbers or booleans is a read-only table: COS :: s32.[4096, 4095, 4091] or #run s32.[...], of any length and over as many lines as it needs. Reading COS[i] reads one table the module shares (a file-scope static const in C, a package var in Go, built once in the portable VM) instead of copying the array; copying it into a variable, as in local: [3]s32 = COS, still makes an independent copy, which Go takes straight from the shared var, as it does when passing the table by value. Indexes are bounds-checked as for any array. A function may read a table, and its .count, in as many loops as it needs.

A table of rows is a constant table too. Each row names its type, as in GRID :: [3]s32.[s32.[1, 2, 3], s32.[4, 5, 6]], and #run can build one in a procedure that sets g[a][b][c] and returns it. GRID[i][j] reads the shared table in place without copying the row, and grid: [2][3]s32 = GRID starts a variable from it.

A file-scope variable may start from a table of any length, named as in table: [4096]u8 = Lib.BIG or written out. Each target defines the variable with its values, so nothing fills it when the program starts, and writing it leaves the table unchanged.

Bit counts and byte swaps

clz(x), ctz(x) and popcount(x) count leading zero bits, trailing zero bits and set bits; bswap(x) reverses the bytes. Each takes one u8, u16, u32 or u64 and returns that type, and the counts give the width for zero (clz(cast(u32) 0) == 32). Native C uses the compiler's __builtin_* with a portable fallback, Go uses math/bits, and the portable VM computes the same results. A procedure of your own with one of these names is called instead. Run sh tests/bit_builtins.sh build/bin/ziran.

SIMD vectors

Simd :: #import "simd" gives fixed-width vectors (u8x16, s8x16, u16x8, s16x8, u32x4, s32x4, u64x2, s64x2, f32x4 and f64x2, and 256-bit forms such as s16x16) with operations such as Simd.Add, Simd.Load(s16x8, row, at) and Simd.ByteShuffle; std/simd.zi lists them all. #import "std/simd" works the same, under any alias. Vectors are values, used through these operations rather than + or ==. Every operation gives the same bits on every target. Native C uses SSE2 on x86-64, or SSSE3, SSE4.1 and AVX2 when the build enables them, NEON on AArch64 and ARMv7, and WebAssembly SIMD128 when a wasm32 build passes -msimd128. Go, bundles, the portable VM and TinyCC compute lane by lane.

Simd.Load(u8x16, text, at) and Simd.LoadLow(u8x16, text, at) also read immutable strings directly, including embedded NUL bytes. They check the entire access against the string length without allocating a byte slice. Other lane types and stores require their existing slice, array or pointer inputs.

Mark a procedure #target to let native x86-64 C pick its SIMD instructions when the program runs:

Blend :: (row: []u8, out: []u8) #target {
    ...
}

Generated C holds SSSE3, SSE4.1 and AVX2 copies of Blend and calls the best one the CPU runs, chosen once, so one SSE2 build uses AVX2 where it can. ZIRAN_SIMD_TIER=1 (SSE2) to 4 (AVX2) in the environment lowers the choice for testing, and #asm variants needing a vector feature beyond that tier, such as x86_64+avx2 under ZIRAN_SIMD_TIER=3, are passed over. Builds that already enable AVX2, and other targets, compile the procedure once. Each copy calls its own tier's copy of every #inline procedure of the module, so helpers run with the caller's instructions. A #target procedure cannot take or return a 256-bit vector by value, or pass one to another procedure, because AVX2 code passes those in different registers; use an array or a pointer. It cannot be #inline or hold #asm variants. Run sh tests/simd.sh build/bin/ziran and sh tests/simd_target.sh build/bin/ziran. With WASMTIME set to a wasmtime command, tests/simd.sh also runs its wasm32 modules; with ANDROID_NDK_HOME it builds AArch64 and ARMv7, and runs them under QEMU_AARCH64 and QEMU_ARM or on the ARMv7 device ARMV7_ADB_SERIAL names.

Native vectors (u8xN, s8xN, u16xN, s16xN, u32xN, s32xN, u64xN, s64xN, f32xN and f64xN) are as wide as the code running them handles well: two 128-bit blocks in a #target procedure's AVX2 copy and in builds that enable AVX2, one elsewhere, Go and the VM included. Simd.Lanes(T) and Simd.Blocks(T) give the count, which is a constant for fixed vectors, so a loop steps by it:

Brighten :: (row: []u8) #target {
    i := 0
    while i + Simd.Lanes(u8xN) <= row.count {
        Simd.Store(row, i, Simd.AddSat(Simd.Load(u8xN, row, i), Simd.Splat(u8xN, 8)))
        i += Simd.Lanes(u8xN)
    }
}

They take the operations fixed vectors take. Make, Splat and Shuffle name one block's lanes, which repeat; Shuffle, ByteShuffle, AlignBytes, ShiftBytesLeft, ShiftBytesRight, InterleaveLow and InterleaveHigh work on each block; WidenLow, WidenHigh, MulWideLow, MulWideHigh and Narrow count lanes across the whole vector; LoadLow and StoreLow move half of it. Lane, WithLane, Widen, Low, High and Combine take no native vectors, and native and fixed vectors do not mix. A native vector is a plain local, parameter or result: no global, record field, array, pointer, slice, size_of or #foreign signature holds one, and a #target procedure does not take, return or pass one. Run sh tests/simd_native.sh build/bin/ziran.

HTTP and process capabilities

std/net_http.zi defines an explicit request/response host capability. A platform adapter supplies transport and TLS; checked Ziran code owns request construction and response interpretation. A missing host binding is reported before a portable bundle runs.

std/constant_time.zi compares equal-length byte spans without branching on byte values. Use fixed-size buffers for secret tags so unequal lengths remain public shape rather than a secret-dependent oracle.

std/process.zi defines a line-oriented child-process capability with explicit arguments, stdin, an optional credential binding, a timeout, and a desktop isolation request. The host starts the child, yields output lines, and returns its exit result. Its contract runs from source and saved .zir as a portable bundle and through native C and Go mocks.

Files

#import "std/file" gives one set of file operations on every native C target: it uses std/file_linux.zi on Linux, Android, the web, and Windows. The shared operations are OpenRead, OpenWrite, OpenUpdate, OpenReplace, OpenNew, Read, Write, ReadAt, WriteAt, FileSize, CloseFile, RemoveFile, RenameNoReplace, PathExists, DirectoryExists, CreateDirectory, CreatePrivateDirectory, and the directory cursor. OpenNew(path) exclusively creates a writable file (0644 restricted by umask); an occupied file or symbolic link fails unchanged. Check the returned handle's valid field and close successful handles with CloseFile. Two helpers read and replace whole files:

#import "std/file"

if WriteEntireFile("notes.txt", "hello\n") {
    text, ok := ReadEntireFile("notes.txt")
}

The Go target and portable bundles do not have these operations; Go programs use std/file_go.

Browser byte snapshots

std/web provides WebBytes(kind, data, count) for a copied JavaScript typed array and WebBlobBytes(data, count) for a browser Blob snapshot. The latter passes a Wasm memory view directly to the Blob constructor within one host call, avoiding a separate copy of encoded file, media or request-body bytes. The source may be changed or freed, and Wasm memory may grow, after it returns. An invalid byte range returns handle 0; an empty valid range returns an empty Blob. Shared heaps require an explicit unshared snapshot because Blob excludes shared BufferSources. Handles follow the usual WebScope/WebKeep lifetime. WebBytes continues to return an independent typed-array copy.

Native Linux adapters

Native Linux C builds can import std/file_linux.zi for positional byte I/O, std/binary_linux.zi for little-endian numbers, std/date_time_linux.zi for Unix time, std/random_linux.zi for operating-system secure randomness, and std/byte_text_linux.zi for borrowed byte-to-text views, and std/environment_linux.zi for environment values (EnvironmentText, EnvironmentNumber). std/socket_linux.zi opens literal-IPv4 TCP sockets with bounded polling, cancellation, and I/O. std/timer_linux.zi supplies monotonic millisecond timestamps and bounded, interruptible sleeps; it does not expose wall-clock time. std/thread_linux.zi runs a procedure on a background thread that the starting thread can poll for completion without waiting, which suits a frame loop with a request in flight; it builds on pthreads, including Android's and Windows winpthreads. ThreadStart also accepts an optional ThreadNotification containing a procedure and its argument. The worker calls that notification once after publishing completion, allowing an event loop or pipe waiter to wake immediately. The notification must not join or restart its own thread. Keep the thread and argument storage alive until ThreadFinished or ThreadWait joins it before consuming results. macOS has no pthread_tryjoin_np, so there ThreadFinished reports the end once the notification has returned and then joins, which can wait for the thread's thread-local destructors. std/thread_windows.zi provides the same worker interface for native Win64 without winpthreads. It starts through the C runtime, polls the native thread handle with a zero timeout, and closes that handle only after the thread and its cleanup finish. Keep its record and arguments at stable addresses until completion, including while a canceled request is being collected. tests/thread_windows.sh compiles and links C source and saved IR with MinGW against the thread completion host. std/parallel_linux.zi supplies a reusable ParallelPool for independent half-open ranges. ParallelRun divides a range among persistent workers and the submitting thread, then waits for every lane before returning. Short ranges use a subset of the pool instead of becoming serial when more lanes are configured. Keep the pool at one address, submit from one owner, and call ParallelClose before releasing it. Callbacks must write only their own range and keep shared inputs immutable. Four lanes run by default; ZIRAN_PAR_THREADS selects 1–64 lanes. Native C source builds define POSIX_THREADS automatically, including Windows. On _WIN32, the pool uses std/thread_windows.zi, native SRW locks and condition variables from kernel32; it needs no winpthreads or -pthread. tests/native_workers_windows.sh compiles and links source and saved IR with MinGW against the native worker pool. MinGW tests accept MCFGTHREADS as the installation prefix of an mcfgthread runtime when its lib directory is outside the compiler's search path. PLATFORM_WEB opts out; untargeted IR stays portable. Explicit C IR freezes the same capability selection as source builds. Libraries can use that capability to parallelize their native adapters by default while keeping portable output independent of pthreads. This does not parallelize arbitrary program state or make shared mutable data safe. The file module can create a private file exclusively, sync its contents, and publish it through a hard link that fails if the destination already exists. std/byte_text_linux.zi also borrows caller-owned C strings and byte buffers; the caller must keep their memory alive. std/mapped_file_linux.zi maps a file privately for bounded byte parsing without copying it. Its borrowed slice is valid only until UnmapFile, and writes to that slice do not change the file. std/process_capture_linux.zi captures a child process without a shell; std/net_http_linux.zi uses it to send JSON over HTTPS through curl. std/process_channel_linux.zi keeps an owned child open for bidirectional byte I/O. StartChannel takes 1 through 32 explicit arguments (each at most 4095 non-NUL bytes) and optionally merges stderr into the channel. Invalid argument counts fail before creating a socket or child. ChannelSend and ChannelReceive use caller-owned buffers and bounded waits. A third discard_stderr argument defaults to false; when true the child sends stderr to /dev/null without mixing it into stdout. Merging and discarding together are rejected before launch. WaitChannel joins the child and records its exit code. CloseChannel closes the stream, waits for the requested grace period, then terminates only the child's own process group if it is still running. CancelChannel instead signals the still-owned unreaped child group before joining, so cancellation also stops descendants retaining stdout after their parent exits. It closes the endpoint and reaps the direct child; repeating it after joining is harmless. Do not reap the parent separately while awaiting stdout, because its unreaped PID proves this process-group ownership. Keep one owner for each channel and close it before releasing its storage; failed sends may have delivered a prefix and must not be blindly replayed.

Build and release programs use three more modules. std/process_run_linux.zi runs a child with any number of arguments, a working directory, environment changes, and each stream inherited, read from or written to a file, or captured (RunProcess); StartProcess, PollProcess, WaitProcess and SignalProcess follow a child left running, and a program that cannot start reports started = false. std/file_tree_linux.zi creates, lists, copies and removes folders, reads a path's kind, owner UID (PathInfo.owner_uid), permissions, size and modification time, replaces a file atomically, takes flock locks and matches shell wildcards. PathInfo.modified gives signed seconds since the Unix epoch; modified_nanoseconds gives its fractional nanoseconds from 0 through 999999999, not total nanoseconds. Missing paths return zero for both fields. Programs that combine them into a signed 64-bit timestamp must check multiplication/addition overflow for their supported date range. PathStatus(path, false) reports a symbolic link's own metadata. PathStatus returns zero PathInfo on any failure. Use PathQuery(path: string, follow_links: bool = true) -> PathQueryResult when absence must be distinguished from inaccessible paths. The result contains info: PathInfo, valid: bool, missing: bool and error: s32. Success sets valid = true, missing = false and error = 0. Failure leaves zero info and sets valid = false; error is the positive Linux errno and missing is true only for ENOENT (2) or ENOTDIR (20). EACCES (13), ELOOP (40) and other errors are not absence. Embedded NUL or paths exceeding 4095 bytes return EINVAL (22), with missing = false. Empty paths reach the OS and return ENOENT. Following a dangling symlink reports absence; follow_links = false queries the link itself. These queries are snapshots; later operations can fail. ListDirectory(path, names) appends independently owned names to the caller's Vec(string). Call FreeDirectoryNames(names) to free every name and the vector storage and reset it to empty. Empty, repeated and null releases are harmless; borrowed views expire on release. Do not mix borrowed strings or duplicate ownership into that vector. CopySymbolicLink(source, target) copies the link itself, including dangling and directory links. CopyFileNew(source, target) and CopyTreeNew(source, target) copy a file or tree exclusively, preserving links and refusing occupied destinations. Tree destinations require an existing parent; self and descendant copies are rejected, including through parent aliases. Keep ancestors and source entries stable during copying. On failure these functions return false and may leave a partial new destination; they do not delete it. Copy files start with 0600 permissions and directories with 0700 (restricted by umask); source permission bits are applied after each successful copy. CopyFile still replaces files and CopyTree still merges into existing directories. ResolvePath(path) returns the canonical absolute path with symbolic links and dot components resolved, or empty for a missing or invalid path. CreateTemporaryDirectory(parent, prefix = "ziran-") atomically creates a unique child under an existing directory owned by the effective user. It returns the absolute path or empty on failure. The prefix must be one name component; six random characters follow it. Permissions are 0700 restricted by umask. Keep the parent and its ancestors stable during creation, and call RemoveTree when finished. Run sh tests/std_file_tree_linux.sh build/bin/ziran for C source and saved-IR query success/errno (including inaccessible parents), ownership, canonicalization and private-directory tests. The EACCES assertion requires a non-root user; root runs explicitly report that assertion as skipped. std/text_tools.zi finds, splits, trims, replaces and joins text, and decodes bounded hexadecimal into a caller-owned byte vector on any target. std/stdio_linux.zi writes to standard output or standard error and reads all of standard input. Run sh tests/std_tools_linux.sh build/bin/ziran for their checks.

These native adapters keep libc calls out of applications and require glibc Linux and a curl executable. Portable bundles should use the host capabilities above instead.

Windows C builds (--define _WIN32) keep the same file and mapping API over Win32. Paths pass as UTF-8 and are converted to UTF-16. Positional reads and writes leave the file position unchanged, as on POSIX, and a private mapping is a copy-on-write view. An open file can itself be renamed or removed, but unlike POSIX, a rename cannot replace a destination that is still open. The libcurl HTTP and WebSocket adapters also build there; std/zip_linux.zi needs a MinGW zlib. tests/std_file_windows.sh compiles and links the file modules with MinGW when it is installed.

Native Go maps

Import map_go to use Map(K, V) with the Go target. Keys must be comparable; values cannot own Vec storage. A map's zero value is nil. Copies share entries, while replacing a map binding changes only that binding. Maps can be compared with null, and their contents are accessed through these operations:

Operation Behavior
MapInit(values) Allocate an empty map if the binding is nil
MapSet(values, key, value) Insert or replace; allocate nil storage first
MapGet(values, key) Read an entry, or return the value type's zero value
MapLookup(values, key) Return Option(V); import option to use this operation
MapContains(values, key) Test key presence, including entries with zero values
MapDelete(values, key) Remove an entry; missing keys are harmless
MapClear(values) Remove every entry while preserving allocated storage
MapCount(values) Return the entry count as s64
MapKeys(values) Return an unordered []K snapshot

MapInit and MapSet require a mutable binding or field. Nil maps support reads, membership checks, deletion and clearing. Map mutation in #parallel regions is rejected because copies share storage. Other targets and portable ABIs reject maps; checked .zir preserves their Go behavior.

Import go_types for the predeclared Go Any and Error interfaces. Any accepts values that do not own Vec storage, including nested maps for JSON.

Native civil clocks and calendars

Import date_time_linux for UnixNow(), LocalNow(), LocalAt(seconds), and UtcAt(seconds). LocalAt uses the operating system's timezone and daylight-saving rules. The shared LocalDateTime record lives in date_time_types; the hosted date_time module re-exports that same type. Months and days are one-based, day_of_year is zero-based, and valid reports whether the provider could obtain a reading. Dates after 2038 retain their full value.

With --define _WIN32, date_time_linux and timer_linux use native Windows APIs instead of POSIX calls. Windows civil clocks use FILETIME and the system's timezone rules, with valid dates from 1601 through 9999. Its named-zone query recognizes UTC/GMT; other IANA names return an invalid offset because these Windows APIs do not expose an IANA database. Monotonic timestamps use QueryPerformanceCounter.

Import calendar for Gregorian LeapYear, DaysInMonth, DayOfWeek, DayOfYear, DateTimeValid, and FormatDateTime. DayOfWeek returns zero for Sunday and minus one for an invalid date. Calendar dates range from year 1 to 9999. FormatDateTime(reading, pattern, output) writes into a caller-owned byte slice, leaves a NUL terminator, and returns a view of the complete result. Invalid readings, unsupported tokens, and insufficient space return an empty view and clear the first output byte. Names are English and independent of locale. Supported civil tokens are %a, %A, %b, %B, %h, %C, %d, %e, %F, %D, %H, %I, %j, %k, %m, %M, %p, %P, %r, %R, %S, %T, %u, %w, %X, %y, %Y, %n, %t, and %%. The calendar calculations and formatting also run from portable bundles without a clock host.

Local conditional construction can share an explicitly named ordinary record type between its arms:

Pair :: struct { value: s32; }
Choose :: (yes: bool) -> Pair {
    result := ifx yes then Pair.{value=41} else .{value=42};
    return result;
}

The reverse arm order works too, including nested fields with their checked record context. At least one arm must explicitly name that record. This does not search types by their fields, infer public signatures or imports, or infer owned, foreign, union, enum or procedure types from an unnamed sibling. Ambiguous constructors and mismatched fields remain errors. The checked source and saved IR retain the resolved record type; both arms keep their ordinary lazy runtime evaluation.

For checked, hash-bound source changes see source edits. For the opt-in scalar direct native compiler, lowered-IR dumps and final-byte source maps, see the ASSEMBLY prototype. It is not yet a replacement for the supported ZX or native runtime paths. An optional byte-exact transport with source maps is described in compact transport; .zi remains canonical source.