A MoonBit implementation for decoding and encoding OCaml's Marshal binary format, enabling interoperability between OCaml and MoonBit programs.
This library provides a decoder and a faithful encoder for OCaml's Marshal format, OCaml's native binary serialization format. It can decode most common OCaml data types including integers, strings, floats, arrays, tuples, records, and shared data references, and re-encode them.
The encoder (marshal) is byte-identical to OCaml 4.14.1 Marshal.to_string v [] for the supported types: it reproduces OCaml's smallest-fit encodings, the shared-object table with backward-distance references (so DAGs and cycles round-trip), the heap-word size accounting, and the 20-byte header. Round-trip marshal(decode(x)) == x holds on genuine OCaml fixtures.
- Integers: All ranges from small integers (0-63) to 64-bit integers
- Strings: All sizes from small strings (<32 chars) to large strings
- Floats: Single doubles and float arrays
- Blocks: Tuples, records, variants, lists (tag-based structures)
- Float Arrays: Native float arrays with proper endianness handling
- Shared References: Handles OCaml's object sharing mechanism
- Custom Blocks: Int32, Int64, Nativeint (with both fixed and length-prefixed formats)
- Custom blocks with custom serializers (e.g., Bigarray)
- Code pointers and closures
- Big header format (for objects >4GB)
Add this package to your MoonBit project:
moon add bobzhang/unmarshalThe module is split so you can depend on only what you need:
| Package | Provides |
|---|---|
bobzhang/unmarshal |
shared value model: MarshalValue, MarshalHeader |
bobzhang/unmarshal/decoder |
Decoder (decode marshal bytes) |
bobzhang/unmarshal/encoder |
marshal (encode to marshal bytes) |
bobzhang/unmarshal/viz |
marshal_to_dot (Graphviz visualization) |
bobzhang/unmarshal/dot |
small Graphviz/Mermaid graph builder |
Imported as @unmarshal, @decoder, @encoder, @viz respectively.
marshal(value) serializes a MarshalValue back to OCaml Marshal bytes,
byte-identical to OCaml's own output for the supported types:
///|
test "marshal_round_trip" {
// Build a value and serialize it (the bytes match `Marshal.to_string`).
let value = @unmarshal.MBlock(tag=0, [
@unmarshal.MInt(42),
@unmarshal.MString(b"hi"),
])
let bytes = @encoder.marshal(value)
// Decoding the bytes gives the value back.
let (_, decoded) = @decoder.Decoder::new(bytes).decode()
inspect(
decoded,
content=(
#|MBlock(tag=0, [MInt(42), MString(<Bytes: [0x68, 0x69]>)])
),
)
}Physically shared sub-values (and cycles) are encoded as backward
references, exactly as OCaml does — share a MarshalValue between fields and
the encoder emits a single copy plus shared references.
///|
test "basic_usage" {
let data : Bytes = [
b'\x84', b'\x95', b'\xa6', b'\xbe', // Magic number
b'\x00', b'\x00', b'\x00', b'\x01', // Data length: 1
b'\x00', b'\x00', b'\x00', b'\x00', // Num objects: 0
b'\x00', b'\x00', b'\x00', b'\x00', // Size 32: 0
b'\x00', b'\x00', b'\x00', b'\x00', // Size 64: 0
b'\x41', // Data: small int 1
]
let decoder = @decoder.Decoder::new(data)
let (header, value) = decoder.decode()
// Verify the result
inspect(header.magic, content="2224400062")
inspect(value, content="MInt(1)")
}///|
test "decode_integers" {
// Small integer (0-63): single byte encoding
let small_int_data : Bytes = [
b'\x84', b'\x95', b'\xa6', b'\xbe', b'\x00', b'\x00', b'\x00', b'\x01', b'\x00',
b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x00',
b'\x00', b'\x00', b'\x6a', // 0x40 + 42 = 0x6a
]
let decoder = @decoder.Decoder::new(small_int_data)
let (_, value) = decoder.decode()
inspect(value, content="MInt(42)")
}///|
test "decode_strings" {
// Small string "Hello" (< 32 chars)
let string_data : Bytes = [
b'\x84', b'\x95', b'\xa6', b'\xbe', b'\x00', b'\x00', b'\x00', b'\x06', b'\x00',
b'\x00', b'\x00', b'\x01', b'\x00', b'\x00', b'\x00', b'\x03', b'\x00', b'\x00',
b'\x00', b'\x02', b'\x25', // PREFIX_SMALL_STRING + 5
b'\x48', b'\x65', b'\x6c', b'\x6c', b'\x6f', // "Hello"
]
let decoder = @decoder.Decoder::new(string_data)
let (_, value) = decoder.decode()
inspect(
value,
content=(
#|MString(b"Hello")
),
)
}///|
test "decode_tuple" {
// Tuple (1, 2)
let tuple_data : Bytes = [
b'\x84', b'\x95', b'\xa6', b'\xbe', b'\x00', b'\x00', b'\x00', b'\x03', b'\x00',
b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x00',
b'\x00', b'\x00', b'\xa0', // PREFIX_SMALL_BLOCK: tag=0, size=2
b'\x41', // Small int 1
b'\x42', // Small int 2
]
let decoder = @decoder.Decoder::new(tuple_data)
let (_, value) = decoder.decode()
match value {
@unmarshal.MBlock(tag~, fields) => {
inspect(tag, content="0")
inspect(fields.length(), content="2")
inspect(fields[0], content="MInt(1)")
inspect(fields[1], content="MInt(2)")
}
_ => abort("Expected MBlock")
}
}///|
test "decode_float_array" {
// Simple float array [3.14, 2.71]
let float_array_data : Bytes = [
b'\x84', b'\x95', b'\xa6', b'\xbe', b'\x00', b'\x00', b'\x00', b'\x12', b'\x00',
b'\x00', b'\x00', b'\x01', b'\x00', b'\x00', b'\x00', b'\x05', b'\x00', b'\x00',
b'\x00', b'\x03', b'\x0e', b'\x02', // CODE_DOUBLE_ARRAY8_LITTLE, count=2
// First double: 3.14 (little-endian)
b'\x1f', b'\x85', b'\xeb', b'\x51', b'\xb8', b'\x1e', b'\x09', b'\x40',
// Second double: 2.71 (little-endian)
b'\x29', b'\x5c', b'\x8f', b'\xc2', b'\xf5', b'\xa8', b'\x05', b'\x40',
]
let decoder = @decoder.Decoder::new(float_array_data)
let (_, value) = decoder.decode()
match value {
@unmarshal.MDoubleArray(arr) => {
inspect(arr.length(), content="2")
// Values are approximately 3.14 and 2.71
assert_true(arr[0] > 3.13 && arr[0] < 3.15)
assert_true(arr[1] > 2.70 && arr[1] < 2.72)
}
_ => abort("Expected MDoubleArray")
}
}OCaml's custom blocks allow specialized types like Int32, Int64, and Nativeint to be marshaled:
///|
test "decode_int32" {
// OCaml Int32.of_int 42
let int32_data : Bytes = [
b'\x84', b'\x95', b'\xa6', b'\xbe', b'\x00', b'\x00', b'\x00', b'\x08', b'\x00',
b'\x00', b'\x00', b'\x01', b'\x00', b'\x00', b'\x00', b'\x03', b'\x00', b'\x00',
b'\x00', b'\x03', b'\x19', // CODE_CUSTOM_FIXED
b'\x5f', b'\x69', b'\x00', // "_i" identifier (null-terminated)
b'\x00', b'\x00', b'\x00', b'\x2a', // 42 in big-endian
]
let decoder = @decoder.Decoder::new(int32_data)
let (_, value) = decoder.decode()
match value {
@unmarshal.MCustom(id, data) => {
inspect(id, content="_i")
// Extract the Int32 value (big-endian)
let val = (data[0].to_int() << 24) |
(data[1].to_int() << 16) |
(data[2].to_int() << 8) |
data[3].to_int()
inspect(val, content="42")
}
_ => abort("Expected MCustom")
}
}
///|
test "decode_int64" {
// OCaml Int64.of_int 1000000
let int64_data : Bytes = [
b'\x84', b'\x95', b'\xa6', b'\xbe', b'\x00', b'\x00', b'\x00', b'\x0c', b'\x00',
b'\x00', b'\x00', b'\x01', b'\x00', b'\x00', b'\x00', b'\x03', b'\x00', b'\x00',
b'\x00', b'\x02', b'\x19', // CODE_CUSTOM_FIXED
b'\x5f', b'\x6a', b'\x00', // "_j" identifier (null-terminated)
b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x0f', b'\x42', b'\x40', // 1000000
]
let decoder = @decoder.Decoder::new(int64_data)
let (_, value) = decoder.decode()
match value {
@unmarshal.MCustom(id, [i64be(val)]) => {
inspect(id, content="_j")
inspect(val, content="1000000")
}
_ => abort("Expected MCustom")
}
}
///|
test "decode_nativeint" {
// OCaml Nativeint.of_int 42 (on 64-bit platform)
let nativeint_data : Bytes = [
b'\x84', b'\x95', b'\xa6', b'\xbe', b'\x00', b'\x00', b'\x00', b'\x0c', b'\x00',
b'\x00', b'\x00', b'\x01', b'\x00', b'\x00', b'\x00', b'\x03', b'\x00', b'\x00',
b'\x00', b'\x02', b'\x19', // CODE_CUSTOM_FIXED
b'\x5f', b'\x6e', b'\x00', // "_n" identifier (null-terminated)
b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x00', b'\x2a', // 42
]
let decoder = @decoder.Decoder::new(nativeint_data)
let (_, value) = decoder.decode()
match value {
@unmarshal.MCustom(id, [i64be(val)]) => {
inspect(id, content="_n")
inspect(val, content="42")
}
_ => abort("Expected MCustom")
}
}Custom block identifiers:
"_i"- Int32"_j"- Int64"_n"- Nativeint
OCaml's Marshal format supports object sharing to avoid duplicating data and handle cyclic structures:
///|
test "shared_references" {
// Tuple with shared string: ("shared", "shared")
// The second string is a reference to the first
let shared_data : Bytes = [
b'\x84', b'\x95', b'\xa6', b'\xbe', b'\x00', b'\x00', b'\x00', b'\x0a', b'\x00',
b'\x00', b'\x00', b'\x02', b'\x00', b'\x00', b'\x00', b'\x06', b'\x00', b'\x00',
b'\x00', b'\x05', b'\xa0', // Small block, tag=0, size=2
b'\x26', b'\x73', b'\x68', b'\x61', // Small string "shar"
b'\x72', b'\x65', b'\x64', // "ed"
b'\x04', b'\x01', // SHARED8, index 1
]
let decoder = @decoder.Decoder::new(shared_data)
let (_, value) = decoder.decode()
match value {
@unmarshal.MBlock(tag~, fields) => {
inspect(tag, content="0")
inspect(
fields[0],
content=(
#|MString(b"shared")
),
)
inspect(
fields[1],
content=(
#|MString(b"shared")
),
) // Reference to first field
}
_ => abort("Expected MBlock")
}
}Contains metadata about the marshaled data:
magic : UInt- Magic number (0x8495A6BE or 0x8495A6BF)data_len : Int- Length of data sectionnum_objects : Int- Number of shared objectssize_32 : Int- Size on 32-bit platformssize_64 : Int- Size on 64-bit platforms
Represents decoded OCaml values:
MInt(Int)- Integer valuesMString(Bytes)- String/bytesMFloat(Double)- Floating pointMDoubleArray(Array[Double])- Float arrayMBlock(tag~ : Int, Array[MarshalValue])- Structured data (tuples, records, variants)MCustom(String, Bytes)- Custom blocks (Int32, Int64, Nativeint, etc.)
Creates a new decoder from marshal data.
Decodes the marshal data, returning the header and value. Raises an error if the data is malformed.
Serializes a MarshalValue to OCaml Marshal wire format (small header),
byte-identical to Marshal.to_string value [] for the supported types.
Physical sharing between sub-values is reproduced as backward references, so
DAGs and cycles round-trip. Raises on an unsupported custom block identifier
(only the _i/_j/_n scalars are supported).
The OCaml Marshal format uses a tag-based encoding system:
- Small integers (0-63): Single byte
0x40 + n - Small strings (<32 chars):
0x20 + lenfollowed by data - Small blocks:
0x80 + tag + (size << 4)for tag < 16, size < 8 - Larger values: Use specific code tags (INT8, INT16, STRING8, etc.)
- Integers are stored in big-endian format
- Floats can be either big-endian or little-endian (indicated by different tags)
- The decoder handles both endianness variants automatically
Every decoded object (except shared references themselves) is registered in an internal object table. When a shared reference is encountered, it points to an index in this table, enabling:
- Memory-efficient representation of repeated values
- Support for cyclic data structures
- Preservation of object identity
Run the test suite:
moon testGenerate test data from OCaml:
ocaml test_generator.ml > marshal_data_test.mbtContributions are welcome! Areas that need work:
- Custom blocks with custom serializers (Bigarray, etc.)
- Big header format for large objects
- Better error messages with position information
- More comprehensive test coverage
- Performance optimizations
This project is licensed under the Apache-2.0 License - see the LICENSE file for details.
This implementation is based on the OCaml Marshal format specification and the OCaml runtime's extern.c implementation.