bytecode-lang 0.3.0

LSB, the LexerSketch bytecode format: instruction set, verifier, disassembler, and versioned encoding.
Documentation
# bytecode-lang v0.3.0 — LSB Format Version 2: PHP Semantics the VM Cannot Fake

**A breaking 0.x minor that closes the format gaps found by the first compiler and the first VM.**
bcgen-lang 0.2 compiled PHP-flavoured HIR into LSB and bvm-lang 2.0.0-alpha.2 ran all of it; both
hit the same walls: nested array writes that had to copy on every write, no references into array
slots or properties, no way to pass arguments by reference when only the callee knows, dynamic
calls fixed to an exact arity, host functions that could not be values, and a generator key rule
that disagreed with PHP. Format version 2 removes each of them, in the spec (`specs/LSB.md`) and in
this crate, and adds what OPS v2 and HIR v3 defined since 0.2.0 (`pow`, `shift = saturate`,
`NoMatch`).

## What is bytecode-lang?

The LXRT-tier crate that owns LSB, the one bytecode format of the LexerSketch toolchain: its
instruction set, module model, versioned encoding, hostile-input decoder, disassembler, and
builder. bvm-lang executes LSB, bcgen-lang emits it from HIR, loader-lang will store it, and
dap-lang will debug it. The crate executes nothing and has no dependencies.

## What's new in 0.3.0

### Dynamic calls with real signatures (LSB §5.15)

A function or an import can now say how dynamic calls bind to it: a **`ParamList`** of parameters
with kinds (positional-only, normal, named-only, rest, rest map, named rest), names, by-reference
flags, and defaults, plus `ignore_extra` for PHP user functions. When a parameter has a default,
the signature ends with an `i64` **presence mask** so the callee computes its own defaults (they
are expressions in PHP and Python). A call site describes its window with a **`CallShape`**
(positional, named, spread, named spread) and calls with `dcall_shape`; `dcall` is the
all-positional case. bcgen's argument packets (two containers per dynamic call, unpacked by a
prologue) are no longer needed.

The binding rule is written once, as **`ParamList::bind`**, so the VM, the T0 evaluator, and a
native tier cannot disagree on PHP's or Python's argument rules: extra positionals, unknown and
repeated names, positional-after-named, missing parameters (all `ArgumentError`, E0114), and where
named extras go. `dparam_ref` and `dparam_ref_named` let a PHP code generator ask, at run time and
before evaluating an argument, whether the callee takes it by reference, which is how PHP decides.
And `load_import` makes any import a first-class function value with any signature, so `printf` and
`array_push` can be passed around and called with spreads and named arguments.

```rust
use bytecode_lang::{ArgItem, Bound, ModuleBuilder, Param, ParamKind, ParamList};

let mut m = ModuleBuilder::new();
let (a, b) = (m.string("a"), m.string("b"));
let module = m.finish().unwrap();
// PHP: function f($a, $b = 2, ...$rest)
let list = ParamList::new(vec![
    Param::normal(a),
    Param::normal(b).with_default(),
    Param::new(ParamKind::RestMap, None),
]);
// f(1, 2, 3, x: 4): the extras collect in $rest as [0 => 3, 'x' => 4].
let p = ArgItem::Positional;
let bound = list.bind(&module, &[p, p, p, ArgItem::Named(b"x")]).unwrap();
assert_eq!(bound.slots(), &[Bound::Arg(0), Bound::Arg(1), Bound::Collected(vec![2, 3])]);
// f(b: 5, a: 6): named arguments bind by name.
let bound = list.bind(&module, &[ArgItem::Named(b"b"), ArgItem::Named(b"a")]).unwrap();
assert_eq!(bound.slots(), &[Bound::Arg(1), Bound::Arg(0), Bound::Collected(vec![])]);
```

### Separation only when shared (LSB §5.16)

