tape build
Usage
tape build <file.tape> -o <output> [options]Examples
tape build app.tape -o app.exe # Windows executable
tape build app.tape -o app --target linux # Linux ELF64
tape build app.tape -o app --target linux-arm64 # Linux ARM64 ELF64
tape build app.tape -o app --target uusi # Uusi ELF64
tape build app.tape -o app --target macos-arm64 # macOS ARM64 Mach-O
tape build boot.tape -o BOOTX64.EFI --target uefi # UEFI application (entry efi_main)
tape build lib.tape -o lib.dll --dll # Windows DLL (with exports)
tape build lib.tape -o lib.so --dll --target linux # Linux shared object
tape build lib.tape -o lib.dylib --dll --target macos-arm64 # macOS dylib (signed)
tape build lib.tape -o lib.sto --sto --target uusi # Uusi shared object
tape build lib.tape -o lib.o --sto --target linux # Linux relocatable object
tape build app.tape -o app.exe --runtime shared # import tapert100.dll instead of compiling it in
tape build app.tape -o app.exe --runtime my_rt.tape # compile your own runtime in
tape build app.tape -o app.exe --set BUILD=Release # a build setting (@setting const BUILD)Options
| Flag | Description |
|---|---|
-o <path> | Output file path (required); its base name without the extension is @program in @tape code |
--target <t> | Target platform: win64, linux, linux-arm64, macos, macos-arm64, uusi, uefi (default: host) |
--target <t> (libraries) | A cross build copies the runtime libraries the program loads beside the output from tape’s <target>/ subdirectory when it exists (linux/tapevm100.so, linux/uwm100.so, …: put there from that platform’s tape build or release package, at least as new as the tape building); without it nothing is copied, and the program needs the libraries beside it on the target |
--dll | Emit a shared library, linked by tape itself: DLL on Windows, .so on Linux, .dylib on macOS (STO on Uusi). See Shared libraries |
--sto | Emit a relocatable object for another toolchain’s linker (STO on Uusi, .o on Linux/macOS) |
--set NAME=VALUE | A build setting: a @setting const of the root file by its name, another module’s as module.NAME. Repeatable. A name no setting of the build has, a module not in the build, a value that doesn’t parse as the setting’s type, or one name set twice is an error before anything compiles. See Build settings |
--runtime <r> | How the program gets tape’s runtime: static (default) compiles it in, shared imports the tapert100 library, a .tape path compiles that runtime source in instead. See Runtime library |
--skip-link-check | Skip @link library/symbol verification |
--uusi-syscall=<entry> | With --target uusi: how the program calls the kernel, int80 (default) or syscall (the faster entry, for a kernel that serves it). Arguments and results are in the same registers either way |
--profile[=file] | Build the program with a sampling profiler: while main runs, it and every worker thread are sampled about every millisecond, and when main returns a report goes to stderr (or to file, relative to where the program runs): each function’s self time (where the time is spent) and total time (with what it calls) over all threads, each thread’s share when there are workers (main, pool 1 worker 2, …), and the call tree from main, a worker’s paths under its name. With a file, the samples also go to file.folded as collapsed stacks (main;work;hot 1234, pool 1 worker 2;threads.worker_main;work__f__run;hot 300: a call path and how many samples ended there), which flame-graph tools such as flamegraph.pl, inferno and speedscope read, separating the threads. On Windows, macOS and Linux x86-64 (on Linux each sampled thread is interrupted by a signal about once a millisecond, which a program’s own blocking calls must tolerate as EINTR, as the standard library’s do); a program that ends through os.exit or a trap writes no report |
--map[=file] | Also write a symbol map: every function’s address, size and name, one per line in address order, in <output without extension>.map (app.exe gives app.map) or in file. The addresses are the ones a disassembler shows for the file (llvm-objdump -d app.exe, objdump -d app, otool -tv app), so the map tells you which function an address is in. Methods are named Type.name and functions of other modules module.name, as in the profiler’s report. For executables and --dll, not --sto |
-O0 | Turn off the optimizing code generator (register allocation on x86-64 and arm64): every value lives on the stack. The program behaves the same, only slower; useful for comparing |
--no-dce | Keep every function and constant table of every module the program imports, used or not (see What goes into the output). For debugging: the program behaves the same, only its file is bigger |
--dce-report | Print, on stderr, how many functions of each module were kept and left out, and the bytes of string and table data left out |
-I <path> | Add module search path (for imports; searched before the defaults below). Repeatable, as many as needed; a directory given twice is searched once, at its first position |
--human | Human-readable errors with color, source context, and carets (default: LLM-friendly terse format) |
--max-errors <N> | Stop after N errors (default: unlimited) |
--tape-fuel <n> | The compile-time budget: how many operations each @tape block, constant initializer and @tape block in a function body may run (a generator it calls counts against the block that called it). Default 10,000,000; each block starts with the whole budget. Running out is a compile error that names this flag. See compile-time execution |
Build process
- Split source into regions (
#code,#view, etc.) - Parse
#coderegion (with rt0 preamble if no imports) - Parse
#viewregion, merge components into module - Resolve imports (if any) → parse dependencies recursively
- Execute
@tapeblocks (compile-time metaprogramming) - Type check
- Lower to TAC IR
- Verify
@linkreferences (unless--skip-link-check) - Leave out what nothing reaches (see What goes into the output)
- Native codegen (x86-64 or ARM64 depending on target)
- Emit binary (PE64, ELF64, or Mach-O)
- On Linux and macOS hosts, mark executables executable (
chmod 755). A macOS executable or dylib comes out of the emitter already signed (see macOS code signing); tape never runscodesign
What goes into the output
Only what the program can reach is built into it. Starting from main,
each module’s initializer (its globals’ initial values), every @export
function and, for a --dll or --sto, every pub function of the root
module, tape follows the calls, the functions used as values (function
pointers, event handlers, a component’s methods when the component is
made or shown) and the strings and @tape const tables those functions
use. Everything else is left out: importing a module costs only what you
call of it. A hello world is about 18 KB on Windows; importing intl and
calling nothing adds about 26 KB (its initializers), where it added 5 MB
of tables and code before. A flag parameter given as a constant counts
too: when every call of fn face(px: i64, bold: bool, italic: bool = false) passes italic as the literal false or leaves it to its
default, what only if (italic) { ... } calls is left out.
Nothing changes in what the program does. --no-dce turns this off,
--dce-report shows what was left out. A function left out that something
calls after all (a bug in tape) stops the program with
trap: unreachable function rather than running anything else.
tape run does the same (--no-dce there too); tape run --hot keeps
everything, since a reload may call what the first build did not.
Module search path
Imports are looked up in, in order:
- Every
-I <path>, in the order given (any number of them; the same directory given again, asdir/,dir\or, on Windows, in another letter case, counts once, where it was first given) stdlib/in the current directorystdlib/next to the tape binary itself (symlinks resolved): the standard library of the release package or of the.debinstall (/usr/lib/tape/stdlib), sotape buildworks from any directory without-I
Runtime preamble
For simple programs without imports, the compiler adds a minimal runtime preamble that declares platform-specific extern functions: GetStdHandle/WriteFile/ExitProcess/heap functions on Windows, and __syscall1..__syscall6 on Uusi and Linux x86-64 (the code generator synthesizes these as system-call stubs). macOS and Linux ARM64 have no preamble. If the source has import statements, the preamble is skipped — the runtime library provides these through tapert100. Libraries (--dll, --sto) never get it. The preamble is compiled apart from your file: its @link binds its own externs only, so an extern fn you write still needs a @link of yours written before it, as in a program with imports.
Runtime library
Programs that use the standard library, or whose code allocates (string concatenation, interpolation, components), need tape’s runtime: memory, console and file I/O, arguments, the clock. By default it is compiled into the program, so a program that does not use the GUI or image libraries is a single file with nothing to ship beside it.
--runtime | Result |
|---|---|
static (default) | The target’s runtime source (rt/<platform>/tapert100.tape next to tape) is compiled into the program. |
shared | The program imports tapert100.dll / .so / .dylib and needs it next to itself (on Linux also found in /usr/lib/tape). |
path/to/runtime.tape | Your own runtime is compiled in instead. It must define, as @export pub fn, every __tapert_* primitive your program uses (the contract is in stdlib/tapert100.tape); a missing one is a build error naming it. Copy rt/<platform>/tapert100.tape as a starting point (on Linux and macOS it imports ../posix/posix.tape: keep rt/posix/ beside it). |
The option applies to native output (tape build, shared libraries, and tape test’s native runs). tape run always loads the shared library, and relocatable objects (--sto) keep the import for your linker. GUI programs still load uwm, gfx and image from next to themselves.
Shared tape libraries
A module whose root declares @library("toolkit3"); can be built once as a shared library and loaded by programs instead of compiled into each of them (Uusi for now):
tape build toolkit.tape --target uusi --dll -o toolkit3 # the library
tape build app.tape --target uusi --libraries shared -o app # a program using itThe program’s source does not change: it imports the library’s modules as before and compiles against their source, but each function, method and component of the library it uses is loaded from /lib/toolkit3.sto (Leivos: /libraries/toolkit3) instead of compiled in. Without --libraries shared (the default, static) the library is compiled into the program as any module is. The rules a library follows to change without breaking the programs that use it are in Shared tape libraries.
Cross-compilation
The compiler can target any supported platform from any host:
# On Windows, build for Uusi:
tape build kernel.tape -o kernel --target uusi
# On Windows, build for Linux:
tape build server.tape -o server --target linux
# On any host, build for macOS ARM64 or Linux ARM64:
tape build app.tape -o app --target macos-arm64
tape build app.tape -o app --target linux-arm64Code generation and binary emission are built into the compiler; no external toolchain is involved. The target’s runtime is compiled in, so a cross-compiled program runs on its own. Its source is rt/<target>/tapert100.tape next to tape (rt/linux/ and rt/macos/ import rt/posix/): the release packages carry every platform’s rt/, and a copy of tape made by hand must keep them all, not only its own platform’s. A program that uses the GUI, image or audio libraries (uwm, gfx, image, audio), or one built with --runtime shared, needs that platform’s libraries (uwm100.dll, image100.dll, …) next to it. Built for the machine tape runs on, it gets them: tape build copies the ones the program loads from tape’s own directory next to the output (one line + <path> each; a file already there with the same contents is left alone). Built for another platform, take them from that platform’s release package. On Linux a program also finds them in /usr/lib/tape, where the .deb installs them. On Uusi the window library is compiled in with the runtime: a ui program built with --target uusi is one file that opens its window through Leivos’s compositor, which knows it by the name the program was started by, without directory or extension (/apps/notes is the app notes), so the same uwm100.sto names each app that loads it. A program started with no name falls back to the output’s name without its extension (-o apps/notes), which is @program in @tape code (Platform code).
Shared libraries
--dll makes a shared library on every desktop target, with no system linker, C compiler or install_name_tool, from any host:
tape build mylib.tape -o mylib.so --dll --target linux
tape build mylib.tape -o mylib.dylib --dll --target macos-arm64- Exports: the
@export pub fnfunctions of the library’s root module and of every module it imports, under their C names. A name is exported once: two@exportfunctions of one name in two modules are a compile error naming both. Nothing else is visible (a plainpub fnof an imported module stays inside the library). - Dependencies: each
@linklibrary. A library named without an extension (@link("tapert100"), another library you built with tape) is looked up next to this one: on LinuxDT_NEEDED tapert100.sowithDT_RUNPATH $ORIGIN:/usr/lib/tape, on macOS@rpath/tapert100.dylibwithLC_RPATH @loader_path. System libraries keep their names (libc.so.6,/usr/lib/libSystem.B.dylib). - Its own name is the output file’s name:
DT_SONAME mylib.so, install name@rpath/mylib.dylib, the DLL name in a PE export directorymylib.dll. - Globals are set up when the library loads (
DllMain,.init_array,__mod_init_func), before any export is called. - Runtime:
--runtimeapplies as for a program. Build with--runtime sharedwhen the library should use thetapert100next to it instead of its own copy (tape’s own image library does). - macOS: the dylib is signed ad hoc by tape, like an executable (see macOS code signing).
A C program uses it like any shared library (dlopen/dlsym, or link it), and a tape program with @link("mylib") and extern fn declarations, in the VM and natively.
Windowed programs
A console program double-clicked in Explorer opens a black console beside its own window. A game or other GUI program does without by saying so in its root file’s #resource region (#resource):
#resource
@exeicon("assets/game.ico")
@windowed(BUILD != Build.Dev) // a dev build keeps its console for logs; @windowed alone is alwaystape build game.tape -o game.exe --set BUILD=Releasetape run game.tape runs the same program in a console, as do tape test and the --dll and --sto outputs: only an executable built for win64 is windowed (PE subsystem GUI instead of console). It works with either --runtime. On other targets it changes nothing. There is no flag for it: --windowed is an unknown flag whose message points at @windowed.
io keeps working and never traps for want of a console. At startup, before any module’s initializer, each standard stream is set up:
| The stream | What happens |
|---|---|
| Given by the parent (a file, a pipe) | Used as given: game.exe --frames 60 > out.txt, or a parent capturing the output and the exit code |
| stdout or stderr not given, the parent has a console | Writes to that console, so a summary reaches the terminal |
| stdout or stderr not given, no console (a double click) | Writes succeed and go nowhere |
| stdin not given | Reads see end of file, even with the parent’s console: the shell is reading that console |
What this costs, all of it Windows’ behaviour for a program of this subsystem:
- The shell does not wait. From a terminal the prompt comes back at once and the program’s output lands after it, mixed with whatever you type next. A program using that console also ends with it: closing the terminal, or Ctrl+C in it, stops the program.
>differs by shell. This is what waits and what captures (measured on Windows 11 with cmd and Windows PowerShell 5.1):
| Started with | Waits | Output |
|---|---|---|
cmd: game.exe | no | the console, after the prompt |
cmd: game.exe > out.txt | no | out.txt, complete only when the program ends |
cmd: game.exe | more, start /wait game.exe | yes | the pipe; start /wait sets %errorlevel% |
a batch file, or cmd /c "game.exe > out.txt" | yes | as redirected; sets %errorlevel% |
PowerShell: .\game.exe | no | the console, after the prompt |
PowerShell: .\game.exe > out.txt | no | not captured: the file stays empty |
PowerShell: .\game.exe | Out-File out.txt | yes | out.txt; sets $LASTEXITCODE |
PowerShell: Start-Process -Wait .\game.exe | yes | the console |
- A trap with no console and no redirect says nothing. Its message has nowhere to go; the exit code, 101, is still there for a parent to see. To see one, run the program from a terminal or send stderr to a file.
macOS code signing
Apple Silicon runs no unsigned code. tape signs every macOS executable and dylib it builds itself, from any host, with the ad-hoc signature a linker gives what it links (the same as ld -adhoc_codesign: SHA-256 page hashes, identifier the output file’s name). So a program cross-built on Windows or Linux runs on a Mac as it is, and tape build never calls codesign.
To distribute with your Developer ID, sign over it as you would anything a linker built, then notarize:
codesign --force --sign "Developer ID Application: ..." --options runtime --timestamp app--force replaces tape’s signature. Changing the file afterwards (install_name_tool, strip) invalidates the signature, as with any linker’s; sign again after it.
Output by target and flags
| Target | Default | --dll | --sto |
|---|---|---|---|
win64 | PE64 executable (console subsystem; GUI with @windowed) | PE64 DLL (export table) | — |
linux | ELF64 executable (dynamic linking) | ELF64 shared object (.so) | Relocatable .o |
linux-arm64 | ELF64 executable (ARM64, dynamic linking) | ELF64 shared object (ARM64) | Relocatable .o |
macos | Mach-O executable (signed) | Mach-O dylib (signed) | Mach-O object |
macos-arm64 | Mach-O executable (ARM64, signed) | Mach-O dylib (ARM64, signed) | Mach-O object |
uusi | ELF64 executable (static, syscalls) | STO (as --sto) | STO (exports bound by name) |
uefi | PE32+ UEFI application: entry efi_main(image, system_table), no imports | — | — |
Last modified: