OBC OpenBikeComputer / docs

Data formats

The device reads two kinds of file: an OBCM map and an OBCR route. Both are binary, and both exist for the same reason — a microcontroller should read them directly off flash, with no JSON to parse, no structure to rebuild in RAM, and no heap to churn. A host produces them once; the device just points at the bytes and draws. (A third format, the catalog manifest, never reaches the device at all — it's how a host decides which map to hand it.)

This page is the guided tour of what's actually in those files. The exhaustive byte-level tables live in the repo specs — OBCM_Spec.md and OBCR_Spec.md — so here we focus on why the bytes are shaped the way they are. Those root specifications remain the normative contracts; the dependency-free obc-formats crate is the code authority beneath them for version numbers, fixed record lengths, flags, sentinels, endian primitives, and the shared byte-I/O seam. Parsing, caching, conversion, and file assembly stay in the reader, route, and packer crates.

Two binaries, one philosophy

The map and the route are siblings: they were designed to feel identical to the code that reads them, so the renderer can treat a route chunk and a map chunk with the same instincts.

Two binaries, one philosophy OSM extract a slice of the planet obc-pack · offline .obcm map GPX upload a ride you planned obc-route device · sim · browser .obcr route the readers obc-reader · obc-route no_std — sim & device shared DNA — little-endian · µdeg integers · anchor + delta geometry · explicit offsets · streamed
The map is baked offline from an OpenStreetMap extract by the packer; the route is converted wherever the GPX lands — on the device, in the simulator, or in a browser tab — from one no_std routine. Different origins, but the very same reader code parses them on every machine — so what you draw in the browser is what the device draws.

Four principles run through both formats:

  • Binary and table-driven. Numbers, not text. Colours and widths live in a small style table the map references by id; geometry is raw integers. Nothing is parsed from strings at read time.
  • Integer microdegrees. Every coordinate is an i32 in millionths of a degree. There are no floats on disk and no projection baked in — turning ground coordinates into pixels is the renderer's job, not the file's.
  • No runtime discovery. Every section is reached through an explicit byte offset, and every count is stored. A no_std reader does zero traversal or sizing work to understand the file's structure — it reads a header and jumps.
  • Streamed, never resident. Both files are read through a ByteSource a piece at a time, so a map far larger than RAM — or a route hundreds of kilometres long — never has to fit in memory at once.

Where they differ is shape: a map is a 2-D area indexed by a quadtree; a route is a 1-D path indexed by a flat list. Everything below follows from that.

OBCM — the map

The file, front to back

An OBCM file (current version 10) opens with a fixed 40-byte header, then a global style table and a level-of-detail (LOD) table, then the LOD layers themselves — coarsest first. Each LOD layer is wholly self-contained: its own quadtree index immediately followed by its own geometry chunks. After the finest layer come three more sections — the POIs, their shared hours pool, and, at the very tail, the navigation graph the device routes over — each reached, like everything else, by an offset stored earlier in the file.

OBCM — the whole file, front to back Header 40 B Style table global LOD table N × 18 B LOD 0 coarsest LOD 1 LOD N−1 finest POIs §7 Nav §8 · tail detail increases → quadtree index flat u32 nodes data chunks fixed-size blocks
The header, style table and LOD table are read once when the file opens — they're tiny. The bulk of the file is the LOD pyramid: each layer its own (index + chunks) pair, simplified to that zoom. Two tail sections are different beasts — not map layers: the POI section (coral), a nearest-list index covered below, and the navigation graph (teal), a routable network the device runs A\* over, covered last. Reaching any section is an explicit offset, so there is no scanning to "find" where a layer begins.

Why a pyramid, rather than one detailed tree with a min-zoom tag on every feature? Because the latter forces the device to decode fine geometry just to discover it should be skipped when zoomed out. With independent layers, zooming out reads a small coarse layer and touches nothing else. The renderer's job of picking the right layer for the current zoom is covered on the rendering page; here we only care that the layers exist side by side in the file.

Each entry in the LOD table is the directory to one layer — the zoom it serves and where its bytes are:

FieldTypeWhat it is
Max meters/pixelf32Upper bound of the zoom range this layer covers; the coarsest is +∞, strictly decreasing toward fine
Index offsetu32Byte offset to this layer's quadtree
Node countu32Number of u32 nodes in that index
Chunk sizeu16Fixed byte size of every data chunk in this layer
Chunk countu32Number of data chunks

Eighteen bytes per entry — the N × 18 B in the ribbon above. Because the index sits immediately before the chunks and every count is stored, the k-th chunk is reached by arithmetic alone — index_offset + node_count·4 + k·chunk_size — with no scanning and no length-prefix hunting. That's "no runtime discovery" made concrete: the table tells the reader exactly where every layer, and every chunk within it, begins.

The header

The 40-byte header is the one fixed-size, always-present part of the file. Everything else is found through offsets it stores.

The 40-byte header, byte by byte Magic ver global bbox 4 × i32 · µdeg style off n LOD-tbl off mkr POI off → §7 Nav off → §8 nav OBCM 10 0–3 4 5–20 21–24 25 26–29 30–31 32–35 36–39 A short read here is the only "is this even a map?" check the reader needs.
Fixed offsets, no surprises. A few details a reader notices: the bbox is stored lat, lon (a packer ordering quirk); the marker colour — the you-are-here chevron — rides in the header because the marker isn't an OpenStreetMap feature; and the POI (coral) and navigation-graph (teal) offsets at the tail are the growth that carried the header from 32 → 36 → 40 bytes. Earlier fields never move — a v7 reader that stops at byte 36 still parses everything it knew — and v9/v10 changed section internals and the style record without touching the header, only ticking the version byte (now 10).

The style table that follows maps small numeric ids to how a feature looks. Each record is eight bytes (v10 grew it from six):

pub struct Style {
    pub id: u8,             // referenced by feature headers
    pub z_index: i8,        // painter's order: lower draws first
    pub color: u16,         // RGB565 — device-independent
    pub weight: u8,         // nominal line width (px at a reference zoom; the renderer ramps it — see rendering)
    pub priority: u8,       // 1 = keep first … 4 = drop first (flags bits 0–1)
    pub dashed: bool,       // flags bit 2 — a dashed line (v10)
    pub color2: Option<u16>,// flags bit 3 + a trailing RGB565 (v10)
}

The last two fields are packed into the record's flags byte (dashed = bit 2, "color2 present" = bit 3) plus a trailing u16. color2 is a secondary colour: v10 carries it so a later render pass can draw road casings, dashed admin borders, railway stripes and building outlines at the finest zoom — the line-styles work. It's written 0x0000 with the bit clear when unused, and readers ignore it then — black is a real colour (rails), not a "none" sentinel — so a map that uses no line styles is byte-for-byte the old record padded to eight, and renders identically.

Two things worth knowing about style ids. First, they're assigned by the packer, not authored — the reader never depends on a specific value, only that ids are unique within the file, so the format can't be broken by an id collision. Second, 0xFF is reserved as the "end of features" sentinel inside a chunk (more below), which caps a file at 254 distinct styles. The colour is stored once, device-independently, and resolved to the panel's palette at render time — the same RGB565 looks right on a true-colour desktop window and on the device's 64-colour panel.

The quadtree index

Within a LOD layer, geometry is bucketed into fixed-size chunks indexed by a quadtree over the map's bounding box. On disk that tree is just a flat array of u32 — one word per node — and a single high bit tells you what kind of node you're looking at.

One u32 per node — the high bit decides b31 bits 30 … 0 branch flag high bit set branch → low 31 bits = index of the first child (NW) 0x7FFF_FFFF empty leaf → nothing to draw here anything else leaf → the value is a chunk id into this LOD's chunks
A single bit-test classifies every node, so the walk needs no separate node-type table. A branch stores only the index of its first child; the four children (NW · NE · SW · SE) sit consecutively, and their bounding boxes are re-derived on the fly by halving the parent box — never stored. Identical math at every level and every LOD.
const BRANCH_BIT: u32 = 0x8000_0000; // high bit set ⇒ a branch
const EMPTY_LEAF: u32 = 0x7FFF_FFFF; // a leaf with no chunk
// otherwise: the value is a chunk id into this LOD's data chunks

That children's boxes are computed, not stored, is the reason the renderer's subdivision and the packer's must agree exactly — both split at floor-division midpoints. How the renderer walks this tree to cull invisible chunks is the quadtree cull step on the rendering page. The format's contribution is just this: a tree compact enough to stream, where one word says everything about a node.

Features: an anchor, then deltas

Inside a chunk, each feature's geometry is stored as one absolute starting point — the anchor — followed by a chain of small deltas. This is where the format earns its compactness.

Geometry: one anchor, then a chain of deltas absolute · µdeg anchor (47 123 456, 8 654 321) encode encoded anchor X,Y (i32) stored vs the leaf's corner Δx,Δy Δx,Δy Δx,Δy every |Δ| ≤ 127 → int8 · 2 B / point otherwise → int16 · 4 B / point chosen once per feature (flag bit 0)
The first vertex is the anchor, stored once and relative to the leaf's corner so even it stays small; every other vertex is a step from the one before. Most steps are a handful of microdegrees, so a whole ring usually fits in single bytes — and a feature with one long edge simply bumps the whole ring to 16-bit deltas. (The packer also pre-splits any segment longer than 30 000 µdeg, so a delta can never overflow 16 bits.)

The device decodes one complete feature into fixed caller-owned point and ring buffers. Before it publishes that geometry, it validates every encoded ring and checks that the whole feature fits. An over-capacity or malformed feature is therefore consumed and dropped whole with a typed outcome; it is never exposed as a shortened line or an open polygon. Production maps stay within the format's 2,048-vertex cap, while this rule keeps smaller device profiles and damaged files honest without changing a byte on disk. The rendering pipeline explains how those outcomes are counted separately from ordinary frame-budget drops.

Decoding a ring is exactly as simple as it looks — pick the delta width once, then walk:

let (dx, dy) = if is_16 {
    (rd_i16(chunk, off) as i32, rd_i16(chunk, off + 2) as i32) // flag bit 0 set
} else {
    (chunk[off] as i8 as i32, chunk[off + 1] as i8 as i32)     // 8-bit — the common case
};
px += dx;  py += dy;   // each delta steps to the next vertex

A feature is introduced by a 12-byte header, and a flags byte in it says how to read the rest:

A feature on disk style id pt count anchor X (i32) anchor Y (i32) flags 1 B 2 B 4 B 4 B 1 B bit 0 · 16-bit Δ bit 1 · polygon bit 2 · holes …and a polygon with holes, laid out 12 B header exterior deltas hole cnt h1 pts hole 1 deltas h2 pts hole 2 …
The exterior ring comes first; the hole count and each hole's deltas follow only if the holes flag is set, so a line or a simple polygon pays nothing for machinery it doesn't use. A 0xFF style id — an impossible style — marks the end of features in a chunk, so the reader stops without needing a per-chunk feature count.

There's a quiet payoff to the holes layout: a polygon's holes are just extra rings appended after the exterior. The scanline fill treats them as additional edges in the same crossing list, so holes "fall out" of the even-odd rule with no special case — the format and the rasteriser were designed to meet in the middle.

POIs: a nearest-list, not a map layer

Everything so far serves one question — what's on screen right now? — and the quadtree answers it by viewport: give me the chunks a rectangle touches. Version 6 added a section that answers a different question — where's the nearest water / campsite / bakery? — and that changes the shape of the index. The points of interest the packer harvests from OpenStreetMap aren't drawn on the map at all; the device surfaces them as a category → nearest-list browser. So they're indexed for a nearest-N query, not a viewport walk. Version 7 widened each record to carry the POI's opening hours, pooled into a shared section at the file tail.

The section is a small directory followed by, per category, a familiar pair: a quadtree index and its data chunks. There are six categories — Water, Campsite, Accommodation, Resupply, Pharmacy, Bike shop — and each gets its own quadtree, so "nearest bakery" scans only bakeries.

The POI section — a quadtree per category over 36-byte records directory count = 6 · chunk size per cat: id · index off node count · chunk count + hours-pool off · count quadtree flat u32 · §4 POI chunks 512 B · 14 recs same index-then-chunks shape as a LOD one record — a fixed 36 bytes (v7) Lat (i32) Lon (i32) sub type len Name — 24 B printable ASCII Hours Ref u16 0–3 4–7 8 9 10–33 34–35
Each category's index and chunks are laid out exactly like a LOD layer — the same flat u32 quadtree over the same header bbox — so the reader walks a POI category with the very same leaf-walk it uses for geometry. Coordinates are stored absolute: at a fixed 36 bytes the delta saving isn't worth breaking symmetry, and fixed-size records make chunk packing trivial (512 / 36 = 14 per chunk, no length bookkeeping). The final two bytes are a HoursRef (coral) — an index into the hours pool below, or 0xFFFF when the POI has no listed hours.

Two design notes are worth pulling out. First, the category is never stored in the record — it's implied by which category's quadtree the record came from, and each subtype maps to exactly one category anyway. Second, names are folded to printable ASCII at pack time and capped at 24 bytes, because the Name field is a fixed-width, one-byte-per-character slot (the packer transliterates umlauts and accents — ä → ae — rather than store variable-width UTF-8); an unnamed POI (name length 0) shows its subtype's fallback label on-device. A 0xFF subtype byte ends a chunk, mirroring geometry's 0xFF-style-id sentinel.

The same index now answers a second question. Nearest-N around the fix was the query the section was designed for, and it's still the only one that shaped the bytes — but the per-category quadtrees also serve a route-corridor walk: give me the POIs of these categories within 300 m of the route still ahead of me, ordered along the route. Nothing in the format changed for it; what changed is the window the walk is given. Instead of one disc around the rider, the reader takes the route chunk by chunk in route order, inflates each chunk's bounding box by the corridor half-width, and walks the same leaves inside that window — then projects each surviving record onto the chunk's polyline to get a distance along the route and a signed lateral offset (positive = right of travel, the identical convention OBCR waypoints store). The result is capped at 16 and the walk stops early once the slots are full and the next chunk starts farther along than the worst held entry, so the cost tracks POI density, not route length. That's what feeds the riding Up ahead timeline — and why the two spatial questions the device can ask, near me and up ahead, run off one index.

The full directory bytes, the canonical category/subtype id table, and the record fields are in OBCM_Spec.md §7. What the packer harvests and how, and how the device browses the result, are the extraction stage and the POIs browser.

Opening hours: a pooled weekly schedule

That HoursRef at the end of every record points into a pooled section written near the file tail (the navigation graph is the only thing after it): a single hours pool. OSM tags opening hours as a terse little grammar — Mo-Fr 08:00-18:00; Sa 09:00-13:00; PH off — that a microcontroller has no business parsing. So the packer parses it once, at pack time, into a fixed 29-byte weekly schedule the device can read with a single array lookup. No opening_hours string ever reaches the device.

One 29-byte schedule blob — flags + 7 days × 2 slots flags Mon Tue Wed Thu Fri Sat Sun 0 1–4 25–28 open q close q open q close q slot 0 slot 1 each byte = quarter-hours from midnight, 0…96 (96 = 24:00) the pool — identical schedules collapse to one blob POI · HoursRef 0 POI · HoursRef 0 POI · HoursRef 2 POI · HoursRef 0xFFFF 0xFFFF = no hours (no arrow) blob 0 — 29 B blob 1 — 29 B blob 2 — 29 B count u16, then count × 29-byte blobs; blob i at pool_off + 2 + i·29
A schedule is a flags byte then seven days, each holding up to two open intervals of two bytes — an open and a close quarter-hour from midnight (0…96, so 96 = 24:00). A closed day is (0, 0); a 24-hour day is (0, 96); an overnight interval stores close ≤ open and wraps in place. Two flag bits record what the packer couldn't keep verbatim — seasonal (a date rule flattened to an in-season week) and truncated (a public-holiday rule, sunrise/sunset time, or third interval dropped). Because a region's shops share the same handful of schedules, identical blobs collapse to one, and a record's HoursRef is just its pool index — 0xFFFF meaning "no hours listed."

The pool's exact layout — the leading count, the blob byte order, the flag bits, and the overnight/24-hour conventions — is OBCM_Spec.md §7.5. The pack-time parser that fills it is the opening_hours stage; the device-side lookup that turns a blob into today's hours and an open-now answer drives the POI detail view.

The navigation graph: a routable network

Everything so far is geometry you look at. Version 8 added a section for geometry you travel — a routable graph the device runs A\ over, so a rider can pick a POI and get a route to it with no phone and no pre-planning. Highways in the map are drawn but carry no topology — a road is just a styled polyline, with no notion of what connects to what. The packer builds the graph from the OSM node ids highways share: junction nodes joined by edges whose interiors hold no junctions. This section is that graph on disk, at the very tail of the file. Version 9 kept that shape but made routing bike-type-aware* — each edge now carries a way-kind byte and the section opens with a small profile table — and slimmed the records so a chunk holds more of the graph (details below).

Its shape is set by one hard fact: the device has no room for a node-id → offset table. A real region has millions of graph elements; an index over all of them can't stay resident. So the section is arranged for the only access pattern that fits RAM — spatial re-fetch. A node lives in a leaf of a quadtree over the same global bbox, and each junction record carries its neighbours' coordinates inline.

§8 (v9) — directory · profile table · node quadtree · junction records · edge pool nav directory 28 B — resident offsets · counts chunk size · profiles profile table 1..8 × 52 B node quadtree flat u32 · §4 junction records variable · 512 B chunks bin-packed — leaves may share a chunk edge pool polylines · own offset edge id = pool-relative byte offset (chunk = id / 512) — zero index bytes fetched only at route emit one junction record — 13 + 15 × degree B lat · lon · dense id · degree then degree × neighbour (15 B each): nbr id · nbr lat,lon · edge id · cost m · way-kind coord + way-kind inline — a settle relaxes with no extra fetch
The section's resident cost is a 28-byte directory: offsets, counts, the pinned 512 B chunk size, and the profile table's location. The node quadtree is byte-for-byte the same flat-u32 encoding as an LOD index, built over the same header bbox, so the reader walks it with the identical leaf-walk. Its leaves point at junction records, bin-packed first-fit into 512-byte chunks — distinct leaves may share a chunk, so the walk is written to decode a shared chunk idempotently. Each record stores its own coordinate and dense id, then one 15-byte entry per neighbour with the neighbour's coordinate inline, the connecting edge's id, its cost in metres, and its way-kind byte. The edge pool — the actual polylines — is touched only when a route is emitted, and an edge is addressed by byte offset into the pool, so there's no edge-id table to keep resident either.

Why store the neighbour's coordinate twice — once in its own record, once in every record that points at it? Because that redundancy is exactly what makes the router cheap. A\* settling a node needs, for each neighbour, the straight-line distance to the goal (the heuristic h). With the coordinate inline, that number falls straight out of the record already in hand — no chase to the neighbour's own record just to read where it is. One quadtree descent, one chunk read, then relax every neighbour from bytes already decoded.

One A* settle — descend, read one chunk, relax inline ① descend to the settled node's leaf settled node a point query — one leaf, not a viewport 1 chunk read ② its record — one 512 B chunk in RAM junction record lat · lon · id · degree = 3 nbr A · coord · edge · cost nbr B · coord · edge · cost nbr C · coord · edge · cost relax ③ relax — no further read per neighbour, from bytes already in hand: g' = g + cost_m · w w = profile(way_kind) h = gc_dist(nbr, goal) f = g' + ε·h coord inline → h: zero fetches way-kind inline → w: none either the edge pool is untouched until the route is emitted — then the came-from chain's edge ids stitch into one polyline
A settle is one descent + one read + a straight relax. The quadtree walk is a point query resolving to a single chunk; that read brings the whole junction record into RAM, and every neighbour relaxes straight off it — the cost step g scales the stored metres by the bike profile's weight for the edge's way-kind, and the heuristic h is the great-circle distance from the inline coordinate to the goal, with no chase to the neighbour's own record. An eight-slot tile cache holds the frontier's active leaves resident (see the router seam); the edge pool is read once, at the end, to stitch the winning chain into the route's line.

The full byte layout — the 28-byte directory fields, the 52-byte profile records and their 1/16 fixed-point multipliers, the 15-byte neighbour entry, the 0xFF degree sentinel and degree-24 cap, the edge record with its densified int16 deltas, and how an over-long edge is split at synthetic junctions so no record ever straddles a chunk — is OBCM_Spec.md §8. What the packer does to build this graph from raw highways, and how a profile weights it, is the extraction stage; how the device turns it into a route the rest of the system can't tell from a GPX is the router seam.

OBCR — the route

A route is a single ordered polyline with elevation, plus precomputed ride statistics. It borrows every OBCM convention — little-endian, microdegrees, anchor + delta — but where the map is 2-D and indexed by a quadtree, a route is a path, so its index is a flat list scanned in order.

The file

OBCR — the route, front to back Header 128 B Chunk 0 Chunk 1 ··· Chunk N−1 Chunk index N × 44 B Waypoints W × 44 B ↑ Data Offset = 128 ↑ Index Offset ↑ Waypoint Offset data = (point count − 1) × 6 B records: dLon dLat ele
The index and waypoint table are written last: a streaming converter doesn't know how many chunks a route needs until it has emitted them all, so it patches the header's offsets at the end. Because every section is reached by an explicit offset, the physical order isn't load-bearing — the reader would accept the index first just as happily.

The header carries the route's bounding box, its start point (for centering the camera), and the precomputed totals — distance, ascent, descent, elevation range — plus the route name; a small header extension points at the waypoint table: fixed 44-byte records for the points of interest a planner attaches to a route (covered next). A reader that ignores them still skips the section in O(1) by construction. A 44-byte index entry per chunk holds that chunk's bounding box (for the viewport query), its anchor, its point count, the cumulative distance and ascent at its first point, and where its bytes live.

Those cumulative stats are the trick that makes "42 km / 600 m to go" an O(1) subtraction once you know which segment you're on, rather than a walk over the whole route every frame.

Waypoints: a category and a side

A <wpt> in a planner's GPX carries more than a name. Komoot, RideWithGPS and Garmin BaseCamp all tag their waypoints with a symbol — Water, Campground, Lodging — and the device used to drop it on the floor. Format v3 keeps it, along with one more fact the rider actually decides on: how far off the route the stop sits, and on which side.

One waypoint — a fixed 44 bytes (v3) Distance Along (u32) Lon (i32) Lat (i32) ele i16 c n off i16 rsv Name — 24 B UTF-8, null-padded 0–3 4–7 8–11 12–13 14 15 16–17 18–19 20–43 the two coral fields are what v3 brought — category (byte 14, reusing the retired Type byte) and the signed lateral offset (16–17), which with its pad pushed Name to 20 and the record 40 → 44 B
Records are sorted ascending by Distance Along, which is defined by nearest-point placement: the cumulative route distance at the raw track point closest to the waypoint's own coordinate. A free-standing GPX <wpt> carries no ride-order of its own, so both the firmware converter and the phone importer place it that way — and the lateral offset falls straight out of the same projection, which is exactly why it's stored rather than derived on the device: re-measuring it would need the raw track the converter saw, not the decimated geometry it wrote.

Category is the map's own id space, not a second taxonomy. Byte 14 holds 0 for generic or 1..=6 — the same six ids the POI section uses (1 water · 2 campsite · 3 accommodation · 4 resupply · 5 pharmacy · 6 bike shop, OBCM_Spec.md §7.4). That's the whole point: a stored waypoint and a map POI drawn from the same icon can be sorted into one list without the rider having to remember which file a stop came from. 0 is first-class rather than an error case — most hand-placed waypoints ("turn left here") map to nothing and draw as a plain diamond — and an unrecognised value renders generic while surviving a rewrite byte-for-byte.

The offset is signed, and the sign is the side. Its magnitude is the ground distance from the waypoint to the track point that won the placement; its sign is the cross product of the local direction of travel with the offset vector — positive = right, negative = left, 0 = on the line. That's a deliberate agreement with the route-corridor query on the map side, which computes its own offsets the same way, so the riding UI reads one rule for both sources and can draw ←300m or →300m without asking where the entry came from.

Symbols are freeform, so the mapping is a curation. A producer takes <sym> if non-empty, else <type>, and matches it case- and separator-insensitively (Drinking Water = drinking_water = drinking-water; every non-alphanumeric byte is a word break, runs collapse, ends trim). Sixty-nine strings gathered from real Komoot / RideWithGPS / Garmin BaseCamp exports land on the six ids; anything else stores 0, and a waypoint is never dropped for its symbol:

CategoryA few of the symbols that map to it
1 waterWater · Drinking Water · Fountain · Spring · Well
2 campsiteCampground · Campsite · Tent · RV Park
3 accommodationLodging · Hotel · Hostel · Guest House · Alpine Hut
4 resupplyConvenience Store · Supermarket · Bakery · Restaurant · Cafe · Gas Station
5 pharmacyPharmacy · Chemist · Drugstore
6 bike shopBike Shop · Bicycle Repair · Cyclery

Two curation calls are worth naming. Eating and shopping share resupply — there is no food category, because a rider hunting supplies wants the bakery and the café in the same list. And symbols with no honest home among the six stay generic rather than being forced into the nearest one: Restroom, Parking, Ferry, Hospital, First Aid, Viewpoint and Summit are all deliberately absent. A wrong icon is worse than a diamond.

The full table, row for row, is OBCR_Spec.md §4.1 — normative, and mirrored in code by obc-route/src/symbol.rs, with a test asserting every table key is already in normal form so an unreachable row can't make the spec a lie.

v3 rejects, it doesn't reinterpret. The category byte reuses the offset the old 10-value waypoint taxonomy (water/food/…/danger) occupied, so a stored v2 file's byte would decode as a different category — silently wrong is the one outcome worth avoiding. The reader accepts v3 only; a route written by older firmware fails at the header and re-imports from its GPX through the phone or a USB drop. Recorded rides are a separate format and are unaffected. Same posture as the OBCM v8→v9 bump.

Chunks, seams, and deltas

Chunks share their seam; position chains by delta chunk 0 chunk 1 chunk 2 shared chunk k's last point = chunk k+1's anchor index entry (resident) anchor (lon, lat, ele) · bbox cum distance · cum ascent · byte off/len chunk data (streamed) dLon dLat ele × (n−1) position = delta · elevation = absolute
Consecutive chunks repeat their boundary vertex, so each chunk's polyline can be drawn on its own with no gap at the seam, and the cumulative stats join up continuously. Longitude and latitude chain as deltas; elevation is stored absolute — it's small enough to fit the same two bytes, and skipping the running sum keeps the decode trivial.
let (mut lon, mut lat) = (anchor_lon, anchor_lat); // first point IS the anchor
for record in records {            // each record: (dLon: i16, dLat: i16, ele: i16)
    lon += record.d_lon as i32;
    lat += record.d_lat as i32;
    out.push(RoutePoint { lon, lat, ele: record.ele }); // ele is absolute
}

Exact stats, decimated geometry

A route you draw doesn't need every GPS sample — a thinned polyline looks identical at the device's pixel pitch. But a route you plan with does need exact numbers. OBCR keeps both honest by separating them at conversion time: the stored geometry is decimated (drop a vertex within a metre of the line it sits on, but force-keep one at least every ~1.2 km), while the header totals are computed from every raw GPX point. So the line is cheap to draw and the "total climb" you read is real. One last guard runs the other way: a leg longer than 30 000 µdeg is densified with interpolated vertices, so the i16 deltas above can't overflow even when a sparse two-point upload has no intermediate vertex to keep — the same split the packer applies to map geometry.

Convert where it lands. There is no offline route step. The GPX→OBCR converter is one portable no_std routine, and every place a GPX can land runs that routine: the device on a USB upload, the simulator on import, and — compiled to wasm — the web builder in the browser tab, so a route you drop on the site is converted client-side with no server involved at all. All three produce the same bytes, and the shared fixtures hold them to it. It streams the GPX in a single pass — O(1) RAM regardless of route length — emitting each finished chunk while keeping only a bounded index in memory. (BLE uploads arrive already converted: the companion app encodes imported GPX/TCX to OBCR on the phone, per the BLE interface spec, so the device just writes the bytes to storage.)

One thing the file doesn't store is the elevation profile the Statistics screen draws. That's rebuilt once when a route loads — a multi-resolution min/max pyramid over distance, the same coarse-to-fine idea as the map's LODs, so the profile can be zoomed and panned without ever re-reading geometry. It's a runtime structure rather than a format concern; the UI page covers how it's drawn. The route's climbs are the same kind of runtime derivation — segmented from that profile when the route loads, never stored in the file or sent over the link (the Climb panel draws them).

Recorded rides — the track log and the ride object

obc-route owns one more pair of formats beyond the route you load: the two the device writes when it records a ride. They share the family's DNA — little-endian, integer coordinates — but they're logs, not decimated drawings, so they keep every accepted fix at full fidelity. This is also where a ride's BLE-sensor data lives (epic #707): heart rate, cadence, and power ride along inside both records.

The 20-byte track record — 16 bytes of fix, then a 4-byte sensor tail (v2) lon (i32) lat (i32) ele flags t_ms (u32) hr cad pwr bit0 = seg millis 0–3 4–7 8–9 10–11 12–15 16 17 18–19 sensor tail 0xFF/0xFFFF = absent recorded log .obct · N × 20 B headerless Finish — one streaming pass GPX — gpxtpx:hr / gpxtpx:cad + a bare <power> ride object v2 — RD{id}.ORD, the phone's download
One record is a fixed 20 bytes: the original 16 (position, elevation, a segment-start flag, a millisecond timestamp) plus a 4-byte sensor tailhr u8 · cad u8 · pwr u16, each written as a sentinel (0xFF / 0xFF / 0xFFFF) when the value was absent or stale. At Finish the headerless log is converted in one streaming pass into a GPX (with gpxtpx heart-rate/cadence extensions and a bare <power>) and a ride object v2 — the durable per-ride file that is the BLE wire object, so a ride download is a verbatim copy.

The track log (.obct) — a headerless record array. While you ride, the device appends one fixed 20-byte record per accepted GPS fix. There is no header: the file is just the array, so truncating it to any 20-byte boundary is always valid, and the worst a power-loss can cost is the one in-flight record. That headerlessness is exactly why there's no in-band version byte to tell a 20-byte (v2) log from an old 16-byte one — so the upgrade guard is structural instead. The log is only ever converted through an in-RAM handle set by this boot's Finish; that handle can't survive a reboot, and the next ride's start opens the temp file truncating — so an orphaned 16-byte log left by older firmware can never reach the converter to be misparsed as 20-byte records. Boot provably discards it, so no versioned temp filename is needed.

The ride object (RD{id}.ORD) — what the phone downloads. At Finish the log is converted into the durable per-ride file that is the BLE wire object, so a ride download is a verbatim byte copy with no re-encode. It is not OBCR: coordinates are stored as degrees × 1e7 in lat, lon order (the layout the companion app pins — the extra digit over OBCR's microdegrees buys a ~1 cm grid for nothing), and the header carries precomputed totals — distance, moving time, average speed, climb — plus a UTF-8 name.

v1 and v2 coexist. Version 2 is a pure additive widening for sensor data: the header grows an 8-byte summary — avg_hr / max_hr / avg_cad (+ a reserved pad) / avg_pwr / max_pwr, each sentinel-marked — and each point record grows the same hr u8 · cad u8 · pwr u16 tail as the track log. The byte length stays fully determined per version — v1 is 23 + name_len + 14 × points, v2 is 31 + name_len + 18 × points — so a reader takes the version byte, then rejects any payload whose length disagrees for that version. That length check is also the torn-write guard: an interrupted save leaves a short file, and the version byte is written last as the commit point, so a half-written object is rejected rather than mistaken for a ride. A device that has never seen a sensor keeps writing v1, and both firmware and app accept either — old v1 rides on the card still list, download, and delete. Because it's an additive object version, there's no protocolVersion bump.

The exhaustive byte tables — every header and point field in both versions — are the normative BLE interface spec §7.2; this is the readable tour. The ride object crosses to a phone as an object on the companion link, where the Sensors section covers how those sensor values were captured in the first place.

Streaming: resident vs on-demand

Both formats are read through one trait. Neither reader touches a filesystem directly — they ask a ByteSource for bytes at an offset:

pub trait ByteSource {
    fn read_at(&self, offset: u32, buf: &mut [u8]) -> Result<(), Error>;
    fn len(&self) -> u32;
}

On the host that's a slice of memory; on the device it's a file on the SD card. The reader holds a &dyn ByteSource and stays monomorphic, so the genericity never leaks into the renderer or the screen stack — it's one of the project's four seams. What changes between the two formats is how much they keep resident.

What stays in RAM, what streams from the card .obcm on SD header·styles·LOD quadtree index geometry chunks megabytes ≫ RAM resident — read once at open header · style table · LOD table (a few hundred bytes) streamed — pulled on demand index nodes → 512 B block cache geometry chunks → 4 KB slot cache OBCR: header + the whole (small, flat) index resident; only geometry chunks stream. The list is cheap to keep.
A map never has to fit in RAM — the device has 512 KB and no external memory to hold the whole file. Even the quadtree index streams, through a small block cache that coalesces the 4-byte node reads; geometry chunks stream through a slot cache too, and the renderer touches each visible chunk at most twice a frame (once to pick features, once to draw the survivors) so the SD reads stay bounded. The route's index is a short flat list, so it's read whole at open; its geometry streams chunk-by-chunk through a small resident cache of its own, so a redraw of the same route re-reads nothing either.

The map's caches matter because the stub-select collector walks the same visible chunks twice per frame — once in pass A to pick the surviving features, once in pass B to re-decode the winners; without a cache, pass B would re-read every winner chunk off the SD. With it, pass B's winner chunks are already resident, and a slow pan re-hits last frame's chunks. The cache changes when a byte is read, never what decodes — so a render stays byte-identical whether the whole file was resident or streamed one chunk at a time.

The catalog — a third format, for finding the first two

Everything above is read by the device. There is one more format in the family that the device never sees, and it exists because of a single number in the OBCM header: the version byte.

The reader supports exactly one OBCM version at a time — v10 today, and the versions before it were hard cuts, not fallbacks. That's the right trade for a microcontroller (no branching parsers, no dead code paths in 512 KB), and it costs nothing while maps are built one at a time on the rider's own machine. But a distribution that bakes maps centrally — build the popular regions once, serve them as static files — turns that one byte into a hazard: the artifacts are large, cached at the edge, and the day the format moves, every one of them silently becomes a file the device will refuse.

The fix is to make the version knowable before the download. OBCC — the catalog manifest — is a single JSON document listing every baked artifact with its region, preset, coverage box, size, digest, and the OBCM version read out of the artifact's own header rather than taken from the build recipe. The other half of the comparison comes from the device itself: it reports the OBCM version its reader accepts in the same open, pre-pairing identity read that carries its store epoch — one byte, taken straight from the constant the reader validates every header against, so what a device claims to read and what it does read can't drift apart. A site holding the manifest can then grey out a map the connected device can't read, instead of streaming two hundred megabytes and failing at the last byte.

Both versions known before a byte moves bake job packs regions OBCC manifest per artifact: region · preset bbox · size · SHA-256 obcm_version — from the header static site compares per map device reads OBCM v10 identity read match → offered, digest-verified mismatch → greyed out, with reason
The manifest states each artifact's version as read from its own bytes; the device states the version its reader accepts in its open identity read. The site compares the two per map — a mismatch is shown as unsupported with the reason, never hidden (that would read as a coverage hole) and never offered (the download would be refused).

The generator keeps the manifest honest in a few deliberate ways:

  • All-or-nothing versioning. It refuses to emit a manifest whose artifacts aren't all at the packer's current OBCM version, so a half-re-baked catalog is a failed build rather than a published one; and it writes temp-file-then-rename, so a consumer sees the whole previous manifest or the whole new one — a truncated catalog can't parse.
  • The bbox is the coverage box copied from the artifact header, not the extract box it was cut from — completing partially-in-box ways pushes the packed box outward, so the header box is the honest answer to what does this download cover? (and must never be fed back in as an extract box, or it ratchets wider on every re-pack).
  • Facts the bytes can't state — region name, build time, source extract, the style-preset revision it was packed with — are recorded by the bake job in a sidecar and read back verbatim, never re-derived at generation time. A preset restyle invalidates only that preset's artifacts, so partial re-bakes are normal; recording the preset revision at bake time lets the manifest say plainly that a map is a revision behind.
  • Deterministic output. The generator reads a wall clock exactly once (generated_at); build times come from the bake and ordering is content-derived, so the same tree produces byte-identical output.

The manifest layout, the bake-tree shape, the sidecar, and the version law are normative in OBCC_Spec.md; it is generated by obc-pack catalog.

The "bake job" in all of that is obc-bake: a curated list of regions (regions.toml — DACH to start, one line per region so adding coverage is a one-line change) crossed with the shipped style presets. Per (region, preset) it fetches or reuses the Geofabrik extract, packs, and — before the artifact is allowed to exist under a name the generator would walk — opens it with the real obc-reader and walks every feature of every chunk. A corrupt or truncated artifact therefore never reaches the manifest, because it never reaches the tree; a re-run skips whatever is unchanged, keyed on the content hashes of the extract and the preset rather than on timestamps; and a region that fails is loud in the summary and in the exit status, because a silently missing region reads to a rider as not covered, which is indistinguishable from a curation choice. (The facts that live only in the sidecar — a region's display name, the extract's date — are keyed separately, so re-dating an otherwise identical extract rewrites four lines of JSON instead of re-packing a country.)

Publishing follows the same one-way order the spec demands: every artifact uploaded and re-checked at the destination first, the manifest swapped in last. It also refuses, by default, to publish a catalog smaller than the one already live — ordering makes a publish atomic, but nothing about it stops a partial bake from completing successfully and quietly un-offering fifteen regions, which is the same coverage hole arriving by a different road.


Where this lives

Maps are produced by the packer and routes by the GPX converter — how those work, and how a route is matched to the map you're riding, is the subject of packer & routing. For how these bytes become pixels, see the rendering pipeline. Routes and rides also cross to a phone over Bluetooth as these same bytes — how that link is shaped is the companion link.