Architecture
This page explains how tile57 turns an ENC chart — a cell, in the spec's vocabulary — into vector tiles, how the codebase is layered, and the memory design that keeps it small.
The pipeline
A chart flows through these stages, all inside the engine. Both source formats converge on the same S-101 feature + attribute records — a native S-101 dataset produces them directly; an S-57 chart is adapted into them:
S-101 ENC (.000) S-57 ENC cell (.000)
│ decode 8211 │ decode 8211 src/iso8211/ (iso8211)
▼ ▼
S-100 spatial + feature S-57 feature + geometry src/s57/ (s57)
records model
│ assemble to the │ adapt S-57 → S-101 src/s101/ native.zig
│ S-101 model │ features / adapter.zig
└───────────┬─────────────┘
▼
S-101 feature + attribute records
│ apply S-101 portrayal src/portray/ + embedded Lua 5.4
▼ (vendor/S-101_Portrayal-Catalogue)
portrayal instruction stream
│ parse the instruction stream src/s101/ instructions.zig
▼
scene generation src/scene/ (project + clip + draw calls)
▼
render Surface src/render/surface.zig
├─► tile surfaces: MVT / MLT encode + PMTiles src/tiles/
│ + MapLibre style.json + portrayal assets src/style/, src/sprite/
└─► pixel surfaces: PNG raster · vector PDF · terminal text (src/render/)
- Decode (ISO 8211). Both S-101 and S-57 charts use the ISO 8211 binary container format; the decoder reads its raw records and fields. The chart format is detected from the file's own record schema.
- Build the S-101 model. A native S-101 dataset (S-100 Part 10a) is read straight into S-101 feature + attribute records — its own in-band code tables already carry the S-101 class and attribute names, and its complex attributes are explicit, so no conversion is needed. An S-57 chart instead builds the S-57 feature + geometry model (depth areas, buoys, coastlines, …) and adapts it into the same S-101 records. Geometry (assembled from the vector topology) and attributes become a queryable in-memory model either way.
- Apply S-101 portrayal. The official IHO S-101 Portrayal Catalogue — the
real Lua rule files — runs in embedded Lua 5.4 and decides how to draw each
feature: symbol, colour, line style, conditional symbology. Zig implements the
Host*query callbacks the rules call back into (plus a small C shim, since Lua's macros are easiest from C). - Adapt the instructions. The portrayal output is turned into simple drawing primitives: filled polygons, stroked lines, symbols, patterns, soundings, text.
- Generate the scene. Each primitive is projected to web-mercator tile coordinates, clipped (extent 4096, buffer 64), and emitted as draw calls on a Surface — the backend contract described below.
The Surface contract
Every output format implements one vtable — Surface
(src/render/surface.zig). The engine emits semantic draw calls (S-52 colour
tokens, symbol names, raw depths, per-feature display metadata); what happens
next depends on the listening backend: the tile surface serializes the
semantics into MVT/MLT tiles for a client renderer, the pixel surface
resolves them (tokens → RGB, symbol names → vector outlines) and paints PNG
raster or deterministic vector PDF, and the ASCII surface lowers them to a
Unicode terminal grid (with optional ANSI colour, or real pixels inline via the
kitty graphics protocol). One engine, pluggable outputs — see
The Rendering Engine.
Tile formats: MLT by default, MVT optional
Bakes encode MLT (MapLibre Tile)
by default; MapLibre GL JS ≥ 5.12 decodes it natively via the vector source
encoding option, and the generated styles carry that hint. The engine can also
encode Mapbox Vector Tiles for consumers without an MLT decoder.
tile57_info.tile_type reports which encoding a chart's tiles use, so a
host hints its renderer correctly.
Band handoff (coverage-clipped ownership)
Overlapping charts of different compilation scales are resolved per tile to
the best band. Rather than carry a coarser chart's features down into a finer
band's tiles (the old smax-tagged carry-down), the compositor clips each
chart to the ownership partition: at every tile the finer chart's M_COVR
coverage wins the ground it holds, and a coarser chart renders only where no
finer chart covers it.
Band boundaries hand off without holes or double-draws, and there is no
per-feature handoff tag for the style to gate.
Overscale indication (oscl)
Per S-52 §10.1.10, every contributing chart's coverage polygon is baked as an
OVERSC01 vertical-line hatch tagged oscl = the chart's compilation-scale
denominator. The hatch shows only while the display is finer than 1:oscl,
and the style sandwiches it between the overscaled and at-scale fill passes so
finer opaque data occludes a coarser chart's hatch. The show_overscale
mariner toggle (default on) drives its visibility.
The Zig modules
The stages are separate Zig modules (see build.zig), most of them pure (no
libc/Lua) and target-agnostic:
| Module | Role |
|---|---|
iso8211 | the ISO/IEC 8211 container reader (the bottom layer; std-only) |
s57 | the S-57 chart parser + geometry model (reads 8211 records through iso8211) |
s101 | the S-101 catalogue, the native S-101 dataset reader, the S-57 → S-101 adapter, and the portrayal instruction stream |
portray | the embedded-Lua S-101 runner (links libc) |
tiles | MVT + MLT encoders, gzip, the PMTiles container, web-mercator tile math |
render | the Surface contract, the resolver (colours, display gates), and the pixel machinery (Canvas, PNG, PDF, ASCII) |
scene | S-57 → tile-surface scene generation + the banded ENC_ROOT baker (bake_enc.zig) |
style | the S-101 color tables and line styles, the MapLibre style.json layer set, and the S-52 mariner settings model (mariner.zig) |
sprite | the S-101 sprite + area-fill pattern atlases from the catalogue Symbols/AreaFills (SVG raster; links libc) |
The style and sprite modules generate the S-101/S-52 portrayal assets — color
tables, line styles, sprites, and patterns — from the S-101 Portrayal Catalogue.
They read the catalogue bytes as input rather than importing s101, so they stay
independent modules a caller can grab on their own.
| engine | the pure packages re-exported as one import (the test root) |
| tile57 | the curated public surface (src/tile57.zig) |
portray and sprite are the only modules that link libc, and neither is
imported by the pure test build. The C ABI (src/capi.zig) is a thin shim over
the same Zig API as the tile57 module.
The layering: chart / compose / style
The public surface composes the packages into high-level entry points:
Chart— ONE open chart, no composition. Open a baked PMTiles archive (openPmtilesPath, mmap'd;openBytes) or a live chart (openByteson.000bytes), then take its outputs: view renders (renderView— PNG, PDF, or a callback canvas;renderSurfaceView— world-space GPU callbacks), the cursor pick (queryPoint), the metadata getters, and — for an archive — its stored tiles verbatim throughpmtilesReader(). In the C ABI:tile57_chart_tile/tile57_chart_png/tile57_chart_pdf/tile57_chart_canvas/tile57_chart_surface/tile57_chart_query. A streaming ENC_ROOT open (openPath/openCharts/openChartsStreaming) is the metadata + extraction view of raw source data (the Ctile57_enc_*readers); it serves no tiles or renders.- Tile production — bake each chart to its own PMTiles at its compilation scale
(
tile57_bake_chart_bytes, which runs the banded bake enginescene/bake_enc.zigon a single chart), then a runtime compositor stitches the overlapping charts through an ownership partition and offers the SAME outputs as a chart, composed:tile57_compose_tilefor any(z, x, y)on demand,tile57_compose_png/_pdf/_canvas/_surface/_queryfor composed views and the composed pick. Baking is strictly per-chart: one chart, one archive. style.build(style/maplibre.zig) +style/sprite— generate the MapLibre style and the portrayal assets it references (tile57_style_build/tile57_bake_assetsin the C ABI).
The memory design
tile57 is built to hold only its working set:
- Lazy per-chart reads. An ENC_ROOT is opened by scanning each chart's header for a cheap spatial index (band + bbox). A chart's bytes are read and parsed only when a metadata or feature query needs them, then released under an LRU bound.
- Ownership, not overlays. Overlapping charts of different compilation scales are resolved by the precomputed ownership partition — each tile's ground belongs to exactly one chart per band, so composing never loads every overlapping chart.
- Streaming open.
openChartsStreaming(and its on-disk driveropenPath, which backs the Ctile57_enc_*readers) take per-chart metadata (bbox + scale) plus a reader; a chart's bytes are read only on demand and freed on eviction. A host then holds only the working set's bytes, not the whole catalogue — the right choice for a large ENC_ROOT. - Per-chart bakes. Each chart bakes independently at its own compilation scale, so a bake holds a single chart's parsed data at a time — memory doesn't grow with the size of the catalogue.
- Tile cache. Generated/decoded tiles are memoized per chart (keyed
z<<48 | x<<24 | y) and released with the chart, so a long-running host bounds memory by closing charts it no longer renders.
The live-composite bake
One bake command (tile57 bake <cell.000 | ENC_ROOT> -o out/) writes the
live-composite structure — per-chart tiles plus the ownership partition a runtime
compositor serves them from:
out/
tiles/US5MD1MC.pmtiles one PMTiles per chart, baked at its compilation scale
tiles/US4MD81M.pmtiles (M_COVR coverage embedded in each archive's metadata)
partition.tpart the ownership partition: which chart renders which ground
There is no merged archive: any (z, x, y) tile is composed from the overlapping
charts on demand (tile57_compose_tile), so re-baking one chart doesn't
rewrite the whole ENC_ROOT. The portrayal assets are generated separately (tile57 assets /
style); the tiles carry S-52 colour tokens, never RGB, and both halves come
from the same S-101 catalogue, so they cannot drift. The tile-schema vocabulary
(tile57/2) is the contract a renderer checks.
See the Tile Schema for the vector-tile layer contract.