Xenomorph:   binary encoding & decoding
1 Installation
2 Quick tutorial
3 The big picture
3.1 Bytes and byte strings
3.2 Encodings
3.2.1 Text encodings
3.2.2 Binary encodings
3.2.3 In sum
4 Core functions
decode
encode
size
5 Binary ingredients
5.1 Numbers
5.2 Strings
5.3 Arrays
5.4 Lazy arrays
5.5 Structs
5.6 Versioned structs
5.7 Pointers
5.8 Bitfields
5.9 Enumerations
5.10 Optional
5.11 Reserved
array?
6 License & source code
7.1

Xenomorph: binary encoding & decoding

Matthew Butterick <[email protected]>
and Devon Govett

 (require xenomorph) package: xenomorph

This package is in development. I make no commitment to maintaining the public interface documented below.

Hands up: who likes parsing and writing binary formats?

OK, just a few of you, in the back. You’re free to go.

Everyone else: Xenomorph eases the pain of working with binary formats. Instead of fiddling with counting bytes:

  1. You define an encoding describing the binary format using smaller ingredients — e.g., integers, strings, arrays, pointers, and sub-encodings.

  2. This encoding can then be used as a binary compiler, converting Racket values to binary and writing them out to a file.

  3. But wait, there’s more: this encoding can also be used as a binary parser, reading bytes and parsing them into Racket values. So one encoding definition can be used for both input and output.

Derived principally from Devon Govett’s restructure library for Node. Thanks for doing the heavy lifting, dude.

1 Installation

At the command line:

raco pkg install xenomorph

After that, you can update the package from the command line:

raco pkg update xenomorph

Invoke the library in a source file by importing it in the usual way:

(require xenomorph)

2 Quick tutorial

