Byte-coded HTML5 and the wire that carries it. Big-endian everywhere, fixed-size packed records, no padding, no alignment. The reader looks up an op and branches to a handler. There is nothing to parse.
Stunt Grok Jockey Tony (@tswain555 on X)
BML is HTML5 in bytecode form, built for headless Linux daemons and the SDL3 BlitzBrowse client. Each element and attribute has a one-byte id. Attribute values are typed. A stream is walked, not parsed.
Blitz is the transport. Its hot path:
read 4-byte header word -> handlers[op] -> read len bytes -> handler
The handler works on fixed-size big-endian records. blitz_hdr_unpack checks each op's minimum and maximum length before dispatch, so a handler never sees a bad length.
The library is plain C99 with no SDL or other GUI dependency.
| Item | Value |
|---|---|
| BML stream | 'B' 'M' 'L' 0x02, then minor u16 BE 0x0001 = v2.1. Bytes: 42 4D 4C 02 00 01 |
| Blitz v2 | HELLO / HELLO_ACK carry magic u32 "BLZ2" (42 4C 5A 32) plus caps u16 |
| Byte order | Big-endian (network order) everywhere. No padding, no alignment. |
| Records | Fixed-size packed records. Fixed records must match their exact length. |
| Blitz TCP port | 23232 (also embeddable behind HTTP) |
Byte order is defined in one place, blitz_wire.h. Read and write multi-byte values only with bw_get16/32/64/f64 and bw_put16/32/64/f64. Do not shift bytes by hand anywhere else.
Each frame is a 4-byte header (one BE u32) followed by len bytes of payload.
bit 31..25 op 7 bits (1..127; 0 invalid)
bit 24 MORE 1 bit
bit 23..0 len 24 bits payload length
header word = op<<25 | MORE<<24 | len
The header has no reserved bits. The old v1 header was 8 bytes and had a 16-bit reserved field. Because the op sits in the top 7 bits, the first header byte is op<<1 | MORE. That is why HELLO (op 01) starts with 0x02 on the wire.
Length limits are enforced at unpack: BODY ≤ 16 KiB (16384), REQUEST ≤ 13 + 2048, and fixed records exact.
| op | name | payload (all BE) |
|---|---|---|
| 01 | HELLO | 6B: magic "BLZ2" u32, caps u16 (client → server) |
| 02 | HELLO_ACK | 6B: same; caps = intersection (server → client) |
| 03 | REQUEST | 13B + url: id u16, mth_acc u8, etag u32, body_len u32, url_len u16, url bytes. mth_acc = method(3 bits)<<5 | accept(5 bits). MORE=1: HEADER frames follow (the last has MORE=0). body_len>0: BODY frames + END follow the headers. |
| 04 | HEADER | 1B + value: hid u8, value bytes (len−1) |
| 05 | RESPONSE | 19B: id u16, status u16, ct_fl u8, max_age u16, etag u32, mtime u32, body_len u32. ct_fl = ctype(5 bits)<<3 | flags(3 bits). MORE=1: HEADER frames follow. |
| 06 | BODY | raw bytes, ≤ 16384 |
| 07 | END | empty. Every response ends with exactly one END, including 304, HEAD and empty bodies. |
| 08 | CLOSE | empty |
| 09 / 0A | PING / PONG | 4B: nonce u32 |
| 0B | ERROR | 2B: code u16. The sender closes after ERROR. |
| Field | Values |
|---|---|
| method | 0 GET, 1 HEAD, 2 POST, 3 PUT |
| accept bits | 1 BML, 2 CHDD, 4 TEXT, 8 IMAGE, 0x10 ANY (wildcard). Mask 0 = all. |
| ctype | 0 bin, 1 bml, 2 chdd, 3 html, 4 text, 5 png, 6 jpeg, 7 gif, 8 json, 9 css, 10 js |
| response flags | 1 PUBLIC (shared cache ok), 2 NOBODY (HEAD/304), 4 LENUNK |
| caps | 1 BML, 2 CHDD, 4 KEEPALIVE, 8 CACHE, 0x10 PUT |
| header hid | 1 Host, 2 User-Agent, 3 Accept, 4 Client-IP, 5 Content-Type, 6 Auth, 7 CV-Pub, 8 CV-Sig, 9 Prefix, 10 Location |
| ERROR code | 1 magic, 2 frame, 3 too large, 4 unsupported, 5 timeout, 6 internal, 7 sequence |
| status | HTTP status numbers |
| etag | u32 validator, blitz_etag(size, mtime), never 0. A request etag that matches the current one gets a 304. |
| max_age | seconds |
HELLO -> HELLO_ACK
-> { REQUEST [HEADER*] [BODY* END]
-> RESPONSE [HEADER*] BODY* END }*
-> CLOSE
One connection carries many requests (keep-alive).
These come from blitz_vectors.h, the shared golden test vectors. The Blitz vectors were derived by hand from the layout, not produced by the codec. All bytes are big-endian.
HELLO caps=0x001F
02 00 00 06 42 4C 5A 32 00 1F
HELLO_ACK caps=0x0007
04 00 00 06 42 4C 5A 32 00 07
REQUEST more=1 id=0x0102 GET accept=BML|TEXT etag=0xDEADBEEF body=0 url=/a.bml
07 00 00 13 01 02 05 DE AD BE EF 00 00 00 00 00 06 2F 61 2E 62 6D 6C
HEADER hid=1 (Host) value="h"
08 00 00 02 01 68
RESPONSE id=0x0102 200 BML|PUBLIC max_age=3600 etag=0xCAFEBABE mtime=0x65000000 body_len=0x123
0A 00 00 13 01 02 00 C8 09 0E 10 CA FE BA BE 65 00 00 00 00 00 01 23
END 0E 00 00 00
CLOSE 10 00 00 00
ERROR 16 00 00 02 00 03 (code 3, too large)
The test suite also includes vectors that must be rejected: op 0, unknown ops 12 and 127, a BODY of length 16385, a HELLO with length 5, an END with length 1, and a bad magic "BLZ1".
'B' 'M' 'L' 0x02 | minor u16 BE = 0x0001 | nodes
string = len u16 BE + UTF-8
BOOL UINT8 UINT16 UINT32 INT32 DOUBLE STRING ENUM URL TOKENS (BML_TYPE_* in bml_tables.h).bml_tables.h.BML_WALK_ERR_VERSION.bml_walk fires a close for every open. On the wire, void elements (br, img, input, …), DOCTYPE, comments and CDATA carry no END byte, so the walker synthesizes their close. A renderer that prints HTML should skip the closing tag when:
op == BML_OP_DOCTYPE || op == BML_OP_COMMENT || op == BML_OP_CDATA ||
bml_is_void(bml_element_info(op))
The full element and attribute opcode tables live in bml_tables.h / bml_tables.c and are not repeated here.
Over HTTP/1.1, BML is sent as Content-Type: application/x-bml with ETag: "xxxxxxxx", using the same validator as the Blitz etag. It can be served by bmld --http or by Apache through mod_bml, a gateway that talks Blitz v2 upstream.
| Header | Role |
|---|---|
| bml_tables.h | Opcodes, attr ids, value types, element/attr tables |
| bml.h | Runtime: Cell/Table, handlers, bml_html_to_bml / bml_bml_to_html |
| bml_walk.h | Bounds-checked callback walker |
| bml_handlers.h | Per-tag handler decls (optional if you only use the walker) |
| bml_min.h | Drop-in BMLMinRunBuf / BMLMinRunFile |
| blitz_wire.h | The byte-order and wire constants header (big-endian), Blitz v2 layout |
| blitz_frame.h | Blitz v2 frame/record codec (no I/O) |
All includes are flat (#include "bml.h"). Compile with -I pointing at the BML folder.
gcc -std=c99 -Wall -Wextra -Werror -pedantic -Ibml_port \
-c bml_tables.c bml_walk.c bml.c bml_functions.c bmlo.c
ar rcs libbml.a bml_tables.o bml_walk.o bml.o bml_functions.o bmlo.o
You can also run ./build_portable.sh in bml_port/ (no make, no env vars). Add blitz_frame.c when you need the transport; it depends only on blitz_wire.h. After editing, run ./sync_bml.sh to refresh the bmld and bml_apache copies, and ./sync_bml.sh --check to verify them.
int bml_walk_check_header(const unsigned char *bw_buf, size_t bw_len);
int bml_walk(const unsigned char *bw_buf, size_t bw_len,
const struct BmlWalkCbs *bw_cbs);
int bml_html_to_bml(const char *html, unsigned char **out, size_t *out_len);
int bml_bml_to_html(const unsigned char *bml, size_t bml_len,
char **out, size_t *out_len); /* caller frees *out */
struct BmlWalkCbs has the callbacks bw_on_open, bw_on_attr, bw_on_text, bw_on_close and bw_on_error, plus bw_user.
bw_val is raw big-endian payload bytes and is empty for BOOL. Read it with bml_val_u16/u32/i32/f64.data-*, aria-* and unknown names are restored as full HTML names in bw_name.BML_WALK_MAX_DEPTH (256).| Return | Meaning |
|---|---|
| 0 OK | Success |
| -1 ARG | NULL buffer/cbs or too short |
| -2 MAGIC | Bad magic |
| -3 TRUNC | Truncated stream |
| -4 DEPTH | Exceeded max depth |
| -5 OPCODE | Unknown / invalid opcode |
| -6 ATTR | Bad attribute |
| -7 VALUE | Bad typed value |
| -8 END | Unexpected OP_END |
Constants are named BML_WALK_ERR_*. A wrong minor version is reported as BML_WALK_ERR_VERSION.
bmld is a standalone BML server written as one C file plus the shared Blitz codec. It serves a docroot of pre-encoded .bml and other static files over the Blitz v2 TCP port and over plain HTTP/1.1 as application/x-bml. It has no TLS; terminate TLS in front of it. It does not convert HTML to BML on the fly, so it serves pre-encoded files only.
bmld --root DIR [--blitz PORT] [--http PORT] [--bind ADDR]
[--no-blitz] [--no-http] [--max-age SECONDS] [--max-conns N]
[--idle SECONDS] [-v]
bmld --root DIR --check
It takes options on the command line only and reads no environment variables. Tools in the source tree: html2bml, bml2html, bmlget, and the smoke client blitz_get.
Samples in bml_port/samples/ (mirrored in bmld/www/) are all v2.1 BE, so each one starts with 42 4D 4C 02 00 01:
01_hello.bml02_headings_lists_links.bml03_form_inputs.bml04_table_spans.bml05_media_aria_data.bml06_realistic.bmlBML v1.22 and little-endian Blitz v1 are superseded by BML v2.1 and Blitz v2, which are big-endian.
The formats are not wire-compatible. A Blitz v1 client or server cannot talk to a Blitz v2 one. bmld refuses legacy little-endian .bml files, and the v2.1 walker rejects v2.0 streams. Clients and servers need to move to v2 together.
op|MORE|len word.0x0001, big-endian.bml.h are kept only for history. Use bml_tables.h from the v2.1 library instead.The spec is frozen. The decision (2026-10-03) is that all wire formats are big-endian, fixed-size packed records.
application/x-bml MIME mapping is not configured on the public site yet.This page will be updated when these go live.