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.
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
i32in 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_stdreader 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
ByteSourcea 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.
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:
| Field | Type | What it is |
|---|---|---|
| Max meters/pixel | f32 | Upper bound of the zoom range this layer covers; the coarsest is +∞, strictly decreasing toward fine |
| Index offset | u32 | Byte offset to this layer's quadtree |
| Node count | u32 | Number of u32 nodes in that index |
| Chunk size | u16 | Fixed byte size of every data chunk in this layer |
| Chunk count | u32 | Number 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.
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.
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.
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:
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.
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.
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.
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.
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
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.
<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:
| Category | A few of the symbols that map to it |
|---|---|
1 water | Water · Drinking Water · Fountain · Spring · Well |
2 campsite | Campground · Campsite · Tent · RV Park |
3 accommodation | Lodging · Hotel · Hostel · Guest House · Alpine Hut |
4 resupply | Convenience Store · Supermarket · Bakery · Restaurant · Cafe · Gas Station |
5 pharmacy | Pharmacy · Chemist · Drugstore |
6 bike shop | Bike 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
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_stdroutine, 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.
hr 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.
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.
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
bboxis 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
- Map reader, quadtree walk, chunk decode, the POI nearest-16 query, and the nav directory / node-leaf walk / edge fetch:
obc-reader/src/reader.rs - The canonical POI category/subtype ids and fallback labels (shared by reader + packer):
obc-formats/src/obcm.rs; the packer's OSM-tag classifier stays inobc-pack/src/poi.rs - The route-corridor POI query, its
RoutePathseam and the projection maths:obc-reader/src/corridor.rs - Route reader, index, and decode:
obc-route/src/reader.rs; the GPX<sym>/<type>→ category table:obc-route/src/symbol.rs - The recorded-track log + its GPX export:
obc-route/src/track.rs; the ride object (v1/v2) codec + the Finish-time converter:obc-route/src/ride.rs - The browser's copy of both converters — a thin wasm shim over the same routines, plus the error vocabulary a dropped file needs:
obc-web-convert - Checked-in bytes both directions are held to (a route and its OBCR, a track log and its GPX export):
specs/vectors/ - Normative OBCM / OBCR / ride / track constants, primitive codecs, and the shared byte seam:
obc-formats - The byte-level specs:
OBCM_Spec.md·OBCR_Spec.md·obc-ble-interface-spec.md(the wire contract routes/rides cross to the companion app) - The catalog manifest — spec
OBCC_Spec.md, generatorobc-pack/src/catalog.rs, JSON Schemacatalog.schema.json - The bakery that fills the tree the generator walks, and publishes it — the curated region list
regions.toml, the runnerobc-bake/src/bake.rs, the read-it-back gateobc-bake/src/verify.rs, the ordered publishobc-bake/src/publish.rs
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.