Examples:
> (define four-ints (+ArrayT uint8 4))
> (decode four-ints #"\1\2\3\4")

'(1 2 3 4)

> (decode four-ints #"\1\2\3")

bytes->list: contract violation

  expected: bytes?

  given: #<eof>

> (decode four-ints #"\1\2\3\4\5\6")

'(1 2 3 4)

> (define op (open-output-string))
> (encode four-ints '(1 2 3 4) op)
> (get-output-bytes op)

#"\1\2\3\4"

3 The big picture

3.1 Bytes and byte strings

Suppose we have a file on disk. What’s in the file? Without knowing anything else, we can at least say the file contains a sequence of bytes. A byte is the smallest unit of data storage. It’s not, however, the smallest unit of information storage — that would be a bit. But when we read (or write) from disk (or other source, like memory), we work with bytes.

A byte holds eight bits, so it can take on values between 0 and 255, inclusive. In Racket, a sequence of bytes is also known as a byte string. It prints as a series of values between quotation marks, prefixed with #:

#"ABC"

Caution: though this looks similar to the ordinary string "ABC", we’re better off thinking of it as a sequence of integers that are sometimes displayed as characters for convenience. For instance, the byte string above represents three bytes valued 65, 66, and 67. This byte string could also be written in hexadecimal like so:

#"\x41\x42\x43"

Or octal like so:

#"\101\102\103"

Both of these mean the same thing. (If you like, confirm this by trying them on the REPL.)

We can also make an equivalent byte string with bytes. As above, Racket doesn’t care how we notate the values, as long as they’re between 0 and 255:

TODO: escape the chars below

Examples:
> (bytes 65 66 67)

#"ABC"

> (bytes 65 66 67)

#"ABC"

> (bytes 65 66 67)

#"ABC"

> (apply bytes (map char->integer '(#\A #\B #\C)))

#"ABC"

Byte values between 32 and 127 are printed as characters. Other values are printed in octal:

Example:
> (bytes 65 66 67 154 206 255)

#"ABC\232\316\377"

If you think this printing convention is a little weird, I agree. But that’s how Racket does it. If we prefer to deal with lists of integers, we can always use bytes->list and list->bytes:

Examples:
> (bytes->list #"ABC\232\316\377")

'(65 66 67 154 206 255)

> (list->bytes '(65 66 67 154 206 255))

#"ABC\232\316\377"

The important thing is that when we see the #" prefix, we know we’re looking at a byte string, not an ordinary string.

3.2 Encodings

Back to files. Typically, files on disk are classified as being either binary or text. (A distinction observed by Racket functions such as write-to-file.) When we speak of binary vs. text, we’re saying something about the internal structure of the byte sequence — what values those bytes represent. This internal structure is also called an encoding. An encoding is a way of representing a sequence of arbitrary values as a sequence of bytes.

3.2.1 Text encodings

Text files are a just a particular subset of binary files that use a text encoding — that is, a binary encoding that stores human-readable characters.

But since we all have experience with text files, let’s use text encoding as a way of starting to understand what’s happening under the hood with binary encodings.

For example, ASCII is a familiar encoding that stores each character in seven bits, so it can describe 128 distinct characters. Because every ASCII code is less than 255, we can store ASCII text with one byte per character.

But if we want to use more than 128 distinct characters, we’re stuck. That’s why Racket instead uses the UTF-8 text encoding by default. UTF-8 uses between one and three bytes to encode each character, and can thus represent up to 1,112,064 distinct characters. We can see how this works by converting a string into an encoded byte sequence using string->bytes/utf-8:

Examples:
> (string->bytes/utf-8 "ABCD")

#"ABCD"

> (bytes->list (string->bytes/utf-8 "ABCD"))

'(65 66 67 68)

> (string->bytes/utf-8 "ABÇ战")

#"AB\303\207\346\210\230"

> (bytes->list (string->bytes/utf-8 "ABÇ战"))

'(65 66 195 135 230 136 152)

For ASCII-compatible characters, UTF-8 uses one byte for each character. Thus, the string "ABCD" is four bytes long in UTF-8.

Now consider the string "ABÇ战", which has four characters, but the second two aren’t ASCII-compatible. In UTF-8, it’s encoded as seven bytes: the first two characters are one byte each, the "Ç" takes two bytes, and the "战" takes three.

Moreover, for further simplicity, text files typically rely on a small set of pre-defined encodings, like ASCII or UTF-8 or Latin-1, so that those who write programs that manipulate text only have to support a smallish set of encodings.

3.2.2 Binary encodings
3.2.3 In sum

Three corollaries follow:

  1. A given sequence of bytes can mean different things, depending on what encoding we use.

  2. We can only make sense of a sequence of bytes if we know its encoding.

  3. A byte sequence does not describe its own encoding.

For those familiar with programming-language lingo, an encoding somewhat resembles a grammar, which is a tool for describing the syntactic structure of a program. A grammar doesn’t describe one particular program. Rather, it describes all possible programs that are consistent with the grammar, and therefore can be used to parse any particular one. Likewise for an encoding.

Can a grammar work as a binary encoding? In limited cases, but not enough to be practical. Most grammars have to assume the target program is context free, meaning that the grammar rules apply the same way everywhere. By contrast, binary files are nonrecursive and contextual.

4 Core functions

procedure

(decode template [byte-source])  any/c

  template : (is-a?/c xenomorph-base%)
  byte-source : (or/c bytes? input-port?) = (current-input-port)
TK

procedure

(encode template v [byte-dest])  (or/c void? bytes?)

  template : (is-a?/c xenomorph-base%)
  v : any/c
  byte-dest : (or/c output-port? #f) = (current-output-port)
TK

procedure

(size template v)  exact-nonnegative-integer?

  template : (is-a?/c xenomorph-base%)
  v : any/c
TK

5 Binary ingredients

5.1 Numbers

 (require xenomorph/number) package: xenomorph

5.2 Strings

 (require xenomorph/string) package: xenomorph

5.3 Arrays

 (require xenomorph/array) package: xenomorph

5.4 Lazy arrays

 (require xenomorph/lazy-array) package: xenomorph

5.5 Structs

 (require xenomorph/struct) package: xenomorph

5.6 Versioned structs

 (require xenomorph/versioned-struct) package: xenomorph

5.7 Pointers

 (require xenomorph/pointer) package: xenomorph

5.8 Bitfields

 (require xenomorph/bitfield) package: xenomorph

5.9 Enumerations

 (require xenomorph/enum) package: xenomorph

5.10 Optional

 (require xenomorph/optional) package: xenomorph

5.11 Reserved

 (require xenomorph/reserved) package: xenomorph

procedure

(array? type)  void?

  type : any/c
TK

6 License & source code

This module is licensed under the MIT license.

Source repository at http://github.com/mbutterick/xenomorph. Suggestions & corrections welcome.