Whale brings together an intermediate representation, assembler, object-file
library, and linker infrastructure. Developed within the
Wave ecosystem, its components are intended to
serve language implementations and compiler tooling through separately owned source
modules. Most components are Rust; the complete assembly-to-object asmobj
module is Wave.
Whale is in active, early development. APIs and IR formats are evolving; the current capabilities are listed below.
Getting started · Usage · Development · Contributing · Support
- Defined behavior: specify program behavior explicitly, including invalid operations, with the goal of an IR without undefined behavior.
- Explicit O0 IR: keep operations and required safety behavior visible in IR, preserving unused operations and unreachable blocks for debugging. O0 is the current development priority; O1 and higher optimizations are future work. Verification diagnoses invalid IR without removing or simplifying it. Explicit constant declarations retain their typed initializer expressions and evaluated values, including unused local declarations. Their compile-time evaluation does not introduce runtime arithmetic instructions.
- Reusable components: separate IR, assembler, assembly-to-object mapping, object formats and linking by responsibility.
These are project goals. Complete memory-safety semantics and an end-to-end native compilation pipeline are still being developed.
| Component | Available today | Status |
|---|---|---|
| Assembler | AMD64 plus initial AArch64, RV64I and LoongArch64 integer subsets; sections, symbols, ELF64 and initial AMD64/AArch64 COFF and Mach-O output | Object emission requires the complete Wave asmobj module; initial linked host Linux x86_64 GNU |
| IR | Typed IR construction, text parsing/printing, bounded verification, integer/control-flow, tracked stack-memory and bounded Whale-call interpretation, and scalar AST JSON lowering | Experimental |
| Object library | Object model, bounded ELF64 reading and ELF64/COFF/Mach-O relocatable serialization | Available |
| Object CLI | Wrap raw input bytes in an ELF64 object with a .text section |
Limited |
| Reverse | Bounded ELF/raw image inspection and typed result records | Inspection available; decoder and CFG/xrefs unavailable |
| Linker | Static Linux AMD64 ELF executables from objects and ordinary archives, checked relocations and explicit entry | Initial subset |
The IR target selector accepts only x86_64-whale-linux; unknown targets fail
with the supported choice, including with --no-verify. Its output data layout
is 64-bit little endian on every build host. Library lowering and verification
reject target/layout mismatches. The IR layout API computes checked sizes,
field offsets, array strides, and natural/allocation alignments for this target.
It does not implement aggregate ABI passing or native code generation.
BSS reservations retain a logical zero_fill count instead of allocating their
zero bytes. Object section memory size is data.len() + zero_fill; non-BSS
sections require zero zero_fill. ELF output uses checked field conversions and
layout arithmetic and rejects extended section numbering. Its default output
budget is 256 MiB; library clients can override it with write_with_limit.
The linker layout API returns Result and records separate file/memory positions
and sizes for every input section. The executable layout additionally accounts for
ELF headers and page-congruent RX/R/RW load segments with sparse BSS.
The object input API reads the supported bounded AMD64 ELF64 relocatable subset and preserves section, symbol and relocation semantics. Shared diagnostic conversions retain machine-readable phase/category and available source/object context alongside typed component errors.
COFF object output supports AMD64 and
AArch64 with whale asm --arch ID --format coff source.s -o leaf.obj.
Assembly-to-object mapping is implemented entirely in Wave under
assembler/asmobj/. Rust buffer conversion lives outside that module in
toolchain; the Rust object module serializes the result. Output has a checked
256 MiB default file budget, sparse BSS and explicit format-specific relocations.
The initial linked module host is Linux x86_64 GNU; emitting a COFF object does
not establish native Windows execution support for this module.
ELF64 object output supports AMD64, AArch64, RV64I and LoongArch64 little-endian
identities. The bounded reader, static linker and reverse tools retain their AMD64
scope. Object metadata records machine, format, byte order and address width;
writers and consumers check their respective supported combinations. ObjectFile::new(ObjectFormat::ELF64) remains an AMD64 convenience
constructor; explicit identities use ObjectFile::with_target, and format access
is now object.target.format. An object file is not a linked
executable. CI runs host checks on Linux, Windows, and macOS; running Whale on a
host does not imply support for that host's native object format or instruction
set as an output target.
See the assembly module guide for the new ISA instruction subsets, source syntax, relocation types and examples.
Use Meson for the complete product build. It builds the Wave asmobj module
and the Rust components in dependency order. See BUILDING.md for
LLVM 21, the pinned Wave compiler, Rust toolchains and archive reuse.
On the initial Linux x86_64 GNU host:
git clone https://github.com/wavefnd/Whale.git
cd Whale
cargo fetch --locked
export LLVM_SYS_211_PREFIX=/usr/lib64/llvm21
meson setup /tmp/whale-build --buildtype=release \
-Dwave-source=/absolute/path/to/Wave
meson compile -C /tmp/whale-build
meson test -C /tmp/whale-build --print-errorlogs
/tmp/whale-build/whale --versionRust components require Rust 1.86.0 or newer. Cargo remains available for
Rust development. To run the cargo run assembly examples below, first build
and select the complete Wave module:
python3 tools/build_asmobj.py --wave-source /absolute/path/to/Wave \
--out-dir /tmp/whale-asmobj
export WHALE_ASMOBJ_DIR=/tmp/whale-asmobj
cargo build --release --lockedWithout that module, Cargo still builds the Rust components and IR commands;
assembly-to-object commands return an explicit unavailable error. The executable
is written to target/release/whale on Linux. Enable experimental AST JSON
lowering with -Dsocket-cli=true in Meson or --features socket-cli in Cargo.
Save the following as example.asm:
section .text
global answer
answer:
mov eax, 42
retcargo run --release --locked -- asm --amd64 example.asm -o example.oThis produces a relocatable ELF64 object. Assembly is implemented within Whale; no external assembler is needed.
The asm and object commands reject unknown options, extra input paths,
duplicate -o options, and missing output paths before reading input or writing
output. Specify the assembly architecture once. For a path beginning with -,
use a relative ./ prefix or an absolute path.
cargo run --locked -- object raw.bin -o raw.o
cargo run --locked -- object --helpThis places the input bytes in an AMD64 ELF64 .text section with a global
start symbol. The input must already contain the intended machine code.
Both asm and object support --debug-whale with --bytes, --dump-hex,
--dump-bin, or --dump-json to inspect the first 256 bytes of the emitted
object. JSON includes the full object length and a bounded byte array.
For example:
cargo run --locked -- object raw.bin -o raw.o --debug-whale --dump-hexwhale --version (or -V) prints the package version. CLI arguments must be
UTF-8; unsupported non-UTF-8 OS paths and option names produce a diagnostic
with exit status 2 before any input is read or output is replaced.
asm reads at most 8 MiB by default and object reads at most 256 MiB.
Use --max-input N to set an explicit decimal byte budget for either command.
The reader consumes at most N + 1 bytes, including for growing or streamed
files. This budget is independent of assembly reservation and ELF output limits.
Oversized or invalid UTF-8 assembly input preserves an existing output file.
CLI stdout writes are fallible: a closed pipe does not cause a panic, and other
write errors exit with status 1 and a diagnostic. Assembly, object and link
reporting continues after a closed pipe, preserving completed artifacts and
real command failures. Successful IR output also treats a closed pipe as success. Canonical IR and scalar results
retain their normal stdout format. -o still publishes atomically. The Wave module build likewise replaces its archive only after successful creation,
preserving the previous archive on an archiver failure.
Save this format 7 module as answer.wir:
module {
format_version 7
semantics_version 1
target "x86_64-whale-linux"
datalayout { ptr=64, endian=little }
declare @f7 "answer": whale () -> i32, linkage internal
fn @f7 "answer"() -> i32, entry %b11 {
%b11 "entry":
%v42: i32 = const i32 42
ret i32 %v42
}
}
cargo run --locked -- ir run answer.wir --function @f7The output is i32 42. The default build verifies the whole module and runs
one explicit Whale-convention function with integer/bool/float parameters and scalar
or void return. Use repeated --arg decimal integer literals (true/false for Bool,
exact-width hexadecimal storage bits for floats)
and --max-steps to set the instruction/terminator budget (default 1,000,000).
Zero division/remainder and explicit traps return structured errors; unused
executed operations still trap. Loops evaluate phi inputs simultaneously.
This oracle supports integer constants, arithmetic, comparisons, bit casts,
checked pairs, select and control flow. It also supports bit-exact f16/f32/f64 constants, mov/phi/select and tracked
stack allocations, integer/Bool/float/data-pointer storage,
typed GEP, byte copies and fills, with initialization and capability checks.
It executes direct and signature-checked indirect Whale calls with integer/Bool/float,
data-pointer and function-pointer arguments/results, using one shared fuel and
memory arena. Function pointers retain their full signature and calling convention
through mov/phi/select, storage and complete or split byte copies. It rejects
external/SysV execution and general aggregate values in every
block of the entry and reachable callees, including unreachable blocks. Native
code generation remains unfinished. Legacy undef is a verification error. See the Rust interpret_with_options API for
adjustable verification limits and trap IR identities.
f16, f32 and f64 use exactly 4, 8 and 16 hexadecimal storage digits.
Constants, arguments/returns, mov/phi/select, stack/global loads and stores, and
byte copies preserve every bit, including signaling/quiet NaN payloads and
signed zero. These operations perform no host floating-point conversion.
Equal-width integer/float bitcast reinterprets those bits. Numeric fext,
ftrunc, signed/unsigned integer-to-float and float-to-integer conversions execute
with the defined rounding and range traps. Bitcasts cannot recover pointer or function-pointer authority from numerical bits.
cargo run --locked -- ir run ir/tests/fixtures/float-storage-v7.wir --function @f0 --arg 0x7c01
cargo run --locked -- ir run ir/tests/fixtures/float-storage-v7.wir --function @f3These print f16 0x7c01 and u64 9218868437227405603. Floating storage obeys
the same bounds, explicit alignment, permissions, initialization masks and work
budgets as integer storage. A typed field load checks its value bytes without
requiring surrounding aggregate padding to be initialized. Plain storage and
copies never canonicalize a NaN; arithmetic and width-changing conversions have
separate semantics.
Scalar floating arithmetic and unary negation execute at their declared precision; see the rounding, remainder and NaN contract.
Generic floating comparisons have the following scalar contract in both
constant evaluation and interpreted IR. Every result has type bool.
| Generic operator | Explicit fcmp predicate |
Result if either operand is NaN |
|---|---|---|
cmp feq |
oeq |
false |
cmp fne |
une |
true |
cmp flt |
olt |
false |
cmp fle |
ole |
false |
cmp fgt |
ogt |
false |
cmp fge |
oge |
false |
Thus generic != is always the logical negation of generic ==, including
NaN on either side. +0 and -0 compare equal; infinities follow numerical
ordering. All NaNs, including signaling NaNs and every sign/payload, make the
operands unordered. Comparisons do not change their original storage bits or
raise floating-point traps.
Explicit fcmp supports oeq, one, olt, ole, ogt, oge, ord, uno,
ueq, une, ult, ule, ugt and uge. Ordered relational predicates
require neither operand to be NaN; unordered relational predicates also return
true if either operand is NaN. ord tests that neither is NaN, and uno tests
that at least one is NaN. In particular, explicit one returns false for NaN,
while generic fne maps to une and returns true. CmpOp::float_predicate
exposes this mapping and FloatBits::compare evaluates equal-width predicates.
Runtime execution preserves instruction/trap order, including unused comparisons.
cargo run --locked -- ir run ir/tests/fixtures/float-comparisons-v7.wir --function @f0 --arg 0x7ff0000000000001 --arg 0x3ff0000000000000
cargo run --locked -- ir run ir/tests/fixtures/float-comparisons-v7.wir --function @f1 --arg 0x7ff0000000000001 --arg 0x3ff0000000000000These print bool true (generic inequality) and bool false (ordered inequality).
Save the complete memory fixture
as tracked-memory.wir. It copies an initialized pointer and then reads its
pointee. Pointer bits and capability metadata travel separately.
cargo run --locked -- ir run tracked-memory.wir --function @f0
cargo run --locked -- ir run tracked-memory.wir --function @f1The first command prints u32 42. The second exits nonzero with
uninitialized byte at allocation offset 0: physical zero bytes do not count
as initialization. AST lowering emits uninit at each uninitialized declaration,
including each execution of a loop body, without writing a value.
The interpreter uses synthetic 64-bit addresses, never host address dereferences. Tracked access checks provenance, generation/lifetime, bounds, permissions and explicit alignment. One-past pointers can be formed but cannot read or write nonempty ranges. Address arithmetic overflow traps. Integer-to-pointer casts create raw addresses without recovered authority. Byte copies transfer both initialization state and byte fragments of pointer metadata; nonempty overlapping copies trap. Zero-length copy/fill succeeds without an access.
--max-memory sets the logical allocation-byte budget (default 64 MiB).
InterpreterOptions::memory_limits separately limits allocation count (16384),
pointer metadata fragments (262144), and byte/metadata work (256 Mi units).
Failures return IR-located MemoryLimit errors. Scalar/checked-pair reads exclude
padding from initialization checks. Each invocation owns its stack allocations and
retires them on return. Caller-owned pointers remain usable across nested calls;
returned callee-local pointers retain expired capabilities and trap on access.
Explicit lifetime_end and addressable scalar globals are supported as described
below. Foreign-memory adapters and native shadow metadata are future work.
The executable storage fixture covers mutable/read-only/uninitialized globals, calls, expired aliases and byte differences:
cargo run --locked -- ir run ir/tests/fixtures/runtime-storage-v6.wir --function @f1
cargo run --locked -- ir run ir/tests/fixtures/runtime-storage-v6.wir --function @f3
cargo run --locked -- ir run ir/tests/fixtures/runtime-storage-v6.wir --function @f7These print u32 12, u32 42 and i128 16. @f4 traps on an uninitialized
global read, @f5 on a read-only write, and @f6 on an expired stored pointer.
Typed IR format 6 adds explicit addressable declarations:
storage @g0 "counter": u32 = value u32 10, align 4, access readwrite, linkage internal
storage @g1 "answer": u32 = value u32 42, align 4, access readonly, linkage external, link_name "public_answer"
storage @g2 "pending": u32 = uninitialized, align 4, access readwrite, linkage internal
%v0: ptr<u32> = global_addr @g0
GlobalStorage/Module.global_storage share GlobalId and global-name uniqueness
with existing compile-time Global declarations, while functions have their own
source-name namespace. ConstRef still refers only to compile-time declarations;
it never becomes a runtime address. Initialized storage accepts exact scalar
literal values; pointer/function-pointer cells can start explicitly uninitialized.
Read-only uninitialized declarations and aggregate storage are rejected. Integer/Bool/float
initializers initialize their value bytes; physical
zero-fill of uninitialized storage does not initialize a value.
External storage definitions require an explicit nonempty link_name; internal
storage has none. Names are preserved unchanged. Function/global external-name
collisions are errors. These identities are an IR boundary; object/linker migration
remains separate. ModuleBuilder::try_add_global_storage and
FunctionBuilder::try_global_addr provide checked construction.
Each interpret/interpret_values call creates a fresh runtime instance and
initializes its globals once before any invocation frame. Direct/indirect/nested
calls share these allocations and permissions; frame returns never retire globals.
A second public invocation starts again from the declarations. Persistent runtime
sessions are not exposed. Returned data addresses cannot regain authority in a
fresh arena. Global initialization and stack allocations share cumulative byte,
allocation, metadata and work budgets; initialization failures identify GlobalId.
lifetime_end %v0 is an explicit observable effect. It retires the entire current
frame-owned stack allocation, using a live full-bounds pointer at offset zero.
Mov/stored/bitcast aliases of that exact base are valid, including GEP back to
base; a nonzero GEP offset, restricted bounds, global or caller-owned allocation
is an invalid request. Null/raw pointers trap, and repeated end/use traps as
expired allocation. All stored/copied/GEP aliases expire together. Zero-length
copy/fill retains its no-access behavior. Return retirement tolerates an already
ended allocation. Bytes/allocation budgets are cumulative, pointer-fragment
metadata is released, and synthetic addresses and allocation slots are never
reused. Frontends choose lifetime ends explicitly; Whale does not infer lexical
scope ends or shorten an O0 lifetime.
%v2: i128 = ptrdiff %v0, %v1 returns lhs offset minus rhs offset in bytes.
Both operands must have the identical data-pointer type and the same live tracked
allocation/generation. The signed i128 result represents every possible difference
of u64 offsets. It is independent of pointee stride: three u32 elements differ by
12 bytes; an array<u32,4> base and one-past differ by 16 bytes. Equal offsets yield
zero and reversed operands negate the result. cmp/icmp ordered predicates on
matching data pointers compare allocation offsets; signed/unsigned predicate
spellings agree. Null, raw, expired and cross-allocation ordering/difference trap.
Equality retains its existing allocation-identity rules, including inequality of
different live allocations. try_lifetime_end and try_ptrdiff are checked builder
APIs; all new forms, including pointer ordering, require format 6 or later.
Save this minimal typed AST as program.json:
{
"format_version": 3,
"semantics_version": 1,
"features": [],
"program": {
"globals": [],
"functions": [
{
"name": "answer",
"parameters": [],
"return_type": {
"Int": {
"bits": 32,
"signed": true
}
},
"body": [
{
"Return": {
"Lit": {
"Int": {
"bits": 32,
"signed": true,
"value": "42"
}
}
}
}
],
"convention": "Whale",
"linkage": "Internal",
"link_name": null
}
],
"declarations": []
}
}cargo run --release --locked --features socket-cli -- ir lower program.json -o program.wirThe command lowers and verifies the module, then writes textual IR to
program.wir. Omit -o program.wir to print it to standard output. The input
schema is documented in AST JSON Schema and
the frontend AST types.
The envelope requires format_version: 5 (legacy formats 2, 3 and 4 are also accepted), semantics_version: 1, and
features: []. Unversioned inputs, unknown fields/versions/features, duplicate
JSON keys, and trailing JSON are rejected even with --no-verify. The raw JSON
entry point is ir::lower_ast::interchange::decode; its default input limit is
8 MiB, configurable with decode_with_limit. Integers use decimal strings;
f16/f32/f64 values use 0x followed by exactly 4/8/16 hexadecimal storage digits.
For example, {"Float":{"bits":32,"value":"0x80000000"}} preserves negative zero.
Migrate format 1 inputs by setting format_version to 5, adding a declarations
array to program, and adding convention and linkage to every definition.
Internal functions use "Whale" and "Internal"; external functions must supply
a nonempty link_name. Missing versions and format 1 inputs are rejected.
Integers and floats retain their exact string encoding. AST and printed typed IR have independent format versions and a shared
semantics version; printed IR includes both version fields. Typed text IR formats 3, 4, 5, 6, 7 and 8
can be read and verified with ir::parse_module.
The AST supports scalar literals, variables/constants, arithmetic, comparisons,
assignment, return, if/while, break/continue, function declarations, direct calls,
and typed function pointers with indirect calls. Aggregate expressions, variadic
signatures, and SysV64 aggregate signatures are not supported. Unsupported forms fail explicitly.
If the binary lacks socket-cli, whale ir lower exits with status 2 and prints the
feature-enabled recovery command shown above.
For a complete invalid input, save the following as invalid.json:
{
"format_version": 99,
"semantics_version": 1,
"features": [],
"program": {
"declarations": [],
"globals": [],
"functions": []
}
}cargo run --locked --features socket-cli -- ir lower invalid.json -o rejected.wirThis exits nonzero with unsupported AST format_version 99; expected 5. It does
not create rejected.wir; an existing output is preserved. Lowering/type errors
likewise fail before output publication.
AST format 3 adds integer BitAnd, BitOr, BitXor, Div, Rem,
ShiftLeft, ShiftRight, unary BitNot, and Bool-only LogicalAnd/LogicalOr.
Integer operands have the same width and signedness. Division/remainder by zero
trap at runtime and report a constant-evaluation error when evaluated at compile
time; signed MIN/-1 wraps (remainder zero). Shift counts use their unsigned
N-bit pattern modulo N, including signed negative bit patterns; unsigned negative
literals are invalid. Right shift is arithmetic for signed values and logical
for unsigned values. Bool logical expressions branch and merge through phi,
evaluating left first and the right side only when needed. O0 retains the right
side's IR and full constant-expression tree even when it is skipped.
Verification builds one borrowed SSA type index per function and shares verified
global constants through a read-only overlay. Dominance queries use intervals
in an iteratively constructed dominator tree; expensive CFG construction,
intersection and phi work use the module-wide IrLimits::max_analysis_work
budget (default 16,777,216 units). Exhaustion returns an AnalysisWork resource
error. verify_module_with_stats reports type lookups, dominance queries and
analysis work without changing IR identities or ordering.
Repeatable scaling probes can be run explicitly; they record operation counts and timings without timing-based pass/fail assertions:
cargo test -p ir --locked --test verification_scaling -- --ignored --nocapture
cargo test -p object --locked --test relocation_grouping -- --ignored --nocaptureELF serialization groups validated relocations in one input-order pass, keeping section order and the order of signed-addend entries within each section.
Optional original source metadata and bounded call traces
preserve locations for stores, traps, terminators and unused constants. AST 4
Located wrappers retain innermost lowering errors; unannotated inputs retain
IR identities. Runtime errors expose the underlying cause through root_cause()
and structured source/trace details through diagnostic().
The printer emits typed IR format 8 with semantics version 1. AST JSON writes
format 5. Definitions and references use explicit @fN, @gN, %vN and %bN
identities; names are quoted annotations, and each function records its entry
block. Name/string fields escape quotes, backslashes, control characters and
Unicode line separators. See the identity fixture
for repeated block names and IDs scoped to different functions.
The verifier checks cast operands, opcode categories and width direction, and
checks signed/unsigned checked arithmetic with a tuple<T, bool> result.
Tuple extraction requires an existing field and its exact type. These static checks are distinct from runtime conversion traps and tracked
pointer metadata.
These commands are available in the default build:
cargo run --locked -- ir verify ir/tests/fixtures/calls-v3.wir
cargo run --locked -- ir print ir/tests/fixtures/calls-v3.wir -o canonical.wirir::parse_module reads and verifies formats 3, 4, 5, 6, 7 and 8. Print-parse-print
preserves explicit scoped IDs, quoted names, entry and block order, types,
exact integer/float payloads, constant-expression trees, alignment, signatures
and external link names. Whitespace and // comments are canonicalized. It
accepts current printer instruction forms; format 4 adds uninit, and format 5 adds preserved constant bitwise,
division/remainder, shift and lazy logical operators. Formats 3/4 without undef
remain readable and canonicalize to format 8. Extended constant syntax requires
format 5. Format 6 adds explicit storage, global addresses, lifetime_end and same-allocation
pointer ordering/differences; extended frontend operators require AST format 3. Regenerate old
undef lowering from its AST rather than replacing unspecified values with zero. Unknown versions, fields,
opcodes, escapes, trailing input and inconsistent type annotations are errors.
Diagnostics include a byte offset and one-based Unicode-scalar line/column;
semantic errors are attached to the containing function/global when available.
IrLimits configures input bytes, tokens, traversal nodes, type depth and
constant-expression depth. Defaults are 8 MiB, 1,000,000 tokens/nodes, and depth
128 (root depth 0). Nesting can be raised up to MAX_IR_NESTING (256), or lowered.
Use parse_module_with_limits, verify_module_with_limits,
ConstExpr::evaluate_with_limits, validate_signature_with_limits or
ModuleBuilder::declare_function_with_limits at the appropriate boundary.
Iterative preflight precedes recursive type helpers; constant evaluation uses
an explicit work stack. Limit failures return structured errors. Budgets bound
traversal, not elapsed time or allocations outside these APIs. Borrowed Rust
inputs remain caller-owned; arbitrary unverified trees still have Rust's usual
recursive clone/drop behavior. Checked declaration rejects and disposes its
owned oversized signature iteratively.
FunctionBuilder provides try_* operations for checked parameter/block/value
IDs and construction order. Failed operations preserve instructions, values and
terminators. Legacy convenience operations panic on invalid construction order;
use call, function_addr, and null_function directly for their existing
fallible call/signature checks. Public layout_of_with_limit clamps requested
nesting to the safe MAX_IR_NESTING ceiling while accepting shallow types with
larger overrides.
Direct frontend AST processing uses AstLimits: default depth 32, 100,000 nodes,
8 MiB total string bytes, 8 MiB encoded output and 16,777,216 source-mapping work units. lower_o0_with_limits and
interchange::encode_with_limits accept overrides; recursive work remains capped
at MAX_AST_DEPTH (32). Shared iterative inspection precedes lowering or pretty
JSON serialization, and owned rejected ASTs are disposed iteratively. Borrowed
ASTs stay caller-owned. The bounded encoder stops before output storage exceeds
its byte budget.
ObjectFile::validate checks target identity, section alignment/names, symbol
names/references/extents and relocation patch ranges without serialization.
Symbol resolution applies this contract before consuming any input object. ELF
writing additionally preflights all payloads, names, synthesized symbols, string
tables, metadata tables and alignment against the output budget before copying
payloads or names. The default remains 256 MiB; write_with_limit overrides it.
IR construction, parsing, printing, verification, lowering and interpretation remain Rust implementations. Canonical text regressions check retained IR fixtures independently of a Wave compiler. Text parsing does not provide native code generation. Format 2 printed text requires explicit migration; it is not silently accepted. New syntax or semantics requires an appropriate version change, with unknown versions rejected.
The complete call input lowers a local function, a stored function pointer, an indirect call, and a SysV64 external call. Its printed IR is checked by a regression test.
cargo run --locked --features socket-cli -- ir lower ir/tests/fixtures/ast-v2-calls.jsonDeclarations carry FunctionId, parameter/result types, calling convention,
linkage, and explicit external link names. Calls resolve by function identity or
by a fnptr<signature> SSA value. The verifier checks arity, argument/result
types, conventions, and indirect-callee dominance. Callee expressions evaluate
before arguments, which evaluate left to right. Void calls have no result;
unused nonvoid results remain in O0 IR. Null or invalid indirect targets have a
defined runtime trap contract in the interpreter; native dispatch remains future work.
This IR support does not implement machine ABI lowering or carry identities
through object emission and linking yet.
The executable call fixture covers caller-owned pointers, nested calls, recursion and callable returns:
cargo run --locked -- ir run ir/tests/fixtures/interpreted-calls-v4.wir --function @f3
cargo run --locked -- ir run ir/tests/fixtures/interpreted-calls-v4.wir --function @f6 --arg 4These print u32 42 and u32 4. InterpreterOptions::max_call_depth limits
simultaneously active frames (default 256, including entry), and max_frames
limits total invocations (default 100000), including frames already returned.
max_live_values bounds reserved SSA slots across active frames (default 1000000).
Calls and callee instructions/terminators all consume the same max_steps budget.
Every frame keeps its own SSA environment; recursion does not use the host stack.
Before effects, preflight follows direct calls and function-address identities
that can flow to matching indirect calls from retained reachable bodies or
checked entry arguments. Unrelated declarations/bodies remain verified without
requiring executable support.
The scalar interpret/CLI boundary remains integer/Bool only. Library users can
use interpret_values with checked RuntimeValue constructors and inspect typed
results via ty, scalar_value, pointer_address and function_target. Data
addresses from RuntimeValue::raw_pointer never grant allocation authority, and
each run owns a distinct arena so a returned expired pointer cannot acquire a
new invocation's allocation. No CLI raw-address literal is introduced. Callable
constructors and dispatch check the complete signature, convention and target
identity. Defined callable values borrow their originating module and cannot
rebind an equal FunctionId/signature in another module; ordinary bytes cannot manufacture callable authority. Pointer-to-pointer
bitcasts permit storage byte views, while casts of actual function-pointer values
remain forbidden. The interpreter's synthetic callable bits are not a native ABI.
For an existing raw binary file:
cargo run --release --locked -- object code.bin -o code.oThis places the input bytes in an ELF64 .text section and defines a global
start symbol at offset zero. It does not compile textual IR or disassemble
existing object files.
assembler/asmobj/ contains the complete Wave implementation of assembly-to-object
validation and mapping, split into model, section, symbol, relocation and entry
files. It replaces the former Rust asmobj crate. The external Rust data boundary
in toolchain/src/assembly_object.rs supplies buffers and converts the returned
records; object-file layout and serialization remain the separate Rust object
module's responsibility.
Meson compiles and links this module as part of the product.
Cargo development builds select a prebuilt archive with WHALE_ASMOBJ_DIR.
There is one implementation of its policies: an absent module returns an explicit
unavailable error. The former partial Wave ELF-record and reverse-analysis
implementations have been removed. IR remains Rust.
Unannotated labels remain ELF STT_NOTYPE, size zero and default visibility.
Optional declarations attach explicit metadata without changing the label's
binding or inventing an external name:
section .text
global entry
symbol entry, function, 1, hidden
entry: ret
section .data
symbol payload, object, 4, default
payload: dd 42symbol NAME, KIND, SIZE, VISIBILITY accepts function, object, or notype;
SIZE is a nonnegative integer or equ expression; visibility is default or
hidden. Metadata may precede or follow the label/extern declaration. Local
names retain the surrounding nonlocal label's scope. Unknown symbols, duplicate
metadata, invalid fields and extents outside the defining section are errors.
The Rust AsmSymbol and ObjectSymbol APIs expose kind, size and visibility.
The linker library's SymbolTable::resolve preserves object-local scope. Strong
definitions override weak ones in either input order; weak/weak selects the first
input, and strong/strong diagnoses both input indices. Actual relocation
references resolve before layout. Unreferenced undefined declarations impose no
requirement; a referenced undefined weak symbol has the defined value zero.
Unresolved strong references report their input object, section and offset.
SymbolTable::references records each selected target and whether it is weak
undefined. The static executable path uses this selection before patching relocations.
Normal ELF regression tests independently decode symbols and RELA records without external programs. An additional GNU binutils oracle is opt-in:
cargo test -p whale --locked --test symbol_metadata readelf_fixture_confirms_symbol_metadata -- --ignored --nocaptureThe checked-in fixture is symbol-metadata.asm.
Native Linux, macOS and Windows x64/ARM64 packaging, extracted-binary checks and exact checksum manifests are documented in the release guide. The manual workflow validates one tagged source commit across every package; draft creation is opt-in and runs only after all checks pass.
| Path | Responsibility |
|---|---|
| assembler/ | Assembly parsing and instruction encoding |
| ir/ | IR types, builders, lowering, verification, and printing |
| object/ | Sections, symbols, relocations, and ELF serialization |
| linker/ | Static symbol/archive selection, relocation application and ELF executable output |
| src/ | Command-line interface |
| tests/ | CLI integration tests |
| tools/ | CI helpers and standalone CLI smoke checks |
Run the workspace checks from the repository root:
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --locked
cargo test --workspace --all-features --lockedGitHub Actions also covers native host configurations, the minimum supported Rust version, optimized tests, rustdoc, coverage, and scheduled maintenance. The checked-in workflows contain the commands used by CI.
See the scalar instruction reference for LEA, Jcc aliases, shifts, division, TEST, NOT/NEG, and immediate IMUL forms and their boundaries.
Materialized assembly output is limited to 64 MiB across all sections by default.
The public assemble_with_limits(source, &AMD64, &AssemblyLimits { ... }) API
adjusts max_output_bytes and max_relax_iterations (default 1024 passes).
Reservations and data growth are checked before allocation; exceeding a budget
returns AsmError. BSS reservations use logical u64 storage and do not consume
this byte budget. No artifact is returned until branch label layouts converge.
A global name requires a label definition, including a forward definition.
global name with extern name is allowed and exports an undefined external;
a local definition and extern remain conflicting declarations.
Contributions to correctness, diagnostics, tests, and toolchain capabilities are welcome. Read CONTRIBUTING.md for setup, validation, and signed-off commits.
- Find a bounded task in good first issues.
- Follow planned work in the toolchain backlog.
- Report a bug or propose a feature.
- See MAINTAINERS for review contacts and the Code of Conduct for community expectations.
The repository's AI-use policy is recorded in ai.txt.
Support development through Open Collective or GitHub Sponsors.
Whale is licensed under the Mozilla Public License 2.0. See COPYRIGHT and NOTICE for attribution and notices.
whale asm --amd64 start.asm -o start.o
whale asm --amd64 helper.asm -o helper.o
whale link start.o helper.o -o app --entry _start
./appInputs are the supported ELF64 little-endian AMD64 relocatable subset or ordinary
Unix archives. The entry defaults to _start and must be a defined global symbol
inside file-backed executable code. No libc, startup object, main wrapper, dynamic
loader, GOT or dynamic PLT is added. A statically resolved PLT32 is a direct PC32
reference. Thin archives, shared libraries and unsupported relocation kinds fail.
Archives are searched left to right and rescanned to a fixed point internally. Only unresolved strong references and the entry demand extract members; undefined weak references do not. Later inputs do not rescan earlier archives. GNU and BSD long names are supported. Unused members do not introduce duplicate definitions.
--max-input caps the sum of input bytes and --max-output caps the complete
executable, both defaulting to 256 MiB. Decoded inputs share additional cumulative
limits: 256 MiB of copied payloads, 16 MiB of inspected/copied names, 65,279 raw
sections, one million raw symbols and relocations each, and 536,870,912 reader
work units. Auxiliary ELF records and unused archive members consume these limits;
physical archive bytes are counted once. Archive parsing also limits each archive
to 4096 members and 1 MiB of member names, checking names before copying them.
Selection has 10 million work units and a 4096-object cap.
Library clients can choose reader limits with Linker::with_read_limits and load
objects through add_object_bytes; all archives and byte-loaded objects share the
same remaining budget. ObjectFile::read_with_usage exposes successful reader
charges for other streaming consumers. Failed additions leave both the input list
and remaining budget unchanged. Already-owned objects passed to add_object
remain caller allocations and still undergo structural and output validation.
Loadable ranges must fit the lower 48-bit user address space, excluding its final 4 KiB guard page; optional five-level paging is not assumed. BSS remains sparse: a 1 TiB zero-filled region can still produce a small executable when its addresses fit this range. Alignment and logical BSS extent cannot bypass the address check. Outputs publish atomically after validation and protect every input alias. Unix executables receive mode 0755 without copying setuid/setgid bits. The produced artifact targets Linux AMD64 regardless of the machine running Whale.
The reusable reverse module provides bounded AMD64 ELF/raw image inspection and typed result records. Inspection works in ordinary Cargo builds:
cargo run --locked -- reverse inspect app --jsonInstruction decoding and CFG/xrefs currently report an explicit unavailable capability. The former partial Wave decoder/CFG implementation and archive integration have been removed. Existing output files are preserved on these failures. Image inspection does not infer source code or program reachability.
Numeric cast legality, rounding and trap rules: IR casts.
Typed struct, pointer and fixed-array frontend forms: storage lowering.
Observable traps, volatile memory and verified pass APIs: effects and passes.
AMD64/AArch64 Mach-O output: object scope and relocation mappings.