LEB128
WorksLEB128 variable-length integers, signed and unsigned — the format DWARF, WebAssembly and Protocol Buffers pack integers with — at arbitrary precision.
- Version
1.0zef:jnthn- Depends
none beyond the core- License
- Artistic-2.0
- Its own test suite
- 3 files, green
- Checked
- 2026-09-15 against Raku++ 3.28.0 and Rakudo 2026.08
Install it #
$ rakupp install LEB128zef install LEB128 writes the same store; either installer leaves the module usable by both engines.
What it is for #
A four-byte integer field wastes three bytes when the value is under 128, and most values in a binary format are small. LEB128 fixes that: seven bits of payload per byte, the top bit meaning "another byte follows". WebAssembly uses it for every index and offset, DWARF for every line-table delta, Protocol Buffers for every varint field.
Reading or writing any of those formats from Raku means having this.
Encoding and decoding #
use LEB128;
sub hex(Buf $b) { $b.list.map({ .fmt('%02X') }).join(' ') }
say 'unsigned:';
for 0, 1, 127, 128, 300, 624485, 2**64 -> $v {
my Buf $e = encode-leb128-unsigned($v);
say sprintf(' %-22s %2d bytes %-30s -> %s',
$v.Str, $e.elems, hex($e), decode-leb128-unsigned($e).Str);
}
say '';
say 'signed:';
for 0, 63, 64, -1, -64, -65, 624485, -624485 -> $v {
my Buf $e = encode-leb128-signed($v);
say sprintf(' %-10s %2d bytes %-12s -> %s',
$v.Str, $e.elems, hex($e), decode-leb128-signed($e).Str);
}unsigned:
0 1 bytes 00 -> 0
1 1 bytes 01 -> 1
127 1 bytes 7F -> 127
128 2 bytes 80 01 -> 128
300 2 bytes AC 02 -> 300
624485 3 bytes E5 8E 26 -> 624485
18446744073709551616 10 bytes 80 80 80 80 80 80 80 80 80 02 -> 18446744073709551616
signed:
0 1 bytes 00 -> 0
63 1 bytes 3F -> 63
64 2 bytes C0 00 -> 64
-1 1 bytes 7F -> -1
-64 1 bytes 40 -> -64
-65 2 bytes BF 7F -> -65
624485 3 bytes E5 8E 26 -> 624485
-624485 3 bytes 9B F1 59 -> -624485624485 encoding to E5 8E 26 is the DWARF specification's own worked example. Note the byte-boundary asymmetry in the signed form: 63 fits in one byte and 64 needs two, while -64 fits and -65 does not — the sign extension comes from bit 6 of the final byte.
Splicing into a buffer #
use LEB128;
my Buf $buf .= new;
my int $offset = 0;
for 300, 7, 624485 -> $v {
my $written = encode-leb128-unsigned($v, $buf, $offset);
say sprintf('encoded %-8s at offset %-2d -> %d byte(s) written, buffer now %s',
$v.Str, $offset, $written, $buf.list.map({ .fmt('%02X') }).join(' '));
$offset += $written;
}encoded 300 at offset 0 -> 2 byte(s) written, buffer now AC 02
encoded 7 at offset 2 -> 1 byte(s) written, buffer now AC 02 07
encoded 624485 at offset 3 -> 3 byte(s) written, buffer now AC 02 07 E5 8E 26The three-argument form returns bytes written, not the new offset, so you must accumulate. The natural misreading works for the first two values and silently corrupts the third. decode's $read is rw parameter is likewise a count, not a position.
Malformed input #
use LEB128;
for 'empty', Buf.new,
'a continuation with nothing after it', Buf.new(0x80),
'all continuations', Buf.new(0xFF, 0xFF, 0xFF) -> $label, $b {
my $r = try decode-leb128-unsigned($b);
say sprintf('%-38s -> %s', $label, $! ?? $!.^name !! $r.Str);
}
say '';
say 'trailing bytes after a complete value are IGNORED:';
say ' decode(01 DE AD) = ', decode-leb128-unsigned(Buf.new(0x01, 0xDE, 0xAD));
say 'and a non-canonical padded zero is accepted:';
say ' decode(80 80 80 00) = ', decode-leb128-unsigned(Buf.new(0x80, 0x80, 0x80, 0x00));empty -> X::LEB128::Incomplete
a continuation with nothing after it -> X::LEB128::Incomplete
all continuations -> X::LEB128::Incomplete
trailing bytes after a complete value are IGNORED:
decode(01 DE AD) = 1
and a non-canonical padded zero is accepted:
decode(80 80 80 00) = 0Truncation raises a typed X::LEB128::Incomplete rather than returning garbage, which is the right behaviour. Trailing data is not reported, so if you need "this buffer held exactly one value", check the byte count yourself.
The one thing to know #
encode-leb128-unsigned on a negative number never returns. Not an exception you can catch — the process hangs.
The parameter is typed Int, not UInt, so a negative sails past the type check; the encoder then shifts right forever, because -1 +> 7 is -1 at arbitrary precision, appending bytes until memory runs out.
The signed encoder handles every negative correctly, so the trap is specifically routing a possibly-negative value into the unsigned sub — which is exactly what happens when a delta, an offset difference, or a subtraction result reaches it. Guard with $v >= 0 at your own call site, or use the signed form.
Where the two engines differ #
On the native int in the splicing form's signature. Raku++ coerces a boxed Int; Rakudo refuses to dispatch at all:
use LEB128;
my Buf $buf .= new;
my int $native = 0;
say 'a native int offset works on both engines : ',
encode-leb128-unsigned(300, $buf, $native), ' byte(s)';
say 'and the buffer holds : ', $buf.list.map({ .fmt('%02X') }).join(' ');a native int offset works on both engines : 2 byte(s)
and the buffer holds : AC 02Declare my int $offset, as above, and the two agree. A plain my Int $off works on Raku++ and dies with X::Multi::NoMatch on Rakudo.
Everything else in this page — the round trips, the DWARF vector, the 64-bit-and-beyond values, the typed truncation error, the tolerance for trailing and padded bytes — was byte-identical on both engines.