bytecode-lang 0.3.0

LSB, the LexerSketch bytecode format: instruction set, verifier, disassembler, and versioned encoding.
Documentation
# bytecode-lang v0.2.0 — LSB, the One Bytecode Format

**The foundation, and the hard part of the format.** v0.2.0 turns the scaffold into LSB: one
register-machine instruction set for static and dynamic languages, the module that holds it, a
versioned binary encoding with exactly one encoding per module, a decoder that treats every byte as
hostile, a deterministic disassembler, and a builder whose branches cannot point anywhere but at an
instruction. The normative specification, `specs/LSB.md` in the LexerSketch plan, was written with
this release. The verifier is the next milestone (0.5.0).

## What is bytecode-lang?

The LXRT-tier crate that owns the LexerSketch bytecode format: a load-bearing wall of the system.
It replaces two incompatible instruction sets that grew separately (codegen-lang's `Program`/`Op`
and bvm-lang's `Op`) and aot-lang's private byte container. bvm-lang 2.0 will execute LSB,
codegen-lang 2.0 will emit it, loader-lang will store it in program images, and dap-lang will debug
it through its line tables. The crate executes nothing and has no dependencies.

## What's new in 0.2.0

### The instruction set

182 instructions, every one eight bytes (`opcode`, a modifier byte, and three 16-bit or one 16-bit
plus one 32-bit operand), and the decoded `Inst` enum is eight bytes too, so a function body is a
flat array an interpreter walks without parsing (bvm-lang's design, kept on purpose). One table
generates the enum, the opcode metadata, the encoder, the decoder, and the renderer.

- **Typed registers, `dyn` included.** Every register has a declared type: unboxed scalars for
  static languages, `dyn` for dynamic values, `str` and `ref t` for heap objects. GC roots are exact
  and the verifier will be linear.
- **OPS on every instruction.** Integer instructions carry an `IntOp`: the operand type and the
  complete policy set (overflow, division by zero, shift range, float-to-int), so `iadd.i64.wrap`
  and `iadd.i64` are different instructions and no tier consults a global setting (the H10 lesson).
- **Dynamic languages built in.** `dadd` … `dge` with a numeric fast path defined by OPS (int is
  i64, mixed int/float computes in f64), `dtruthy`, `dconcat`, indexing, properties, `dcall`,
  iteration, and 27 per-module **hooks** through which a language supplies its own slow path (PHP's
  loose `==`, Python's list equality) instead of needing its own instruction set.
- **Everything a runtime needs.** Direct, indirect, host, tail, and dynamic calls; closures with
  by-value captures and cells; structs with single inheritance and methods; arrays;
  insertion-ordered hash maps with PHP's next-integer key; typed and dynamic iterators; byte
  strings; type tests and casts; exceptions with try regions; safepoints for GC and fuel.
- **PHP arithmetic.** `overflow = promote` (OPS §2) makes `PHP_INT_MAX + 1` a float and `7 / 2`
  `3.5` on the dynamic instructions. It is valid only where the result is `dyn`: the builder
  refuses it on a destination declared with a static type, and the verifier will refuse it
  everywhere else. `dlnot` is PHP's `!` (truthiness negated); `dnot` stays the bitwise `~`.
- **Coroutines, generators, async.** Stackful, asymmetric coroutines: `coro_new`, `yield`,
  `yield_kv` (PHP's `$k => $v`, with PHP's automatic keys), `await`, `resume`, `resume_throw`,
  `coro_close` (runs pending `finally` blocks; a `yield` while closing is `CloseIgnored`, as
  Python's "generator ignored GeneratorExit"), `coro_key`, `coro_result` (generator return
  values), `coro_status`, `coro_current`, and `spawn`, which hands a task to the host scheduler
  through a hook. One mechanism covers Python and PHP generators, PHP
  fibers, and Mox's await-anywhere tasks; `for` over a generator is one `iter_next` per element.
  The full design (what a suspended coroutine owns, how the GC traces it, errors, close, and
  cancellation across a suspension, async generators, and why dropping one runs no code) is
  `specs/LSB.md` §5.13.
- **Exceptions, precisely.** Every fallible instruction is an unwind point that raises at its own
  pc without writing its destination, and `finally` has one canonical lowering (completion-kind and
  completion-value registers plus a dispatching `switch`) in which a `return`/`break` out of
  `finally` cancels the in-flight error or return, as in PHP, Python, and JavaScript
  (`specs/LSB.md` §4.3). They are in the ISA now because the instruction
  set is a wall; bvm-lang 2.0 executes them.

```rust
use bytecode_lang::{BuildError, FuncId, Inst, ModuleBuilder, Overflow, Policy, ValType};

let promote = Policy::new().with_overflow(Overflow::Promote);
let mut m = ModuleBuilder::new();
let mut f = m.function("f", &[ValType::Dyn, ValType::Dyn], &[]);
let static_result = f.reg(ValType::I64);
f.emit(Inst::DAdd { dst: static_result, lhs: f.param(0), rhs: f.param(1), pol: promote });
f.ret_void();
assert_eq!(m.add_function(f), Err(BuildError::PromoteNotDynamic { func: FuncId(0), pc: 0 }));
```

```rust
use bytecode_lang::{Inst, IntOp, IntTy, Overflow, Policy, Reg};

let wrap = IntOp::new(IntTy::U32).with_policy(Policy::new().with_overflow(Overflow::Wrap));
let mul = Inst::IMul { dst: Reg(0), lhs: Reg(1), rhs: Reg(2), op: wrap };
assert_eq!(mul.to_string(), "imul.u32.wrap r0, r1, r2");
assert_eq!(Inst::from_bytes(mul.to_bytes()), Ok(mul));
```

### The module

Strings, types (function signatures, structs, arrays, maps, cells, iterators), constants (exact
integers, IEEE bit patterns, chars, UTF-8 and byte strings, arrays and maps of earlier constants),
imports, globals, functions (typed registers, captures, per-function name and type lists, jump
tables, try regions, code), exports, hooks, a name and start function, and per-function line tables
and local variables. The debug tables were planned for 0.5.0; they belong to the module structure,
so they are here, round-tripped and tested.

### Encoding and decoding

A 12-byte header (`LSB\0`, format version 1, reserved flags) and ten length-prefixed sections in a
fixed order, fixed-width little-endian throughout. Every value has one encoding, so
`decode(encode(m)) == m` and `encode(decode(b)) == b` for every accepted `b`. The decoder refuses
unknown versions, checks every tag, UTF-8, `char`, canonical instruction words, section framing,
and that aggregate constants refer only backwards (so their nesting depth is one forward pass and a
budget); it checks every count against `Limits` **and** against the bytes that remain before it
reserves anything, so a 20-byte file claiming four billion strings gets an error, not an
allocation. It never recurses and never panics.

```rust
use bytecode_lang::{decode, encode, Const, DecodeErrorKind, ModuleBuilder};

let mut m = ModuleBuilder::new();
m.constant(Const::f64(-0.0));
let module = m.finish().unwrap();
let bytes = encode(&module);
assert_eq!(decode(&bytes).unwrap(), module);
assert_eq!(encode(&decode(&bytes).unwrap()), bytes);
assert_eq!(decode(&bytes[..bytes.len() - 1]).unwrap_err().kind(), &DecodeErrorKind::UnexpectedEnd);
```

### The builder, and the H01 lesson

Branches, jump tables, try regions, and local ranges take labels; `add_function` resolves them and
then range-checks every branch target, label-built or raw, so a built function cannot hold an
unresolved or out-of-range target (the H02 class). `parallel_move` lowers simultaneous moves (block
arguments) correctly: moves nobody waits on run first, and each remaining cycle is broken through a
temporary of the right type, with exactly one extra move per cycle. That is the defect class of
family issue H01, where `jump H(b, a)` was emitted as two plain moves and lost a value. The
temporary is cached per type, and the builder never uses one that the current move set names.

```rust
use bytecode_lang::{Inst, ModuleBuilder, ValType};

let mut m = ModuleBuilder::new();
let mut f = m.function("swap", &[ValType::I64, ValType::I64], &[]);
let (a, b) = (f.param(0), f.param(1));
f.parallel_move(&[(a, b), (b, a)]);
f.ret_void();
let id = m.add_function(f).unwrap();
let module = m.finish().unwrap();
assert_eq!(module.function(id).unwrap().code().len(), 4); // three moves and the return
```

### The disassembler

A deterministic listing with labels, modifier suffixes, and name and constant comments, safe on
unverified modules (bad references print `<invalid>`). `Module` and `Inst` implement `Display`.

### Decision: no value-lang dependency

Constants are format values, not runtime values. value-lang 1 stores integers as `i32` and symbols
as process-local interner ids: it cannot represent `i64`, `u64`, `f32`, `char`, or byte-string
constants, and its symbols do not serialize. The format is also read by static-language tiers with
unboxed registers. The NaN-boxed layout is applied by the VM at load time; the spec states the one
representation constraint LSB needs (`str`, `ref`, and `dyn` share one reference representation,
and nil is null).

### Tests and verification

- **Round trip and canonicity**: arbitrary modules (instructions generated from the opcode
  metadata, so every opcode and modifier is covered) encode, decode, and re-encode exactly.
- **Robustness**: 4,096 arbitrary inputs, 4,096 valid headers with arbitrary bodies, 4,096
  plausibly framed section sets, and 1,024 mutated encodings (substitutions, truncations,
  insertions) per run; nothing panics, and every accepted input re-encodes byte for byte. Every
  strict prefix of a module is refused.
- **Budgets**: random modules under random limits; each limit, the 65,536-entry format caps, and
  huge-count claims are tested directly; a 100,000-deep constant chain is refused in linear time.
- **References**: label resolution against a reference resolver (2,048 random programs); parallel
  moves against parallel assignment with an independent cycle count (4,096 random move sets that
  also name the cached temporaries, plus a 65,000-move rotation). Removing the temporary guard
  makes both the unit and the property tests fail.
- **Specification checked against CPython**: the float `//` and `%` algorithm in the spec matches
  CPython 3.14.4 bit for bit on 296,457 operand pairs (`dev/verify/float_floor.py`).
- **Every error path**: each `DecodeErrorKind` from hand-assembled bytes; each `BuildError` the
  builder can reach in memory.
- **Golden listing** for the disassembler; every `rust` example in `README.md` and `docs/API.md`
  runs as a doctest.

## Breaking changes

**None.** v0.1.0 had no public API.

## Verification

Run on Windows x86_64 from the crate directory, on Rust stable and on the 1.85 MSRV:

```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo clippy --all-targets --all-features -- -D warnings
cargo clippy --all-targets --no-default-features -- -D warnings
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features
cargo +1.85 clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo test --no-default-features
cargo +1.85 test --all-features
cargo bench --no-run
```

All green. Counts, identical with all and with no default features: 16 unit, 65 integration
(19 builder, 17 codec, 3 disassembly, 11 ISA, 15 property), and 228 doctests (including every
`rust` example in `README.md` and `docs/API.md`). Linux, macOS, `cargo deny`, and `cargo audit`
run in CI.

Benchmarks (`cargo bench --bench bench`, release profile, x86_64, Windows; a module of 1,000
functions × 1,000 instructions):

| Benchmark | Windows |
|---|---:|
| Encode 1M instructions (8.0 MB) | ~2.4 ms (~419 M inst/s, ~3.2 GiB/s) |
| Decode 1M instructions, every check | ~4.1–4.8 ms (~210–245 M inst/s) |
| `Inst::from_bytes` on 1M words | ~2.2 ms (~457 M inst/s) |
| Build 1M instructions with the builder | ~3.8–4.5 ms (~220–260 M inst/s) |
| Disassemble 100k instructions | ~12.3 ms (~8.1 M inst/s) |

Decoding's canonicality check started as a re-encode of every word (6.8 ns per word); a per-opcode
used-bits mask computed at compile time replaced it (2.2 ns), and taking each function's code as
one slice removed per-word reader bookkeeping. The builder's `promote` check (added at review)
costs about 0.1–0.5 ms per million instructions (≈3–12%, median ≈8%) in interleaved runs on a
loaded machine: it runs inside the existing branch-target pass, as one generated match per
instruction. A first version, a separate pass, cost ≈20%. Linux numbers come from the
coordinator's gate.

## What's next

- **0.5.0 — the verifier**, per `specs/LSB.md` §8: total, sound, linear, with precise errors,
  including the rule that every control-flow cycle passes a safepoint or a call (so fuel bounds
  every loop).

## Installation

```toml
[dependencies]
bytecode-lang = "0.2"

# no_std (alloc only)
bytecode-lang = { version = "0.2", default-features = false }
```

MSRV: Rust 1.85.

## Documentation

- [README]https://github.com/jamesgober/bytecode-lang/blob/main/README.md
- [API Reference]https://github.com/jamesgober/bytecode-lang/blob/main/docs/API.md
- [Roadmap]https://github.com/jamesgober/bytecode-lang/blob/main/dev/ROADMAP.md
- [CHANGELOG]https://github.com/jamesgober/bytecode-lang/blob/main/CHANGELOG.md

---

**Full diff:** [`v0.1.0...v0.2.0`](https://github.com/jamesgober/bytecode-lang/compare/v0.1.0...v0.2.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/bytecode-lang/blob/main/CHANGELOG.md#020---2026-10-08).