3 - RBKC: The Chunked .rabook Container
Contents
Books are the reason this project exists, so this is where the e-reader stops being a graphics demo and starts being a reader.
The interesting tension here is that solving one problem creates another. Moving all the EPUB parsing to a host tool makes the device’s job trivial, but it produces a single flat object that is bigger than RAM – so now you need a way to read a piece of it without holding the whole thing. This post is also a useful comparison with the previous one: the two formats put their index at opposite ends of the file, for reasons that are entirely about the producer.
Magic: RBKC |
Library: libs/ra8_book |
Extension: .rabook |
Producer: tools/epub_compile/epub_compile.py
1. Synopsis
RBKC is how a whole book gets onto the device in a form that can be read without ever being resident in full.
An EPUB is a ZIP of XHTML, CSS, fonts and images. Parsing that on a
microcontroller – unzipping, tokenising XML, resolving a CSS cascade – is a
large, unbounded, allocation-hungry job, and it is the same job every single
time the book is opened. So the device does not do it. epub_compile.py does it
once on the host and emits a flat blob: a single contiguous byte image with
fixed-width tables (nodes, attributes, chapters, stylesheets) and string pools,
where every cross-reference is an integer index rather than a pointer. That blob
is designed to be used in place – the renderer reads a node by indexing into
it, with no parsing at all.
That solves parsing but creates a new problem: the blob is big, and it is one object. The Walden fixture inflates to 67 KB; a real illustrated book runs to many megabytes, well past the 1.6 MB of SRAM. Compressing it as one zlib stream would make it small on disk but force a whole-blob inflate to use any of it.
RBKC is the wrapper that fixes exactly that, and nothing else. It slices the flat blob into fixed-size chunks, each independently zlib-compressed, and records where each chunk’s stream begins in a table at the front. The reader inflates one chunk at a time, on demand, into a page-cache frame.
The number that makes it click: chunks are produced at exactly the size of the
reader’s ra8_vmem cache frame, so
one chunk == one cache frame == one inflate call.
There is no double-buffering, no second decompressed-chunk cache, and no partial-chunk arithmetic. A page fault in the book’s byte space maps to exactly one bounded read plus one bounded inflate.
If you stop reading here: RBKC is a demand-paging transport wrapped around a pre-parsed flat book blob – uniform independently-compressed chunks plus an offset table, sized so one chunk fills one page-cache frame, letting a book far larger than RAM be read with a bounded working set.
2. Design rationale
Two layers, and why they are separate
The single most important thing to understand about a .rabook file is that
there are two formats stacked, and they have different jobs:
.rabook file
+--------------------------------------------------+
| RBKC container <- TRANSPORT: compression + |
| chunking + demand paging |
| +--------------------------------------------+ |
| | RABOOK1 flat blob | |
| | <- CONTENT: node/attr/chapter tables, | |
| | string pools, index-based references | |
| +--------------------------------------------+ |
+--------------------------------------------------+
The inner blob starts with the ASCII magic RABOOK1 and is described by
ra8_book_header_t. The outer container knows nothing about books – it sees
an opaque byte range it must be able to serve any window of. That separation is
what lets the same paging machinery serve a comic container (RCBZ) and a book,
and it is why this post specifies only the outer layer.
You can see both layers in a hexdump, which is the fastest way to convince
yourself: the file begins 52 42 4B 43 (RBKC), and the first chunk’s zlib
stream inflates to bytes beginning 52 41 42 4F 4F 4B 31 (RABOOK1). Section 6
shows exactly that.
The comparison that makes it click
Consider reading a few hundred bytes near the end of a 4 MB book blob – a node the renderer needs for the chapter the reader jumped to.
DEFLATE’s compression comes from a sliding window over everything already seen, so a single stream is inherently sequential: byte N cannot be produced without having produced every byte before it. Chunking deliberately discards that cross-chunk history. Each chunk restarts the window, which costs a few percent of compression ratio, and buys random access. That trade – a little ratio for O(1) seek – is the entire design.
Why the chunk table is at the front (and JOF’s index is at the back)
This looks like an inconsistency with JOF, which puts its index at the end. It is not: the two producers have different constraints.
| JOF | RBKC | |
|---|---|---|
| Producer knows the total size up front? | No – depends on how each tile compresses | Yes – chunk_count is ceil(inflated_total / chunk_bytes), known before compressing anything |
| Therefore | index must trail the data | table size is known, so it can be reserved at the front and filled in |
| Reader benefit | reads a fixed-size footer from the end | reads the header and table in one contiguous front-of-file read |
Because inflated_total and chunk_bytes are both decided before compression
starts, the producer knows exactly how many table entries it needs and can
reserve the space. Putting the table first then lets the reader slurp the header
and the whole table in one sequential read from offset 0 – which is the cheapest
possible access pattern on an SD card, where seeks cost far more than sequential
bytes.
Why zlib here and raw DEFLATE in JOF
RBKC chunks are zlib (RFC 1950) streams – they begin 78 xx and carry a
trailing Adler-32 checksum. JOF tiles are raw DEFLATE (RFC 1951) with no
wrapper. The difference is a deliberate 6-byte-per-unit trade:
- A JOF atlas can hold thousands of small tiles, and it already validates every tile by requiring the inflate to produce an exact byte count. A per-tile checksum would add overhead on every tile to re-detect what the size check already catches.
- An RBKC chunk is 64 KiB, so 6 bytes is negligible, and the Adler-32 gives a genuine integrity check on data that came off removable media and may have been sitting there for months. A silently bit-rotted chunk fails the checksum rather than rendering as corrupt text.
Why uint64 offsets
The chunk table stores uint64 offsets and inflated_total is uint64, where
JOF uses uint32 throughout. JOF is bounded by its geometry caps to well under
4 GiB. A book is not bounded that way – an omnibus with full-resolution art can
exceed 4 GiB inflated, and a format that silently wraps at that boundary would
be a latent corruption bug. The table costs 8 bytes per 64 KiB chunk, which is
0.012% overhead; paying it to remove a whole class of overflow is trivially
worth it.
3. Wire format
All integers are little-endian. Offsets in the chunk table are relative to the start of the payload, not to byte 0 of the file.
+--------------------------------------------------+ offset 0
| header 24 bytes |
+--------------------------------------------------+ offset 24
| chunk table (chunk_count + 1) x 8 bytes |
+--------------------------------------------------+ payload_off
| chunk 0 zlib stream |
| chunk 1 zlib stream | concatenated,
| ... | back to back
| chunk N-1 zlib stream |
+--------------------------------------------------+ end of file
with
payload_off = 24 + 8 * (chunk_count + 1)
3.1 Header (24 bytes, at offset 0)
| Offset | Size | Field | Meaning and valid range |
|---|---|---|---|
| 0 | 4 | magic |
The bytes 52 42 4b 43 ("RBKC"). Compared with memcmp. |
| 4 | 4 | chunk_bytes |
Inflated bytes per chunk; the last chunk is short. Must be non-zero. Equals the reader’s ra8_vmem frame size. |
| 8 | 8 | inflated_total |
Total length of the flat blob this container serves. |
| 16 | 4 | chunk_count |
Must equal ceil(inflated_total / chunk_bytes). |
| 20 | 4 | reserved |
Must be 0. |
The record sizes are pinned by ra8_book_container_t:
k_ra8_book_container_header_len = 24, k_ra8_book_container_entry_len = 8,
k_ra8_book_container_magic_len = 4.
3.2 Chunk table
chunk_count + 1 little-endian uint64 entries, immediately after the header.
Entry i is the payload-relative byte offset where chunk i’s zlib stream begins. Chunk i therefore occupies
[ payload_off + offset[i], payload_off + offset[i+1] )
which is why there is one more entry than there are chunks: the final entry is the end sentinel, and every chunk’s length is a subtraction rather than a stored field. A length can disagree with the next offset; a subtraction cannot.
Invariants, all enforced at open:
offset[0] == 0- strictly increasing (so every chunk has non-zero length)
offset[chunk_count] == file_len - payload_off(the table closes the file)- every chunk’s length fits the caller’s
staging_cap
3.3 Chunk streams
Each chunk is one standalone zlib (RFC 1950) stream – 78 xx header,
DEFLATE body, Adler-32 trailer – that inflates to exactly
min(chunk_bytes, inflated_total - i * chunk_bytes)
bytes. Only the final chunk is ever short. No chunk references any other chunk’s compression state; that independence is the property the whole format exists to provide.
3.4 The inner blob
The concatenation of every inflated chunk is the RABOOK1 flat blob described
by ra8_book_header_t – an 8-byte magic "RABOOK1" (7 chars plus NUL), a
format_version (currently k_ra8_book_format_version = 1), a flags word of
ra8_book_flag_t bits, and then the tables and pools. The container neither
inspects nor validates any of it; ra8_book_validate() does that after the
bytes are available.
4. Algorithms
4.1 Producing (epub_compile.py)
Because step 3 resolves the table size before any compression happens, the producer never has to seek backwards – unlike JOF, it can emit the file strictly in order once it holds the blob.
4.2 Opening (ra8_book_chunked_open())
- Read the 24-byte header through the caller’s
file_readseam. - Check the magic,
chunk_bytes != 0, and thatchunk_countagrees withceil(inflated_total / chunk_bytes). - Load
chunk_count + 1table entries into the caller’stable_buf(which must have capacity for all of them). - Validate the table:
offset[0] == 0, strictly increasing, andoffset[chunk_count]equal to the payload length implied byfile_len. - Check every chunk’s compressed length against
staging_cap, so no later read can overflow the staging buffer.
Note this is the opposite split from JOF, which defers per-entry validation to read time. The reason is a size argument, not a philosophical one: a JOF index can be 512 KiB, too large to hold; an RBKC table for a 4 MB book at 64 KiB chunks is 64 entries – 512 bytes. It is cheap to validate the whole table once at open and never think about it again.
4.3 Reading (ra8_book_chunked_read())
This function has exactly the ra8_vsource_read_fn signature, so it plugs
straight into the paging stack. Reads are chunk-aligned by contract:
offset must be a multiple of chunk_bytes, and len must equal that chunk’s
exact inflated span.
1. i = offset / chunk_bytes (no search)
2. Range-check i < chunk_count.
3. Require len == min(chunk_bytes,
inflated_total - i * chunk_bytes).
4. comp_len = table[i+1] - table[i]
5. Read comp_len bytes from payload_off + table[i] into staging.
6. Inflate staging directly into the caller's frame.
7. Require the inflated byte count to equal len exactly.
That contract sounds restrictive but it is precisely what ra8_vsource_loader
passes when the cache’s frame_bytes equals the container’s chunk_bytes – the
loader clips the final frame to the object end, which is exactly the short last
chunk. The alignment requirement is therefore not a limitation the caller must
work around; it is the invariant that removes buffering.
5. Memory behaviour
The resident cost of an open chunked book is:
| Buffer | Size | Owner |
|---|---|---|
| chunk table | (chunk_count + 1) * 8 bytes |
caller |
| staging (one compressed chunk) | staging_cap, >= largest compressed chunk |
caller |
| one cache frame | chunk_bytes |
ra8_vmem |
ra8_book_chunked_t |
~64 bytes | caller |
For a 4 MB book at 64 KiB chunks: 64 chunks, table = 520 B, staging ~64 KiB, frame 64 KiB – about 130 KB resident to read a 4 MB book, and the only term that grows with book size is the table, at 8 bytes per 64 KiB (0.012%).
There is a second, simpler entry point worth knowing about: ra8_book_open()
inflates every chunk into one resident SDRAM buffer. That is the right
choice for a small book on a device with 64 MB of SDRAM, and the wrong choice
for a large one. The chunked reader exists for the second case; both consume the
identical container.
Zero allocation throughout (NASA P10 Rule 3) – the caller supplies the table
and staging storage at open, which is why ra8_book_chunked_open() takes so
many buffer arguments.
6. Worked example – real bytes
Reproducible from the in-tree fixture. tests/fixtures/rabook_realbook is a real
book (Thoreau’s Walden) in unpacked EPUB form:
$ cd tests/fixtures/rabook_realbook && zip -q -X -r /tmp/real.epub . && cd -
$ python3 tools/epub_compile/epub_compile.py /tmp/real.epub /tmp/real.rabook --stats
Walden -- Henry David Thoreau
chapters=2 nodes=338 attrs=47 css=2 images=0
epub=27 KB -> rabook=25 KB (95%); inflated=66 KB
$ ./build/ra8_fmt/ra8_fmt inspect /tmp/real.rabook --verbose
RBKC rabook container: 26315 bytes
chunk_bytes : 65536
inflated_total : 68527
chunk_count : 2
reserved : 0
entry start end length
0 48 24796 24748
1 24796 26315 1519
verdict: VALID (chunk table monotonic and complete)
6.1 The header and table, byte by byte
00000000 52 42 4B 43 00 00 01 00 AF 0B 01 00 00 00 00 00 |RBKC............|
00000010 02 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
00000020 AC 60 00 00 00 00 00 00 9B 66 00 00 00 00 00 00 |.`.......f......|
| Bytes | Hex | Field | Decoded |
|---|---|---|---|
| 0-3 | 52 42 4B 43 |
magic |
"RBKC" – a byte-string magic, reads forwards |
| 4-7 | 00 00 01 00 |
chunk_bytes |
0x00010000 = 65536 (64 KiB) |
| 8-15 | AF 0B 01 00 00 00 00 00 |
inflated_total |
0x00010BAF = 68527 bytes |
| 16-19 | 02 00 00 00 |
chunk_count |
2 |
| 20-23 | 00 00 00 00 |
reserved |
zero, as required |
| 24-31 | 00 ... 00 |
offset[0] |
0 – required |
| 32-39 | AC 60 00 00 ... |
offset[1] |
0x60AC = 24748 |
| 40-47 | 9B 66 00 00 ... |
offset[2] |
0x669B = 26267 – end sentinel |
Cross-check chunk_count: ceil(68527 / 65536) = 2. Matches.
Cross-check the payload origin: payload_off = 24 + 8 * (2 + 1) = 48, which is
exactly where inspect says entry 0 starts.
Cross-check closure: payload_off + offset[2] = 48 + 26267 = 26315 = the file
length. The table closes the file exactly.
6.2 Chunk lengths are subtractions
| Chunk | offset[i] |
offset[i+1] |
Compressed length | Inflates to |
|---|---|---|---|---|
| 0 | 0 | 24748 | 24748 | min(65536, 68527 - 0) = 65536 |
| 1 | 24748 | 26267 | 1519 | min(65536, 68527 - 65536) = 2991 |
Note chunk 1 is the short final chunk – 2991 bytes, not 65536. 65536 + 2991 = 68527 = inflated_total. The two chunks tile the blob exactly, which is the same
“coverage is exact” property JOF gets from its edge clamp.
6.3 Both layers, visible
The first chunk’s stream begins at file offset 48:
00000030 78 da cd bd 0b b8 ...
^^^^^
zlib (RFC 1950) header -- contrast with a JOF tile,
which begins straight into raw DEFLATE with no 78 xx
Inflate that chunk and the first sixteen bytes of the result are:
52 41 42 4F 4F 4B 31 00 01 00 00 00 AF 0B 01 00
| R A B O O K 1 \0| ^^^^^^^^^^ ^^^^^^^^^^
format_version=1
blob length 0x00010BAF = 68527
There is the inner layer: magic RABOOK1, format_version 1, and a length word
that agrees with the container’s inflated_total. Two independent formats, one
file, each checkable on its own.
7. Edge cases, failure modes and security
The threat model is a malformed or hostile .rabook on an SD card causing the
application to crash, hang or read out of bounds – not remote code execution.
| Malformed input | What could go wrong | What actually happens |
|---|---|---|
Wrong RBKC magic |
Misparse as another format | Rejected at the first check |
chunk_bytes == 0 |
Division by zero computing chunk_count / chunk index |
Non-zero is checked before any division |
chunk_count disagrees with ceil(inflated_total / chunk_bytes) |
Table shorter than assumed; OOB table read | Cross-checked at open |
table_cap_entries < chunk_count + 1 |
Table write past the caller’s buffer | Capacity checked before the table is loaded |
offset[0] != 0 |
First chunk read from the wrong place | Required to be 0 |
| Non-monotonic table | Negative length via subtraction, huge unsigned wrap | Strictly-increasing check at open |
offset[chunk_count] != payload length |
Truncated or padded file | Must equal file_len - payload_off exactly |
A chunk longer than staging_cap |
Staging-buffer overflow | Every chunk’s length checked against staging_cap at open, before any read |
Unaligned offset in a read |
Reading a chunk boundary the format cannot serve | Contract requires offset % chunk_bytes == 0; violation rejected |
len != the chunk’s exact inflated span |
Short/long frame fill, uninitialised bytes | Required to match exactly |
| Decompression bomb | Tiny stream inflating to gigabytes | Two limits, below |
| Chunk inflating to the wrong size | Frame holds foreign or stale bytes | Inflated count must equal the expected span exactly |
| Bit rot inside a chunk | Silently corrupt text rendered as if valid | zlib Adler-32 fails the inflate |
| Inner blob corrupt but container valid | Renderer indexes garbage tables | Container passes; ra8_book_validate() independently checks the RABOOK1 magic, version and flag mask |
The decompression-bomb caps
As with JOF, two mechanisms stack:
- The format’s own bound. The reader knows the exact expected inflated size
(
min(chunk_bytes, inflated_total - i * chunk_bytes)) before inflating, and the destination is a cache frame already sized tochunk_bytes. A bomb cannot exceed the frame because the frame was sized from validated header geometry, never from the compressed stream. ra8_decomp_limits_t– the shared 64 MiB output ceiling and 1024:1 expansion-ratio ceiling enforced insidera8_io_decompress(). This is the backstop: even if a caller’s size arithmetic were wrong, the inflate still cannot run away.
Here mechanism 2 is more load-bearing than it is for JOF, because
inflated_total is read from the file. A hostile header claiming a 400 GB
inflated_total is caught by the ratio and absolute ceilings rather than by
geometry.
What is deliberately not defended
- Semantic garbage. A structurally valid book whose text is nonsense renders nonsense. Not a failure mode.
- Integrity of the whole file. Adler-32 per chunk detects bit rot; it is not
a cryptographic checksum and does not detect deliberate tampering. A
.rabookis parsed safely but is not authenticated. If a book must be trusted rather than merely parsed safely, it belongs inside something signed (see ROT1). - Compression-ratio fairness. A book that compresses badly is merely large.
8. Versioning
RBKC follows the discriminator-byte scheme from
the overview, with a twist worth spelling out because
the two layers version independently:
"RBKC" "RABOOK1" + format_version
\__/ | \_____/ \___________/
| | | |
family| family inner revision
| (currently 1)
"Chunked" -- names the container
variant, not a revision number
- The outer container is identified by the four bytes
RBKC. TheCmarks it as the chunked variant of the.rabookfamily rather than revision three. An incompatible container change spends a new fourth byte; every existing readermemcmps and rejects it cleanly. - The inner blob carries an explicit
format_versionfield (k_ra8_book_format_version, currently 1) alongside itsRABOOK1magic. Because the content layout is expected to evolve far more often than the transport, a numeric field is the better tool there – andra8_book_validate()checks it independently of anything the container did. - Feature flags extend without a version bump.
ra8_book_header_t.flagscarriesra8_book_flag_tbits, andra8_book_validate()rejects any blob setting a bit outsidek_ra8_book_flag_mask_known. The rule is that a flag may only change how content is presented (for examplek_ra8_book_flag_rtlfor right-to-left reading order), never where a table or pool lives. So old firmware refuses a book depending on a semantic it does not implement, rather than silently mis-rendering it.
Under the project’s zero-backward-compatibility policy the expected path for a
breaking change is: bump the magic or format_version, update
epub_compile.py and the reader in the same commit, regenerate fixtures, delete
the old handling. There is no dual-version reader.
See also
ra8_book.h– container constants, the innerRABOOK1blob, flagsra8_book_chunked.h– the demand-paged chunk reader specified herera8_book_paged.h– binds the paged object as a book sourcera8_vsource.h– the paging registry the reader plugs into- RCBZ – the same paging idea keyed by page instead of by uniform chunk
- JOF – the same seekability problem solved for an image grid, with the index at the end instead of the front