PHP arrays are values; a code generator `dup`s on every transfer (O(1), copy-on-write). A nested
write (`$a[$k][] = $v`) used to need a `dup` of the inner array on every write, which copied its
contents each time (about 28 times slower than plain appends in bcgen's measurement).
`dsep_index` and `dsep_prop` make the element safe to write in place and copy it only when another
container may share it. The spec states the guarantee exactly (for code that follows the value
discipline, a write through the result is visible through nothing else), leaves the identity of
the result unspecified (exact reference counts and tracing collectors can differ there, and PHP
code never compares arrays by identity), and gives a conforming two-bit implementation for
tracing collectors under which the steady loop `$g[$i % 64][] = $i` copies nothing.

### References (LSB §5.17)

`reference` is a new dynamic kind: a box holding one value, read and written with `cell_get` and
`cell_set`. `dref_index`/`dref_prop` turn a map slot or property into one (`&$a['k']`, `&$o->p`,
`foreach ($a as &$v)`), `dbind_index`/`dbind_prop` bind a slot to an existing one
(`$a['k'] = &$x`), and `new_ref` makes one for a variable. A slot holding a reference is
transparent: every value read and write of it goes through, and a copy of the container shares the
reference, as PHP's does. PHP also drops such a reference on copy when nothing else holds it, which
needs reference counts a tracing collector does not keep; LSB keeps it until `dunref_index`/
`dunref_prop` unbinds it, and the spec says where a code generator can prove that is safe.

### OPS v2 and HIR v3

- `ipow`, `fpow`, and `dpow` (`pow` in every form: exact integer powers under the overflow policy,
  `promote` to the nearest `f64`, `NegativeExponent` E0006 otherwise; IEEE `pow` by the shared
  `ls_pow` routine for floats), and `dabs` (`abs` with `promote` for PHP's `abs(PHP_INT_MIN)`).
- `shift = saturate`, PHP's shifts, on `ishl`/`ishr` and `dshl`/`dshr`. Fitting a third shift code
  into the modifier bytes repacked them: `Policy` is six bits, `IntOp` carries type, overflow,
  div_zero, and shift, and the float-to-integer conversions got their own `FloatConv`.
- `NoMatch` (E0200, HIR §8.10) and `raise`, which raises any catchable kind with a payload, so
  bcgen can raise `NoMatch` with the scrutinee; `err_payload` reads the payload back.
- The not/bit-not names follow HIR's ops: `dnot` is the logical not (PHP `!`), `dbit_not` and
  `ibit_not` the bitwise complements.

### Coroutine keys follow PHP's generators (LSB §5.13 rule 10)

The automatic key is one more than the largest integer key yielded so far, never below 0: after
only `yield -5 => x` it is 0, as PHP's generators give (format 1 gave -4, the array rule, which
maps keep because PHP 8.3 arrays do that).

### Stated: NaN on dynamic floats

A `dyn` float holds one canonical NaN (`0x7FF8_0000_0000_0000`), so its sign and payload are not
observable; what is observable (bit casts after unboxing, `total_cmp` ordering it as a positive
NaN) is now written in LSB §5.6 and in a note in OPS §4, together with the fact that typed NaN
results stay tier-dependent.

## Breaking changes

- `FORMAT_VERSION` is 2; format 1 files are refused (`UnsupportedVersion(1)`) and must be
  regenerated. Imports and functions carry an optional parameter list, functions a call-shape
  table.
- `Policy`, `IntOp`: new bit layouts (see above); `IntOp` no longer carries `float_to_int`.
  `Inst::F32ToInt`/`F64ToInt` take `conv: FloatConv` instead of `op: IntOp`.
- Renamed: `Inst::INot` → `IBitNot` (`ibit_not`), `Inst::DNot` (bitwise) → `DBitNot`
  (`dbit_not`), `Inst::DLNot` → `DNot` (`dnot`, logical). Same opcodes and meanings; the new
  `DNot` has no `pol` field, so old code that named `DNot { pol, .. }` fails to compile instead of
  changing meaning.
- `Import` gains `params` and is no longer `Copy`.
- `Kind`, `Hook`, `FieldKind` gain variants (`Reference`; `Pow`, `Abs`, `CallShape`; `Shape`,
  `FloatConv`, `ErrKind`); `Opcode::ALL` has 200 entries.
- `dcall` reports an argument-count mismatch as `ArgumentError` (E0114) instead of `TypeError`;
  the generator key rule changed (above); `dup` of a reference is a `TypeError`.

## What the VM and the compiler implement next

This crate defines, encodes, decodes, disassembles, and builds the format 2 instructions and ships
the binder; it executes nothing. What it takes to use them is listed in `dev/ROADMAP.md` and
summarized here:

- **bvm-lang 2.0.0-alpha.3**: load format 2 (layout, policy bytes, `FloatConv`); execute the 18
  new instructions and the renamed three; transparent reference slots in every dynamic slot read
  and write; separation (`dsep_*`, and the `cow`/`aliased` bits or exact counts); parameter-list
  binding for `dcall`, `dcall_shape`, `coro_new_indirect`, `spawn` (using or differential-testing
  against `ParamList::bind`), the `call_shape` hook, fuel at the stated point, `ArgumentError`;
  import parameter lists for host functions; `ipow`/`fpow`/`dpow`/`dabs` and `shift = saturate`;
  `raise`/`err_payload` and error payloads; the new generator key rule.
- **bcgen-lang 0.3**: emit parameter lists instead of argument packets, `dcall`/`dcall_shape`
  with shapes for named and spread arguments, `dparam_ref` before each place argument of a dynamic
  call, `dsep_index`/`dsep_prop` for nested writes, `dref_*`/`dbind_*`/`new_ref` for PHP
  references and `foreach` by reference (and `dunref_*` where provable), `raise.E0200` with the
  scrutinee for `match` without an arm, `dabs`/`dpow` and `shift = saturate`, host functions as
  values through import parameter lists, and the renamed not instructions.

## Verification

Run on Windows x86_64 from the crate directory, on Rust stable, 1.99, and 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 +1.99 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 (stable 1.95, 1.99, and 1.85; nightly clippy also clean). Counts, identical with all and
with no default features: 27 unit, 88 integration (8 binding, 24 builder, 19 codec, 3 disassembly,
8 format 2, 11 ISA, 15 property), and 280 doctests (including every `rust` example in `README.md`
and `docs/API.md`). The binding properties were mutation-checked: letting a named argument
overwrite a filled parameter, or letting a positional-only parameter bind by name, makes them fail.
Linux, macOS, `cargo deny`, and `cargo audit` run in CI and in the coordinator's gate.

Benchmarks (`cargo bench --bench bench`, release profile, x86_64, Windows; a module of 1,000
functions × 1,000 instructions; v0.2.0 and v0.3.0 built side by side and run back to back on the
same machine):

| Benchmark | v0.2.0 | v0.3.0 |
|---|---:|---:|
| Encode 1M instructions | ~2.2–3.2 ms | ~2.3–2.6 ms |
| Decode 1M instructions, every check | ~4.2–4.4 ms | ~2.3–2.8 ms |
| Build 1M instructions with the builder | ~3.5–4.0 ms | ~3.4–3.9 ms |
| Disassemble 100k instructions | ~9.9–10.2 ms | ~10.6–10.9 ms |
| Bind 100k dynamic calls (`ParamList::bind`, four PHP call shapes) | — | ~6.7–7.3 ms (~67–73 ns per call) |

The machine was shared with other builds, so ranges are wide. Encoding and building are unchanged
within the spread. Decoding measured faster in 0.3.0 in every interleaved run; the change to the
decoder is two small reads per function, so the difference is not explained and is not claimed as
a gain. Disassembly measured about 7% slower, also not isolated (the per-instruction renderer is a
generated match that grew by 18 arms). The benchmark module has no parameter lists or call shapes,
so it measures the format 2 decoder on format 1-shaped code.

## Installation

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

# no_std (alloc only)
bytecode-lang = { version = "0.3", 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.2.0...v0.3.0`](https://github.com/jamesgober/bytecode-lang/compare/v0.2.0...v0.3.0).
**Changelog:** [`CHANGELOG.md`](https://github.com/jamesgober/bytecode-lang/blob/main/CHANGELOG.md#030---2026-10-09).